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:
2026-09-11 01:03:02 +08:00
parent 9b0aff6f9f
commit 5c5ab3dfc3
7 changed files with 1062 additions and 16 deletions
+200 -12
View File
@@ -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) {
*,