From 5c5ab3dfc3f0b46b9a13214467ae8f9207438401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A0=81=E5=86=9C=E9=98=BF=E6=A5=A0?= Date: Fri, 11 Sep 2026 01:03:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=96=B0=E5=A2=9E=20Markdown=20?= =?UTF-8?q?=E5=8D=B3=E6=97=B6=E6=B8=B2=E6=9F=93=E8=A3=85=E9=A5=B0=E5=B1=82?= =?UTF-8?q?=EF=BC=88Typora=20=E5=BC=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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。 --- web/src/components/Editor.tsx | 28 ++- web/src/editor/decorations.ts | 175 +++++++++++++++++++ web/src/editor/livePreview.ts | 70 ++++++++ web/src/editor/reveal.ts | 106 ++++++++++++ web/src/editor/syntax.ts | 317 ++++++++++++++++++++++++++++++++++ web/src/editor/widgets.ts | 170 ++++++++++++++++++ web/src/index.css | 212 +++++++++++++++++++++-- 7 files changed, 1062 insertions(+), 16 deletions(-) create mode 100644 web/src/editor/decorations.ts create mode 100644 web/src/editor/livePreview.ts create mode 100644 web/src/editor/reveal.ts create mode 100644 web/src/editor/syntax.ts create mode 100644 web/src/editor/widgets.ts diff --git a/web/src/components/Editor.tsx b/web/src/components/Editor.tsx index 51c4491..5fa29d6 100644 --- a/web/src/components/Editor.tsx +++ b/web/src/components/Editor.tsx @@ -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,魔数校验)→ 光标处插入 - * `![描述](/api/images/{id})`。 + * `![描述](/api/images/{id})`,插入后由装饰层立刻渲染为图片。 + * + * 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(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 (
{ 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) => { const files = event.clipboardData?.files diff --git a/web/src/editor/decorations.ts b/web/src/editor/decorations.ts new file mode 100644 index 0000000..6642608 --- /dev/null +++ b/web/src/editor/decorations.ts @@ -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 被丢弃 → 退回显示源码文本,不产出 。 + */ +export const IMAGE_SRC_ALLOW = /^(?:\/api\/images\/|data:image\/(?:png|jpe?g|gif|webp|avif);base64,)/i + +export interface LiveDecorations { + decorations: DecorationSet + atomic: RangeSet +} + +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() + 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[] = [] + const atomic: Range[] = [] + + 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), + } +} diff --git a/web/src/editor/livePreview.ts b/web/src/editor/livePreview.ts new file mode 100644 index 0000000..f02e1e0 --- /dev/null +++ b/web/src/editor/livePreview.ts @@ -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 = 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] +} diff --git a/web/src/editor/reveal.ts b/web/src/editor/reveal.ts new file mode 100644 index 0000000..f8d0860 --- /dev/null +++ b/web/src/editor/reveal.ts @@ -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() + 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() + 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))) +} diff --git a/web/src/editor/syntax.ts b/web/src/editor/syntax.ts new file mode 100644 index 0000000..e6af58c --- /dev/null +++ b/web/src/editor/syntax.ts @@ -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 不产出 ,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 +} diff --git a/web/src/editor/widgets.ts b/web/src/editor/widgets.ts new file mode 100644 index 0000000..28d99f3 --- /dev/null +++ b/web/src/editor/widgets.ts @@ -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 + } +} diff --git a/web/src/index.css b/web/src/index.css index dbba803..a320a70 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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) { *,