Skip to content

Commit 6d49bb2

Browse files
committed
feat(views): add note card flow view
1 parent e73b361 commit 6d49bb2

4 files changed

Lines changed: 463 additions & 2 deletions

File tree

‎app/src/components/NoteMarkdown.tsx‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,35 @@ import type { Note } from "../types";
2424
import { remarkMarkHighlight } from "../utils/remarkMarkHighlight";
2525
import NoteImage from "./NoteImage";
2626

27+
/**
28+
* `remarkPlugins` 槽的元素类型(= `react-markdown` 的 `Options["remarkPlugins"]` 的元素)。
29+
*
30+
* @ai-context 类型取自**已有的默认导入**,而不是再为 `Options` 写一行 import type:
31+
* C8 的机器判据是「全站 `react-markdown` **运行时站点数不增加**(今天 2 处)」,而该判据的原始命令
32+
* 是**按行**匹配那个模块说明符(`tmp/t1/c8-markdown-sites.md` 逐字给了命令与读数)⇒ 多写一行
33+
* import type 会把读数变成 3、污染 C8 的锚。`Parameters<typeof ReactMarkdown>[0]` 与之**语义等价**
34+
* (`Markdown(options: Readonly<Options>)`,见 `react-markdown/lib/index.d.ts`),已由 `tsc` 与
35+
* `views/note/noteViews.test.tsx` 的赋值断言双向钉住。
36+
*/
37+
export type RemarkPlugin = NonNullable<Parameters<typeof ReactMarkdown>[0]["remarkPlugins"]>[number];
38+
2739
interface Props {
2840
note: Note;
2941
/** 搜索关键词(空串=不高亮)——由 NoteReadingView 按 searchActive 门控传入 */
3042
searchQuery: string;
3143
onTaskToggle: (newContent: string) => void;
3244
onOpenSession?: (sessionId: number) => void;
3345
onImageOpen: (src: string, title?: string) => void;
46+
/**
47+
* **只许追加、不许替换**的 remark 插件槽(C8)。
48+
*
49+
* @ai-context 唯一消费者是笔记卡片流(`views/note/NoteCardFlowView`,批 5 T12)。追加项排在既有四条
50+
* **之后**,而 transformer 型插件不参与词法(micromark 扩展在解析前注册)⇒ 既有 GFM / 数学 /
51+
* 荧光笔 / 换行四条语义一条不少(N6 的行为判据钉住)。缺省时 `?? []` ⇒ **不传即与今天逐字相同**
52+
* (`NoteMarkdown.test.tsx` 的 6 条原样绿)。替换式覆盖(`remarkPlugins={remarkPluginsExtra}`)
53+
* 会同时动三条既有语义 ⇒ **禁止**。
54+
*/
55+
remarkPluginsExtra?: readonly RemarkPlugin[];
3456
}
3557

3658
/** 递归展平 React 子节点为纯文本(ReactNode 收窄——替代 as string 断言) */
@@ -91,7 +113,7 @@ function highlightNode(node: ReactNode, query: string): ReactNode {
91113
return walk(node, "hl");
92114
}
93115

