Skip to content

Commit dadd251

Browse files
committed
feat(notes): host views with flushSave guard
1 parent c927d54 commit dadd251

2 files changed

Lines changed: 483 additions & 79 deletions

File tree

Lines changed: 203 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
/**
2-
* NotesReadingColumn — 笔记页右栏(阅读/编辑视图宿主 + 三个插槽)。
2+
* NotesReadingColumn — 笔记页右栏(**视图宿主**:阅读/编辑视图 + 视图切换 + 三个插槽)。
33
*
44
* @ai-context: 本文件是**展示适配器**(自 NotesPage 拆分,行为不变):只做「插槽
55
* 元素构造 + 事件绑定 + 一层组合」,**不含业务逻辑** —— 编辑态与
66
* 编辑器 ref 在 `useNotesPageEditing`,列表/选中/刷新中枢在
77
* `useNotesListData`,命中词深链在 `useNotesDeepLink`,删除/批量删在
88
* `useNotesBatchActions`,选区行动在 `useNoteSelectionActions`。
9-
* @ai-context: prop 分组——
10-
* ① 视图态:selected / editing / setEditing / readerSearch / noteColors / groups
11-
* ② 插槽接线:editorRef(编辑器命令式出口)/ outlineCol(大纲列状态)
12-
* ③ 刷新与错误:onChanged(刷新中枢,完成编辑·版本回滚·移组·精修采纳共用)
13-
* / onError(页面 status 单一真源)/ onCleanNotice(空组清理留痕)
14-
* ④ 跨页深链:onCreateSystem / onOpenSession
15-
* ⑤ 页面命令:onOpenAi / onOpenModelCard / onSelectionAction / onPinToggle
16-
* / onDelete / onTaskToggle / onTagClick / onImageOpen
9+
* @ai-context: prop 分组——① 视图态:selected / editing / setEditing / readerSearch /
10+
* noteColors / groups;② 插槽接线:editorRef / outlineCol;③ 刷新与错误:
11+
* onChanged / onError / onCleanNotice;④ 跨页深链:onCreateSystem /
12+
* onOpenSession;⑤ 页面命令:onOpenAi / onOpenModelCard / onSelectionAction
13+
* / onPinToggle / onDelete / onTaskToggle / onTagClick / onImageOpen;
14+
* ⑥ 批 5 T14 新增:`views`(视图清单,页面注入 —— C1①)。
1715
* @ai-context: DOM 边界——顶层**必须**是原来那个 `flex:1, minWidth:0, display:flex,
18-
* overflow:hidden` 的 div(页面根 flex 的直接子元素,多包一层会改变
19-
* 三栏宽度分配);空态占位的中文文案与硬编码色 `#9ca3af` 逐字保留
20-
* (本批不 token 化)。
16+
* overflow:hidden` 的 div(页面根 flex 的直接子元素,多包一层会改变三栏宽度
17+
* 分配),**它的四个属性一字未改**;T14 新增的两层包装都在它**内部**:列容器
18+
* (column,承载切换器/错误行/槽位/视图区)+ 常驻容器(row = 原顶层 div 的
19+
* display)⇒「大纲列 + 手柄 + 正文区」仍是同一条 flex 行,宽度分配不变。
20+
* 空态占位的中文文案逐字保留。
2121
* @ai-context: 等价红线——`outlineCol` 的 localStorage 键 `notes-outline` 与
2222
* `columnSpec("notes-outline")`(批 3 T8 起:默认 180 / 140·260 /
2323
* autoFoldBelow `breakpointFor("outlineCol")`=1280)由页面逐字传入,
@@ -26,17 +26,47 @@
2626
* ② `onToggleOutline` 在**已折叠**时走 `expand()`——旧实现只翻
2727
* manualFolded,窄窗自动折叠下 `folded` 恒真 ⇒ J1-3「折叠后点窄条
2828
* 永不展开」的死局;未折叠时(✕ 收起)仍是手动折叠,语义不变。
29+
* @ai-context: **批 5 T14 · 规格 §7.3 的三条硬约束落点**:① **原文永远保留**——默认视图
30+
* (`views[0]`)同步渲染并包在 `data-testid="note-resident-view"` 里,只用
31+
* `display` 切显隐 ⇒ 切到 `cardflow` 后原文节点**仍在 DOM**、挂载数不减;
32+
* ② **非默认视图模块级惰性**——`React.lazy` 表按 `spec.load` 构建,默认视图
33+
* **无 `load`** ⇒ 不进表、不经惰性;切走 ⇒ 该子树**卸载**;
34+
* ③ **编辑态切视图先 `flushSave`、失败则阻断**(C4 走 (b):**不改
35+
* `NoteEditHandle` 接口**)——`try { await editorRef.current?.flushSave?.() }
36+
* catch { 阻断 }`:`catch` ⇒ **不切视图 + 保持编辑态 + 在切换器旁渲染一行
37+
* `StatusLine kind="error"`**(`role="alert"`、就近、可测)。**明确不用 toast**
38+
* (C4 逐字拒绝:toast 是全局层、`belowNav` 堆叠缺陷已登记)。非编辑态
39+
* `editorRef.current === null` ⇒ `await undefined` 直接通过(F3)。**显式登记
40+
* 未做**:`RichEditorView` 的 Ctrl+E / 完成按钮路径**仍不阻断**(今天就是
41+
* fire-and-forget,**不是本批引入的回归**、不许顺手扩大面);`flushSave` 返回
42+
* 类型升级(`Promise<void>` → `Promise<boolean>`)登记批 8 的接口卫生 follow-up。
43+
* @ai-context: **C11 槽位**——切换器**下方**恰 1 个空槽位(`class` + `data-` 双钩子,与
44+
* `session-detail/SessionViewHost.tsx` **逐字同形**,T15 的图级守卫跨两侧查它);
45+
* 批 5 **只预留结构与样式钩子**:不加 CSS 规则、**不重排**任何既有 `StatusLine`
46+
* 调用点(本件新增的错误行是**就近新增**,不动别处)。
47+
* @ai-context: **`views` 缺席的退化分支(可判、不静默)**——`views` 是**注入面**(C1①:
48+
* 注册表只许被两个页面直接 import)⇒ 本件**不 import 注册表**;注入缺席时退化
49+
* 为「单视图:原文」(不渲染切换器、不读写记忆,`defaultKey === ""` ⇒ 常驻分支
50+
* 可见),不会静默白屏。棘轮口径:新增代码 0 处颜色/边框/字号字面量(错误色走
51+
* `StatusLine` 原语 · 间距走 `--ed-space-*`),`style` 只承载布局(ADR-033 §4)。
2952
*/
53+
import { Suspense, createElement, lazy, useCallback, useMemo, useState } from "react";
3054
import type { RefObject } from "react";
3155
import type { Note, NoteGroup } from "../../types";
3256
import type { NoteEditHandle } from "../NoteEditView";
3357
import type { ColumnLayout } from "../../hooks/useColumnLayout";
3458
import type { SelectionNoteAction } from "../../utils/noteSelectionMenu";
59+
import type { NoteViewSlot, ViewSpec } from "../../views/registry";
60+
import { useViewMemory } from "../../views/useViewMemory";
3561
import NoteReadingView from "../NoteReadingView";
3662
import RichEditorView from "../RichEditorView";
3763
import NoteHeaderActions from "../NoteHeaderActions";
3864
import VersionPanel from "../VersionPanel";
39-
import { Text } from "../../ui/primitives";
65+
import { ShellFallback } from "../../shell/ShellFallback";
66+
import { StatusLine, Text, ViewSwitcher } from "../../ui/primitives";
67+
68+
/** `views` 缺席时的空清单:**模块级常量**(稳定引用 ⇒ 下面的 `useMemo` 不每渲染重算) */
69+
const NO_VIEWS: readonly ViewSpec<NoteViewSlot>[] = [];
4070

