v61:笔记补齐成熟软件的块(Markdown 引擎加表格与任务清单,命令表扩全)

用户反馈:「笔记应该增加更多的块:段落、清单、图片、代码……去查成熟笔记
软件都分哪些块,我们都应该支持。」

对照 Notion / flomo / 思源 / Logseq 的块清单逐项核对(块类型仍只有
段落/图片两种——v29 用户拍板"清单并入 Markdown"),补齐的是
**Markdown 引擎与编辑器到 GFM 关键子集**:

app/Markdown.php
- GFM 表格:表头+分隔行两行联合识别(正文里有竖线不会误判成表格);
  `:---`/`---:`/`:-:` 左右中对齐;列数向表头看齐(多丢少补,格子不错位);
  单元格走 inline()(先 htmlspecialchars,`<script>` 只会被画成字面文本);
  段落收集遇到"紧跟正文的表格"会断开(GFM 不强制空行)
- GFM 任务清单:`- [ ]`/`- [x]` 渲染成**只读**勾选框(笔记不是任务系统,
  能点却不保存是假交互;要勾就改源码里的 x)
- 摘要清洗:表格分隔行/竖线/任务方括号不进列表卡片

assets/js/note_editor.js
- 命令表补齐:H1/H2/H3、有序列表、待办清单、删除线、行内代码、表格模板、
  分割线(工具条与斜线菜单仍是同一份表)
- 🔴 修真 no-op:v29 的"逐行套"写着逐行实际原样返回,选中三行点列表只给
  第一行挂前缀;现在行首记号型命令逐行套(空行跳过)
- 表格模板光标落在第一行数据的第一格,插完可直接打字

templates/notes/form.php:工具条 8→14 键(高频);全部命令斜线 `/` 可调
assets/css/app.css:.md-table / .md-task 样式(跟主题变量,不写死色)

刻意不做:代码语法高亮(引库=几 MB,用户当年已拍板轻量)、折叠块
(与"逐段编辑"打架,flomo 也没有)、页内子页/数据库(家书没有那个形态)。

