feat(web): 新增 Markdown 即时渲染装饰层(Typora 式)
- web/src/editor/:syntax(lezer 语法树 → 装饰描述,纯函数)、reveal(行粒度展开 + 合并区间二分)、widgets(圆点/引用竖线/水平线/图片/围栏语言标签/任务框,全部 createElement + textContent、零 innerHTML)、decorations(Spec → DecorationSet + 原子区间 + 图片 src 白名单 + 可见区间补解析)、livePreview(ViewPlugin + atomicRanges + Compartment 源码开关 + view.composing 期间冻结装饰) - Editor.tsx:挂载装饰层,新增可选 sourceMode prop - index.css:抽出 --md-* 排版契约变量(阅读态 .pn-note-body 与编辑态 .cm-md-* 共用, 杜绝两份样式漂移)、.cm-md-* 装饰样式(对齐阅读态字号/衬线标题/引用弱化色/代码底色) - 数据层零改动:onChange 拿到的仍是 Markdown 原文,公开页渲染管线与 sanitize 防线不动; 运行时零新增 npm 依赖 P3 加固(长文档惰性解析补解析、Cmd+A 全选退化、光标行在视口外也装饰)包含在 decorations.ts / livePreview.ts 内,回归用例见后续 test 提交,方案与实测见 docs/editor-live-rendering.md §12.5。
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import { useTheme } from '../lib/theme'
|
||||
import { livePreview, livePreviewCompartment } from '../editor/livePreview'
|
||||
|
||||
/**
|
||||
* Editor(编辑态,§8.2):CodeMirror 6 源码编辑 + 图片粘贴/拖拽上传。
|
||||
* Editor(编辑态,§8.2):CodeMirror 6 即时渲染(Typora 式)+ 图片粘贴/拖拽上传。
|
||||
* 粘贴/拖拽 → POST /api/admin/images(≤5MB,魔数校验)→ 光标处插入
|
||||
* ``。
|
||||
* ``,插入后由装饰层立刻渲染为图片。
|
||||
*
|
||||
* sourceMode=true 时卸载全部装饰,退回纯源码编辑(逃生舱,方案 §4.7)。
|
||||
*/
|
||||
|
||||
interface UploadResult {
|
||||
@@ -34,24 +37,40 @@ export default function Editor({
|
||||
onChange,
|
||||
onUploadStart,
|
||||
onUploadEnd,
|
||||
sourceMode = false,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onUploadStart?: (name: string) => void
|
||||
onUploadEnd?: (err: string | null) => void
|
||||
sourceMode?: boolean
|
||||
}) {
|
||||
const viewRef = useRef<{ view?: EditorView } | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const { mode } = useTheme()
|
||||
const appliedSourceMode = useRef<boolean | null>(null)
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
markdown({ base: markdownLanguage }),
|
||||
EditorView.lineWrapping,
|
||||
// 初始值即决定首次挂载是否带装饰;后续切换走 useEffect 的 reconfigure
|
||||
livePreviewCompartment.of(sourceMode ? [] : livePreview()),
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
)
|
||||
|
||||
// 源码模式 O(1) 切换:整体挂载/卸载装饰扩展(方案 §4.7)
|
||||
useEffect(() => {
|
||||
const view = viewRef.current?.view
|
||||
if (!view || appliedSourceMode.current === null || appliedSourceMode.current === sourceMode) return
|
||||
appliedSourceMode.current = sourceMode
|
||||
view.dispatch({
|
||||
effects: livePreviewCompartment.reconfigure(sourceMode ? [] : livePreview()),
|
||||
})
|
||||
}, [sourceMode])
|
||||
|
||||
const insertAtCursor = useCallback(
|
||||
(text: string) => {
|
||||
const wrapper = viewRef.current
|
||||
@@ -89,7 +108,7 @@ export default function Editor({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={dragOver ? 'pn-cm-drag' : undefined}
|
||||
className={[sourceMode ? '' : 'pn-cm-live', dragOver ? 'pn-cm-drag' : ''].filter(Boolean).join(' ') || undefined}
|
||||
style={{ position: 'relative', height: '100%', overflow: 'hidden', borderRadius: 'var(--radius-sm)' }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -112,6 +131,7 @@ export default function Editor({
|
||||
style={{ height: '100%', minHeight: 420 }}
|
||||
onCreateEditor={(view) => {
|
||||
viewRef.current = { view }
|
||||
appliedSourceMode.current = sourceMode
|
||||
}}
|
||||
onPaste={(event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
const files = event.clipboardData?.files
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Spec → CodeMirror 装饰集(方案 §4.2 / §4.3 / §4.4)。
|
||||
//
|
||||
// 与 livePreview.ts 分离:本模块只做「纯转换」,输入是 EditorState + 可见区间,
|
||||
// 输出 DecorationSet / 原子区间集,便于单测(不需要真实 view)。
|
||||
|
||||
import { RangeSet } from '@codemirror/state'
|
||||
import type { EditorState, Range } from '@codemirror/state'
|
||||
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
|
||||
import type { Tree } from '@lezer/common'
|
||||
import { Decoration } from '@codemirror/view'
|
||||
import type { DecorationSet } from '@codemirror/view'
|
||||
import { collectSpecs, type Spec } from './syntax'
|
||||
import { activeLineRanges, applyReveal, selectionEndpointLines } from './reveal'
|
||||
import {
|
||||
BulletWidget,
|
||||
FenceLangWidget,
|
||||
HrWidget,
|
||||
ImageWidget,
|
||||
QuoteBarWidget,
|
||||
TaskWidget,
|
||||
} from './widgets'
|
||||
|
||||
/**
|
||||
* 图片 src 白名单(方案 §6):仅允许站内图片接口与内联 data:image。
|
||||
* 不匹配则整条 image spec 被丢弃 → 退回显示源码文本,不产出 <img>。
|
||||
*/
|
||||
export const IMAGE_SRC_ALLOW = /^(?:\/api\/images\/|data:image\/(?:png|jpe?g|gif|webp|avif);base64,)/i
|
||||
|
||||
export interface LiveDecorations {
|
||||
decorations: DecorationSet
|
||||
atomic: RangeSet<Decoration>
|
||||
}
|
||||
|
||||
function specKey(s: Spec): string {
|
||||
return [s.kind, s.from, s.to, 'line' in s ? s.line : '', 'cls' in s ? s.cls : ''].join('|')
|
||||
}
|
||||
|
||||
/** 强制补解析可见区间所需时间的上限(毫秒)。 */
|
||||
export const PARSE_BUDGET_MS = 20
|
||||
|
||||
/**
|
||||
* 取一棵至少覆盖 `ranges` 末端的语法树。
|
||||
*
|
||||
* 关键点:lezer 是**惰性增量**解析 —— `syntaxTree(state)` 只给出已完成的部分,
|
||||
* 长文档(实测 3000 行 ≈ 15 万字符时首次仅解析约 3000 字符)未覆盖的区间若直接
|
||||
* 返回空装饰,用户会看到裸 Markdown。这里用 `ensureSyntaxTree` 在有限预算内
|
||||
* 把可见区间补齐;预算内没补完就退回已有树,其余部分保持源码态并等解析推进后
|
||||
* 由 `livePreview` 的「树对象变化」触发自动补装饰(不会永久缺失)。
|
||||
*/
|
||||
export function visibleSyntaxTree(
|
||||
state: EditorState,
|
||||
ranges: readonly { from: number; to: number }[],
|
||||
timeoutMs = PARSE_BUDGET_MS,
|
||||
): Tree {
|
||||
let upto = 0
|
||||
for (const r of ranges) if (r.to > upto) upto = r.to
|
||||
if (upto <= 0) return syntaxTree(state)
|
||||
return ensureSyntaxTree(state, upto, timeoutMs) ?? syntaxTree(state)
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集可见区间内的语法 → 应用 reveal → 生成装饰集。
|
||||
* 只遍历 visibleRanges(外加光标所在行),不整篇扫描(方案 §7.4)。
|
||||
*
|
||||
* 为什么额外加选区端点行:CodeMirror 会把选区所在的块单独渲染出来(哪怕它
|
||||
* 在视口之外,用 `cm-gap` 占位)。若只装饰 visibleRanges,光标行在远距离滚动
|
||||
* 后会以裸源码出现。补上这几行成本可忽略,却让「凡渲染出来的行都有装饰」成立。
|
||||
* 注意只取**端点**行 —— 选区跨越多行时 CM 也只渲染端点块,全量取会让 Cmd+A
|
||||
* 在长文档上退化成全文档装饰构建。
|
||||
*
|
||||
* @param reveal 编辑器是否处于聚焦态;未聚焦时整篇按渲染态呈现(决策 E11)
|
||||
*/
|
||||
export function buildDecorations(
|
||||
state: EditorState,
|
||||
visibleRanges: readonly { from: number; to: number }[],
|
||||
reveal: boolean,
|
||||
tree?: Tree,
|
||||
): LiveDecorations {
|
||||
const selectionLines = activeLineRanges(state)
|
||||
const collectRanges: { from: number; to: number }[] = [...visibleRanges]
|
||||
for (const line of selectionEndpointLines(state)) {
|
||||
if (!collectRanges.some((r) => r.from <= line.from && r.to >= line.to)) collectRanges.push(line)
|
||||
}
|
||||
const syntax = tree ?? visibleSyntaxTree(state, collectRanges)
|
||||
|
||||
const raw: Spec[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const range of collectRanges) {
|
||||
// 语法树可能只覆盖文档前段:越界部分不采集(保持源码态,等解析推进后补齐)
|
||||
const to = Math.min(range.to, syntax.length)
|
||||
if (to <= range.from) continue
|
||||
for (const spec of collectSpecs(syntax, state.doc, range.from, to)) {
|
||||
const key = specKey(spec)
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
raw.push(spec)
|
||||
}
|
||||
}
|
||||
|
||||
const specs = reveal ? applyReveal(raw, selectionLines) : raw
|
||||
|
||||
const ranges: Range<Decoration>[] = []
|
||||
const atomic: Range<Decoration>[] = []
|
||||
|
||||
for (const spec of specs) {
|
||||
switch (spec.kind) {
|
||||
case 'line':
|
||||
ranges.push(Decoration.line({ class: spec.cls }).range(spec.from))
|
||||
break
|
||||
case 'mark':
|
||||
ranges.push(Decoration.mark({ class: spec.cls }).range(spec.from, spec.to))
|
||||
break
|
||||
case 'hide': {
|
||||
const deco = Decoration.replace({})
|
||||
const r = deco.range(spec.from, spec.to)
|
||||
ranges.push(r)
|
||||
atomic.push(r)
|
||||
break
|
||||
}
|
||||
case 'bullet': {
|
||||
const deco = Decoration.replace({ widget: new BulletWidget(spec.depth) })
|
||||
const r = deco.range(spec.from, spec.to)
|
||||
ranges.push(r)
|
||||
atomic.push(r)
|
||||
break
|
||||
}
|
||||
case 'quote': {
|
||||
const deco = Decoration.replace({ widget: new QuoteBarWidget(spec.depth) })
|
||||
const r = deco.range(spec.from, spec.to)
|
||||
ranges.push(r)
|
||||
atomic.push(r)
|
||||
break
|
||||
}
|
||||
case 'hr': {
|
||||
const deco = Decoration.replace({ widget: new HrWidget() })
|
||||
const r = deco.range(spec.from, spec.to)
|
||||
ranges.push(r)
|
||||
atomic.push(r)
|
||||
break
|
||||
}
|
||||
case 'image': {
|
||||
if (!IMAGE_SRC_ALLOW.test(spec.src)) break // 非白名单 → 退回源码文本
|
||||
const deco = Decoration.replace({ widget: new ImageWidget(spec.src, spec.alt) })
|
||||
const r = deco.range(spec.from, spec.to)
|
||||
ranges.push(r)
|
||||
atomic.push(r)
|
||||
break
|
||||
}
|
||||
case 'task': {
|
||||
const deco = Decoration.replace({ widget: new TaskWidget(spec.from, spec.to, spec.checked) })
|
||||
const r = deco.range(spec.from, spec.to)
|
||||
ranges.push(r)
|
||||
atomic.push(r)
|
||||
break
|
||||
}
|
||||
case 'fenceLang': {
|
||||
const deco = Decoration.replace({ widget: new FenceLangWidget(spec.lang) })
|
||||
const r = deco.range(spec.from, spec.to)
|
||||
ranges.push(r)
|
||||
atomic.push(r)
|
||||
break
|
||||
}
|
||||
case 'tableMark': {
|
||||
ranges.push(Decoration.mark({ class: 'cm-md-table-mark' }).range(spec.from, spec.to))
|
||||
if (spec.delimiter) ranges.push(Decoration.mark({ class: 'cm-md-table-delim' }).range(spec.from, spec.to))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
decorations: Decoration.set(ranges, true),
|
||||
atomic: RangeSet.of(atomic, true),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// 即时渲染扩展入口(方案 §3.1 / §4.7)。
|
||||
//
|
||||
// - ViewPlugin:装饰计算只跑可见区间;selection 变化即重算(行粒度 reveal);
|
||||
// - EditorView.atomicRanges:隐藏的纯标记区间原子化,方向键整段跳过;
|
||||
// - livePreviewCompartment:源码模式开关整体挂载/卸载,O(1) 且无残留装饰。
|
||||
//
|
||||
// IME(方案 §7.1):视图处于组合输入(view.composing)且文档变化时,
|
||||
// 不做重算,只把旧装饰按变更映射 —— 组合范围内绝不替换 DOM。
|
||||
|
||||
import { Compartment, RangeSet } from '@codemirror/state'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { Decoration, EditorView, ViewPlugin } from '@codemirror/view'
|
||||
import type { DecorationSet, PluginValue, ViewUpdate } from '@codemirror/view'
|
||||
import { buildDecorations } from './decorations'
|
||||
|
||||
class LivePreviewPlugin implements PluginValue {
|
||||
decorations: DecorationSet = Decoration.none
|
||||
atomic: RangeSet<Decoration> = RangeSet.empty
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.build(view)
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
if (update.docChanged && update.view.composing) {
|
||||
// 组合输入期间冻结:只映射位置,不重建装饰(§7.1)
|
||||
this.decorations = this.decorations.map(update.changes)
|
||||
this.atomic = this.atomic.map(update.changes)
|
||||
return
|
||||
}
|
||||
// 语法树对象换代 = 后台解析推进:必须重建,否则未解析区间的装饰会一直缺失
|
||||
const treeAdvanced = syntaxTree(update.startState) !== syntaxTree(update.state)
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.selectionSet ||
|
||||
update.viewportChanged ||
|
||||
update.focusChanged ||
|
||||
treeAdvanced
|
||||
) {
|
||||
this.build(update.view)
|
||||
}
|
||||
}
|
||||
|
||||
private build(view: EditorView): void {
|
||||
// 未聚焦时整篇按渲染态呈现(决策 E11):编辑器失焦 = 只读观感,
|
||||
// 避免「首行因光标停在行首而永远显示 #」的观感缺陷。
|
||||
const { decorations, atomic } = buildDecorations(
|
||||
view.state,
|
||||
view.visibleRanges,
|
||||
view.hasFocus,
|
||||
)
|
||||
this.decorations = decorations
|
||||
this.atomic = atomic
|
||||
}
|
||||
}
|
||||
|
||||
export const livePreviewPlugin = ViewPlugin.fromClass(LivePreviewPlugin, {
|
||||
decorations: (value) => value.decorations,
|
||||
provide: (plugin) =>
|
||||
EditorView.atomicRanges.of((view) => view.plugin(plugin)?.atomic ?? RangeSet.empty),
|
||||
})
|
||||
|
||||
/** 源码模式开关的 compartment;`empty` 表示回到纯源码编辑。 */
|
||||
export const livePreviewCompartment = new Compartment()
|
||||
|
||||
/** 组装即时渲染扩展集。 */
|
||||
export function livePreview(): Extension {
|
||||
return [livePreviewPlugin]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// 光标处展开(方案 §4.5)。
|
||||
//
|
||||
// 实现为纯函数:装饰集 = f(语法树, selection)。v1 取行粒度(决策 E6):
|
||||
// 光标/选区触及的行,其语法标记整体展开为源码。行粒度对 IME 最安全 ——
|
||||
// 组字所在行永远是展开态,组合过程中不会被 replace 掉 DOM。
|
||||
//
|
||||
// span 级展开(Typora 的「光标进入行内节点才展开」)列入方案 §10.2。
|
||||
|
||||
import type { EditorState } from '@codemirror/state'
|
||||
import type { Spec } from './syntax'
|
||||
|
||||
export interface Range {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
/** 选区(全部 range,非仅 main)所触及的整行区间集合(升序、互不相交)。 */
|
||||
export function activeLineRanges(state: EditorState): Range[] {
|
||||
const out: Range[] = []
|
||||
const seen = new Set<number>()
|
||||
for (const r of state.selection.ranges) {
|
||||
const start = state.doc.lineAt(r.from)
|
||||
const end = state.doc.lineAt(r.to)
|
||||
for (let n = start.number; n <= end.number; n++) {
|
||||
if (seen.has(n)) continue
|
||||
seen.add(n)
|
||||
const line = state.doc.line(n)
|
||||
out.push({ from: line.from, to: line.to })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 选区**端点**所在的整行(CM 只把选区端点所在的块渲染在视口之外,
|
||||
* 不会渲染整个选区跨越的每一行)。用于决定「除可见区间外还要装饰哪些行」,
|
||||
* 避免 Cmd+A 这种整篇选区触发全文档装饰构建。
|
||||
*/
|
||||
export function selectionEndpointLines(state: EditorState): Range[] {
|
||||
const out: Range[] = []
|
||||
const seen = new Set<number>()
|
||||
for (const r of state.selection.ranges) {
|
||||
for (const pos of [r.from, r.to]) {
|
||||
const line = state.doc.lineAt(pos)
|
||||
if (seen.has(line.number)) continue
|
||||
seen.add(line.number)
|
||||
out.push({ from: line.from, to: line.to })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** [from, to) 是否与任一展开区间相交。零长区间不与任何行相交。 */
|
||||
export function intersectsAny(ranges: readonly Range[], from: number, to: number): boolean {
|
||||
if (to <= from) return false
|
||||
for (const r of ranges) {
|
||||
if (from <= r.to && r.from <= to) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 把区间排序并合并,得到互不相交的升序区间,供二分查询使用。 */
|
||||
function mergeRanges(ranges: readonly Range[]): Range[] {
|
||||
if (ranges.length < 2) return ranges.map((r) => ({ from: r.from, to: r.to }))
|
||||
const sorted = ranges
|
||||
.map((r) => ({ from: r.from, to: r.to }))
|
||||
.sort((a, b) => a.from - b.from || a.to - b.to)
|
||||
const out: Range[] = [sorted[0]]
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const last = out[out.length - 1]
|
||||
if (sorted[i].from <= last.to) last.to = Math.max(last.to, sorted[i].to)
|
||||
else out.push(sorted[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 消除 O(装饰数 × 展开区间数):展开区间先合并再二分。
|
||||
* 整篇选区(Cmd+A)在长文档下会产生数千个展开区间,线性扫描会让单帧成本失控。
|
||||
*/
|
||||
export function intersectsMerged(sortedMerged: readonly Range[], from: number, to: number): boolean {
|
||||
if (to <= from) return false
|
||||
let lo = 0
|
||||
let hi = sortedMerged.length - 1
|
||||
let idx = -1
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (sortedMerged[mid].from <= to) {
|
||||
idx = mid
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
return idx >= 0 && sortedMerged[idx].to >= from
|
||||
}
|
||||
|
||||
/**
|
||||
* 丢掉落在展开区间内的「标记替代物」,保留样式类装饰。
|
||||
* 结果仍是文档顺序(collectSpecs 输出即有序)。
|
||||
*/
|
||||
export function applyReveal(specs: readonly Spec[], ranges: readonly Range[]): Spec[] {
|
||||
if (ranges.length === 0) return specs.slice()
|
||||
const merged = mergeRanges(ranges)
|
||||
return specs.filter((s) => !(s.reveal && intersectsMerged(merged, s.from, s.to)))
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// 即时渲染(Typora 式)语法采集层(方案 §4.1)。
|
||||
//
|
||||
// 职责:把 lezer Markdown 语法树翻译成「与 CodeMirror 解耦」的装饰描述(Spec)。
|
||||
// 纯函数、零 DOM、零 CM view 依赖,便于 vitest 直接断言区间。
|
||||
//
|
||||
// 设计约束:
|
||||
// - 只认语法树节点,不写正则(方案 §4.1);
|
||||
// - 采集结果不做 reveal 过滤(reveal 是 selection 的函数,见 reveal.ts),
|
||||
// 由 decorations.ts 在构建阶段调用 applyReveal —— 这样「识别」与「展开」
|
||||
// 可以分别单测;
|
||||
// - 任何 from/to 都不跨越行边界(CM6 对 replace 跨行的硬性约束,方案 §4.2)。
|
||||
|
||||
import type { SyntaxNode, Tree } from '@lezer/common'
|
||||
import type { Text } from '@codemirror/state'
|
||||
|
||||
/** 装饰描述类型(kind 决定 decorations.ts 里用哪种 Decoration)。 */
|
||||
export type SpecKind =
|
||||
| 'hide' // 纯定界符,replace 为空 + 原子
|
||||
| 'mark' // 行内样式,Decoration.mark
|
||||
| 'line' // 整行样式,Decoration.line
|
||||
| 'bullet' // 无序列表圆点 widget(§5.1)
|
||||
| 'quote' // 引用竖线 widget(§5.3)
|
||||
| 'hr' // 水平线 widget(§5.4)
|
||||
| 'image' // 图片 widget(§5.4)
|
||||
| 'task' // 任务复选框 widget(§5.4)
|
||||
| 'fenceLang' // 代码围栏语言标签 widget(§5.4)
|
||||
| 'tableMark' // 表格定界符淡化(§4.4)
|
||||
|
||||
interface BaseSpec {
|
||||
from: number
|
||||
to: number
|
||||
/**
|
||||
* 是否属于「语法标记替代物」:为 true 时,光标所在行会撤销该装饰、展开源码
|
||||
* (方案 §4.5 行粒度 reveal)。样式类(mark/line)恒为 false。
|
||||
*/
|
||||
reveal: boolean
|
||||
}
|
||||
|
||||
export interface HideSpec extends BaseSpec {
|
||||
kind: 'hide'
|
||||
}
|
||||
export interface MarkSpec extends BaseSpec {
|
||||
kind: 'mark'
|
||||
cls: string
|
||||
}
|
||||
export interface LineSpec extends BaseSpec {
|
||||
kind: 'line'
|
||||
line: number
|
||||
cls: string
|
||||
}
|
||||
export interface BulletSpec extends BaseSpec {
|
||||
kind: 'bullet'
|
||||
depth: number
|
||||
}
|
||||
export interface QuoteSpec extends BaseSpec {
|
||||
kind: 'quote'
|
||||
depth: number
|
||||
}
|
||||
export interface HrSpec extends BaseSpec {
|
||||
kind: 'hr'
|
||||
}
|
||||
export interface ImageSpec extends BaseSpec {
|
||||
kind: 'image'
|
||||
alt: string
|
||||
src: string
|
||||
}
|
||||
export interface TaskSpec extends BaseSpec {
|
||||
kind: 'task'
|
||||
checked: boolean
|
||||
}
|
||||
export interface FenceLangSpec extends BaseSpec {
|
||||
kind: 'fenceLang'
|
||||
lang: string
|
||||
}
|
||||
export interface TableMarkSpec extends BaseSpec {
|
||||
kind: 'tableMark'
|
||||
/** 是否为分隔行(`| --- |`),用于「淡化」与「更淡」两级 */
|
||||
delimiter: boolean
|
||||
}
|
||||
export type Spec =
|
||||
| HideSpec
|
||||
| MarkSpec
|
||||
| LineSpec
|
||||
| BulletSpec
|
||||
| QuoteSpec
|
||||
| HrSpec
|
||||
| ImageSpec
|
||||
| TaskSpec
|
||||
| FenceLangSpec
|
||||
| TableMarkSpec
|
||||
|
||||
const HEADING_RE = /^ATXHeading([1-6])$/
|
||||
|
||||
/** 行内样式类名(对齐 .pn-note-body 的阅读态,方案 §4.3)。 */
|
||||
export const CLS_STRONG = 'cm-md-strong'
|
||||
export const CLS_EM = 'cm-md-em'
|
||||
export const CLS_DEL = 'cm-md-del'
|
||||
export const CLS_CODE = 'cm-md-code'
|
||||
export const CLS_LINK = 'cm-md-link'
|
||||
export const CLS_TABLE = 'cm-md-table'
|
||||
export const CLS_FENCED = 'cm-md-fenced'
|
||||
export const CLS_QUOTE_LINE = 'cm-md-quote'
|
||||
export const CLS_OL_MARK = 'cm-md-ol-mark'
|
||||
|
||||
/** 折行到下一个非空白位置(用于把 `#` / `-` 后的空格一并吃掉)。 */
|
||||
function afterSpaces(doc: Text, pos: number, limit: number): number {
|
||||
let p = pos
|
||||
while (p < limit && doc.sliceString(p, p + 1) === ' ') p++
|
||||
return p
|
||||
}
|
||||
|
||||
/** 向上找最近的同名祖先。 */
|
||||
function nearestAncestor(node: SyntaxNode, name: string): SyntaxNode | null {
|
||||
for (let p = node.parent; p; p = p.parent) if (p.name === name) return p
|
||||
return null
|
||||
}
|
||||
|
||||
/** 列表嵌套深度 = 祖先链中 BulletList / OrderedList 的个数(方案 §5.1)。 */
|
||||
function listDepth(node: SyntaxNode): number {
|
||||
let d = 0
|
||||
for (let p = node.parent; p; p = p.parent) {
|
||||
if (p.name === 'BulletList' || p.name === 'OrderedList') d++
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
/** 引用嵌套深度 = 祖先链中 Blockquote 的个数。 */
|
||||
function quoteDepth(node: SyntaxNode): number {
|
||||
let d = 0
|
||||
for (let p = node.parent; p; p = p.parent) if (p.name === 'Blockquote') d++
|
||||
return d
|
||||
}
|
||||
|
||||
/** 该列表项是否为任务项(`- [ ] x`)—— 任务项不画圆点,只画复选框。 */
|
||||
function isTaskItem(node: SyntaxNode): boolean {
|
||||
const li = nearestAncestor(node, 'ListItem')
|
||||
return !!li?.getChild('Task')
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集 [from, to) 区间内所有装饰描述。区间通常取 `view.visibleRanges`,
|
||||
* 保证只做视口内的工作(方案 §7.4)。
|
||||
*/
|
||||
export function collectSpecs(tree: Tree, doc: Text, from: number, to: number): Spec[] {
|
||||
const specs: Spec[] = []
|
||||
const push = (s: Spec) => {
|
||||
if (s.kind === 'line' || s.to > s.from) specs.push(s)
|
||||
}
|
||||
const hide = (f: number, t: number) => push({ kind: 'hide', from: f, to: t, reveal: true })
|
||||
const mark = (f: number, t: number, cls: string) => push({ kind: 'mark', from: f, to: t, cls, reveal: false })
|
||||
|
||||
tree.iterate({
|
||||
from,
|
||||
to,
|
||||
enter: (node) => {
|
||||
const name = node.name
|
||||
const nodeFrom = node.from
|
||||
const nodeTo = node.to
|
||||
|
||||
const heading = HEADING_RE.exec(name)
|
||||
if (heading) {
|
||||
const line = doc.lineAt(nodeFrom)
|
||||
push({ kind: 'line', from: line.from, to: line.to, line: line.number, cls: `cm-md-h${heading[1]}`, reveal: false })
|
||||
const hm = node.node.getChild('HeaderMark')
|
||||
if (hm) hide(hm.from, afterSpaces(doc, hm.to, line.to))
|
||||
return
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'StrongEmphasis':
|
||||
case 'Emphasis':
|
||||
case 'Strikethrough': {
|
||||
const markName = name === 'Strikethrough' ? 'StrikethroughMark' : 'EmphasisMark'
|
||||
const marks = node.node.getChildren(markName)
|
||||
if (marks.length >= 2) {
|
||||
const open = marks[0]
|
||||
const close = marks[marks.length - 1]
|
||||
const cls = name === 'StrongEmphasis' ? CLS_STRONG : name === 'Emphasis' ? CLS_EM : CLS_DEL
|
||||
hide(open.from, open.to)
|
||||
hide(close.from, close.to)
|
||||
mark(open.to, close.from, cls)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'InlineCode': {
|
||||
// 行内代码内容为字面量,不再向下解析
|
||||
const marks = node.node.getChildren('CodeMark')
|
||||
if (marks.length >= 2) {
|
||||
hide(marks[0].from, marks[0].to)
|
||||
hide(marks[marks.length - 1].from, marks[marks.length - 1].to)
|
||||
mark(marks[0].to, marks[marks.length - 1].from, CLS_CODE)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
case 'Link': {
|
||||
const marks = node.node.getChildren('LinkMark')
|
||||
if (marks.length >= 2) {
|
||||
hide(marks[0].from, marks[0].to)
|
||||
mark(marks[0].to, marks[1].from, CLS_LINK)
|
||||
// `](url)` 整段隐藏(URL 不产出 <a>,v1 只做文本样式,方案 §6)
|
||||
hide(marks[1].from, nodeTo)
|
||||
}
|
||||
// 继续下钻:链接内可嵌图片
|
||||
return
|
||||
}
|
||||
|
||||
case 'Image': {
|
||||
const url = node.node.getChild('URL')
|
||||
const imgMarks = node.node.getChildren('LinkMark')
|
||||
const alt = imgMarks.length >= 2 ? doc.sliceString(imgMarks[0].to, imgMarks[1].from) : ''
|
||||
push({
|
||||
kind: 'image',
|
||||
from: nodeFrom,
|
||||
to: nodeTo,
|
||||
reveal: false,
|
||||
alt,
|
||||
src: url ? doc.sliceString(url.from, url.to) : '',
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
case 'HorizontalRule':
|
||||
push({ kind: 'hr', from: nodeFrom, to: nodeTo, reveal: true })
|
||||
return false
|
||||
|
||||
case 'QuoteMark': {
|
||||
const line = doc.lineAt(nodeFrom)
|
||||
// 行级样式与竖线 widget 分工:widget 承接标记,行类把正文调成阅读态的弱化色
|
||||
push({ kind: 'line', from: line.from, to: line.to, line: line.number, cls: CLS_QUOTE_LINE, reveal: false })
|
||||
push({ kind: 'quote', from: nodeFrom, to: nodeTo, reveal: true, depth: quoteDepth(node.node) })
|
||||
return false
|
||||
}
|
||||
|
||||
case 'ListMark': {
|
||||
const list = nearestAncestor(node.node, 'BulletList') ?? nearestAncestor(node.node, 'OrderedList')
|
||||
const line = doc.lineAt(nodeFrom)
|
||||
const end = afterSpaces(doc, nodeTo, line.to)
|
||||
if (list?.name === 'BulletList') {
|
||||
if (isTaskItem(node.node)) {
|
||||
hide(nodeFrom, end) // 任务项:标记让位给复选框
|
||||
} else {
|
||||
push({ kind: 'bullet', from: nodeFrom, to: end, reveal: true, depth: listDepth(node.node) })
|
||||
}
|
||||
} else {
|
||||
// 有序列表:保留数字(携带序号语义,方案 §5.2 / 决策 E3),仅上样式
|
||||
mark(nodeFrom, nodeTo, CLS_OL_MARK)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
case 'TaskMarker': {
|
||||
// 复选框始终渲染(不参与 reveal),点击即改写源文本(方案 §5.4)
|
||||
const checked = doc.sliceString(nodeFrom, nodeTo).toLowerCase().includes('x')
|
||||
push({ kind: 'task', from: nodeFrom, to: nodeTo, reveal: false, checked })
|
||||
return false
|
||||
}
|
||||
|
||||
case 'FencedCode': {
|
||||
const marks = node.node.getChildren('CodeMark')
|
||||
const info = node.node.getChild('CodeInfo')
|
||||
const startLine = doc.lineAt(nodeFrom)
|
||||
const endLine = doc.lineAt(nodeTo)
|
||||
if (marks.length >= 1) {
|
||||
const open = marks[0]
|
||||
const end = info ? info.to : open.to
|
||||
if (info) {
|
||||
push({ kind: 'fenceLang', from: open.from, to: end, reveal: true, lang: doc.sliceString(info.from, info.to) })
|
||||
} else {
|
||||
hide(open.from, end)
|
||||
}
|
||||
}
|
||||
if (marks.length >= 2) hide(marks[marks.length - 1].from, marks[marks.length - 1].to)
|
||||
for (let n = startLine.number; n <= endLine.number; n++) {
|
||||
const line = doc.line(n)
|
||||
const cls = [
|
||||
CLS_FENCED,
|
||||
n === startLine.number ? 'cm-md-fenced-first' : '',
|
||||
n === endLine.number ? 'cm-md-fenced-last' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
push({ kind: 'line', from: line.from, to: line.to, line: n, cls, reveal: false })
|
||||
}
|
||||
return false // 代码块内不解析行内语法
|
||||
}
|
||||
|
||||
case 'Table': {
|
||||
const startLine = doc.lineAt(nodeFrom)
|
||||
const endLine = doc.lineAt(nodeTo)
|
||||
for (let n = startLine.number; n <= endLine.number; n++) {
|
||||
const line = doc.line(n)
|
||||
push({ kind: 'line', from: line.from, to: line.to, line: n, cls: CLS_TABLE, reveal: false })
|
||||
}
|
||||
return // 下钻以标记 TableDelimiter
|
||||
}
|
||||
|
||||
case 'TableDelimiter': {
|
||||
// 分隔行(`| --- |`)比普通竖线更淡,用于区分表头分隔
|
||||
const text = doc.sliceString(nodeFrom, nodeTo)
|
||||
push({ kind: 'tableMark', from: nodeFrom, to: nodeTo, reveal: false, delimiter: text.includes('-') })
|
||||
return false
|
||||
}
|
||||
|
||||
case 'TableCell':
|
||||
// 表格为「源码对齐」呈现(决策 E5),单元格内不隐藏语法
|
||||
return false
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return specs
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// 即时渲染的 widget 集合(方案 §5)。
|
||||
//
|
||||
// 硬性约束(§5.5):
|
||||
// - 一律 document.createElement / textContent / setAttribute,禁止 innerHTML;
|
||||
// - widget DOM 不接受任何用户 HTML,只消费语法树里解析出的「文本/白名单 URL」;
|
||||
// - 除任务复选框外均 aria-hidden="true"(它们是语法标记的视觉替代物);
|
||||
// - widget 不改变文档内容与选区语义(任务框例外:它显式改写源文本)。
|
||||
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
|
||||
const BULLET_CHARS = ['•', '◦', '▪']
|
||||
|
||||
/** 无序列表圆点(§5.1):按嵌套深度取 • / ◦ / ▪。 */
|
||||
export class BulletWidget extends WidgetType {
|
||||
constructor(readonly depth: number) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: BulletWidget): boolean {
|
||||
return other.depth === this.depth
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'cm-md-bullet'
|
||||
span.setAttribute('aria-hidden', 'true')
|
||||
span.textContent = BULLET_CHARS[Math.min(this.depth - 1, BULLET_CHARS.length - 1)] ?? BULLET_CHARS[0]
|
||||
return span
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 引用竖线(§5.3):CSS 画 2px 竖线,宽度 = 原标记宽度,嵌套自然叠加。 */
|
||||
export class QuoteBarWidget extends WidgetType {
|
||||
constructor(readonly depth: number) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: QuoteBarWidget): boolean {
|
||||
return other.depth === this.depth
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'cm-md-quote-bar'
|
||||
span.setAttribute('aria-hidden', 'true')
|
||||
span.dataset.depth = String(this.depth)
|
||||
return span
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 水平线(§5.4):整行内容替换为一条贯穿的分隔线。 */
|
||||
export class HrWidget extends WidgetType {
|
||||
eq(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'cm-md-hr'
|
||||
span.setAttribute('aria-hidden', 'true')
|
||||
return span
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 图片(§5.4):src 白名单在 decorations.ts 里已校验,此处只负责 DOM。 */
|
||||
export class ImageWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly src: string,
|
||||
readonly alt: string,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: ImageWidget): boolean {
|
||||
return other.src === this.src && other.alt === this.alt
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const img = document.createElement('img')
|
||||
img.className = 'cm-md-image'
|
||||
img.src = this.src
|
||||
img.alt = this.alt
|
||||
img.loading = 'lazy'
|
||||
// 图片加载失败(如已删除)时保持占位,不破坏行高
|
||||
img.addEventListener('error', () => {
|
||||
img.classList.add('cm-md-image-error')
|
||||
})
|
||||
return img
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 围栏语言标签(§5.4):` ```js ` → 语言名小标签。 */
|
||||
export class FenceLangWidget extends WidgetType {
|
||||
constructor(readonly lang: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: FenceLangWidget): boolean {
|
||||
return other.lang === this.lang
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'cm-md-fence-lang'
|
||||
span.setAttribute('aria-hidden', 'true')
|
||||
span.textContent = this.lang
|
||||
return span
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务复选框(§5.4):点击直接改写源文本 `[ ]` ↔ `[x]`,真相源仍是 Markdown。
|
||||
* 该 widget 不参与 reveal(复选框始终可见可点,与 Typora 一致)。
|
||||
*/
|
||||
export class TaskWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly from: number,
|
||||
readonly to: number,
|
||||
readonly checked: boolean,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: TaskWidget): boolean {
|
||||
return other.from === this.from && other.to === this.to && other.checked === this.checked
|
||||
}
|
||||
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'checkbox'
|
||||
input.className = 'cm-md-task'
|
||||
input.checked = this.checked
|
||||
input.setAttribute('aria-label', this.checked ? '标记为未完成' : '标记为已完成')
|
||||
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
// 阻止 CM 把焦点/光标移走,自己处理取反
|
||||
event.preventDefault()
|
||||
view.dispatch({
|
||||
changes: { from: this.from, to: this.to, insert: this.checked ? '[ ]' : '[x]' },
|
||||
})
|
||||
}
|
||||
input.addEventListener('mousedown', onMouseDown)
|
||||
return input
|
||||
}
|
||||
|
||||
/** 复选框要自己处理事件(默认 true 即「编辑器忽略」),无需覆写。 */
|
||||
ignoreEvent(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
+200
-12
@@ -99,6 +99,20 @@
|
||||
--font-serif-display: var(--font-serif);
|
||||
--tracking-tight: -0.012em;
|
||||
|
||||
/* Markdown 排版契约(方案 §4.6):阅读态 .pn-note-body 与编辑态 .cm-md-* 共用,
|
||||
杜绝两份样式漂移。改这里 = 同时改编辑态与公开页。 */
|
||||
--md-body-size: 15px;
|
||||
--md-body-lh: 1.8;
|
||||
--md-h1-size: 26px;
|
||||
--md-h2-size: 24px;
|
||||
--md-h3-size: 19px;
|
||||
--md-h4-size: 16px;
|
||||
--md-heading-lh: 1.35;
|
||||
--md-heading-font: var(--font-serif);
|
||||
--md-code-size: 13px;
|
||||
--md-table-size: 13.5px;
|
||||
--md-img-radius: var(--radius-md);
|
||||
|
||||
/* 圆角 / 阴影 / 间距基数 */
|
||||
--radius: 0.75rem;
|
||||
--radius-sm: 0.375rem;
|
||||
@@ -804,8 +818,8 @@ button.pn-note-back:hover {
|
||||
============================================================ */
|
||||
.pn-note-body {
|
||||
margin-top: 40px;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
font-size: var(--md-body-size);
|
||||
line-height: var(--md-body-lh);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.pn-note-body > :first-child {
|
||||
@@ -819,23 +833,23 @@ button.pn-note-back:hover {
|
||||
.pn-note-body h3,
|
||||
.pn-note-body h4 {
|
||||
margin: 40px 0 16px;
|
||||
font-family: var(--font-serif);
|
||||
font-family: var(--md-heading-font);
|
||||
font-weight: 400;
|
||||
line-height: 1.35;
|
||||
line-height: var(--md-heading-lh);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.pn-note-body h1 {
|
||||
font-size: 26px;
|
||||
font-size: var(--md-h1-size);
|
||||
}
|
||||
.pn-note-body h2 {
|
||||
font-size: 24px;
|
||||
font-size: var(--md-h2-size);
|
||||
}
|
||||
.pn-note-body h3 {
|
||||
font-size: 19px;
|
||||
font-size: var(--md-h3-size);
|
||||
}
|
||||
.pn-note-body h4 {
|
||||
font-size: 16px;
|
||||
font-size: var(--md-h4-size);
|
||||
}
|
||||
.pn-note-body blockquote {
|
||||
margin: 32px 0;
|
||||
@@ -851,7 +865,7 @@ button.pn-note-back:hover {
|
||||
}
|
||||
.pn-note-body code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-size: var(--md-code-size);
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
@@ -870,7 +884,7 @@ button.pn-note-back:hover {
|
||||
display: block;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-size: var(--md-code-size);
|
||||
line-height: 1.7;
|
||||
color: var(--foreground);
|
||||
white-space: pre;
|
||||
@@ -892,7 +906,7 @@ button.pn-note-back:hover {
|
||||
}
|
||||
.pn-note-body img {
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--md-img-radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.pn-note-body hr {
|
||||
@@ -904,7 +918,7 @@ button.pn-note-back:hover {
|
||||
width: 100%;
|
||||
margin: 24px 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 13.5px;
|
||||
font-size: var(--md-table-size);
|
||||
}
|
||||
.pn-note-body th,
|
||||
.pn-note-body td {
|
||||
@@ -1975,6 +1989,180 @@ button.pn-ae-tool svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
即时渲染(Typora 式)装饰层 —— 与 .pn-note-body 共用 --md-* 契约
|
||||
仅 .pn-cm-live 包裹时生效;源码模式下整块不生效(§4.7 逃生舱)
|
||||
============================================================ */
|
||||
.pn-cm-live .cm-scroller {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.pn-cm-live .cm-content {
|
||||
font-size: var(--md-body-size);
|
||||
line-height: var(--md-body-lh);
|
||||
padding: 8px 2px 40px;
|
||||
}
|
||||
.pn-cm-live .cm-editor {
|
||||
font-size: var(--md-body-size);
|
||||
}
|
||||
/* Typora 无行号、无活动行高亮(§4.6-4) */
|
||||
.pn-cm-live .cm-gutters {
|
||||
display: none;
|
||||
}
|
||||
.pn-cm-live .cm-activeLine {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* 行内:对齐 .pn-note-body(§4.3) */
|
||||
.cm-md-strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.cm-md-em {
|
||||
font-style: italic;
|
||||
}
|
||||
.cm-md-del {
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.cm-md-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--md-code-size);
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.cm-md-link {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* 块级:标题(§4.4) */
|
||||
.cm-md-h1,
|
||||
.cm-md-h2,
|
||||
.cm-md-h3,
|
||||
.cm-md-h4,
|
||||
.cm-md-h5,
|
||||
.cm-md-h6 {
|
||||
font-family: var(--md-heading-font);
|
||||
font-weight: 400;
|
||||
line-height: var(--md-heading-lh);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.cm-md-h1 {
|
||||
font-size: var(--md-h1-size);
|
||||
}
|
||||
.cm-md-h2 {
|
||||
font-size: var(--md-h2-size);
|
||||
}
|
||||
.cm-md-h3 {
|
||||
font-size: var(--md-h3-size);
|
||||
}
|
||||
.cm-md-h4 {
|
||||
font-size: var(--md-h4-size);
|
||||
}
|
||||
.cm-md-h5 {
|
||||
font-size: var(--md-body-size);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cm-md-h6 {
|
||||
font-size: var(--md-body-size);
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* 块级:引用行(竖线由 widget 承接,§5.3) */
|
||||
.cm-md-quote {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.cm-md-quote-bar {
|
||||
display: inline-block;
|
||||
width: 0.6em;
|
||||
height: 1.05em;
|
||||
margin-right: 8px;
|
||||
border-left: 2px solid var(--border-strong);
|
||||
vertical-align: -0.12em;
|
||||
}
|
||||
|
||||
/* 块级:无序列表圆点(§5.1) */
|
||||
.cm-md-bullet {
|
||||
display: inline-block;
|
||||
width: 1.15em;
|
||||
text-align: center;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.cm-md-ol-mark {
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 块级:代码围栏(§4.4) */
|
||||
.cm-md-fenced {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--md-code-size);
|
||||
line-height: 1.7;
|
||||
background: var(--muted);
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
.cm-md-fenced-first {
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-top-right-radius: var(--radius-md);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.cm-md-fenced-last {
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.cm-md-fence-lang {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* 块级:表格(源码对齐,决策 E5) */
|
||||
.cm-md-table {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--md-table-size);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cm-md-table-mark {
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.cm-md-table-delim {
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
/* 块级:图片 / 水平线 / 任务框(§5.4) */
|
||||
.cm-md-image {
|
||||
max-width: 100%;
|
||||
border-radius: var(--md-img-radius);
|
||||
border: 1px solid var(--border);
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.cm-md-image-error {
|
||||
display: inline-block;
|
||||
min-width: 140px;
|
||||
min-height: 42px;
|
||||
background: var(--muted);
|
||||
}
|
||||
.cm-md-hr {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
border-top: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.cm-md-task {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0 2px 0 0;
|
||||
vertical-align: -2px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 动效偏好 ─────────────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user