4171
interface Props {
4272
/** 当前选中笔记(null=空态占位) */
@@ -49,7 +79,7 @@ interface Props {
4979
/** 笔记色板(noteId → 解析色,含组继承;headerExtra 色点用) */
5080
noteColors: Record<number, string | null>;
5181
groups: NoteGroup[];
52-
/** 编辑器命令式出口(RichEditorView 的 ref 目标——ESC 先 await 保存再刷新) */
82+
/** 编辑器命令式出口(RichEditorView 的 ref 目标——ESC 先 await 保存再刷新;C4 的守卫读它) */
5383
editorRef: RefObject<NoteEditHandle | null>;
5484
/** 大纲列状态(页面 useColumnLayout("notes-outline")) */
5585
outlineCol: ColumnLayout;
@@ -77,12 +107,15 @@ interface Props {
77107
onImageOpen: (src: string, title?: string) => void;
78108
/** 空组清理留痕上抛(移组触发,父层 toast) */
79109
onCleanNotice: (groupNames: string[]) => void;
110+
/** 视图清单(`NotesPage` 注入 `viewsFor("note")` —— C1①;缺席 ⇒ 单视图退化,见文件头) */
111+
views?: readonly ViewSpec<NoteViewSlot>[];
80112
}
81113

82114
export default function NotesReadingColumn({
83115
selected, editing, setEditing, readerSearch, noteColors, groups, editorRef, outlineCol,
84116
onChanged, onError, onCreateSystem, onOpenAi, onOpenModelCard, onSelectionAction,
85117
onPinToggle, onDelete, onTaskToggle, onTagClick, onOpenSession, onImageOpen, onCleanNotice,
118+
views,
86119
}: Props) {
87120
// H3:辅助面板插槽——VersionPanel(知识补充已迁移至编辑态 🤖 AI 菜单——
88121
// v0.17.0 REQ-246:阅读态独立面板移除,用 AI 直接进入编辑态)
@@ -92,75 +125,166 @@ export default function NotesReadingColumn({
92125
</>
93126
) : null;
94127

95-
return (
96-
<div style={{ flex: 1, minWidth: 0, display: "flex", overflow: "hidden" }}>
97-
{selected ? (
98-
<NoteReadingView
128+
// ── 批 5 T14:视图宿主(C1① 注入 · C5 记忆 · C4 守卫 · C11 槽位)──
129+
const specs = views ?? NO_VIEWS;
130+
const keys = useMemo(() => specs.map((v) => v.key), [specs]);
131+
/** 默认视图键(= `views[0].key`;空清单 ⇒ `""`,此时常驻分支恒可见) */
132+
const defaultKey = specs.length > 0 ? specs[0].key : "";
133+
// C5:键口径 `view:default:note`(`objectType` 粒度、不含 kind);垃圾值/无记忆 ⇒ 回退 defaultKey
134+
const [viewKey, setViewKey] = useViewMemory("note", defaultKey, keys);
135+
const [viewError, setViewError] = useState<string | null>(null);
136+
// 异步守卫期间整组禁用(C4:`flushSave` 在飞 ⇒ 连点两次不会产生第二条切换)
137+
const [pending, setPending] = useState(false);
138+
139+
/** C4 (b):先 flushSave 再切;抛异常 ⇒ **阻断**(不切视图 + 保持编辑态 + 就近提示) */
140+
const changeView = useCallback(
141+
async (key: string) => {
142+
if (key === viewKey) return;
143+
setPending(true);
144+
setViewError(null);
145+
try {
146+
// 非编辑态 `editorRef.current === null` ⇒ `await undefined` 直接通过(F3)
147+
await editorRef.current?.flushSave?.();
148+
} catch (e) {
149+
// 阻断:不 setViewKey(值/视图都不动)、保持在编辑态;提示不用 toast(C4 逐字)
150+
setViewError(`保存失败,未切换视图:${e}`);
151+
setPending(false);
152+
return;
153+
}
154+
setViewKey(key);
155+
setPending(false);
156+
},
157+
[viewKey, editorRef, setViewKey],
158+
);
159+
160+
/**
161+
* 惰性映射:**默认视图无 `load` ⇒ 值为 `null`**,永不进 `React.lazy`(§7.3② 的构造性证据)。
162+
* `lazy()` 只按 `specs` 引用建一次 —— 每渲染新建会让子树恒重挂(F5 的挂载计数会当场红)。
163+
*/
164+
const lazyOf = useMemo(
165+
() => new Map(specs.map((spec) => [spec.key, spec.load ? lazy(spec.load) : null])),
166+
[specs],
167+
);
168+
const isDefault = viewKey === defaultKey;
169+
const LazyView = isDefault ? null : lazyOf.get(viewKey) ?? null;
170+
/** 非默认视图的槽(数据全在这里,视图自身零取数 —— C14②);空态 ⇒ `null`(连卡片流都不挂) */
171+
const slot: NoteViewSlot | null = selected
172+
? { note: selected, onTaskToggle, onOpenSession, onImageOpen }
173+
: null;
174+
175+
/** 默认视图(原文)节点:T14 之前逐字相同,只是现在由常驻容器承载(§7.3①) */
176+
const residentView = selected ? (
177+
<NoteReadingView
178+
note={selected}
179+
editing={editing}
180+
// v0.19.1:命中词阅读搜索(仅当请求属于当前选中笔记——过期请求不注入)
181+
externalSearch={readerSearch && selected?.id === readerSearch.noteId
182+
? { key: readerSearch.key, query: readerSearch.search }
183+
: null}
184+
outlineFolded={outlineCol.folded}
185+
// 批 3 T8:大纲列宽/拖拽经 props 注入(此前 NoteReadingView 写死 180,
186+
// hook 的宽度记忆与 min/max 夹取全部失效——审计 J1-6「假可调」)
187+
outlineWidth={outlineCol.width}
188+
onOutlineResize={outlineCol.resizeBy}
189+
onOutlineReset={outlineCol.resetWidth}
190+
// 批 3 T8(J1-3):窄条(ColumnBar)点击必须走 expand()——它同时清自动/手动
191+
// 折叠态;旧实现只翻 manualFolded,窄窗自动折叠下 folded 恒为真 ⇒ 点窄条
192+
// 永不展开。未折叠时的 ✕「收起大纲」仍是手动折叠(与 NotesGroupsColumn 的
193+
// bar=expand / onCollapse=setManualFolded 同款形态)
194+
onToggleOutline={() =>
195+
outlineCol.folded ? outlineCol.expand() : outlineCol.setManualFolded(true)
196+
}
197+
editor={
198+
<RichEditorView
199+
key={selected.id}
200+
ref={editorRef}
99201
note={selected}
100-
editing={editing}
101-
// v0.19.1:命中词阅读搜索(仅当请求属于当前选中笔记——过期请求不注入)
102-
externalSearch={readerSearch && selected?.id === readerSearch.noteId
103-
? { key: readerSearch.key, query: readerSearch.search }
104-
: null}
105-
outlineFolded={outlineCol.folded}
106-
// 批 3 T8:大纲列宽/拖拽经 props 注入(此前 NoteReadingView 写死 180,
107-
// hook 的宽度记忆与 min/max 夹取全部失效——审计 J1-6「假可调」)
108-
outlineWidth={outlineCol.width}
109-
onOutlineResize={outlineCol.resizeBy}
110-
onOutlineReset={outlineCol.resetWidth}
111-
// 批 3 T8(J1-3):窄条(ColumnBar)点击必须走 expand()——它同时清自动/手动
112-
// 折叠态;旧实现只翻 manualFolded,窄窗自动折叠下 folded 恒为真 ⇒ 点窄条
113-
// 永不展开。未折叠时的 ✕「收起大纲」仍是手动折叠(与 NotesGroupsColumn 的
114-
// bar=expand / onCollapse=setManualFolded 同款形态)
115-
onToggleOutline={() =>
116-
outlineCol.folded ? outlineCol.expand() : outlineCol.setManualFolded(true)
117-
}
118-
editor={
119-
<RichEditorView
120-
key={selected.id}
121-
ref={editorRef}
122-
note={selected}
123-
onCancel={() => {
124-
// v0.13.6:完成编辑 → 列表重载 + 选中笔记重取(右栏立即显示新标题/正文)
125-
setEditing(false);
126-
void onChanged();
127-
}}
128-
// v0.14 A:编辑态图片点击放大(与阅读态同一入口)
129-
onImageOpen={(src, title) => onImageOpen(src, title)}
130-
// 批 8(REQ-317):编辑态选区行动类(转问题/模型卡预填)
131-
onSelectionAction={onSelectionAction}
132-
/>
133-
}
134-
auxPanels={auxPanels}
135-
headerExtra={
136-
<NoteHeaderActions
137-
key={`hdr-${selected.id}`}
138-
note={selected}
139-
resolvedColor={noteColors[selected.id] ?? null}
140-
groups={groups}
141-
onChanged={() => void onChanged()}
142-
onError={(m) => onError(m)}
143-
onGotoKnowledgeSystem={onCreateSystem}
144-
onOpenAi={onOpenAi}
145-
onOpenModelCard={onOpenModelCard}
146-
onCleanNotice={onCleanNotice}
147-
/>
148-
}
149-
onEdit={() => setEditing(true)}
150-
onPinToggle={() => void onPinToggle(selected)}
151-
onDelete={() => void onDelete(selected.id)}
152-
onTagClick={onTagClick}
153-
onOpenSession={(id) => onOpenSession?.(id)}
154-
onTaskToggle={onTaskToggle}
202+
onCancel={() => {
203+
// v0.13.6:完成编辑 → 列表重载 + 选中笔记重取(右栏立即显示新标题/正文)
204+
setEditing(false);
205+
void onChanged();
206+
}}
207+
// v0.14 A:编辑态图片点击放大(与阅读态同一入口)
155208
onImageOpen={(src, title) => onImageOpen(src, title)}
156-
// 批 8(REQ-317):阅读态选区行动类(转问题/模型卡预填)
209+
// 批 8(REQ-317):编辑态选区行动类(转问题/模型卡预填)
157210
onSelectionAction={onSelectionAction}
158211
/>
159-
) : (
160-
<Text as="div" size={4} tone="ink-3" style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
161-
从左侧选择一条笔记查看
162-
</Text>
163-
)}
212+
}
213+
auxPanels={auxPanels}
214+
headerExtra={
215+
<NoteHeaderActions
216+
key={`hdr-${selected.id}`}
217+
note={selected}
218+
resolvedColor={noteColors[selected.id] ?? null}
219+
groups={groups}
220+
onChanged={() => void onChanged()}
221+
onError={(m) => onError(m)}
222+
onGotoKnowledgeSystem={onCreateSystem}
223+
onOpenAi={onOpenAi}
224+
onOpenModelCard={onOpenModelCard}
225+
onCleanNotice={onCleanNotice}
226+
/>
227+
}
228+
onEdit={() => setEditing(true)}
229+
onPinToggle={() => void onPinToggle(selected)}
230+
onDelete={() => void onDelete(selected.id)}
231+
onTagClick={onTagClick}
232+
onOpenSession={(id) => onOpenSession?.(id)}
233+
onTaskToggle={onTaskToggle}
234+
onImageOpen={(src, title) => onImageOpen(src, title)}
235+
// 批 8(REQ-317):阅读态选区行动类(转问题/模型卡预填)
236+
onSelectionAction={onSelectionAction}
237+
/>
238+
) : (
239+
<Text as="div" size={4} tone="ink-3" style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
240+
从左侧选择一条笔记查看
241+
</Text>
242+
);
243+
244+
return (
245+
<div style={{ flex: 1, minWidth: 0, display: "flex", overflow: "hidden" }}>
246+
{/* 批 5 T14:列包装(在顶层 div 内部——顶层那四个属性一字未改) */}
247+
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", overflow: "hidden" }}>
248+
{/* 空态(无对象 ⇒ 无视图):切换器/错误行/槽位一律不渲染,**不写记忆**(F8) */}
249+
{selected && specs.length > 0 ? (
250+
<>
251+
<div style={{ display: "flex", alignItems: "center", gap: "var(--ed-space-8, 8px)", padding: "var(--ed-space-4, 4px) var(--ed-space-8, 8px)" }}>
252+
<ViewSwitcher
253+
options={specs}
254+
value={viewKey}
255+
onChange={(key) => void changeView(key)}
256+
ariaLabel="笔记视图"
257+
disabled={pending}
258+
testId="note-view-switcher"
259+
/>
260+
</div>
261+
{/* C4:就近错误行(`role="alert"` 由原语给;不用 toast) */}
262+
{viewError !== null ? (
263+
<div style={{ padding: "0 var(--ed-space-8, 8px)" }}>
264+
<StatusLine kind="error" testId="note-view-error">{viewError}</StatusLine>
265+
</div>
266+
) : null}
267+
{/* C11:固定错误区槽位——恰 1 个、批 5 恒空、批 8 才落像素与重排 */}
268+
<div className="ed-view-error-slot" data-view-error-slot="" />
269+
</>
270+
) : null}
271+
272+
{/* §7.3① 默认视图常驻:`display` 三元只切显隐,节点与挂载数不减 */}
273+
<div
274+
data-testid="note-resident-view"
275+
style={{ flex: 1, minWidth: 0, display: isDefault ? "flex" : "none", overflow: "hidden" }}
276+
>
277+
{residentView}
278+
</div>
279+
280+
{/* §7.3② 非默认视图:模块级惰性 + `Suspense`(复用 `ShellFallback`);切走 ⇒ 卸载。
281+
滚动容器由本件提供 —— `NoteCardFlowView` 是纯展示列(自身无滚动容器) */}
282+
{!isDefault && LazyView && slot ? (
283+
<div style={{ flex: 1, minWidth: 0, overflowY: "auto", padding: "var(--ed-space-16, 16px)" }}>
284+
<Suspense fallback={<ShellFallback />}>{createElement(LazyView, slot)}</Suspense>
285+
</div>
286+
) : null}
287+
</div>
164288
</div>
165289
);
166290
}

0 commit comments

Comments
 (0)