Skip to content

Commit 8789edb

Browse files
committed
feat(editor): 标题切换语义与 remark-breaks 换行一致(v0.15 ①⑥)
1 parent 51896a7 commit 8789edb

8 files changed

Lines changed: 223 additions & 49 deletions

File tree

‎app/package-lock.json‎

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"react-dom": "^19.1.0",
2323
"react-markdown": "^10.1.0",
2424
"rehype-katex": "^7.0.1",
25+
"remark-breaks": "^4.0.0",
2526
"remark-gfm": "^4.0.1",
2627
"remark-math": "^6.0.0"
2728
},

‎app/src/commands/headingCommand.test.ts‎

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,23 @@ describe("computeHeadingChanges(纯逻辑)", () => {
4141
expect(r.cursor).toBe(4 + 2 + 2); // 末行行首 + 首行插入 2 + 末行插入 2
4242
});
4343

44-
it("已是标题的行跳过不叠加", () => {
44+
it("同级别标题行剥除标记(切回普通段)", () => {
45+
const r = computeHeadingChanges(Text.of(["### 已有"]), 0, 0, 3);
46+
expect(r.changes).toEqual([{ from: 0, to: 4, insert: "" }]);
47+
expect(r.cursor).toBe(0);
48+
});
49+
50+
it("不同级别标题行换级(剥旧标记+插新标记)", () => {
51+
const r = computeHeadingChanges(Text.of(["## 已有"]), 0, 0, 1);
52+
expect(r.changes).toEqual([{ from: 0, to: 3, insert: "# " }]);
53+
expect(r.cursor).toBe(2);
54+
});
55+
56+
it("混合选区:普通段插入 / 同级别剥除 / 不同级别换级", () => {
4557
const r = computeHeadingChanges(doc, 0, doc.length, 2);
46-
// 第三行是标题跳过;其余三行插入
47-
expect(r.changes).toHaveLength(3);
48-
expect(r.changes.some((c) => c.insert === "### ")).toBe(false);
58+
expect(r.changes).toHaveLength(4);
59+
// 第三行 "### 已有标题" 换级为 "## "(替换 [行首, 行首+4))
60+
expect(r.changes[2]).toEqual({ from: doc.line(3).from, to: doc.line(3).from + 4, insert: "## " });
4961
});
5062

5163
it("H6 边界:level 夹取 1..6", () => {
@@ -61,10 +73,13 @@ describe("computeHeadingChanges(纯逻辑)", () => {
6173
expect(r.cursor).toBe(2);
6274
});
6375

64-
it("全选已全是标题 → 无变更", () => {
65-
const allHeadings = Text.of(["# a", "## b"]);
66-
const r = computeHeadingChanges(allHeadings, 0, allHeadings.length, 3);
67-
expect(r.changes).toHaveLength(0);
76+
it("全选全部同级别 → 全部剥除", () => {
77+
const allHeadings = Text.of(["# a", "# b"]);
78+
const r = computeHeadingChanges(allHeadings, 0, allHeadings.length, 1);
79+
expect(r.changes).toEqual([
80+
{ from: 0, to: 2, insert: "" },
81+
{ from: 4, to: 6, insert: "" },
82+
]);
6883
});
6984

7085
it("CRLF 行尾文档正常处理", () => {
@@ -87,10 +102,20 @@ describe("headingCommand(真实 Command)", () => {
87102
view.destroy();
88103
});
89104

90-
it("已是标题 → 返回 false 无变化", () => {
105+
it("同级别标题再点 → 取消回普通段", () => {
91106
const { view, docOf } = makeView("## 已有");
92-
expect(headingCommand(2)(view)).toBe(false);
93-
expect(docOf()).toBe("## 已有");
107+
expect(headingCommand(2)(view)).toBe(true);
108+
expect(docOf()).toBe("已有");
109+
// 剥除后光标落新行行首
110+
expect(view.state.selection.main.anchor).toBe(0);
111+
view.destroy();
112+
});
113+
114+
it("混合行选区:H1 行取消 / H2 行换级", () => {
115+
const { view, docOf } = makeView("# a\n## b\nc");
116+
view.dispatch({ selection: { anchor: 0, head: 7 } }); // 选 "# a\n## b"(到 'b' 前)
117+
headingCommand(1)(view);
118+
expect(docOf()).toBe("a\n# b\nc");
94119
view.destroy();
95120
});
96121

‎app/src/commands/headingCommand.ts‎

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
/**
2-
* headingCommand — CodeMirror 标题转换命令(v0.14 子项目 A)。
2+
* headingCommand — CodeMirror 标题转换命令(v0.14 子项目 A / v0.15 切换语义)。
33
*
44
* @ai-context: 输入驱动(`## ` 即时生效)之外,工具栏按钮与 Ctrl+1/2/3 快捷键需要
5-
* 显式命令:对单行/多行选区逐行行首设置 `#`×level;已是标题的行跳过
6-
* (重复按不叠加);光标落末行标记后。不复用 markdownEdit.ts 的
7-
* headingLines(字符串+offset 模型)——CM 原生 Text.lines + lineAt
8-
* 原生支持 CRLF 边界(spec §4.3)。computeHeadingChanges 纯逻辑可单测。
5+
* 显式命令:对单行/多行选区逐行 set-or-toggle——行已精确等于所点级别
6+
* 则剥掉 `#×level+空格` 切回普通段(v0.15 修复"点 H1 无取消"),
7+
* 其他级别/普通段一律置为该级别(原"已是标题跳过"语义废弃——
8+
* 按钮高亮态的取消承诺与跳过行为冲突;Overleaf/Obsidian 同款)。
9+
* 不复用 markdownEdit.ts 的 headingLines(字符串+offset 模型)——
10+
* CM 原生 Text.lines + lineAt 原生支持 CRLF 边界(spec §4.3)。
11+
* computeHeadingChanges 纯逻辑可单测。
912
*/
1013
import { Text, type Extension } from "@codemirror/state";
1114
import { EditorView, keymap, type Command } from "@codemirror/view";
1215

13-
/** 单行插入变更(行首插入 marker) */
16+
/** 单行变更(行首插 marker / 剥同级 marker / 换级 = 替换区间) */
1417
export interface HeadingChange {
1518
from: number;
1619
to: number;
@@ -19,13 +22,14 @@ export interface HeadingChange {
1922

2023
export interface HeadingChangeResult {
2124
changes: HeadingChange[];
22-
/** 转换后光标应落的位置(末行标记后) */
25+
/** 转换后光标应落的位置(新文档坐标:末行标记后;剥除时落行首) */
2326
cursor: number;
2427
}
2528

2629
/**
27-
* 计算标题转换变更集(纯逻辑):选区范围 [from, to) 覆盖的每行行首插入
28-
* `#`×level + 空格;已是标题的行跳过。选区尾恰在行首时该行不纳入(对齐
30+
* 计算标题转换变更集(纯逻辑):选区范围 [from, to) 覆盖的每行逐行判定——
31+
* 行已等于目标级别 → 删除 `#×level+空格`;已是其他级别 → 替换为该级别;
32+
* 普通段 → 行首插入 `#×level+空格`。选区尾恰在行首时该行不纳入(对齐
2933
* textarea 版 headingLines 语义)。level 夹取 1..6。
3034
*/
3135
export function computeHeadingChanges(
@@ -43,17 +47,34 @@ export function computeHeadingChanges(
4347
end -= 1;
4448
}
4549
const changes: HeadingChange[] = [];
46-
let insertedBeforeEnd = 0;
47-
let endLineGotMarker = false;
50+
// 末行之前各行的净变更增量(插入为正、剥除/换级为负)——末行行首平移量
51+
let deltaBeforeEnd = 0;
52+
// 末行自身变换后光标在"新末行"内的落点(无变换=0;插入/换级=标记后)
53+
let endCursorDelta = 0;
4854
for (let n = start; n <= end; n++) {
4955
const line = doc.line(n);
50-
if (/^#{1,6}\s/.test(line.text)) continue; // 已是标题不叠加
51-
changes.push({ from: line.from, to: line.from, insert: marker });
52-
if (n < end) insertedBeforeEnd += marker.length;
53-
else endLineGotMarker = true;
56+
const m = /^(#{1,6})\s/.exec(line.text);
57+
if (m) {
58+
const cur = m[1].length;
59+
if (cur === clamped) {
60+
// 同级别 → 剥除(切换取消:H1 按 H1 → 普通段)
61+
changes.push({ from: line.from, to: line.from + clamped + 1, insert: "" });
62+
if (n < end) deltaBeforeEnd -= clamped + 1;
63+
} else {
64+
// 其他级别 → 剥旧标记 + 插新标记(净效果=换级)
65+
changes.push({ from: line.from, to: line.from + cur + 1, insert: marker });
66+
if (n < end) deltaBeforeEnd += marker.length - (cur + 1);
67+
else endCursorDelta = marker.length;
68+
}
69+
} else {
70+
// 普通段 → 行首插入
71+
changes.push({ from: line.from, to: line.from, insert: marker });
72+
if (n < end) deltaBeforeEnd += marker.length;
73+
else endCursorDelta = marker.length;
74+
}
5475
}
55-
// 光标 = 末行行首 + 末行之前行累计插入 +(末行自身加标记则再 + 标记长)
56-
const cursor = doc.line(end).from + insertedBeforeEnd + (endLineGotMarker ? marker.length : 0);
76+
// 光标 = 新末行行首 + 末行落点(selection 坐标为新文档坐标)
77+
const cursor = doc.line(end).from + deltaBeforeEnd + endCursorDelta;
5778
return { changes, cursor };
5879
}
5980

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* NoteMarkdown.test.tsx — 换行语义锁定(v0.15 remark-breaks)。
4+
*
5+
* @ai-context: 修复"编辑态换行、阅读态连上"——单换行(软换行)必须渲染为 <br>;
6+
* 段落分隔(\n\n)仍是两段不产生 <br>;`\` 强断行语义保留。
7+
* 锁契约防未来 remark 插件顺序回归。
8+
*/
9+
import { afterEach, describe, expect, it, vi } from "vitest";
10+
import { cleanup, render } from "@testing-library/react";
11+
import type { Note } from "../types";
12+
import NoteMarkdown from "./NoteMarkdown";
13+
14+
const note = (content: string): Note => ({
15+
id: 1,
16+
title: "标题",
17+
content,
18+
source: "session",
19+
session_id: 42,
20+
rule_version: null,
21+
purify_stats: null,
22+
tags: "[]",
23+
properties: null,
24+
pin: 0,
25+
group_id: null,
26+
created_at: 1,
27+
updated_at: 2,
28+
});
29+
30+
const noop = vi.fn();
31+
32+
const renderMd = (content: string) =>
33+
render(
34+
<NoteMarkdown
35+
note={note(content)}
36+
searchQuery=""
37+
onTaskToggle={noop}
38+
onImageOpen={noop}
39+
/>,
40+
);
41+
42+
afterEach(() => cleanup());
43+
44+
describe("NoteMarkdown 换行语义(v0.15 remark-breaks)", () => {
45+
it("单换行渲染为 <br>(软换行=所见即所得)", () => {
46+
const { container } = renderMd("第一行\n第二行");
47+
expect(container.querySelector("br")).toBeTruthy();
48+
expect(container.textContent).toContain("第一行");
49+
expect(container.textContent).toContain("第二行");
50+
});
51+
52+
it("段落分隔(空行)不产生 <br>,渲染为两个段落", () => {
53+
const { container } = renderMd("甲段\n\n乙段");
54+
expect(container.querySelector("br")).toBeNull();
55+
expect(container.querySelectorAll("p")).toHaveLength(2);
56+
});
57+
58+
it("反斜杠强断行保留为 <br>", () => {
59+
const { container } = renderMd("第一行\\\n第二行");
60+
expect(container.querySelector("br")).toBeTruthy();
61+
});
62+
63+
it("列表项内单换行同样断行(符合直观)", () => {
64+
const { container } = renderMd("- 项一\n续行内容");
65+
const li = container.querySelector("li");
66+
expect(li).toBeTruthy();
67+
// br 打断文本节点——按整段文本断言(queryByText 全文匹配对打断文本失效)
68+
expect((li as HTMLElement).textContent).toContain("续行内容");
69+
expect((li as HTMLElement).querySelector("br")).toBeTruthy();
70+
});
71+
});

‎app/src/components/NoteMarkdown.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import { isValidElement, cloneElement, useMemo, useRef } from "react";
1717
import type { ReactElement, ReactNode } from "react";
1818
import ReactMarkdown from "react-markdown";
19+
import remarkBreaks from "remark-breaks";
1920
import remarkGfm from "remark-gfm";
2021
import remarkMath from "remark-math";
2122
import rehypeKatex from "rehype-katex";
@@ -152,7 +153,10 @@ export default function NoteMarkdown({ note, searchQuery, onTaskToggle, onOpenSe
152153

153154
return (
154155
<ReactMarkdown
155-
remarkPlugins={[remarkGfm, remarkMath, remarkMarkHighlight]}
156+
// v0.15:remark-breaks——单换行(软换行)渲染为 <br>,与编辑态所见一致
157+
// (CommonMark 标准语义是软换行=空格,导致"编辑态换行、阅读态连上";
158+
// 存量笔记内容零数据变更自动修复;`\` 与两空格强断行语义保留)
159+
remarkPlugins={[remarkGfm, remarkMath, remarkMarkHighlight, remarkBreaks]}
156160
rehypePlugins={[rehypeKatex]}
157161
components={{
158162
// v0.14 B:==文本== 荧光笔(remark 插件产出 mark 节点;单色黄,浅/深主题通用)

‎app/src/components/markdownEdit.test.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,23 @@ describe("headingLines", () => {
3030
expect(r.selEnd).toBe(7);
3131
});
3232

33-
it("已是标题的行跳过不叠加(含末行为标题时光标落在末行行首)", () => {
34-
// Arrange:第一行正文,第二行已是 H2(## )——光标在第一行(offset 0)
33+
it("切换语义:同级别剥除 / 不同级别换级 / 普通段插入", () => {
34+
// Arrange:第一行正文,第二行已是 H2 —— 光标在第一行(offset 0)
3535
const current = "正文\n## 已有标题\n尾巴";
36-
// Act:仅第一行转换
36+
// Act:目标 H1——逐行独立判定:第一行插入;第二行不在选区不处理
3737
const r = headingLines(current, 0, 0, 1);
38-
// Assert:第一行加 #;第二行保持 ##(不叠加成 ###)
38+
// Assert
3939
expect(r.value).toBe("# 正文\n## 已有标题\n尾巴");
4040
expect(r.selStart).toBe(2);
41-
// 多行选区含已标题行:末行(## )不加标记,光标落在末行行首(3 + 前面插入 2)
42-
const r2 = headingLines(current, 0, 4, 1);
43-
expect(r2.value).toBe("# 正文\n## 已有标题\n尾巴");
44-
expect(r2.selStart).toBe(5);
41+
// 多行选区(0..5 覆盖前两行):第一行插入 `# `;第二行 ## 换级为 #
42+
const r2 = headingLines(current, 0, 5, 1);
43+
expect(r2.value).toBe("# 正文\n# 已有标题\n尾巴");
44+
// 光标 = 新末行行首(3 + 前面插入 2)+ 标记后(2)= 7
45+
expect(r2.selStart).toBe(7);
46+
// 同级别剥除:H1 行再点 H1 → 切回普通段,光标落行首
47+
const r3 = headingLines("# 一级", 0, 0, 1);
48+
expect(r3.value).toBe("一级");
49+
expect(r3.selStart).toBe(0);
4550
});
4651

4752
it("选区尾恰在行首:不含下一行(选中到上一行结尾止)", () => {

0 commit comments

Comments
 (0)