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)由页面逐字传入,
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" ;
3054import type { RefObject } from "react" ;
3155import type { Note , NoteGroup } from "../../types" ;
3256import type { NoteEditHandle } from "../NoteEditView" ;
3357import type { ColumnLayout } from "../../hooks/useColumnLayout" ;
3458import type { SelectionNoteAction } from "../../utils/noteSelectionMenu" ;
59+ import type { NoteViewSlot , ViewSpec } from "../../views/registry" ;
60+ import { useViewMemory } from "../../views/useViewMemory" ;
3561import NoteReadingView from "../NoteReadingView" ;
3662import RichEditorView from "../RichEditorView" ;
3763import NoteHeaderActions from "../NoteHeaderActions" ;
3864import 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
4171interface 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
82114export 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