94-
export default function NoteMarkdown({ note, searchQuery, onTaskToggle, onOpenSession, onImageOpen }: Props) {
116+
export default function NoteMarkdown({ note, searchQuery, onTaskToggle, onOpenSession, onImageOpen, remarkPluginsExtra }: Props) {
95117
// H1:任务行索引(源文本中每个任务行的行号,按出现顺序)
96118
// 正则与 remark-gfm 清单语法对齐:-/*/+ 无序 + 有序列表(\d{1,9}[.)]),
97119
// 勾选框大小写均认([x]/[X])——否则渲染序号与索引数组错位会写错行
@@ -156,7 +178,7 @@ export default function NoteMarkdown({ note, searchQuery, onTaskToggle, onOpenSe
156178
// v0.15:remark-breaks——单换行(软换行)渲染为 <br>,与编辑态所见一致
157179
// (CommonMark 标准语义是软换行=空格,导致"编辑态换行、阅读态连上";
158180
// 存量笔记内容零数据变更自动修复;`\` 与两空格强断行语义保留)
159-
remarkPlugins={[remarkGfm, remarkMath, remarkMarkHighlight, remarkBreaks]}
181+
remarkPlugins={[remarkGfm, remarkMath, remarkMarkHighlight, remarkBreaks, ...(remarkPluginsExtra ?? [])]}
160182
rehypePlugins={[rehypeKatex]}
161183
components={{
162184
// v0.14 B:==文本== 荧光笔(remark 插件产出 mdast mark 节点 + hName/hProperties)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* NoteCardFlowView —— 笔记的「卡片流」视图(批 5 · T12 · C8/C14)。
3+
*
4+
* @ai-context 形态:把 `NoteMarkdown` 的**既有** react-markdown 管线再渲染一次,只是**追加**
5+
* 一个 remark 插件(`noteCardPlugin`)⇒ 顶层块各自成为一张卡片。**不新写渲染器、不新写 CSS**:
6+
* 卡片面走 `ed-surface` 类族(`noteCardModel.CARD_SURFACE_CLASSES`),与 `Surface` 原语同源。
7+
* @ai-context 数据面(C14② 第一条判据):**全部由容器注入** —— `note` / `onTaskToggle` /
8+
* `onOpenSession` / `onImageOpen` 即 `NoteViewSlot` 的全部字段;本组件**零 `@tauri-apps` import、
9+
* 零 `invoke`**(N7 的机器判据)。继承自 `NoteMarkdown` → `NoteImage` 的本地图片解析
10+
* (`invoke("resolve_note_image")`)是**既有行为、本任务零改动**,N7 用阳性对照如实披露。
11+
* @ai-context `searchQuery=""`:卡片流不做阅读态搜索高亮(高亮是原文视图的语义);空串即「不高亮」,
12+
* 与 `NoteMarkdown` 对 `searchQuery` 的既有约定一致(`utils/...` 侧无副作用分支)。
13+
* 副作用:只挂 React 树(读 `note.content` 渲染);不写盘、不发请求、不读 store。
14+
* 边界:`NoteViewSlot` 的 `onOpenSession` 可选(`session_id` 为空的手动笔记没有跳转目标),
15+
* 原样透传给 `NoteMarkdown`(它自己按 `note.session_id` 门控)。
16+
*/
17+
import type { NoteViewSlot } from "../registry";
18+
import NoteMarkdown from "../../components/NoteMarkdown";
19+
import { noteCardPlugin } from "./noteCardModel";
20+
21+
export default function NoteCardFlowView({ note, onTaskToggle, onOpenSession, onImageOpen }: NoteViewSlot) {
22+
return (
23+
<div
24+
data-testid="note-card-flow"
25+
// 只做布局(ADR-033 §4:`style` 供布局用;底色/边框/圆角一律走类)
26+
style={{ display: "flex", flexDirection: "column", gap: "var(--ed-space-8,8px)" }}
27+
>
28+
<NoteMarkdown
29+
note={note}
30+
searchQuery=""
31+
onTaskToggle={onTaskToggle}
32+
onOpenSession={onOpenSession}
33+
onImageOpen={onImageOpen}
34+
remarkPluginsExtra={[noteCardPlugin]}
35+
/>
36+
</div>
37+
);
38+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/** 结构派生器,批 7 归一 */
2+
/**
3+
* noteCardModel —— 笔记卡片流的**结构派生器**(批 5 · T12 · C8 的落点)。
4+
*
5+
* @ai-context 为什么叫「派生器」不叫「解析器」(C8 逐字):笔记卡片流的派生器**不得手写
6+
* markdown 解析器** —— 必须复用既有 `react-markdown` 栈。故本文件**没有一行 markdown
7+
* 词法/语法分析**:它拿到的是 `react-markdown`(unified 的 remark 阶段)**已经解析好的
8+
* mdast 树**,只做一次结构改写 —— 给 `tree.children`(**仅顶层**)的每个块挂上「卡片」的
9+
* 类名族与 `data-card-kind`;渲染仍走 `NoteMarkdown` 那条既有管线(GFM / 数学 / 荧光笔 /
10+
* 换行四条语义一条不少)。全站 `react-markdown` 站点数因此**不增加**(仍是 2,见 N5)。
11+
* @ai-context **临时件,批 7 归一**:批 5 **不做**三套 markdown 渲染器的归一(`ChatMessageMarkdown`
12+
* 的归并、`NotePreviewView` 手写解析器的替换都转批 7,裁决见 `rulings.md` C8)。本文件是批 7
13+
* 归一时会被吸收/替换的中间形态 ⇒ **文件头第一行**显式命名,避免「3 套变 4 套」时无人认领。
14+
* @ai-context 为什么只改**顶层**:嵌套的 `p` / `ul` 若也挂卡片类会得到「卡中卡」(每个列表项再
15+
* 加一层底与边框)⇒ 遍历**只**走 `tree.children`,绝不下钻(N2 的机器判据钉住这一点)。
16+
* @ai-context 为什么走 `data.hProperties` 而不是 `hName`:`hName` 会**换掉元素本身**
17+
* (`utils/remarkMarkHighlight.ts` 头注记录了 v0.16.1 的教训:自定义节点无 hName 时落到
18+
* `defaultUnknownHandler` 的 `<div>`,`components.mark` 永远匹配不到);`hProperties` 是
19+
* `mdast-util-to-hast` **应用 data 的通道**(同该头注 `:25` 的结论),只加属性、不动元素身份。
20+
* 副作用:无 —— 纯 AST 变换:就地改写入参树;不取数、不渲染、不 import Tauri、无 I/O。
21+
* 边界:① 只处理 mdast 顶层块;不产出新节点、不解析文本;不认识的块类型照样挂类(挂得上挂不上
22+
* 由 `mdast-util-to-hast` 决定,本层不猜、不跳过);② 顶层 `code` 节点有一条**实测形状**:
23+
* `mdast-util-to-hast` 的 code handler 先对**内层 `<code>`** 应用 data、之后才包外层 `<pre>`
24+
* ⇒ 卡片类落内层 `<code>`(如实登记,不在本层绕开;见 T12 报告「派生器契约」)。
25+
*/
26+
import type { RemarkPlugin } from "../../components/NoteMarkdown";
27+
28+
/** 顶层块的**卡片种类**(派生器的输出词汇;`other` 是不认识的块的诚实降级) */
29+
export type CardKind = "heading" | "para" | "list" | "quote" | "code" | "table" | "rule" | "media" | "other";
30+
31+
/**
32+
* 卡片面类族 —— 逐字取自 `ui/primitives/Surface` 的类名族(`level="surface"` + `radius="panel"`
33+
* + `bordered` + `padded`)。**零新 CSS 文件**:卡片样式复用既有 `ed-surface` 类族 ⇒ 不发散设计
34+
* 体系、不新增被守卫扫描的 CSS。与 `Surface` 的一致性由 `noteViews.test.tsx` 的对拍用例钉住。
35+
*/
36+
export const CARD_SURFACE_CLASSES: readonly string[] = [
37+
"ed-surface",
38+
"ed-surface--bordered",
39+
"ed-surface--surface",
40+
"ed-surface--r-panel",
41+
"ed-surface--padded",
42+
];
43+
44+
/** `h1`–`h6`(hast 侧)与 `heading`(mdast 侧)同属一类 */
45+
const HEADING_TAG = /^h[1-6]$/;
46+
47+
/**
48+
* 块 → 卡片种类。入参同时接受 **mdast 节点类型**(插件侧:`heading` / `paragraph` / `code`…)
49+
* 与 **hast 标签名**(判据侧:`h2` / `ul` / `pre`…)—— 两套命名指向同一个种类,映射只有这一处。
50+
* 纯函数:同入参恒同出参,不读外部状态、无副作用(AAA 可单测)。
51+
*/
52+
export function cardKindOf(tag: string): CardKind {
53+
if (tag === "heading" || HEADING_TAG.test(tag)) return "heading";
54+
switch (tag) {
55+
case "paragraph": case "p": return "para";
56+
case "list": case "listItem": case "ul": case "ol": case "li": return "list";
57+
case "blockquote": return "quote";
58+
case "code": case "pre": return "code";
59+
case "table": case "tableRow": case "tableCell": return "table";
60+
case "thead": case "tbody": case "tr": case "th": case "td": return "table";
61+
case "thematicBreak": case "hr": return "rule";
62+
case "image": case "imageReference": case "img": return "media";
63+
default: return "other";
64+
}
65+
}
66+
67+
/** mdast 节点的最小形状(**自声明**,不引外部类型包 —— 照 `remarkMarkHighlight` 的形态先例) */
68+
interface MdBlockData {
69+
hName?: string;
70+
hChildren?: unknown[];
71+
hProperties?: Record<string, unknown>;
72+
[key: string]: unknown;
73+
}
74+
interface MdBlock {
75+
type?: string;
76+
data?: MdBlockData;
77+
children?: unknown[];
78+
}
79+
interface MdTree {
80+
children?: MdBlock[];
81+
}
82+
83+
/**
84+
* remark 插件:给**顶层**块挂卡片类族 + `data-card-kind`(只加属性,**不换元素**)。
85+
* 形态照 `remarkMarkHighlight`(无参工厂返回 transformer)⇒ 与既有插件同构、可混排。
86+
* `satisfies RemarkPlugin` 把「它就是 `remarkPlugins` 槽要的那个类型」钉在**定义点**上
87+
* (同时保留可调用性 ⇒ 变换函数可脱离 DOM 单测)。
88+
*/
89+
export const noteCardPlugin = (() => (tree: MdTree | null | undefined): void => {
90+
const blocks = tree?.children;
91+
if (!Array.isArray(blocks)) return;
92+
for (const block of blocks) {
93+
if (!block || typeof block !== "object") continue;
94+
const prev = block.data?.hProperties;
95+
const raw = prev?.className;
96+
const own = Array.isArray(raw) ? raw.filter((c): c is string => typeof c === "string") : [];
97+
block.data = {
98+
...block.data,
99+
hProperties: {
100+
...prev,
101+
className: [...CARD_SURFACE_CLASSES, ...own],
102+
"data-card-kind": cardKindOf(block.type ?? ""),
103+
},
104+
};
105+
}
106+
}) satisfies RemarkPlugin;

0 commit comments

Comments
 (0)