dsh-codinput 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +18 -0
  4. package/README.en.md +167 -0
  5. package/README.md +167 -0
  6. package/TESTING.md +95 -0
  7. package/assets/en/preview-ball-detail.png +0 -0
  8. package/assets/en/preview-float.png +0 -0
  9. package/assets/en/preview-normal.png +0 -0
  10. package/assets/en/preview-side.png +0 -0
  11. package/assets/en/preview-split.png +0 -0
  12. package/assets/icon.svg +1 -0
  13. package/assets/preview-ball-detail.png +0 -0
  14. package/assets/preview-float.png +0 -0
  15. package/assets/preview-normal.png +0 -0
  16. package/assets/preview-side.png +0 -0
  17. package/assets/preview-split.png +0 -0
  18. package/cordis.patch.yml +5 -0
  19. package/lib/client.js +544 -0
  20. package/lib/index.js +5 -0
  21. package/package.json +79 -0
  22. package/src/client/attachments.ts +43 -0
  23. package/src/client/cm.ts +207 -0
  24. package/src/client/command.ts +78 -0
  25. package/src/client/components/AttachmentRail.tsx +241 -0
  26. package/src/client/components/CodinputBar.tsx +357 -0
  27. package/src/client/components/CodinputSurface.tsx +375 -0
  28. package/src/client/components/EditorPane.tsx +214 -0
  29. package/src/client/components/ExpandBall.tsx +122 -0
  30. package/src/client/components/MenuSurface.tsx +72 -0
  31. package/src/client/components/ModelMenu.tsx +234 -0
  32. package/src/client/components/PermissionMenu.tsx +257 -0
  33. package/src/client/components/PopupSelectView.tsx +140 -0
  34. package/src/client/components/SlashMenu.tsx +255 -0
  35. package/src/client/components/StatsRow.tsx +188 -0
  36. package/src/client/components/Toolbar.tsx +239 -0
  37. package/src/client/components/popup-filter.ts +13 -0
  38. package/src/client/components/useSnapshot.ts +18 -0
  39. package/src/client/diag.ts +58 -0
  40. package/src/client/host-types.ts +328 -0
  41. package/src/client/hotkey.ts +82 -0
  42. package/src/client/i18n.ts +312 -0
  43. package/src/client/icons.tsx +296 -0
  44. package/src/client/index.tsx +178 -0
  45. package/src/client/modeldir.ts +42 -0
  46. package/src/client/permission.ts +123 -0
  47. package/src/client/prefs.ts +123 -0
  48. package/src/client/services.ts +78 -0
  49. package/src/client/settings.tsx +236 -0
  50. package/src/client/sidebar.tsx +114 -0
  51. package/src/client/sideinput.ts +171 -0
  52. package/src/client/stats.ts +166 -0
  53. package/src/client/styles.ts +489 -0
  54. package/src/client/triggers.ts +84 -0
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Codinput 主体表面:头部行(品牌 · 编辑/预览双开关)、编辑预览区
3
+ * (左右分屏)、工具行、数据行。主输入(composer bar 遮蔽条目)与
4
+ * 侧边标签页共用本表面;读写同一台官方输入机,草稿天然同源。
5
+ */
6
+
7
+ import { useEffect, useMemo, useRef, useState } from 'react';
8
+ import type { EditorView } from '@codemirror/view';
9
+ import { marked } from 'marked';
10
+ import * as DOMPurifyModule from 'dompurify';
11
+ import type {
12
+ ComposerKeyboard,
13
+ InputActions,
14
+ InputState,
15
+ InputTriggerController,
16
+ UseProjection,
17
+ } from '../host-types';
18
+ import { stash } from '../services';
19
+ import { loadPrefs, savePrefs } from '../prefs';
20
+ import { addFilesToDraft, dragHasFiles } from '../attachments';
21
+ import { diagError, diagSession, diagWindow } from '../diag';
22
+ import { IconEdit, IconEye } from '../icons';
23
+ import { t, useT } from '../i18n';
24
+ import { EditorPane } from './EditorPane';
25
+ import { SlashMenu } from './SlashMenu';
26
+ import { PopupSelectView } from './PopupSelectView';
27
+ import { AttachmentRail } from './AttachmentRail';
28
+ import { Toolbar } from './Toolbar';
29
+ import { StatsRow } from './StatsRow';
30
+
31
+
32
+ export interface CodinputSurfaceProps {
33
+ sessionId: string | undefined;
34
+ state: InputState | undefined;
35
+ actions: InputActions | undefined;
36
+ useProjection: UseProjection | undefined;
37
+ /** 宿主 bar owner props 透传:占位文案与禁用。 */
38
+ placeholder?: string;
39
+ disabled?: boolean;
40
+ /** 官方 bar variant:hero(新会话,无对话记录)不渲染数据行。 */
41
+ hero?: boolean;
42
+ /** chat 树解析的轮次计时(模型用时 / TTFT),数据行详情面板用。 */
43
+ chatTimings?: { modelMs?: number; ttftMs?: number };
44
+ /** 侧栏形态(视觉略紧凑)。 */
45
+ side?: boolean;
46
+ /** 悬浮形态(卡片在 FloatingShell 内 portal 渲染;拖动由 CodinputBar 的会话管理)。 */
47
+ float?: boolean;
48
+ /** 顶部行拖动区的 pointerdown(悬浮进出会话,CodinputBar 提供)。 */
49
+ onDragZonePointerDown?: (event: React.PointerEvent<HTMLDivElement>) => void;
50
+ }
51
+
52
+ marked.setOptions({ gfm: true, breaks: true });
53
+
54
+ /** dompurify 的导出形状随打包条件漂移:默认导出与命名空间都兼容。 */
55
+ const DOMPurify = ((DOMPurifyModule as unknown as { default?: unknown }).default ??
56
+ DOMPurifyModule) as unknown as { sanitize(html: string): string };
57
+
58
+ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
59
+ const { sessionId, state, actions, useProjection, placeholder, disabled, side, hero, chatTimings, float, onDragZonePointerDown } = props;
60
+ // 语言切换时整棵表面重渲染(文案都走 i18n 的 t())。
61
+ useT();
62
+
63
+ const [editOn, setEditOn] = useState<boolean>(() => loadPrefs().defaultEdit);
64
+ const [previewOn, setPreviewOn] = useState<boolean>(() => loadPrefs().defaultPreview);
65
+ const [bodyHeight, setBodyHeight] = useState<number | null>(() => loadPrefs().composerBodyHeight);
66
+ /** 拖文件悬停卡片时的落区提示(松手即入草稿附件)。 */
67
+ const [dropActive, setDropActive] = useState(false);
68
+ const cardRef = useRef<HTMLDivElement | null>(null);
69
+ const viewRef = useRef<EditorView | null>(null);
70
+ const caretRef = useRef<number | null>(null);
71
+
72
+ // 高度调节:仅顶边把手,1:1 跟手(顶边上移多少、编辑区就高多少),松手持久化。
73
+ // 宽度不做自有调节——沿用官方聊天宽度调节器(wSkVaW_widthHandle):
74
+ // 官方样式 --dsh-composer-card-max-width = --dsh-chat-content-width + 32px,
75
+ // 卡片的 max-width 取同一变量,天然跟随。
76
+ const startDrag =
77
+ (event: React.PointerEvent<HTMLDivElement>): void => {
78
+ event.preventDefault();
79
+ const handle = event.currentTarget;
80
+ const startY = event.clientY;
81
+ const baseH =
82
+ bodyHeight ?? Math.max(60, cardRef.current?.querySelector('.dci-body')?.getBoundingClientRect().height ?? 0);
83
+ handle.dataset.dragging = 'true';
84
+ let nextH = bodyHeight;
85
+ const onMove = (e: PointerEvent): void => {
86
+ nextH = Math.round(Math.min(window.innerHeight - 260, Math.max(96, baseH - (e.clientY - startY))));
87
+ setBodyHeight(nextH);
88
+ };
89
+ const onUp = (): void => {
90
+ handle.dataset.dragging = 'false';
91
+ window.removeEventListener('pointermove', onMove);
92
+ window.removeEventListener('pointerup', onUp);
93
+ window.removeEventListener('pointercancel', onUp);
94
+ savePrefs({ composerBodyHeight: nextH });
95
+ };
96
+ try {
97
+ handle.setPointerCapture(event.pointerId);
98
+ } catch {
99
+ /* 合成事件无活动指针:window 监听兜底 */
100
+ }
101
+ window.addEventListener('pointermove', onMove);
102
+ window.addEventListener('pointerup', onUp);
103
+ window.addEventListener('pointercancel', onUp);
104
+ };
105
+
106
+
107
+ // 服务 stash 由 ctx.inject 异步填充:未就绪时短轮询触发重算。
108
+ const [svcEpoch, setSvcEpoch] = useState(0);
109
+
110
+ useEffect(() => {
111
+ // 至少开启一个开关。
112
+ if (!editOn && !previewOn) setEditOn(true);
113
+ }, [editOn, previewOn]);
114
+
115
+ // 逐会话解析官方服务面:触发管线 + 键盘面 + popupSelect。
116
+ // 每次渲染现解析(Map 级开销):会话绑定重生成后旧控制器会被宿主
117
+ // dispose,挂载时缓存一次性解析会持有死实例且永不自愈。
118
+ const resolveTriggers = (): InputTriggerController | undefined => {
119
+ if (!sessionId || !stash.conversation) return undefined;
120
+ try {
121
+ return stash.conversation.input.inputTriggers(sessionId) ?? undefined;
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ };
126
+ const resolveKeyboard = (): ComposerKeyboard | undefined => {
127
+ if (!sessionId || !stash.conversation) return undefined;
128
+ try {
129
+ return stash.conversation.input.keyboard(sessionId);
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ };
134
+ const triggers = resolveTriggers();
135
+ const keyboard = resolveKeyboard();
136
+
137
+ // faces 未解析(stash 未就绪或宿主瞬时不可解析)时短轮询触发重算。
138
+ useEffect(() => {
139
+ if (!sessionId || (triggers && keyboard)) return;
140
+ const timer = window.setInterval(() => setSvcEpoch((n) => n + 1), 300);
141
+ const stop = window.setTimeout(() => window.clearInterval(timer), 10_000);
142
+ return () => {
143
+ window.clearInterval(timer);
144
+ window.clearTimeout(stop);
145
+ };
146
+ }, [sessionId, triggers, keyboard]);
147
+
148
+ const popup = useMemo(() => {
149
+ // alpha:sessions.scope('') 会抛错——必须先判空 sessionId。
150
+ if (!sessionId || !stash.sessions || !stash.commandUi) return undefined;
151
+ if (typeof stash.commandUi.popupFor !== 'function') return undefined;
152
+ try {
153
+ return stash.commandUi.popupFor(stash.sessions.scope(sessionId));
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ // eslint-disable-next-line react-hooks/exhaustive-deps
158
+ }, [sessionId, svcEpoch]);
159
+
160
+ // popup 拾取后的 composer 焦点归还(官方 overlay wiring 的等价绑定)。
161
+ // alpha 的 commandUi 未提供 bindComposerFocus——按需可用。
162
+ useEffect(() => {
163
+ if (!sessionId || !stash.commandUi) return;
164
+ const face = stash.commandUi as unknown as Record<string, unknown>;
165
+ if (typeof face['bindComposerFocus'] !== 'function') return;
166
+ return (face['bindComposerFocus'] as (id: string, fn: () => void) => () => void)(
167
+ sessionId,
168
+ () => viewRef.current?.focus(),
169
+ );
170
+ }, [sessionId]);
171
+
172
+ // 诊断口:__dshCodinputSessionId / Triggers / Keyboard(docs/idea.md 附录约定)。
173
+ useEffect(() => {
174
+ diagSession(sessionId ?? null);
175
+ const w = diagWindow();
176
+ if (sessionId) {
177
+ if (triggers) w.__dshCodinputTriggers[sessionId] = triggers;
178
+ if (keyboard) w.__dshCodinputKeyboard[sessionId] = keyboard;
179
+ w.__dshCodinputShell[sessionId] = { surface: true, side: side === true };
180
+ }
181
+ return () => {
182
+ diagSession(null);
183
+ };
184
+ }, [sessionId, triggers, keyboard, side]);
185
+
186
+ const caretMoved = (caret: number): void => {
187
+ caretRef.current = caret;
188
+ };
189
+
190
+ const sendGesture = (): void => {
191
+ if (!actions) return;
192
+ const st = state;
193
+ const draftEmpty = !st || st.draft.trim() === '';
194
+ const noAttachments = !st || st.attachmentIds.length === 0;
195
+ if (draftEmpty && noAttachments) {
196
+ keyboard?.steerQueue(); // 空草稿:发送手势转队列全量 Steer
197
+ return;
198
+ }
199
+ actions.submit();
200
+ };
201
+
202
+ // 官方同语义:草稿与附件皆空时发送禁用(空草稿手势仍可经键盘 Steer)。
203
+ const draftEmpty = (state?.draft ?? '').trim() === '';
204
+ const noAttachments = (state?.attachmentIds.length ?? 0) === 0;
205
+ const sendDisabled =
206
+ disabled ||
207
+ actions === undefined ||
208
+ (state !== undefined && state.phase === 'submitting') ||
209
+ (draftEmpty && noAttachments);
210
+
211
+ const html = useMemo(
212
+ () => DOMPurify.sanitize(marked.parse(state?.draft ?? '') as string),
213
+ [state?.draft, previewOn],
214
+ );
215
+
216
+ const toggleEdit = (): void => {
217
+ if (!editOn && previewOn) setEditOn(true);
218
+ else if (editOn && previewOn) setEditOn(false);
219
+ };
220
+ const togglePreview = (): void => {
221
+ if (!previewOn && editOn) setPreviewOn(true);
222
+ else if (previewOn && editOn) setPreviewOn(false);
223
+ };
224
+
225
+ const placeholderText = placeholder ?? t("editor.placeholder");
226
+
227
+ // ---- 附件入口 #2 / #3:粘贴与拖入 ----
228
+ // 粘贴/拖放的捕获相位处理:只在带文件时接管(纯文本粘贴照常交给编辑器),
229
+ // 且 stopPropagation 不让 CodeMirror 再处理一次(否则同一张图会入两份)。
230
+ const notify = notifyFor(sessionId);
231
+ const onPasteCapture = (event: React.ClipboardEvent<HTMLDivElement>): void => {
232
+ const files = event.clipboardData?.files;
233
+ if (!files || files.length === 0) return;
234
+ event.preventDefault();
235
+ event.stopPropagation();
236
+ addFilesToDraft(sessionId, actions, files, notify);
237
+ };
238
+ const onDragOverCapture = (event: React.DragEvent<HTMLDivElement>): void => {
239
+ if (!dragHasFiles(event.dataTransfer)) return;
240
+ event.preventDefault();
241
+ event.dataTransfer.dropEffect = 'copy';
242
+ if (!dropActive) setDropActive(true);
243
+ };
244
+ const onDragLeave = (event: React.DragEvent<HTMLDivElement>): void => {
245
+ if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
246
+ setDropActive(false);
247
+ };
248
+ const onDropCapture = (event: React.DragEvent<HTMLDivElement>): void => {
249
+ setDropActive(false);
250
+ const files = event.dataTransfer?.files;
251
+ if (!files || files.length === 0) return;
252
+ event.preventDefault();
253
+ event.stopPropagation();
254
+ addFilesToDraft(sessionId, actions, files, notify);
255
+ };
256
+
257
+ return (
258
+ <div className="dci-surface" data-side={side || undefined} data-float={float || undefined}>
259
+ <div
260
+ className="dci-card"
261
+ data-composer-card="codinput"
262
+ data-drop={dropActive || undefined}
263
+ ref={cardRef}
264
+ onPasteCapture={onPasteCapture}
265
+ onDragOverCapture={onDragOverCapture}
266
+ onDragLeave={onDragLeave}
267
+ onDropCapture={onDropCapture}
268
+ >
269
+ <div className="dci-rip" data-axis="y" data-side="top" onPointerDown={startDrag} />
270
+ <div className="dci-accessory">
271
+ <button type="button" className="dci-iconbtn" data-on={editOn} onClick={toggleEdit} title={t("head.edit")}>
272
+ <IconEdit size={14} />
273
+ </button>
274
+ <button type="button" className="dci-iconbtn" data-on={previewOn} onClick={togglePreview} title={t("head.preview")}>
275
+ <IconEye size={14} />
276
+ </button>
277
+ {/* 主输入卡片:开关右侧的空白即拖动区——一拖自动脱出悬浮,拖回底部自动回普通。 */}
278
+ {!side ? <div className="dci-float-dragzone" onPointerDown={onDragZonePointerDown} /> : null}
279
+ </div>
280
+
281
+ {actions && (state?.attachmentIds.length ?? 0) > 0 ? (
282
+ <AttachmentRail
283
+ state={state}
284
+ sessionId={sessionId}
285
+ onRemove={(id) => {
286
+ const conversation = stash.conversation;
287
+ actions.removeAttachment(id);
288
+ try {
289
+ conversation?.releaseDraftAttachment(id);
290
+ } catch (error) {
291
+ diagError('attachments', error);
292
+ }
293
+ }}
294
+ />
295
+ ) : null}
296
+
297
+ <div
298
+ className="dci-body"
299
+ data-split={editOn && previewOn}
300
+ style={
301
+ !side && !float && bodyHeight !== null
302
+ ? ({ '--dci-body-h': `${bodyHeight}px`, '--dci-body-max': `${bodyHeight}px` } as React.CSSProperties)
303
+ : undefined
304
+ }
305
+ >
306
+ {editOn ? (
307
+ <EditorPane
308
+ state={state}
309
+ actions={actions}
310
+ triggers={triggers}
311
+ keyboard={keyboard}
312
+ onSendGesture={sendGesture}
313
+ placeholder={placeholderText}
314
+ viewRef={viewRef}
315
+ onCaretMoved={caretMoved}
316
+ disabled={disabled === true}
317
+ autoFocus={true}
318
+ solo={!previewOn}
319
+ stateKey={sessionId ?? 'hero'}
320
+ />
321
+ ) : null}
322
+ {previewOn ? (
323
+ <div className="dci-preview" data-solo={!editOn}>
324
+ {html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : <span style={{ opacity: 0.4 }}>{t("preview.empty")}</span>}
325
+ </div>
326
+ ) : null}
327
+ </div>
328
+
329
+ <Toolbar
330
+ sessionId={sessionId}
331
+ state={state}
332
+ actions={actions}
333
+ triggers={triggers}
334
+ caretRef={caretRef}
335
+ useProjection={useProjection}
336
+ disabled={disabled}
337
+ onNotify={notifyFor(sessionId)}
338
+ onSend={sendGesture}
339
+ sendDisabled={sendDisabled}
340
+ />
341
+
342
+ {/* 数据行:悬浮形态收进卡片(工具行下方,随卡片圆角一体)。 */}
343
+ {float && hero !== true ? (
344
+ <StatsRow useProjection={useProjection} sessionId={sessionId} chatTimings={chatTimings} />
345
+ ) : null}
346
+
347
+ {/* 官方 MenuView 锚定:主模式以卡片顶上方弹出;侧栏/悬浮形态由
348
+ SlashMenu 按 dropdown 改为头部行下方向下弹出(不被面板/视口裁剪) */}
349
+ {triggers ? <SlashMenu triggers={triggers} dropdown={side === true || float === true} /> : null}
350
+ </div>
351
+
352
+ {/* 数据行:主输入/侧栏渲染在卡片下方(官方 hero 新会话态无 stats)。 */}
353
+ {!float && hero !== true ? (
354
+ <StatsRow useProjection={useProjection} sessionId={sessionId} chatTimings={chatTimings} />
355
+ ) : null}
356
+
357
+ {popup ? <PopupSelectView popup={popup} /> : null}
358
+ </div>
359
+ );
360
+ }
361
+
362
+ function notifyFor(sessionId: string | undefined): ((level: 'info' | 'error', text: string) => void) | undefined {
363
+ if (!sessionId) return undefined;
364
+ return (level, text) => {
365
+ try {
366
+ const actx = stash.sessions?.scope(sessionId);
367
+ if (!actx || !stash.conversation) return;
368
+ stash.conversation.input.for(actx).notify(level, text);
369
+ } catch (error) {
370
+ diagError('notify', error);
371
+ }
372
+ };
373
+ }
374
+
375
+ export type { InputActions };
@@ -0,0 +1,214 @@
1
+ /**
2
+ * 编辑区:CodeMirror 6 本体 + 草稿单写镜像。
3
+ *
4
+ * 官方输入机是唯一事实源:本地键入经 setDraft 单写入口上报;官方侧变化
5
+ * (pick 补全、claim、提交清空、会话切换)回写灌入编辑器(external 标注,
6
+ * 不再回写)。草稿与光标全程保留,形态切换不触碰。
7
+ *
8
+ * 撤销历史跨形态保留:三态切换会让本组件重挂载(inline ↔ portal ↔ 侧栏),
9
+ * 挂载时按 stateKey(会话)取回上次的 EditorState(含撤销栈/选区/文档),
10
+ * 卸载时暂存回去;滚动位置一并记忆。扩展里的回调走模块级 hooks 槽(见 cm.ts),
11
+ * 因此复用旧 state 不会调到已卸载实例的 props。
12
+ */
13
+
14
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
15
+ import { EditorView } from '@codemirror/view';
16
+ import { Transaction } from '@codemirror/state';
17
+ import type { ComposerKeyboard, InputState, InputActions, InputTriggerController } from '../host-types';
18
+ import {
19
+ createEditorExtensions,
20
+ createEditorState,
21
+ applyEditorOptions,
22
+ subscribeEditorPrefs,
23
+ setEditorEditable,
24
+ setActiveEditorHooks,
25
+ clearActiveEditorHooks,
26
+ takeEditorSnapshot,
27
+ putEditorSnapshot,
28
+ externalAnnotation,
29
+ } from '../cm';
30
+ import { reportTrack, arbitrateForMenu, adjudicateSpace } from '../triggers';
31
+ import { loadPrefs } from '../prefs';
32
+ import { matchEvent } from '../hotkey';
33
+ import { diagDebug } from '../diag';
34
+
35
+ export interface EditorPaneProps {
36
+ state: InputState | undefined;
37
+ actions: InputActions | undefined;
38
+ triggers: InputTriggerController | undefined;
39
+ /** 键盘面:track 上报取实时 snapshot(pick 的 draftRev CAS 要求机器当拍修订号)。 */
40
+ keyboard?: ComposerKeyboard;
41
+ /** 发送手势(Enter/Ctrl+Enter 由偏好决定):空草稿转队列全量 Steer。 */
42
+ onSendGesture(): void;
43
+ placeholder: string;
44
+ /** 宿主表面持有的编辑器句柄(caret 屏幕坐标锚定候选菜单)。 */
45
+ viewRef: { current: EditorView | null };
46
+ /** caret 移动回调(表面用于菜单锚点)。 */
47
+ onCaretMoved?(caret: number): void;
48
+ /** 挂载后自动聚焦(侧栏打开等场景)。 */
49
+ autoFocus?: boolean;
50
+ /** owner disabled(blocked/disabled 场景软禁用)。 */
51
+ disabled?: boolean;
52
+ /** 独占整行(预览关闭时);分屏下必须为 false,两栏各占一半。 */
53
+ solo?: boolean;
54
+ /** 撤销历史/选区暂存的键(会话 id;hero 态用固定键)。 */
55
+ stateKey?: string;
56
+ onFocusedChange?(focused: boolean): void;
57
+ }
58
+
59
+ export function EditorPane(props: EditorPaneProps): JSX.Element {
60
+ const { state, actions, triggers, keyboard, onSendGesture, placeholder, autoFocus, viewRef, onCaretMoved, disabled, solo, stateKey } = props;
61
+ const hostRef = useRef<HTMLDivElement | null>(null);
62
+ const composingRef = useRef(false);
63
+ const [pos, setPos] = useState<{ line: number; col: number }>({ line: 1, col: 1 });
64
+
65
+ // 每次渲染读取最新协作对象进闭包(避免重建编辑器)。
66
+ const liveRef = useRef(props);
67
+ liveRef.current = props;
68
+
69
+ const draft = state?.draft ?? '';
70
+ /** 撤销历史/选区暂存键:会话变了就换一份编辑器(各自的历史互不串台)。 */
71
+ const key = stateKey ?? 'default';
72
+
73
+ // ---- 创建编辑器(一次) ----
74
+ useLayoutEffect(() => {
75
+ const host = hostRef.current;
76
+ if (!host) return;
77
+ const prefs = loadPrefs();
78
+ const hooks = {
79
+ onKeydown: (view: EditorView, event: KeyboardEvent): boolean => {
80
+ const live = liveRef.current;
81
+ // 1) 候选菜单打开时键盘 ↑↓/Enter/Tab/Esc 经 arbitrate 裁决。
82
+ if (arbitrateForMenu(live.triggers, event, composingRef.current)) {
83
+ event.preventDefault();
84
+ return true;
85
+ }
86
+ // 2) 输入法 composition 状态守卫:中文选词回车绝不触发发送。
87
+ if (composingRef.current) return false;
88
+ // 3) 空格裁决:真 = 输入机已应用 claim。
89
+ if (event.key === ' ' && !event.ctrlKey && !event.metaKey && !event.altKey) {
90
+ if (adjudicateSpace(live.triggers)) {
91
+ event.preventDefault();
92
+ return true;
93
+ }
94
+ }
95
+ // 4) 发送/换行手势(彻底自定义:偏好里存任意组合串,逐键精确匹配;
96
+ // 换行组合若非普通 Enter 需显式插入,普通 Enter 走默认插入)。
97
+ const prefsNow = loadPrefs();
98
+ if (matchEvent(prefsNow.sendHotkey, event)) {
99
+ event.preventDefault();
100
+ live.onSendGesture();
101
+ return true;
102
+ }
103
+ if (matchEvent(prefsNow.lineBreakHotkey, event) && prefsNow.lineBreakHotkey !== 'enter') {
104
+ event.preventDefault();
105
+ view.dispatch({ changes: { from: view.state.selection.main.head, insert: '\n' } });
106
+ return true;
107
+ }
108
+ return false;
109
+ },
110
+ onLocalChange: (text: string): void => {
111
+ const live = liveRef.current;
112
+ if (!live.actions) return;
113
+ try {
114
+ live.actions.setDraft(text); // 单写入口:持久化与跨形态同步全部由官方承担
115
+ } catch (error) {
116
+ diagDebug('setDraft failed', error);
117
+ }
118
+ },
119
+ onCaret: (text: string, caret: number): void => {
120
+ const live = liveRef.current;
121
+ // 官方同源:track 带机器当拍修订号(keyboard 面实时读,React props
122
+ // 滞后一拍——带旧 rev 会让 pick/claim 的 draftRev CAS 必然失败)。
123
+ reportTrack(live.triggers, text, caret, live.keyboard?.snapshot ?? live.state);
124
+ const view = viewRef.current;
125
+ const line = view?.state.doc.lineAt(caret);
126
+ setPos({ line: line ? line.number : 1, col: caret - (line ? line.from : 0) + 1 });
127
+ live.onCaretMoved?.(caret);
128
+ },
129
+ onCompositionChange: (composing: boolean): void => {
130
+ composingRef.current = composing;
131
+ },
132
+ onFocusChange: (focused: boolean): void => {
133
+ liveRef.current.onFocusedChange?.(focused);
134
+ },
135
+ };
136
+ // 绑定本实例的逻辑回调(模块级单槽,恢复旧 state 时也走当前实例)。
137
+ setActiveEditorHooks(hooks);
138
+ // 按会话取回上次离开时的编辑器(含撤销历史);没有则新建。
139
+ const restored = takeEditorSnapshot(key);
140
+ const view = new EditorView({
141
+ state: restored?.state ?? createEditorState(createEditorExtensions({
142
+ lineNumbers: prefs.lineNumbers,
143
+ fontFamily: prefs.fontFamily,
144
+ placeholder,
145
+ editable: true,
146
+ }), ''),
147
+ parent: host,
148
+ });
149
+ if (restored) view.scrollDOM.scrollTop = restored.scrollTop;
150
+ viewRef.current = view;
151
+ // 行列指示按恢复后的选区初始化(否则要先动一下才显示真实位置)。
152
+ const head = view.state.selection.main.head;
153
+ const line = view.state.doc.lineAt(head);
154
+ setPos({ line: line.number, col: head - line.from + 1 });
155
+ if (autoFocus) view.focus();
156
+ return () => {
157
+ viewRef.current = null;
158
+ // 暂存编辑器状态(撤销栈 + 选区 + 文档 + 滚动),下次挂载原样装回。
159
+ putEditorSnapshot(key, { state: view.state, scrollTop: view.scrollDOM.scrollTop });
160
+ view.destroy();
161
+ clearActiveEditorHooks(hooks);
162
+ };
163
+ // key 变化(切会话)= 换一份编辑器,各自历史互不串台。
164
+ // eslint-disable-next-line react-hooks/exhaustive-deps
165
+ }, [key]);
166
+
167
+ // ---- 官方草稿 → 编辑器回写(外部变化:pick / claim / 提交清空 / 会话切换) ----
168
+ // addToHistory:false —— 官方侧灌入不进撤销栈:否则「宿主刚清空草稿」这件事
169
+ // 自己成了一条可撤销记录,用户按 Ctrl+Z 会把已发送的内容又拉回编辑器。
170
+ useEffect(() => {
171
+ const view = viewRef.current;
172
+ if (!view) return;
173
+ const current = view.state.doc.toString();
174
+ if (draft === current) return;
175
+ const anchor = Math.min(view.state.selection.main.head, draft.length);
176
+ view.dispatch({
177
+ annotations: [externalAnnotation.of(true), Transaction.addToHistory.of(false)],
178
+ changes: { from: 0, to: current.length, insert: draft },
179
+ selection: { anchor: draft === '' ? 0 : anchor },
180
+ scrollIntoView: true,
181
+ });
182
+ }, [draft]);
183
+
184
+ // ---- 偏好(行号/字体)与占位文案热更新 ----
185
+ useEffect(() => {
186
+ const view = viewRef.current;
187
+ if (!view) return;
188
+ const dispose = subscribeEditorPrefs(view, placeholder, true);
189
+ applyEditorOptions(view, { ...currentOptions(placeholder), editable: true });
190
+ return dispose;
191
+ }, [placeholder]);
192
+
193
+ // ---- busy 阶段软锁(adjudicating / submitting 只读)与 owner disabled ----
194
+ const locked =
195
+ disabled === true || state !== undefined && (state.phase === 'adjudicating' || state.phase === 'submitting');
196
+ useEffect(() => {
197
+ const view = viewRef.current;
198
+ if (view) setEditorEditable(view, !locked);
199
+ }, [locked, viewRef]);
200
+
201
+ return (
202
+ <div className="dci-editor-wrap" data-solo={solo || undefined}>
203
+ <div className="dci-cm-host" ref={hostRef} />
204
+ <div className="dci-pos">
205
+ {pos.line}, {pos.col}
206
+ </div>
207
+ </div>
208
+ );
209
+ }
210
+
211
+ function currentOptions(placeholder: string): { lineNumbers: boolean; fontFamily: string; placeholder: string } {
212
+ const prefs = loadPrefs();
213
+ return { lineNumbers: prefs.lineNumbers, fontFamily: prefs.fontFamily, placeholder };
214
+ }