验证:_fb_v61_ui.py 真机 30/0(含 XSS 夹具、紧跟正文断段、实时预览同源、
逐段编辑接口、v28 老语法零回退、历史 list 块不丢);
_b61_falsify.py 8 处注入 8/8 全红在预期断言;回归 b29 38/0、v55 30/0、v60 28/0。
This commit is contained in:
2026-10-18 11:22:34 +08:00
parent 54ab9f825b
commit 4c63e42091
4 changed files with 189 additions and 18 deletions
+115 -3
View File
@@ -13,6 +13,16 @@
* 引用 / 链接 / 分割线),不是完整 CommonMark —— 有界的东西自己写**更可靠** * 引用 / 链接 / 分割线),不是完整 CommonMark —— 有界的东西自己写**更可靠**
* (不会在某次依赖升级后行为漂移)。 * (不会在某次依赖升级后行为漂移)。
* *
* 🔴 v61(用户 2026-10-18 第 2 条「笔记要支持成熟软件的那些块」):
* 按 Notion / flomo / Obsidian 的块清单逐项核对后,**只补 Markdown 原生就能表达的**:
* · GFM 表格(| 列 | 列 | + 分隔行,支持 :-: 对齐)
* · GFM 任务清单(- [ ] / - [x],勾选框渲染成**只读** checkbox)
* (行内代码 / 围栏代码 / 引用 / 分割线 / 多级列表 / 标题 / 删除线 / 链接 v28 起就有)
* 代码块语言标注仍然只作 class 记号(引高亮库 = 又一坨几百 KB,与"轻量"冲突,
* 用户当时已拍板;这里不偷偷改道)。
* "折叠块(toggle)"刻意不做:阅读页有逐段编辑、块列表可上移下移,折叠会把
* 内容藏进用户找不到地方改的界面里;flomo 也没有它。要做要整个交互重新设计。
*
* 🔴 安全:**不允许任何原始 HTML 通过**。所有输入先 `htmlspecialchars`, * 🔴 安全:**不允许任何原始 HTML 通过**。所有输入先 `htmlspecialchars`,
* 再按我们自己的语法加标记。这样 `<script>` 只会被画成一行字, * 再按我们自己的语法加标记。这样 `<script>` 只会被画成一行字,
* 而不是被执行 —— 富文本编辑器最常见的 XSS 来源就是"允许内嵌 HTML"。 * 而不是被执行 —— 富文本编辑器最常见的 XSS 来源就是"允许内嵌 HTML"。
@@ -77,6 +87,19 @@ final class Markdown
continue; continue;
} }
// ---- 表格(GFM:表头行 + `---` 分隔行 + 数据行)----------------
// 🔴 v61(用户 2026-10-18:笔记要支持成熟软件的块)。
// 识别条件**必须两行一起看**:本行像 `|a|b|` 且**下一行是分隔行**
// (只含 - : | 空格且至少一个 -)。只看本行会把"正文里恰好有竖线
// 的一行"当表格(比如「3 | 2 | 1」),假表格比没表格更糟。
if (str_contains($line, '|') && $i + 1 < $n
&& preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lines[$i + 1])
&& str_contains($lines[$i + 1], '-')) {
[$html, $i] = self::table($lines, $i, $n);
$out[] = $html;
continue;
}
// ---- 引用 > (支持多层,用前缀数决定层级)---------------------- // ---- 引用 > (支持多层,用前缀数决定层级)----------------------
if (preg_match('/^\s*>\s?(.*)$/', $line)) { if (preg_match('/^\s*>\s?(.*)$/', $line)) {
$buf = []; $buf = [];
@@ -112,7 +135,12 @@ final class Markdown
$buf = [$line]; $buf = [$line];
$i++; $i++;
while ($i < $n && trim($lines[$i]) !== '' while ($i < $n && trim($lines[$i]) !== ''
&& !preg_match('/^\s*(#{1,6}\s|>|```|~~~|[-*+]\s|\d+[.)]\s)/', $lines[$i])) { && !preg_match('/^\s*(#{1,6}\s|>|```|~~~|[-*+]\s|\d+[.)]\s)/', $lines[$i])
// 🔴 v61:正文里出现表格也要**断开段落**,否则表格头会被吸进
// 上一段(GFM 允许表格紧跟正文,不强制空行)。
&& !(str_contains($lines[$i], '|') && $i + 1 < $n
&& preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lines[$i + 1])
&& str_contains($lines[$i + 1], '-'))) {
$buf[] = $lines[$i]; $buf[] = $lines[$i];
$i++; $i++;
} }
@@ -176,7 +204,19 @@ final class Markdown
$html .= '</li>'; $html .= '</li>';
} }
$html .= '<li class="md-li">' . self::inline(trim($content)); // 🔴 v61:任务清单(GFM `- [ ]` / `- [x]`)。
// checkbox 渲染成**只读**(disabled)—— 笔记不是任务系统,
// "点一下勾上却没保存"是最糟糕的体验;要勾就去改源码里的 [x]。
$body = trim($content);
if (!$ordered && preg_match('/^\[( |x|X)\]\s+(.*)$/s', $body, $tm)) {
$on = ($tm[1] !== ' ');
$html .= '<li class="md-li md-task">'
. '<input type="checkbox" class="md-check" disabled'
. ($on ? ' checked' : '') . '>'
. '<span class="md-task-text">' . self::inline($tm[2]) . '</span>';
} else {
$html .= '<li class="md-li">' . self::inline($body);
}
$i++; $i++;
} }
// 收尾:把还开着的层全部关掉(每层一个 </li> + </ul>) // 收尾:把还开着的层全部关掉(每层一个 </li> + </ul>)
@@ -187,6 +227,68 @@ final class Markdown
return [$html, $i]; return [$html, $i];
} }
/**
* 解析一段 GFM 表格:本行是表头、下一行是 `---` 分隔行(已在调用处确认)。
*
* 🔴 v61。要点:
* · **先 htmlspecialchars**(走 `inline()`)再把内容塞进单元格 —— 单元格里的
* `<script>` 只会当字面文本,绝不会被当 HTML 执行(这是全站 Markdown 的底线)。
* · 对齐取自分隔行:`:---` 左、`---:` 右、`:---:` 居中,翻成 inline style。
* · 每行的列数**向表头看齐**(多出来的丢掉、少了补空)—— 否则某行漏一列,
* 整张表的格子会错位(用户看到的"表格烂了"多半就是这么来的)。
* · 分隔行之前那行表头单独渲染成 `<th>`;数据行渲染成 `<td>`。
*
* @return array{0:string,1:int} [html, 新行号]
*/
private static function table(array $lines, int $i, int $n): array
{
$split = static function (string $row): array {
// 去掉首尾竖线,再按 | 切;每格去首尾空格(内容里的 ** 等交给 inline)
$row = trim($row);
$row = preg_replace('/^\|/', '', $row) ?? $row;
$row = preg_replace('/\|$/', '', $row) ?? $row;
return array_map('trim', explode('|', $row));
};
$header = $split($lines[$i]);
$aligns = $split($lines[$i + 1]);
$cols = count($header);
// 对齐数组补齐到列数,缺的按左对齐
for ($k = 0; $k < $cols; $k++) {
if (!isset($aligns[$k])) { $aligns[$k] = '---'; }
}
$styleOf = static function (string $sep): string {
$l = str_starts_with(trim($sep), ':');
$r = str_ends_with(trim($sep), ':');
if ($l && $r) { return ' style="text-align:center"'; }
if ($r) { return ' style="text-align:right"'; }
if ($l) { return ' style="text-align:left"'; }
return '';
};
$html = '<table class="md-table"><thead><tr>';
for ($c = 0; $c < $cols; $c++) {
$html .= '<th class="md-th"' . $styleOf($aligns[$c]) . '>'
. self::inline((string)$header[$c]) . '</th>';
}
$html .= '</tr></thead><tbody>';
$i += 2; // 跳过表头与分隔行
while ($i < $n && str_contains($lines[$i], '|') && trim($lines[$i]) !== '') {
$cells = $split($lines[$i]);
$html .= '<tr class="md-tr">';
for ($c = 0; $c < $cols; $c++) {
$val = (string)($cells[$c] ?? '');
$html .= '<td class="md-td"' . $styleOf($aligns[$c]) . '>'
. self::inline($val) . '</td>';
}
$html .= '</tr>';
$i++;
}
$html .= '</tbody></table>';
return [$html, $i];
}
/** /**
* 行内语法:`code` → 粗体 → 斜体 → 链接 → 删除线。 * 行内语法:`code` → 粗体 → 斜体 → 链接 → 删除线。
* *
@@ -263,7 +365,17 @@ final class Markdown
$s = preg_replace('/^\s*(```+|~~~+).*$/m', '', $s) ?? $s; $s = preg_replace('/^\s*(```+|~~~+).*$/m', '', $s) ?? $s;
$s = preg_replace('/^\s*#{1,6}\s*/m', '', $s) ?? $s; $s = preg_replace('/^\s*#{1,6}\s*/m', '', $s) ?? $s;
$s = preg_replace('/^\s*>\s?/m', '', $s) ?? $s; $s = preg_replace('/^\s*>\s?/m', '', $s) ?? $s;
$s = preg_replace('/^\s*(?:[-*+]|\d+[.)])\s+/m', '', $s) ?? $s; // 🔴 v61:表格与任务清单的记号也要去,且**顺序要紧**:
// ① 先整行丢掉表格的"分隔行"(只含 : - | 空格的那种)—— 摘要里出现
// `--- ---` 是纯粹的噪音;
// ② 再剥任务清单的方括号 —— **必须在剥 `- ` 之前**:否则先把项目符
// 吃掉、剩下 `[ ]` 就匹配不上任务模式了;
// ③ 最后才是普通列表符与表格竖线(竖线换空格,格与格之间还能读)。
$s = preg_replace('/^\s*\|?[\s:|-]+\|?\s*$/m', '', $s) ?? $s; // ①(含 - ,只吞分隔行)
$s = preg_replace('/^(\s*)(?:[-*+]|\d+[.)])\s+\[[ xX]\]\s+/m', '$1', $s) ?? $s; // ②
$s = preg_replace('/^\s*(?:[-*+]|\d+[.)])\s+/m', '', $s) ?? $s; // ③
$s = preg_replace('/^\||\|$/m', '', $s) ?? $s;
$s = str_replace('|', ' ', $s);
// 去掉行内标记 // 去掉行内标记
$s = preg_replace('/\*\*(.+?)\*\*/s', '$1', $s) ?? $s; $s = preg_replace('/\*\*(.+?)\*\*/s', '$1', $s) ?? $s;
$s = preg_replace('/\*(.+?)\*/s', '$1', $s) ?? $s; $s = preg_replace('/\*(.+?)\*/s', '$1', $s) ?? $s;
+15
View File
@@ -5129,6 +5129,21 @@ h5.md-h, h6.md-h { font-size: 14px; }
} }
.md-link { color: var(--brand); text-decoration: underline; } .md-link { color: var(--brand); text-decoration: underline; }
.md-hr { border: 0; border-top: 1px solid var(--line); margin: 10px 0; } .md-hr { border: 0; border-top: 1px solid var(--line); margin: 10px 0; }
/* 🔴 v61(用户 2026-10-18):表格与任务清单 —— 与 Notion/GFM 同款的块形态。
表格必须 overflow-x 包一层的可能交给外层(笔记卡有 padding,窄屏横滚更稳);
边框用 --line,深色主题下自动跟色,不写死颜色。 */
.md-table {
border-collapse: collapse; margin: 6px 0 10px; width: auto; max-width: 100%;
font-size: 13.5px;
}
.md-table .md-th, .md-table .md-td {
border: 1px solid var(--line); padding: 4px 10px; text-align: left;
vertical-align: top;
}
.md-table .md-th { background: var(--line-2); font-weight: 600; }
/* 任务清单:去掉圆点,用只读 checkbox 顶位(ul 的 padding 提供缩进) */
.md-task { list-style: none; }
.md-task .md-check { margin: 0 6px 0 0; accent-color: var(--brand); vertical-align: -2px; }
/* ---- 编辑页底部附件区 ---- */ /* ---- 编辑页底部附件区 ---- */
.note-files { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; } .note-files { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
+42 -10
View File
@@ -41,6 +41,7 @@
|| document.body.getAttribute('data-media-base') || document.body.getAttribute('data-media-base')
|| 'media.php?k='); || 'media.php?k=');
// ---------------------------------------------------------------------
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// 🔴 v29 第 7 条:Markdown 命令表(**工具条与斜线菜单共用这一份**) // 🔴 v29 第 7 条:Markdown 命令表(**工具条与斜线菜单共用这一份**)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -48,13 +49,29 @@
// ⚠️ 为什么只有一份:如果工具条和斜线各写一套,迟早出现"按钮能插、 // ⚠️ 为什么只有一份:如果工具条和斜线各写一套,迟早出现"按钮能插、
// 斜线插不了"或者"两处语法不一样"(这正是用户抱怨的"要记住所有命令")。 // 斜线插不了"或者"两处语法不一样"(这正是用户抱怨的"要记住所有命令")。
// 每个命令:wrap = 用来包住选中文本(含 %s),或 insert = 无选中时插的模板。 // 每个命令:wrap = 用来包住选中文本(含 %s),或 insert = 无选中时插的模板。
// 🔴 v61(用户 2026-10-18):按成熟笔记软件的块清单补齐 ——
// 标题一/二/三、有序列表、任务清单、表格、分割线。
// ⚠️ 这仍然是**往段落块里插 Markdown 记号**(不是新增块类型):
// v29 用户拍板"清单并入 markdown,块只留 段落/图片",服务端白名单不变。
// line:true = "行首记号"型命令 —— 选中多行时**逐行**套(选中三行点「列表」
// 应得到三个列表项;只套第一行是老实现的缺陷,这次一并修)。
var CMDS = { var CMDS = {
b: { word: '粗体', keys: ['bold', 'b'], wrap: '**%s**', tip: '加粗' }, b: { word: '粗体', keys: ['bold', 'b'], wrap: '**%s**', tip: '加粗' },
i: { word: '斜体', keys: ['italic', 'i'], wrap: '*%s*', tip: '斜体' }, i: { word: '斜体', keys: ['italic', 'i'], wrap: '*%s*', tip: '斜体' },
h2: { word: '标题', keys: ['h2', 'head'], wrap: '## %s', tip: '小标题' }, s: { word: '删除线', keys: ['strike', 's'], wrap: '~~%s~~', tip: '删除线' },
ul: { word: '列表', keys: ['ul', 'list'], wrap: '- %s', tip: '列表项' }, h1: { word: '标题一', keys: ['h1', 'title'], wrap: '# %s', line: true, tip: '一级标题' },
quote: { word: '引用', keys: ['quote', 'q'], wrap: '> %s', tip: '引用' }, h2: { word: '标题二', keys: ['h2', 'head'], wrap: '## %s', line: true, tip: '小标题' },
code: { word: '代码', keys: ['code'], wrap: '```\n%s\n```', tip: '代码块' }, h3: { word: '标题三', keys: ['h3'], wrap: '### %s', line: true, tip: '三级标题' },
ul: { word: '无序列表', keys: ['ul', 'list', 'bullet'], wrap: '- %s', line: true, tip: '项目符号列表' },
ol: { word: '有序列表', keys: ['ol', 'number', 'num'], wrap: '1. %s', line: true, tip: '编号列表' },
todo: { word: '待办清单', keys: ['todo', 'task', 'check'], wrap: '- [ ] %s', line: true, tip: '任务清单([x] 已完成)' },
quote: { word: '引用', keys: ['quote', 'q'], wrap: '> %s', line: true, tip: '引用' },
code: { word: '代码块', keys: ['code', 'codeblock'], wrap: '```\n%s\n```', tip: '围栏代码块' },
icode: { word: '行内代码', keys: ['inline', 'icode'], wrap: '`%s`', tip: '行内代码' },
table: { word: '表格', keys: ['table', 'tf'],
// %s = 光标落点(第一行数据的第一格),插完可以直接打字
insert: '| 列一 | 列二 | 列三 |\n| --- | --- | --- |\n| %s | | |\n', tip: '插入表格' },
hr: { word: '分割线', keys: ['hr', 'divider', 'split'], insert: '\n---\n', tip: '横向分割线' },
link: { word: '链接', keys: ['link', 'url'], wrap: '[%s](https://)', tip: '链接' }, link: { word: '链接', keys: ['link', 'url'], wrap: '[%s](https://)', tip: '链接' },
img: { word: '图片', keys: ['img'], insert: '+ 图片(点左侧「+ 图片」按钮选文件)' } img: { word: '图片', keys: ['img'], insert: '+ 图片(点左侧「+ 图片」按钮选文件)' }
}; };
@@ -72,14 +89,29 @@
if (c.insert) { if (c.insert) {
out = c.insert; out = c.insert;
ta.value = v.slice(0, s) + out + v.slice(e); ta.value = v.slice(0, s) + out + v.slice(e);
// 🔴 v61:insert 里有 `%s` 的(表格),光标落在**第一个空格子**而不是整段末尾
var mark = out.indexOf('%s');
if (mark >= 0) {
ta.value = v.slice(0, s) + out.replace('%s', '') + v.slice(e);
ta.selectionStart = ta.selectionEnd = s + mark;
} else {
ta.selectionStart = ta.selectionEnd = s + out.length; ta.selectionStart = ta.selectionEnd = s + out.length;
}
} else if (sel !== '' && c.line) {
// 🔴 v61:**行首记号型**命令 + 有选中 → **逐行**套(选中三行点「列表」
// 得到三个列表项)。v29 的老实现这里写着"逐行"、实际是个原样返回的
// no-op(`map(function (l) { return l; })`),所以选中三行只会给
// 第一行挂上前缀 —— 这次把它做成真的。
// ⚠️ 只跳过空行(空行挂 `- ` 会渲染出一个空列表项)。
out = sel.split('\n').map(function (l) {
return l.trim() === '' ? l : c.wrap.replace('%s', l);
}).join('\n');
ta.value = v.slice(0, s) + out + v.slice(e);
ta.selectionStart = s;
ta.selectionEnd = s + out.length;
} else if (sel !== '') { } else if (sel !== '') {
// 有选中 → 包住它(多行时**逐行**套,这样"选中三行 → 列表"会得到三个列表项) // 行内型命令(粗斜体/行内码/链接):整段包住;跨行也包(渲染层支持)
out = c.wrap.indexOf('%s') >= 0 out = c.wrap.indexOf('%s') >= 0 ? c.wrap.replace('%s', sel) : sel + c.wrap;
? c.wrap.replace('%s', sel.indexOf('\n') >= 0
? sel.split('\n').map(function (l) { return l; }).join('\n')
: sel)
: sel + c.wrap;
ta.value = v.slice(0, s) + out + v.slice(e); ta.value = v.slice(0, s) + out + v.slice(e);
ta.selectionStart = s; ta.selectionStart = s;
ta.selectionEnd = s + out.length; ta.selectionEnd = s + out.length;
+16 -4
View File
@@ -290,15 +290,27 @@ $imgParts = static function (string $c): array {
<?php /* 🔴 v29 第 7 条:**工具条**(点一下插到光标处)+ 斜线命令提示。 <?php /* 🔴 v29 第 7 条:**工具条**(点一下插到光标处)+ 斜线命令提示。
工具条按钮与斜线菜单是**同一份命令表**(见 note_editor.js 的 CMDS), 工具条按钮与斜线菜单是**同一份命令表**(见 note_editor.js 的 CMDS),
避免"按钮能插、斜线插不了"这种两套实现。 */ ?> 避免"按钮能插、斜线插不了"这种两套实现。
🔴 v61(用户 2026-10-18):按成熟软件的块清单补齐 ——
标题一/二/三、有序列表、待办清单、删除线、行内代码、表格、分割线。
工具条只摆**高频**的;全部命令(含标题三、行内代码)斜线 `/` 都能调出。 */ ?>
<div class="note-cmdbar" data-cmdbar> <div class="note-cmdbar" data-cmdbar>
<button type="button" class="cmd" data-cmd="b" title="粗体 **文字**"><b>B</b></button> <button type="button" class="cmd" data-cmd="b" title="粗体 **文字**"><b>B</b></button>
<button type="button" class="cmd" data-cmd="i" title="斜体 *文字*"><i>I</i></button> <button type="button" class="cmd" data-cmd="i" title="斜体 *文字*"><i>I</i></button>
<button type="button" class="cmd" data-cmd="s" title="删除线 ~~文字~~"><s>S</s></button>
<span class="cmd-sep"></span> <span class="cmd-sep"></span>
<button type="button" class="cmd" data-cmd="h2" title="小标题 ## 文字">H</button> <button type="button" class="cmd" data-cmd="h1" title="标题一 # 文字">H1</button>
<button type="button" class="cmd" data-cmd="ul" title="列表 - 项目">≡</button> <button type="button" class="cmd" data-cmd="h2" title="标题二 ## 文字">H2</button>
<button type="button" class="cmd" data-cmd="h3" title="标题三 ### 文字(全部命令见斜线)">H3</button>
<span class="cmd-sep"></span>
<button type="button" class="cmd" data-cmd="ul" title="无序列表 - 项目">≡</button>
<button type="button" class="cmd" data-cmd="ol" title="有序列表 1. 项目">1.</button>
<button type="button" class="cmd" data-cmd="todo" title="待办清单 - [ ] 项目">☑</button>
<button type="button" class="cmd" data-cmd="quote" title="引用 > 文字">❝</button> <button type="button" class="cmd" data-cmd="quote" title="引用 > 文字">❝</button>
<button type="button" class="cmd" data-cmd="code" title="代码 ```">&#60;/&#62;</button> <span class="cmd-sep"></span>
<button type="button" class="cmd" data-cmd="code" title="代码块 ```">&#60;/&#62;</button>
<button type="button" class="cmd" data-cmd="table" title="表格 | 列 | 列 |">▦</button>
<button type="button" class="cmd" data-cmd="hr" title="分割线 ---">—</button>
<button type="button" class="cmd" data-cmd="link" title="链接 [文字](地址)">🔗</button> <button type="button" class="cmd" data-cmd="link" title="链接 [文字](地址)">🔗</button>
<span class="cmd-sep"></span> <span class="cmd-sep"></span>
<span class="small muted">行首打 <code>/</code> 也能选命令</span> <span class="small muted">行首打 <code>/</code> 也能选命令</span>