dsh-model-everything 0.5.2 → 0.6.1
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.
- package/lib/annotation.js +323 -0
- package/lib/client.js +1614 -44
- package/lib/diagram-render-tool.js +13 -1
- package/lib/diagram-review-tool.js +1 -1
- package/lib/diagram.js +33 -0
- package/lib/gate.js +23 -19
- package/lib/render/paint.js +3 -0
- package/lib/render/svg.js +8 -2
- package/lib/routing.js +12 -0
- package/lib/transcript.js +327 -0
- package/lib/types/annotation.d.ts +128 -0
- package/lib/types/client.d.ts +89 -6
- package/lib/types/diagram.d.ts +7 -0
- package/lib/types/gate.d.ts +8 -6
- package/lib/types/render/paint.d.ts +2 -0
- package/lib/types/transcript.d.ts +82 -0
- package/package.json +149 -148
- package/skills/model-everything/SKILL.md +3 -2
- package/skills/model-everything-diagram/SKILL.md +21 -1
- package/src/annotation.ts +333 -0
- package/src/client.ts +1841 -307
- package/src/diagram-render-tool.ts +13 -1
- package/src/diagram-review-tool.ts +1 -1
- package/src/diagram.ts +31 -0
- package/src/gate.ts +23 -19
- package/src/render/paint.ts +5 -0
- package/src/render/svg.ts +8 -2
- package/src/routing.ts +12 -0
- package/src/transcript.ts +349 -0
package/src/client.ts
CHANGED
|
@@ -1,307 +1,1841 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Client half: a lazy factory the page binds to this package's name.
|
|
3
|
-
*
|
|
4
|
-
* Everything that decides anything lives in `render/`; this file only turns shapes into React elements and
|
|
5
|
-
* registers them where the page offers a seat. Two seats are used, and they fail independently:
|
|
6
|
-
*
|
|
7
|
-
* - `tool.call.toolview` replaces the card of a tool whose result this package can draw;
|
|
8
|
-
* - `sidebar.right.pane.tab` is the whiteboard: a tab that renders whatever the reader pastes, with no tool
|
|
9
|
-
* and no host data at all, so it keeps working when the card seat does not.
|
|
10
|
-
*
|
|
11
|
-
* This module is not a page script: it exports the factory, and `scripts/build-client.mjs` wraps the
|
|
12
|
-
* compiled output in `window.__ModuleLoader__.load(...)`, which is the loader contract itself.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { paintWith } from './render/paint.js'
|
|
16
|
-
import { schemeNames, type Palette, type PaletteMode, type PaletteScheme } from './render/palette.js'
|
|
17
|
-
import { render } from './render/index.js'
|
|
18
|
-
import { toSvg } from './render/svg.js'
|
|
19
|
-
import { viewModel } from './render/viewmodel.js'
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
*/
|
|
100
|
-
|
|
101
|
-
return
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
*
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The Client half: a lazy factory the page binds to this package's name.
|
|
3
|
+
*
|
|
4
|
+
* Everything that decides anything lives in `render/`; this file only turns shapes into React elements and
|
|
5
|
+
* registers them where the page offers a seat. Two seats are used, and they fail independently:
|
|
6
|
+
*
|
|
7
|
+
* - `tool.call.toolview` replaces the card of a tool whose result this package can draw;
|
|
8
|
+
* - `sidebar.right.pane.tab` is the whiteboard: a tab that renders whatever the reader pastes, with no tool
|
|
9
|
+
* and no host data at all, so it keeps working when the card seat does not.
|
|
10
|
+
*
|
|
11
|
+
* This module is not a page script: it exports the factory, and `scripts/build-client.mjs` wraps the
|
|
12
|
+
* compiled output in `window.__ModuleLoader__.load(...)`, which is the loader contract itself.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { paintWith } from './render/paint.js'
|
|
16
|
+
import { palette, schemeNames, type Palette, type PaletteMode, type PaletteScheme } from './render/palette.js'
|
|
17
|
+
import { render } from './render/index.js'
|
|
18
|
+
import { toSvg } from './render/svg.js'
|
|
19
|
+
import { viewModel } from './render/viewmodel.js'
|
|
20
|
+
import { annotationBlock, annotationMessage, composerRect, fillComposer, findComposer, pageCensus, prependBlock, pushBlock, pushNote, sessionFromLocation, submitComposer, withoutEmptyQuestion, type DocumentLike } from './annotation.js'
|
|
21
|
+
import { decorateTranscript, startTranscriptDecoration, type TranscriptDocument as TranscriptDocumentForClient } from './transcript.js'
|
|
22
|
+
|
|
23
|
+
export interface ReactLike {
|
|
24
|
+
createElement: (type: unknown, props?: Record<string, unknown> | null, ...children: unknown[]) => unknown
|
|
25
|
+
useState?: <T>(initial: T) => [T, (next: T | ((previous: T) => T)) => void]
|
|
26
|
+
useEffect?: (effect: () => void | (() => void), deps?: ReadonlyArray<unknown>) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SlotContext {
|
|
30
|
+
slots: {
|
|
31
|
+
inject: (slot: string, callback: () => unknown) => unknown
|
|
32
|
+
register: (options: Record<string, unknown>, component: unknown) => unknown
|
|
33
|
+
}
|
|
34
|
+
get?: (service: string) => unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PluginShape {
|
|
38
|
+
inject: string[]
|
|
39
|
+
apply: (ctx: SlotContext) => void
|
|
40
|
+
/** The whiteboard mount, with its scheduler injectable. `apply` calls this with the default. */
|
|
41
|
+
mount: (ctx: SlotContext, options?: MountOptions) => unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A settled tool call, as the host hands it to a view. */
|
|
45
|
+
/**
|
|
46
|
+
* The client context, kept from `apply`: a view has no other way to reach a client service.
|
|
47
|
+
*
|
|
48
|
+
* It is one module-level handle because there is one page. A null until `apply` runs, and every reader
|
|
49
|
+
* checks it, so a view that renders before the plugin mounts simply takes the composer path.
|
|
50
|
+
*/
|
|
51
|
+
let services: SlotContext | undefined
|
|
52
|
+
|
|
53
|
+
export interface ToolCallProps {
|
|
54
|
+
phase?: string
|
|
55
|
+
toolName?: string
|
|
56
|
+
block?: { args?: unknown; result?: unknown }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Tool names this package answers. A key that never dispatches simply never renders. */
|
|
60
|
+
/**
|
|
61
|
+
* The drawing tool's wire name. A literal, not an import: the client bundle must not pull in the tool
|
|
62
|
+
* surface, which imports the host's tool SDK.
|
|
63
|
+
*/
|
|
64
|
+
const DRAWING_TOOL = 'diagram_render'
|
|
65
|
+
|
|
66
|
+
/** Tool names this package answers. A key that never dispatches simply never renders. */
|
|
67
|
+
export const TOOL_NAMES: string[] = ['model_verify', DRAWING_TOOL]
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Which surface the page is painting the drawing on.
|
|
71
|
+
*
|
|
72
|
+
* The host publishes its palette as private CSS variables, so naming them here would break on the next theme.
|
|
73
|
+
* The one fact that cannot drift is the rendered background: read it, and fall back to the media query, then
|
|
74
|
+
* to light. Never throws - a view that cannot read the document still draws.
|
|
75
|
+
*/
|
|
76
|
+
export function themeMode(): PaletteMode {
|
|
77
|
+
try {
|
|
78
|
+
// Reached through a loose view of the globals: this module is also loaded in Node by the bundle suite,
|
|
79
|
+
// where `document` does not exist at all.
|
|
80
|
+
const scope = globalThis as unknown as {
|
|
81
|
+
document?: { body?: unknown }
|
|
82
|
+
getComputedStyle?: (element: unknown) => { backgroundColor?: string }
|
|
83
|
+
}
|
|
84
|
+
const body = scope.document?.body
|
|
85
|
+
const computed = typeof scope.getComputedStyle === 'function' && body !== undefined ? scope.getComputedStyle(body) : undefined
|
|
86
|
+
const colour = computed?.backgroundColor ?? ''
|
|
87
|
+
const rgb = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colour)
|
|
88
|
+
if (rgb !== null) {
|
|
89
|
+
const [r, g, b] = [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]
|
|
90
|
+
// Rec. 601 luma: good enough to tell a light surface from a dark one, and cheap.
|
|
91
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5 ? 'dark' : 'light'
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// A document that refuses to be read is not a reason to fail a render.
|
|
95
|
+
}
|
|
96
|
+
return 'light'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The background a drawing sits on, so a card does not paint dark lines on a dark page. */
|
|
100
|
+
function surfaceStyle(colours: Palette): Record<string, string> {
|
|
101
|
+
return { background: colours.background, color: colours.text, padding: '4px 0', overflowX: 'auto' }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
/** The whiteboard's seat key, in both the tab registry and the body slot. */
|
|
106
|
+
export const WHITEBOARD_ID = 'model-everything-diagram'
|
|
107
|
+
|
|
108
|
+
/** What the whiteboard opens with: a diagram the reader can edit rather than an empty box. */
|
|
109
|
+
export const WHITEBOARD_SAMPLE = ['graph TD', ' a[Idea] --> b{Feasible?}', ' b -->|yes| c[Build]', ' b -->|no| d[Revise]', ' d --> a'].join('\n')
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One argument out of the host's lazy arguments view.
|
|
113
|
+
*
|
|
114
|
+
* A settled call exposes `value(key)`; a plain object (a test, or a replayed call) is read directly. The
|
|
115
|
+
* view answers `undefined` until the field closes, which is exactly when a drawing should be attempted.
|
|
116
|
+
*/
|
|
117
|
+
function argOf(args: unknown, key: string): unknown {
|
|
118
|
+
if (args === null || typeof args !== 'object') return undefined
|
|
119
|
+
const view = args as { value?: (name: string) => unknown }
|
|
120
|
+
if (typeof view.value === 'function') return view.value(key)
|
|
121
|
+
return (args as Record<string, unknown>)[key]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The card for a drawing: the source the call carried, drawn by the renderer that wrote the file.
|
|
126
|
+
*
|
|
127
|
+
* Drawing it here rather than replaying the SVG means the card and the file are one picture from one
|
|
128
|
+
* engine, and it costs the result nothing. Lines the renderer refused are listed under the drawing: a
|
|
129
|
+
* card that hid them would show a picture that is missing something, which is the failure the tool
|
|
130
|
+
* exists to prevent.
|
|
131
|
+
*/
|
|
132
|
+
/** A create-element function with this package's calling convention, so a shared builder fits both seats. */
|
|
133
|
+
type H = (type: unknown, props?: Record<string, unknown> | null, ...children: unknown[]) => unknown
|
|
134
|
+
|
|
135
|
+
/** The host's own colours, so the panel looks like the rest of the page instead of like a plugin. */
|
|
136
|
+
/**
|
|
137
|
+
* The two languages this surface speaks, and the one place a sentence lives.
|
|
138
|
+
*
|
|
139
|
+
* The page states its language; the plugin has no locale service of its own, and a shipped string that cannot
|
|
140
|
+
* be translated is a string somebody has to read in a language they did not choose. Keys are the English
|
|
141
|
+
* sentence's sense, not its text, so a wording change never breaks a caller.
|
|
142
|
+
*/
|
|
143
|
+
const MESSAGES = {
|
|
144
|
+
note: { en: 'Note', zh: '批注' },
|
|
145
|
+
noteOn: { en: 'Note: on', zh: '批注: 开' },
|
|
146
|
+
noteHint: { en: 'Click a node to mark it', zh: '点一个节点标记它' },
|
|
147
|
+
node: { en: 'Node', zh: '节点' },
|
|
148
|
+
add: { en: 'Add', zh: '加入' },
|
|
149
|
+
addAndSend: { en: 'Add & send', zh: '加入并发送' },
|
|
150
|
+
close: { en: 'Close', zh: '关闭' },
|
|
151
|
+
send: { en: 'Send', zh: '发送' },
|
|
152
|
+
clear: { en: 'Clear', zh: '清空' },
|
|
153
|
+
remove: { en: 'Remove', zh: '删除' },
|
|
154
|
+
markedOnly: { en: '(marked only)', zh: '(只标记)' },
|
|
155
|
+
pending: { en: 'Notes', zh: '图注' },
|
|
156
|
+
fold: { en: 'hide', zh: '收起' },
|
|
157
|
+
carried: { en: 'Notes in this message', zh: '本消息携带图注' },
|
|
158
|
+
zoomOut: { en: 'Zoom out', zh: '缩小' },
|
|
159
|
+
zoomIn: { en: 'Zoom in', zh: '放大' },
|
|
160
|
+
actualSize: { en: 'Actual size', zh: '实际大小' },
|
|
161
|
+
full: { en: '⤢ Full', zh: '⤢ 全屏' },
|
|
162
|
+
exitFull: { en: '⤡ Exit', zh: '⤡ 退出' },
|
|
163
|
+
diagram: { en: 'Diagram', zh: '图' },
|
|
164
|
+
fitOn: { en: 'fit: on', zh: 'fit: on' },
|
|
165
|
+
fitOff: { en: 'fit: off', zh: 'fit: off' },
|
|
166
|
+
scheme: { en: 'Colours', zh: '配色' },
|
|
167
|
+
unfold: { en: 'unfold', zh: '展开' },
|
|
168
|
+
placeholder: { en: 'A note about this node… (Ctrl+Enter adds and sends)', zh: '批注…(Ctrl+Enter 加入并发送)' },
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type MessageKey = keyof typeof MESSAGES
|
|
172
|
+
|
|
173
|
+
/** The page's language, as the two letter code this surface uses. */
|
|
174
|
+
function languageOf(): 'en' | 'zh' {
|
|
175
|
+
const scope = globalThis as { navigator?: { language?: string }; document?: { documentElement?: { lang?: string } } }
|
|
176
|
+
const asked = (scope.document?.documentElement?.lang ?? scope.navigator?.language ?? 'en').toLowerCase()
|
|
177
|
+
return asked.startsWith('zh') ? 'zh' : 'en'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function t(key: MessageKey): string {
|
|
181
|
+
return MESSAGES[key][languageOf()]
|
|
182
|
+
}
|
|
183
|
+
const TOKEN = {
|
|
184
|
+
surface: 'var(--dsw-alias-bg-overlay, var(--dsw-alias-bg-layer-1, Canvas))',
|
|
185
|
+
border: 'var(--dsw-alias-border-l1, rgba(127,127,127,0.35))',
|
|
186
|
+
text: 'var(--dsw-alias-label-primary, CanvasText)',
|
|
187
|
+
dim: 'var(--dsw-alias-label-secondary, GrayText)',
|
|
188
|
+
accent: 'var(--dsw-alias-brand-primary, #2563eb)',
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** What an annotation editor shows, shared by the two surfaces that offer one. */
|
|
192
|
+
export interface AnnotationState {
|
|
193
|
+
picked: { id: string } | null
|
|
194
|
+
note: string
|
|
195
|
+
pending: Array<{ nodeId: string; note: string }>
|
|
196
|
+
status: string
|
|
197
|
+
/** Whether the pending list is expanded under the count. */
|
|
198
|
+
open?: boolean
|
|
199
|
+
/** Where the editor sits, in the drawing's own coordinates: beside the node the reader picked. */
|
|
200
|
+
anchor?: { left: number; top: number }
|
|
201
|
+
/** The correction the dock applied after measuring where the chip actually landed. */
|
|
202
|
+
dockFix?: { dx: number; dy: number }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface AnnotationActions {
|
|
206
|
+
onNote: (value: string) => void
|
|
207
|
+
onAdd: () => void
|
|
208
|
+
onAddAndSend: () => void
|
|
209
|
+
onSend: () => void
|
|
210
|
+
onClear: () => void
|
|
211
|
+
onCancel: () => void
|
|
212
|
+
onToggle: () => void
|
|
213
|
+
onRemove: (index: number) => void
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The annotation surface: a floating editor over the drawing, and a count of what is waiting.
|
|
218
|
+
*
|
|
219
|
+
* Floating rather than inline, because the reader is looking at a picture and the editor must not move it.
|
|
220
|
+
* The notes stay in a list until one message is composed from them: the reader's input box is theirs, and N raw
|
|
221
|
+
* fragments in it would be exactly the mess this feature is meant to avoid. Colours come from the host's tokens,
|
|
222
|
+
* so light and dark themes need no separate code.
|
|
223
|
+
*/
|
|
224
|
+
export function annotationPanel(h: H, state: AnnotationState, actions: AnnotationActions, options: { floating?: boolean; document?: DocumentLike; dockFix?: { dx: number; dy: number }; dockChip?: boolean } = {}): unknown {
|
|
225
|
+
const button = (label: string, onClick: () => void, accent = false): unknown =>
|
|
226
|
+
h('button', { type: 'button', onClick, style: { fontSize: '12px', padding: '2px 8px', borderRadius: '6px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: accent ? TOKEN.accent : TOKEN.text, cursor: 'pointer' } }, label)
|
|
227
|
+
const editor = state.picked === null
|
|
228
|
+
? null
|
|
229
|
+
: h(
|
|
230
|
+
'div',
|
|
231
|
+
{
|
|
232
|
+
style: {
|
|
233
|
+
...(options.floating === false ? {} : { position: 'absolute', left: (state.anchor?.left ?? 10) + 'px', top: (state.anchor?.top ?? 10) + 'px', zIndex: 20 }),
|
|
234
|
+
width: 'min(260px, calc(100% - 16px))', boxSizing: 'border-box',
|
|
235
|
+
display: 'flex', flexDirection: 'column', gap: '6px', padding: '8px', borderRadius: '10px',
|
|
236
|
+
background: TOKEN.surface, border: '1px solid ' + TOKEN.border, color: TOKEN.text,
|
|
237
|
+
boxShadow: '0 8px 24px rgba(0,0,0,0.28)',
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
h('div', { style: { fontSize: '12px', color: TOKEN.dim } }, t('node') + ' ' + state.picked.id),
|
|
241
|
+
h('textarea', {
|
|
242
|
+
'data-dme-own': '1',
|
|
243
|
+
value: state.note,
|
|
244
|
+
rows: 3,
|
|
245
|
+
placeholder: t('placeholder'),
|
|
246
|
+
style: { width: '100%', boxSizing: 'border-box', font: '12px system-ui, sans-serif', padding: '6px', borderRadius: '6px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.text, resize: 'vertical' },
|
|
247
|
+
onInput: (event: { target?: { value?: unknown } }) => actions.onNote(String(event?.target?.value ?? '')),
|
|
248
|
+
onKeyDown: (event: { key?: string; ctrlKey?: boolean; metaKey?: boolean; preventDefault?: () => void }) => {
|
|
249
|
+
if (event?.key === 'Enter' && (event?.ctrlKey === true || event?.metaKey === true)) { event.preventDefault?.(); actions.onAdd() }
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
h('div', { style: { display: 'flex', gap: '6px', justifyContent: 'flex-end' } }, button('关闭', actions.onCancel), button('加入', actions.onAdd)),
|
|
253
|
+
)
|
|
254
|
+
// The pending count docks above the conversation's own input box, where the message will be sent from: the
|
|
255
|
+
// reader is about to press Return there, and the count has to be in sight without covering the box.
|
|
256
|
+
const docked = options.floating !== false && options.document !== undefined ? composerRect(options.document) : undefined
|
|
257
|
+
// A docked count is a body-level layer now; rendering it here as well put a second, unpositioned copy
|
|
258
|
+
// inside the card - which is what "the chip's coordinates ran off" was.
|
|
259
|
+
const chip = state.pending.length === 0 || options.dockChip === false
|
|
260
|
+
? null
|
|
261
|
+
: h(
|
|
262
|
+
'div',
|
|
263
|
+
{
|
|
264
|
+
style: {
|
|
265
|
+
position: 'fixed', zIndex: 30,
|
|
266
|
+
left: (docked === undefined ? 16 : docked.left) + 'px',
|
|
267
|
+
transform: 'translate(' + String(state.dockFix?.dx ?? 0) + 'px, ' + String(state.dockFix?.dy ?? 0) + 'px)',
|
|
268
|
+
// Anchored to the composer's own top edge, in the gap above it: the count is in sight while the
|
|
269
|
+
// reader is typing, and never covers the box they are typing into.
|
|
270
|
+
top: (docked === undefined ? 16 : Math.max(8, docked.top - 46)) + 'px',
|
|
271
|
+
// Hugs its two buttons. A fixed width turned the control into a wide empty bar across the composer.
|
|
272
|
+
display: 'flex', flexDirection: 'column', gap: '6px', padding: '6px 10px', borderRadius: '12px', width: 'max-content',
|
|
273
|
+
// The composer's own surface and hairline: the control reads as part of that box, not as a dialog.
|
|
274
|
+
background: 'var(--dsw-alias-bg-layer-1, ' + TOKEN.surface + ')',
|
|
275
|
+
border: '1px solid var(--dsw-alias-border-l2, ' + TOKEN.border + ')', color: TOKEN.text,
|
|
276
|
+
boxShadow: '0 8px 24px rgba(0,0,0,0.22)', font: '12px system-ui, sans-serif',
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
h(
|
|
280
|
+
'div',
|
|
281
|
+
{ 'data-dme-dock': '1', style: { display: 'flex', alignItems: 'center', gap: '6px' } },
|
|
282
|
+
h('button', { type: 'button', title: '展开,逐个删除', onClick: () => actions.onToggle(), style: { fontSize: '12px', padding: '1px 6px', borderRadius: '6px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.text, cursor: 'pointer' } }, t('pending') + ' ×' + String(state.pending.length)),
|
|
283
|
+
button(t('send'), actions.onSend, true),
|
|
284
|
+
),
|
|
285
|
+
state.open
|
|
286
|
+
? h(
|
|
287
|
+
'div',
|
|
288
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '2px' } },
|
|
289
|
+
...state.pending.map((item, index) =>
|
|
290
|
+
h(
|
|
291
|
+
'div',
|
|
292
|
+
{ style: { display: 'flex', alignItems: 'center', gap: '6px' } },
|
|
293
|
+
h('span', { style: { color: TOKEN.dim, fontSize: '11px' } }, item.nodeId),
|
|
294
|
+
h('span', { style: { flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, item.note === '' ? t('markedOnly') : item.note),
|
|
295
|
+
button(t('remove'), () => actions.onRemove(index)),
|
|
296
|
+
),
|
|
297
|
+
),
|
|
298
|
+
)
|
|
299
|
+
: null,
|
|
300
|
+
)
|
|
301
|
+
const mode = state.picked === null && state.pending.length === 0
|
|
302
|
+
? h('div', { style: { fontSize: '11px', color: TOKEN.dim, padding: '2px 4px' } }, t('noteHint'))
|
|
303
|
+
: null
|
|
304
|
+
return h(
|
|
305
|
+
'div',
|
|
306
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '2px', color: TOKEN.text } },
|
|
307
|
+
mode,
|
|
308
|
+
chip,
|
|
309
|
+
editor,
|
|
310
|
+
state.status === '' ? null : h('div', { style: { fontSize: '11px', color: TOKEN.dim, padding: '0 4px' } }, state.status),
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The card for a drawing: the source the call carried, drawn by the renderer that wrote the file.
|
|
316
|
+
*
|
|
317
|
+
* Drawing it here rather than replaying the SVG means the card and the file are one picture from one
|
|
318
|
+
* engine, and it costs the result nothing. Lines the renderer refused are listed under the drawing: a
|
|
319
|
+
* card that hid them would show a picture that is missing something, which is the failure the tool
|
|
320
|
+
* exists to prevent.
|
|
321
|
+
*
|
|
322
|
+
* With hooks the card also annotates: the picture is the subject, so the editor stays behind one button
|
|
323
|
+
* until the reader asks for it, and a click then marks a node instead of doing nothing.
|
|
324
|
+
*/
|
|
325
|
+
/**
|
|
326
|
+
* The source of a drawing call, from whichever shape the host handed over.
|
|
327
|
+
*
|
|
328
|
+
* Measured, not assumed: a settled block carries `args`, `call`, `content` and `meta`, and which one holds the
|
|
329
|
+
* arguments differs by host version. Each is read in turn and the first non-empty text wins, so the card draws
|
|
330
|
+
* on a host that ships the lazy view, one that ships the call record, or one that ships a JSON string.
|
|
331
|
+
*/
|
|
332
|
+
function sourceOf(block: unknown): string | undefined {
|
|
333
|
+
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
334
|
+
value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
|
|
335
|
+
const asText = (value: unknown): string | undefined => (typeof value === 'string' && value.trim() !== '' ? value : undefined)
|
|
336
|
+
const fromJson = (value: unknown): string | undefined => {
|
|
337
|
+
const raw = asText(value)
|
|
338
|
+
if (raw === undefined) return undefined
|
|
339
|
+
try {
|
|
340
|
+
return asText(record(JSON.parse(raw))?.source)
|
|
341
|
+
} catch {
|
|
342
|
+
return undefined
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const frame = record(block)
|
|
346
|
+
if (frame === undefined) return undefined
|
|
347
|
+
// The payload the host persists from `presentationMeta`: the one field a settled call keeps on purpose.
|
|
348
|
+
const fromMeta = asText(record(frame.meta)?.source)
|
|
349
|
+
if (fromMeta !== undefined) return fromMeta
|
|
350
|
+
const args = record(frame.args)
|
|
351
|
+
if (args !== undefined && typeof args.value === 'function') {
|
|
352
|
+
const lazy = asText((args as { value: (key: string) => unknown }).value('source'))
|
|
353
|
+
if (lazy !== undefined) return lazy
|
|
354
|
+
}
|
|
355
|
+
const plain = asText(args?.source) ?? fromJson(frame.args)
|
|
356
|
+
if (plain !== undefined) return plain
|
|
357
|
+
const call = record(frame.call)
|
|
358
|
+
const fromCall = asText(call?.source) ?? fromJson(call?.input) ?? fromJson(call?.arguments)
|
|
359
|
+
if (fromCall !== undefined) return fromCall
|
|
360
|
+
return asText(record(frame.meta)?.source)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function drawingCard(React: ReactLike, props: ToolCallProps, overrides: Partial<Palette>): unknown {
|
|
364
|
+
const block = (props.block ?? {}) as { args?: unknown }
|
|
365
|
+
const source = sourceOf(props.block) ?? argOf(block.args, 'source')
|
|
366
|
+
const asked = argOf(block.args, 'scheme')
|
|
367
|
+
const scheme = schemeNames().includes(asked as PaletteScheme) ? (asked as PaletteScheme) : 'default'
|
|
368
|
+
// The arguments are the lazy view while the call runs; by the time it settles the host may hand back only
|
|
369
|
+
// the result. Both carry the drawing, so the card reads whichever one is there.
|
|
370
|
+
const payload = (props.block as { result?: unknown } | undefined)?.result
|
|
371
|
+
const returned = payload !== null && typeof payload === 'object' ? (payload as { svg?: unknown }).svg : undefined
|
|
372
|
+
const markup = typeof returned === 'string' && returned !== '' ? returned : null
|
|
373
|
+
// Nothing to draw yet, or nothing at all. The branch is taken after the hooks below, never before: a running
|
|
374
|
+
// call has no source and a settled one does, and a hook that disappears between those renders takes the whole
|
|
375
|
+
// conversation view down with it.
|
|
376
|
+
const hasDrawing = markup !== null || (typeof source === 'string' && source.trim() !== '')
|
|
377
|
+
const useState = React.useState
|
|
378
|
+
if (useState === undefined) return cardSurface(React, source, markup, scheme, overrides, null, null, null, null)
|
|
379
|
+
const [annotate, setAnnotate] = useState(false)
|
|
380
|
+
const [picked, setPicked] = useState<{ id: string } | null>(null)
|
|
381
|
+
const [note, setNote] = useState('')
|
|
382
|
+
const [status, setStatus] = useState('')
|
|
383
|
+
const document = (globalThis as { document?: DocumentLike }).document
|
|
384
|
+
const caption = typeof source === 'string' ? (source.split(String.fromCharCode(10)).map((line) => line.trim()).find((line) => line !== '') ?? '') : ''
|
|
385
|
+
const [pending, setPending] = useState<Array<{ nodeId: string; note: string }>>([])
|
|
386
|
+
const [open, setOpen] = useState(false)
|
|
387
|
+
// A note that left: this plugin's own chrome says so, where no other plugin works.
|
|
388
|
+
const [sent, setSent] = useState('')
|
|
389
|
+
// A self-report the reader can copy in one click, so a UI question is answered with facts instead of screenshots.
|
|
390
|
+
const [report, setReport] = useState('')
|
|
391
|
+
// The composer grows when a picture is pasted, and the docked control has to follow it.
|
|
392
|
+
const [, setDockTick] = useState(0)
|
|
393
|
+
// `position: fixed` is relative to the nearest transformed ancestor, not to the viewport. The chip therefore
|
|
394
|
+
// measures where it actually landed and corrects itself once, instead of trusting the coordinates we computed.
|
|
395
|
+
const [dockFix, setDockFix] = useState({ dx: 0, dy: 0 })
|
|
396
|
+
React.useEffect?.(() => {
|
|
397
|
+
const doc = (globalThis as { document?: DocumentLike }).document
|
|
398
|
+
const composer = findComposer(doc)
|
|
399
|
+
const bump = (): void => setDockTick((n) => n + 1)
|
|
400
|
+
const scope = globalThis as {
|
|
401
|
+
addEventListener?: (type: string, handler: () => void) => void
|
|
402
|
+
removeEventListener?: (type: string, handler: () => void) => void
|
|
403
|
+
ResizeObserver?: new (callback: () => void) => { observe: (target: unknown) => void; disconnect: () => void }
|
|
404
|
+
}
|
|
405
|
+
scope.addEventListener?.('resize', bump)
|
|
406
|
+
let observer: { observe: (target: unknown) => void; disconnect: () => void } | undefined
|
|
407
|
+
if (composer !== undefined && composer !== null && scope.ResizeObserver !== undefined) {
|
|
408
|
+
try {
|
|
409
|
+
observer = new scope.ResizeObserver(bump)
|
|
410
|
+
observer.observe(composer)
|
|
411
|
+
} catch { observer = undefined }
|
|
412
|
+
}
|
|
413
|
+
return () => {
|
|
414
|
+
scope.removeEventListener?.('resize', bump)
|
|
415
|
+
observer?.disconnect()
|
|
416
|
+
}
|
|
417
|
+
}, [])
|
|
418
|
+
React.useEffect?.(() => {
|
|
419
|
+
const doc = (globalThis as { document?: DocumentLike }).document
|
|
420
|
+
const chip = doc?.querySelector?.('[data-dme-dock]') as { getBoundingClientRect?: () => { left: number; top: number; height: number } } | null | undefined
|
|
421
|
+
const surface = composerRect(doc)
|
|
422
|
+
const landed = chip?.getBoundingClientRect?.()
|
|
423
|
+
if (surface === undefined || landed === undefined) return
|
|
424
|
+
const dx = Math.round(surface.left - landed.left)
|
|
425
|
+
const dy = Math.round((surface.top - landed.height - 8) - landed.top)
|
|
426
|
+
if (Math.abs(dx) > 1 || Math.abs(dy) > 1) setDockFix((was) => ({ dx: was.dx + dx, dy: was.dy + dy }))
|
|
427
|
+
})
|
|
428
|
+
const [anchor, setAnchor] = useState<{ left: number; top: number } | undefined>(undefined)
|
|
429
|
+
// What would leave with the next message: the chip shows it, and the draft stays empty until Enter.
|
|
430
|
+
setPendingBlock(pending.length === 0 ? '' : annotationBlock(pending.map((item) => ({ ...item, caption }))))
|
|
431
|
+
// The count has to clear when the message leaves, and the subscription must not be able to take the card
|
|
432
|
+
// down with it: a hook that throws here unmounts the drawing, which reads as "the card did not render".
|
|
433
|
+
React.useEffect?.(() => {
|
|
434
|
+
try { return onMessageSent(() => { setPending([]); setStatus('') }) } catch { return undefined }
|
|
435
|
+
}, [])
|
|
436
|
+
const [zoom, setZoom] = useState(1)
|
|
437
|
+
const [full, setFull] = useState(false)
|
|
438
|
+
if (!hasDrawing) {
|
|
439
|
+
const keys = props.block === null || typeof props.block !== 'object' ? 'none' : Object.keys(props.block as Record<string, unknown>).join(',')
|
|
440
|
+
return React.createElement('div', { style: { fontSize: '12px', opacity: 0.7 } }, 'no drawing in this call · block: ' + (keys === '' ? 'empty' : keys))
|
|
441
|
+
}
|
|
442
|
+
const sendAll = (list: Array<{ nodeId: string; note: string }>): void => {
|
|
443
|
+
if (list.length === 0) return
|
|
444
|
+
const text = annotationBlock(list.map((item) => ({ ...item, caption })))
|
|
445
|
+
void (async () => {
|
|
446
|
+
// The draft path runs FIRST and synchronously enough to answer the click. The command channel is a
|
|
447
|
+
// second attempt only after the note is already safe: a channel that hangs, refuses or silently does
|
|
448
|
+
// nothing must never be the thing a click waits on. Measured: it hung, and the button looked dead.
|
|
449
|
+
if (placeBlock(text)) {
|
|
450
|
+
const label = '图注 ×' + String(list.length)
|
|
451
|
+
// The button path is a send too: without this the count under the message only ever appeared when the
|
|
452
|
+
// reader pressed Enter, and a click left the message unmarked.
|
|
453
|
+
attachBelowBubble(label, '')
|
|
454
|
+
setSent('已发送 ' + label)
|
|
455
|
+
attachSentChip(label)
|
|
456
|
+
setStatus('已放入输入框并提交(' + String(list.length) + ' 条图注)')
|
|
457
|
+
setPending([])
|
|
458
|
+
submitDraft()
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
const said = prependBlock(document, list, caption)
|
|
462
|
+
setStatus(said)
|
|
463
|
+
if (!said.startsWith('已')) return
|
|
464
|
+
setPending([])
|
|
465
|
+
submitComposer(document)
|
|
466
|
+
})()
|
|
467
|
+
}
|
|
468
|
+
const onPick = (event: { target?: { closest?: (selector: string) => { getAttribute?: (name: string) => string | null } | null } }): void => {
|
|
469
|
+
if (!annotate) return
|
|
470
|
+
const id = event?.target?.closest?.('[data-node-id]')?.getAttribute?.('data-node-id')
|
|
471
|
+
if (typeof id === 'string' && id !== '') {
|
|
472
|
+
// The editor goes beside the node the reader picked, not beside the pointer: the pointer moves, the node
|
|
473
|
+
// does not, and an editor that lands wherever the click happened reads as "it does not follow".
|
|
474
|
+
const pointer = event as {
|
|
475
|
+
target?: { closest?: (selector: string) => { getBoundingClientRect?: () => { left: number; top: number; width: number; height: number } } | null }
|
|
476
|
+
currentTarget?: { getBoundingClientRect?: () => { left: number; top: number; width: number; height: number } }
|
|
477
|
+
}
|
|
478
|
+
const frame = pointer.currentTarget?.getBoundingClientRect?.()
|
|
479
|
+
const node = pointer.target?.closest?.('[data-node-id]')?.getBoundingClientRect?.()
|
|
480
|
+
if (frame !== undefined && node !== undefined) {
|
|
481
|
+
const width = 250
|
|
482
|
+
const left = Math.max(4, Math.min(node.left - frame.left + node.width + 10, Math.max(4, frame.width - width)))
|
|
483
|
+
const top = Math.max(4, Math.min(node.top - frame.top, Math.max(4, frame.height - 40)))
|
|
484
|
+
setAnchor({ left, top })
|
|
485
|
+
}
|
|
486
|
+
setPicked({ id })
|
|
487
|
+
setStatus('')
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
if (note.trim() === '') setPicked(null)
|
|
491
|
+
}
|
|
492
|
+
const toggle = React.createElement(
|
|
493
|
+
'div',
|
|
494
|
+
{ style: { display: 'flex', gap: '6px', alignItems: 'center' } },
|
|
495
|
+
React.createElement('button', { type: 'button', title: 'Point at a node and send a note about it', onClick: () => { setAnnotate(!annotate); setStatus(''); if (annotate) { setPicked(null); setNote('') } }, style: { fontSize: '11px', lineHeight: '16px', padding: '1px 6px', borderRadius: '5px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.text, cursor: 'pointer' } }, annotate ? t('noteOn') : t('note')),
|
|
496
|
+
annotate && picked === null ? React.createElement('span', { style: { fontSize: '11px', opacity: 0.7 } }, '点一个节点') : null,
|
|
497
|
+
React.createElement('button', {
|
|
498
|
+
type: 'button',
|
|
499
|
+
onClick: () => {
|
|
500
|
+
try { setReport(domReport()) } catch (error) { setReport('report failed: ' + String((error as { message?: unknown })?.message ?? error)) }
|
|
501
|
+
},
|
|
502
|
+
style: { fontSize: '11px', lineHeight: '16px', padding: '1px 6px', borderRadius: '5px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.dim, cursor: 'pointer' },
|
|
503
|
+
}, '诊断'),
|
|
504
|
+
sent === '' ? null : React.createElement('span', {
|
|
505
|
+
style: { fontSize: '11px', lineHeight: '16px', padding: '1px 6px', borderRadius: '9px', border: '1px solid ' + TOKEN.border, color: TOKEN.dim, whiteSpace: 'nowrap' },
|
|
506
|
+
}, sent),
|
|
507
|
+
)
|
|
508
|
+
// The dock is a body-level layer (see updateDockLayer); this panel keeps only the editor.
|
|
509
|
+
updateDockLayer(pending, open, {
|
|
510
|
+
onToggle: () => setOpen(!open),
|
|
511
|
+
onSend: () => sendAll(pending),
|
|
512
|
+
onRemove: (index: number) => setPending(pending.filter((_, at) => at !== index)),
|
|
513
|
+
})
|
|
514
|
+
const panel = annotate
|
|
515
|
+
? annotationPanel(
|
|
516
|
+
(type, options, ...children) => React.createElement(type, options, ...children),
|
|
517
|
+
{ picked, note, pending, status, anchor, open, dockFix },
|
|
518
|
+
{
|
|
519
|
+
onNote: setNote,
|
|
520
|
+
onAdd: () => {
|
|
521
|
+
if (picked === null) return
|
|
522
|
+
const merged = [...pending, { nodeId: picked.id, note }]
|
|
523
|
+
setPending(merged)
|
|
524
|
+
setNote('')
|
|
525
|
+
setPicked(null)
|
|
526
|
+
setStatus('已放入输入框,直接回车即可')
|
|
527
|
+
},
|
|
528
|
+
onAddAndSend: () => { if (picked === null) return; const merged = [...pending, { nodeId: picked.id, note }]; setNote(''); setPicked(null); sendAll(merged) },
|
|
529
|
+
onSend: () => sendAll(pending),
|
|
530
|
+
onClear: () => { setPending([]); setStatus('') },
|
|
531
|
+
onCancel: () => { setPicked(null); setNote(''); setStatus('') },
|
|
532
|
+
onToggle: () => setOpen(!open),
|
|
533
|
+
onRemove: (index: number) => setPending(pending.filter((_, at) => at !== index)),
|
|
534
|
+
},
|
|
535
|
+
// The count is the body-level layer; this panel keeps the editor, which still needs its anchor.
|
|
536
|
+
{ document, dockChip: false },
|
|
537
|
+
)
|
|
538
|
+
: null
|
|
539
|
+
const reportPanel = report === '' ? null : React.createElement('pre', {
|
|
540
|
+
style: { margin: '6px 0 0', padding: '8px', borderRadius: '8px', border: '1px solid ' + TOKEN.border, background: 'rgba(127,127,127,0.08)', font: '11px ui-monospace, monospace', whiteSpace: 'pre-wrap', userSelect: 'text', maxHeight: '220px', overflow: 'auto' },
|
|
541
|
+
}, report)
|
|
542
|
+
return cardSurface(React, source, markup, scheme, overrides, onPick, toggle, panel, reportPanel, { zoom: Number(zoom), full: Boolean(full), onZoom: (next: number) => setZoom(next), onFull: (next: boolean) => setFull(next) })
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** The drawing itself, plus whatever the reader is doing to it. One surface, drawn once. */
|
|
546
|
+
function cardSurface(React: ReactLike, source: unknown, returned: string | null, scheme: PaletteScheme, overrides: Partial<Palette>, onClick: ((event: never) => void) | null, toggle: unknown, panel: unknown, report: unknown, view: { zoom: number; full: boolean; onZoom: (next: number) => void; onFull: (next: boolean) => void } = { zoom: 1, full: false, onZoom: () => {}, onFull: () => {} }): unknown {
|
|
547
|
+
if (returned === null && (typeof source !== 'string' || source.trim() === '')) {
|
|
548
|
+
return React.createElement('div', { style: { fontSize: '12px', opacity: 0.7 } }, 'no drawing in this call')
|
|
549
|
+
}
|
|
550
|
+
// A settled result already carries the drawing the file got; a running call has only its source. Either way
|
|
551
|
+
// it is the same picture from the same renderer.
|
|
552
|
+
const drawn = returned === null
|
|
553
|
+
? render({ source: String(source), folded: [] }, overrides, { mode: themeMode(), scheme })
|
|
554
|
+
: { markup: returned, refused: [] as string[], colours: palette(overrides, { mode: themeMode(), scheme }) }
|
|
555
|
+
// The drawing keeps its own size and shrinks only when it has to: stretched to the card's width, a two-box
|
|
556
|
+
// diagram became a poster and the card stopped looking like the other cards. Zoom is the control for size.
|
|
557
|
+
const scaled = drawn.markup.replace('<svg ', '<svg style="max-width: 100%; height: auto; display: block; margin: 0 auto" ')
|
|
558
|
+
const refused =
|
|
559
|
+
drawn.refused.length === 0
|
|
560
|
+
? null
|
|
561
|
+
: React.createElement('pre', { style: { fontSize: '11px', color: '#b7791f', whiteSpace: 'pre-wrap', margin: '6px 8px' } }, drawn.refused.join(String.fromCharCode(10)))
|
|
562
|
+
// Chrome in the host's own colours and type, so the card reads as another code card rather than a plugin:
|
|
563
|
+
// a header line with the drawing's name and its controls, then the picture on its own background.
|
|
564
|
+
const chrome = { border: '1px solid ' + TOKEN.border, borderRadius: '8px', overflow: 'hidden', background: TOKEN.surface, color: TOKEN.text }
|
|
565
|
+
const button = (label: string, onPress: () => void, title: string): unknown =>
|
|
566
|
+
React.createElement('button', { type: 'button', title, onClick: onPress, style: { fontSize: '11px', lineHeight: '16px', padding: '1px 6px', borderRadius: '5px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.text, cursor: 'pointer' } }, label)
|
|
567
|
+
const header = React.createElement(
|
|
568
|
+
'div',
|
|
569
|
+
{ style: { display: 'flex', gap: '6px', alignItems: 'center', padding: '3px 8px', borderBottom: '1px solid ' + TOKEN.border, fontSize: '12px', color: TOKEN.dim } },
|
|
570
|
+
React.createElement('span', null, t('diagram')),
|
|
571
|
+
React.createElement('span', { style: { opacity: 0.7 } }, typeof source === 'string' ? (source.split(String.fromCharCode(10)).map((line) => line.trim()).find((line) => line !== '') ?? '') : ''),
|
|
572
|
+
React.createElement(
|
|
573
|
+
'span',
|
|
574
|
+
{ style: { marginLeft: 'auto', display: 'flex', gap: '4px', alignItems: 'center' } },
|
|
575
|
+
button('−', () => view.onZoom(Math.max(0.5, Math.round((view.zoom - 0.25) * 100) / 100)), t('zoomOut')),
|
|
576
|
+
button(Math.round(view.zoom * 100) + '%', () => view.onZoom(1), t('actualSize')),
|
|
577
|
+
button('+', () => view.onZoom(Math.min(3, Math.round((view.zoom + 0.25) * 100) / 100)), t('zoomIn')),
|
|
578
|
+
button(view.full ? t('exitFull') : t('full'), () => view.onFull(!view.full), t('full')),
|
|
579
|
+
toggle,
|
|
580
|
+
),
|
|
581
|
+
)
|
|
582
|
+
// Zoom scales the wrapper, not the markup: the SVG is written at one size and the card is what has to fit a
|
|
583
|
+
// column, a split pane, or a screen.
|
|
584
|
+
const drawing = React.createElement(
|
|
585
|
+
'div',
|
|
586
|
+
{
|
|
587
|
+
// CSS zoom, not a transform: it reflows, so the scroll area grows with the picture and nothing is clipped.
|
|
588
|
+
style: { zoom: view.zoom as unknown as number, cursor: onClick === null ? undefined : 'crosshair' },
|
|
589
|
+
onClick: onClick === null ? undefined : (onClick as never),
|
|
590
|
+
dangerouslySetInnerHTML: { __html: scaled },
|
|
591
|
+
},
|
|
592
|
+
)
|
|
593
|
+
const body = React.createElement(
|
|
594
|
+
'div',
|
|
595
|
+
{ style: { position: 'relative', background: drawn.colours.background, padding: '6px', maxHeight: view.full ? 'none' : '260px', overflow: 'auto', flex: '1 1 auto', minHeight: '0' } },
|
|
596
|
+
drawing,
|
|
597
|
+
panel,
|
|
598
|
+
report,
|
|
599
|
+
)
|
|
600
|
+
const frame = view.full
|
|
601
|
+
? { position: 'fixed' as const, inset: '16px', zIndex: 60, display: 'flex', flexDirection: 'column', ...chrome, boxShadow: '0 12px 40px rgba(0,0,0,0.35)' }
|
|
602
|
+
: { display: 'flex', flexDirection: 'column', ...chrome }
|
|
603
|
+
const children = [header, body, refused].filter((child) => child !== null)
|
|
604
|
+
return React.createElement('div', { style: frame }, ...children)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The view for one tool name.
|
|
609
|
+
*
|
|
610
|
+
* The drawing is emitted as markup, so React receives it through `dangerouslySetInnerHTML`. That is safe
|
|
611
|
+
* only because the writer escapes every value that came from a report or a model, which the render suite
|
|
612
|
+
* pins with an injection case.
|
|
613
|
+
*/
|
|
614
|
+
/** Where the page thinks it is, short enough to read in a status line. */
|
|
615
|
+
/**
|
|
616
|
+
* The session the page is showing, asked of the client services in the order that actually answers.
|
|
617
|
+
*
|
|
618
|
+
* The address carries no id on this host; the store does. Fourth place is the host's own recorded selection,
|
|
619
|
+
* which is what the shipped annotation plugin falls back to when the first three are silent - a session id is
|
|
620
|
+
* the one thing every path here needs.
|
|
621
|
+
*/
|
|
622
|
+
function currentSessionId(): string | undefined {
|
|
623
|
+
const sessions = services?.get?.('sessions') as { list?: { getSnapshot?: () => { current?: unknown } } } | undefined
|
|
624
|
+
const snapshot = sessions?.list?.getSnapshot?.()
|
|
625
|
+
if (typeof snapshot?.current === 'string' && snapshot.current !== '') return snapshot.current
|
|
626
|
+
const workspace = services?.get?.('uiWorkspace') as {
|
|
627
|
+
mainReference?: { sessionId?: unknown }
|
|
628
|
+
selection?: { getSnapshot?: () => { sessionId?: unknown } }
|
|
629
|
+
} | undefined
|
|
630
|
+
const main = workspace?.mainReference?.sessionId
|
|
631
|
+
if (typeof main === 'string' && main !== '') return main
|
|
632
|
+
const picked = workspace?.selection?.getSnapshot?.()?.sessionId
|
|
633
|
+
if (typeof picked === 'string' && picked !== '') return picked
|
|
634
|
+
const storage = (globalThis as { localStorage?: { getItem?: (key: string) => string | null } }).localStorage
|
|
635
|
+
const raw = storage?.getItem?.('dsh.sessions.current')
|
|
636
|
+
if (typeof raw === 'string' && raw !== '') {
|
|
637
|
+
try {
|
|
638
|
+
const parsed = JSON.parse(raw) as { sessionId?: unknown }
|
|
639
|
+
if (typeof parsed?.sessionId === 'string' && parsed.sessionId !== '') return parsed.sessionId
|
|
640
|
+
} catch {
|
|
641
|
+
// A key that is not the shape we expect is simply not an answer.
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return undefined
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** The conversation's draft, when this host exposes it: the one writer that keeps line breaks intact. */
|
|
648
|
+
interface DraftShell {
|
|
649
|
+
state?: { getSnapshot?: () => { draft?: unknown }; subscribe?: (listener: () => void) => unknown }
|
|
650
|
+
setDraft?: (text: string) => void
|
|
651
|
+
submit?: (mode?: string) => void
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function draftShell(): DraftShell | undefined {
|
|
655
|
+
const conversation = services?.get?.('conversation') as { input?: { for?: (scope: unknown) => DraftShell } } | undefined
|
|
656
|
+
const sessions = services?.get?.('sessions') as { scope?: (id: string) => unknown } | undefined
|
|
657
|
+
if (conversation?.input?.for === undefined || sessions?.scope === undefined) return undefined
|
|
658
|
+
const id = currentSessionId()
|
|
659
|
+
if (id === undefined) return undefined
|
|
660
|
+
try {
|
|
661
|
+
return conversation.input.for(sessions.scope(id))
|
|
662
|
+
} catch {
|
|
663
|
+
return undefined
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Put the block at the top of the draft through the host, and answer whether it landed.
|
|
669
|
+
*
|
|
670
|
+
* The composer's DOM is the wrong writer: a contenteditable keeps line breaks in its own model, and reaching into
|
|
671
|
+
* it produced one long line. The host's draft setter is the same writer the reader's keystrokes use.
|
|
672
|
+
*/
|
|
673
|
+
/** What this plugin last put in the draft, so it can take it back out without touching the reader's words. */
|
|
674
|
+
let placedText = ''
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Put the block in the draft, or take it out again, and keep the reader's own text exactly as it was.
|
|
678
|
+
*
|
|
679
|
+
* The block has to be in the draft BEFORE the message is sent, not at the moment this plugin decides to send.
|
|
680
|
+
* Any Enter handler on the composer - the host's, or another plugin's capture handler - composes the message
|
|
681
|
+
* from the draft it can see, so a block written after that point never reaches the model. Measured: with the
|
|
682
|
+
* annotation plugin active, a note committed at send time arrived as nothing at all.
|
|
683
|
+
*
|
|
684
|
+
* The write is a read-modify-write with the previous block removed first, so committing, adding another note
|
|
685
|
+
* and removing one all converge on one block instead of stacking.
|
|
686
|
+
*/
|
|
687
|
+
function replaceBlock(text: string): boolean {
|
|
688
|
+
const shell = draftShell()
|
|
689
|
+
if (shell === undefined || typeof shell.setDraft !== 'function') return false
|
|
690
|
+
const snapshot = shell.state?.getSnapshot?.()
|
|
691
|
+
const current = typeof snapshot?.draft === 'string' ? String(snapshot.draft) : ''
|
|
692
|
+
let rest = current
|
|
693
|
+
if (placedText !== '' && rest.includes(placedText)) rest = rest.split(placedText).join('')
|
|
694
|
+
rest = rest.replace(/^[\r\n]+/, '').replace(/\s+$/, '')
|
|
695
|
+
// Appended, not prepended. The annotation plugin folds a message by deleting every text node BEFORE its own
|
|
696
|
+
// marker; a block placed in front of that marker is deleted with it, which is how our note vanished whenever
|
|
697
|
+
// both kinds were sent together. After it, the block survives their fold.
|
|
698
|
+
const next = text === '' ? rest : (rest === '' ? text : rest + String.fromCharCode(10, 10) + text)
|
|
699
|
+
try {
|
|
700
|
+
shell.setDraft(next)
|
|
701
|
+
placedText = text
|
|
702
|
+
return true
|
|
703
|
+
} catch {
|
|
704
|
+
return false
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** The block for a list of notes, in the draft. An empty list removes it. */
|
|
709
|
+
/** The block waiting to leave. Empty when there is nothing to send. */
|
|
710
|
+
let pendingBlock = ''
|
|
711
|
+
let enterWatch: unknown = null
|
|
712
|
+
|
|
713
|
+
/** Remember what would leave with the next message, and start watching for that message. */
|
|
714
|
+
export function setPendingBlock(text: string): void {
|
|
715
|
+
pendingBlock = text
|
|
716
|
+
installEnterCapture()
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Put the block into the message at the instant it leaves.
|
|
721
|
+
*
|
|
722
|
+
* This is the only moment that satisfies both requirements at once: the composer shows nothing while a note
|
|
723
|
+
* is being written, and the message still carries the block because the draft is filled before the host
|
|
724
|
+
* composes it. A capture-phase listener runs before the page's own Enter handling, which is why the write
|
|
725
|
+
* lands in time - and why this is the same trick the shipped annotation plugin uses.
|
|
726
|
+
*/
|
|
727
|
+
/**
|
|
728
|
+
* Fold the message that just left, once, at a moment we chose.
|
|
729
|
+
*
|
|
730
|
+
* The earlier attempt started an observer over the document and scanned on every change; a streaming
|
|
731
|
+
* conversation then spent all its time in the selector. This runs one pass, half a second after our own send,
|
|
732
|
+
* and nothing else - the cost is a known single scan per message instead of a continuous one.
|
|
733
|
+
*/
|
|
734
|
+
function foldOnce(): void {
|
|
735
|
+
const doc = (globalThis as { document?: DocumentLike }).document
|
|
736
|
+
const timer = (globalThis as { setTimeout?: (handler: () => void, delay: number) => unknown }).setTimeout
|
|
737
|
+
if (doc === undefined || typeof timer !== 'function') return
|
|
738
|
+
// Four passes, not one. The host re-renders the message after the first pass - a fold that landed a moment
|
|
739
|
+
// ago can be gone a moment later, which reads as "the chip showed for an instant and then went away". Four
|
|
740
|
+
// single scans are still cheaper than one observer over a streaming transcript.
|
|
741
|
+
const fold = (): void => {
|
|
742
|
+
try { decorateTranscript(doc as unknown as TranscriptDocumentForClient, t('pending'), t('carried')) } catch { /* the message keeps its block */ }
|
|
743
|
+
}
|
|
744
|
+
for (const delay of [400, 1200, 2600, 5000]) timer(fold, delay)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Put the count beside the sent message, as an element - never by touching text.
|
|
749
|
+
*
|
|
750
|
+
* The annotation plugin folds a message by deleting the text nodes before its own marker, so anything this
|
|
751
|
+
* plugin wrote inside the message is gone by the time a scan could find it. An element inserted next to its
|
|
752
|
+
* chip is outside that surgery: text nodes are what it removes. When there is no anchor it inserts nothing -
|
|
753
|
+
* a missing chip is a smaller failure than a chip in the wrong place.
|
|
754
|
+
*/
|
|
755
|
+
/**
|
|
756
|
+
* Put a count under the bubble that was just sent, outside it.
|
|
757
|
+
*
|
|
758
|
+
* Inside the bubble two other writers work: the host re-renders it, and the annotation plugin deletes the text
|
|
759
|
+
* before its own marker. An element inserted after the bubble is outside both - the row's children are
|
|
760
|
+
* reconciled by position, and a trailing extra child has nothing to disagree with.
|
|
761
|
+
*
|
|
762
|
+
* The bubble is found by the text the reader typed: after every rewrite, that string is still in the draft
|
|
763
|
+
* and in the message alike.
|
|
764
|
+
*/
|
|
765
|
+
/** The deepest element whose text is the note block, last in document order. */
|
|
766
|
+
function findBlockElement(doc: any): any {
|
|
767
|
+
const all: any[] = Array.from(doc.querySelectorAll('div, p, span'))
|
|
768
|
+
const hits = all.filter((node) => String(node.textContent ?? '').indexOf('Diagram annotations -') !== -1)
|
|
769
|
+
// The deepest match is not enough: a transcript container also "contains" the marker and is thousands of pixels
|
|
770
|
+
// tall, which put the count at y=24375. The smallest box that still holds the text is the message.
|
|
771
|
+
let best: any
|
|
772
|
+
let bestArea = Number.POSITIVE_INFINITY
|
|
773
|
+
for (const node of hits) {
|
|
774
|
+
const box = node.getBoundingClientRect?.()
|
|
775
|
+
const area = box === undefined || box === null ? Number.POSITIVE_INFINITY : box.width * box.height
|
|
776
|
+
if (area > 0 && area < bestArea) { bestArea = area; best = node }
|
|
777
|
+
}
|
|
778
|
+
return best
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** Put the count inside the element that holds the message, after its text: that box already has the padding. */
|
|
782
|
+
function insertChipAfter(doc: any, element: any, label: string): void {
|
|
783
|
+
try {
|
|
784
|
+
const existing = Array.from(doc.querySelectorAll('[data-dme-chip]')) as Array<{ remove?: () => void; textContent?: string }>
|
|
785
|
+
if (existing.some((old) => String(old.textContent ?? '') === label)) return
|
|
786
|
+
for (const old of existing) old.remove?.()
|
|
787
|
+
const chip = doc.createElement('span')
|
|
788
|
+
chip.setAttribute('data-dme-own', '1')
|
|
789
|
+
chip.setAttribute('data-dme-chip', '1')
|
|
790
|
+
chip.textContent = label
|
|
791
|
+
chip.style.cssText = 'position:fixed;z-index:1140;display:inline-block;padding:1px 8px;border-radius:9px;font:11px system-ui;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.35));color:var(--dsw-alias-label-secondary, #9aa0a6);background:var(--dsw-alias-bg-layer-1, rgba(30,30,32,0.92))'
|
|
792
|
+
// A body-level layer, the same construction that works for the dock. Appended into the message it lasted until
|
|
793
|
+
// the next render - the message is React's, and this is not.
|
|
794
|
+
doc.body.appendChild(chip)
|
|
795
|
+
chipAnchor = element
|
|
796
|
+
placeChip(doc)
|
|
797
|
+
// The message may not be laid out yet when this runs: a hidden chip is placed again a few times, then left alone.
|
|
798
|
+
const timer2 = (globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown }).setTimeout
|
|
799
|
+
if (typeof timer2 === 'function') { for (const delay of [300, 900, 2000, 3500]) timer2(() => { try { placeChip(doc) } catch { /* keeps the last placement */ } }, delay) }
|
|
800
|
+
// Scrolling moves the message, not the layer: keep the chip on the block it belongs to.
|
|
801
|
+
const scope = globalThis as { addEventListener?: (t: string, fn: () => void) => void }
|
|
802
|
+
if (!chipScrollWatched && typeof scope.addEventListener === 'function') {
|
|
803
|
+
chipScrollWatched = true
|
|
804
|
+
scope.addEventListener('scroll', () => {
|
|
805
|
+
try {
|
|
806
|
+
const live = doc.querySelector('[data-dme-chip]')
|
|
807
|
+
const anchor = chipAnchor
|
|
808
|
+
const box2 = anchor?.getBoundingClientRect?.()
|
|
809
|
+
if (live === null || box2 === undefined || box2 === null) return
|
|
810
|
+
live.style.setProperty('left', String(Math.round(box2.left)) + 'px')
|
|
811
|
+
live.style.setProperty('top', String(Math.round(box2.bottom) + 4) + 'px')
|
|
812
|
+
} catch { /* a chip that stops following is still a chip */ }
|
|
813
|
+
})
|
|
814
|
+
}
|
|
815
|
+
} catch { /* a chip not shown is not a broken message */ }
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Put the count under the message it belongs to, or hide it while that message is outside the viewport. */
|
|
819
|
+
function placeChip(doc: any): void {
|
|
820
|
+
const chip = doc?.querySelector?.('[data-dme-chip]')
|
|
821
|
+
if (chip === null || chip === undefined) return
|
|
822
|
+
// The cached anchor can be detached by a re-render: a detached node still answers getBoundingClientRect with
|
|
823
|
+
// zeros, so the count would stay hidden forever. Re-find the message by its text when the cached one is gone.
|
|
824
|
+
if (chipAnchor === undefined || chipAnchor.isConnected === false) chipAnchor = findBlockElement(doc)
|
|
825
|
+
const anchor = chipAnchor
|
|
826
|
+
if (anchor === undefined) return
|
|
827
|
+
const box = anchor.getBoundingClientRect?.()
|
|
828
|
+
if (box === undefined || box === null) return
|
|
829
|
+
if (box.width === 0) {
|
|
830
|
+
// Laid out but not measurable yet: leave the count where it is rather than hiding it for good.
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
const view = globalThis as { innerHeight?: number }
|
|
834
|
+
const outside = box.bottom < 0 || box.top > Number(view.innerHeight ?? 0)
|
|
835
|
+
chip.style.setProperty('display', outside ? 'none' : 'inline-block')
|
|
836
|
+
if (outside) return
|
|
837
|
+
chip.style.setProperty('left', String(Math.max(8, Math.round(box.left))) + 'px')
|
|
838
|
+
chip.style.setProperty('top', String(Math.max(8, Math.round(box.bottom) + 4)) + 'px')
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function attachBelowBubble(label: string, typed: string): void {
|
|
842
|
+
const doc = (globalThis as { document?: any }).document
|
|
843
|
+
const timer = (globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown }).setTimeout
|
|
844
|
+
if (doc === undefined || typeof timer !== 'function') return
|
|
845
|
+
const wanted = typed.trim()
|
|
846
|
+
let tries = 0
|
|
847
|
+
const attempt = (): void => {
|
|
848
|
+
tries += 1
|
|
849
|
+
try {
|
|
850
|
+
// Two ways to find the message that just left: by the text the reader typed, or - with nothing typed,
|
|
851
|
+
// which is the usual case for a note - by the note block's own opening line.
|
|
852
|
+
const all: any[] = Array.from(doc.querySelectorAll('div, p, span'))
|
|
853
|
+
let hit: any
|
|
854
|
+
if (wanted !== '') {
|
|
855
|
+
const hits = all.filter((node) => String(node.textContent ?? '').trim().startsWith(wanted) && node.parentElement)
|
|
856
|
+
hit = hits[hits.length - 1]
|
|
857
|
+
} else {
|
|
858
|
+
hit = findBlockElement(doc)
|
|
859
|
+
}
|
|
860
|
+
chipAttempt = hit === undefined ? 'not found, try ' + String(tries) : 'found'
|
|
861
|
+
if (hit !== undefined) { insertChipAfter(doc, hit, label); return }
|
|
862
|
+
} catch { /* a chip not shown is not a broken message */ }
|
|
863
|
+
if (tries < 20) timer(attempt, 500)
|
|
864
|
+
}
|
|
865
|
+
timer(attempt, 300)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function attachSentChip(label: string): void {
|
|
869
|
+
const doc = (globalThis as { document?: DocumentLike }).document
|
|
870
|
+
const timer = (globalThis as { setTimeout?: (handler: () => void, delay: number) => unknown }).setTimeout
|
|
871
|
+
if (doc === undefined || typeof timer !== 'function') return
|
|
872
|
+
let tries = 0
|
|
873
|
+
const attempt = (): void => {
|
|
874
|
+
tries += 1
|
|
875
|
+
// Their chip is the anchor; the BUBBLE is its parent, and the chip goes after the bubble - not inside it.
|
|
876
|
+
// Inside, it lasted until the next render of a message two other writers are rebuilding.
|
|
877
|
+
const anchor = doc.querySelector?.('[data-annotation-chip]') as { parentElement?: any } | null | undefined
|
|
878
|
+
const bubble = anchor?.parentElement
|
|
879
|
+
const row = bubble?.parentElement
|
|
880
|
+
const made = (doc as unknown as { createElement?: (tag: string) => any }).createElement?.('span')
|
|
881
|
+
if (made !== undefined && bubble !== undefined && bubble !== null && row !== undefined && row !== null) {
|
|
882
|
+
made.textContent = label
|
|
883
|
+
made.setAttribute?.('data-dme-own', '1')
|
|
884
|
+
made.style.cssText = 'display:inline-block;margin:4px 8px 0;padding:1px 8px;border-radius:9px;font:11px system-ui;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.35));color:var(--dsw-alias-label-secondary, #9aa0a6)'
|
|
885
|
+
try {
|
|
886
|
+
row.insertBefore(made, bubble.nextSibling)
|
|
887
|
+
return
|
|
888
|
+
} catch {
|
|
889
|
+
// A refused insert is a chip not shown, not a broken message.
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
if (tries < 8) timer(attempt, 250)
|
|
893
|
+
}
|
|
894
|
+
timer(attempt, 300)
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* The dock, as a layer on `document.body`.
|
|
899
|
+
*
|
|
900
|
+
* A `position: fixed` node still positions against the nearest transformed ancestor, and the composer sits
|
|
901
|
+
* inside one - which is why every coordinate this plugin computed landed inside the box. The shipped
|
|
902
|
+
* annotation plugin sidesteps it the same way: its chip is a body-level layer, so fixed means the viewport.
|
|
903
|
+
* Size and place come from the composer card's own rectangle, measured after every layout change.
|
|
904
|
+
*/
|
|
905
|
+
interface DockHandlers { onToggle: () => void; onSend: () => void; onRemove: (index: number) => void }
|
|
906
|
+
let dockLayer: { remove?: () => void } | undefined
|
|
907
|
+
let dockReact: { count: number; open: boolean; items: Array<{ nodeId: string; note: string }>; handlers: DockHandlers } | undefined
|
|
908
|
+
|
|
909
|
+
let dockEl: any
|
|
910
|
+
let dockRetries = 0
|
|
911
|
+
// What the last attempt to place the per-message count saw, for the diagnostic report.
|
|
912
|
+
let chipAttempt = 'not attempted'
|
|
913
|
+
let chipAnchor: any
|
|
914
|
+
let chipScrollWatched = false
|
|
915
|
+
let dockReceipt = ''
|
|
916
|
+
let receiptTimer: unknown
|
|
917
|
+
|
|
918
|
+
/** Show a receipt for a few seconds in the dock layer, and take it down again. */
|
|
919
|
+
function publishDockReceipt(text: string): void {
|
|
920
|
+
dockReceipt = text
|
|
921
|
+
const scope = globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown; clearTimeout?: (id: unknown) => void }
|
|
922
|
+
if (scope.clearTimeout !== undefined) scope.clearTimeout(receiptTimer)
|
|
923
|
+
receiptTimer = scope.setTimeout?.(() => { dockReceipt = ''; try { scheduleDock() } catch { /* gone */ } }, 8000)
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
let dockObserver: { disconnect: () => void } | undefined
|
|
927
|
+
let dockFrame: number | undefined
|
|
928
|
+
|
|
929
|
+
/** The layer, created once. `cssText` is the form the reference plugin uses and the browser applies. */
|
|
930
|
+
function ensureDock(): any {
|
|
931
|
+
// A cached node that is no longer in the document is a stale handle: writing to it updates nothing and the
|
|
932
|
+
// layer that IS in the document keeps its old position. That is how one control became two.
|
|
933
|
+
if (dockEl !== undefined && dockEl.isConnected !== false) return dockEl
|
|
934
|
+
const doc = (globalThis as { document?: any }).document
|
|
935
|
+
if (doc?.body === undefined || typeof doc.createElement !== 'function') return undefined
|
|
936
|
+
// Exactly one layer: a reload, a remount or a stale reference must not leave two counts floating.
|
|
937
|
+
for (const old of Array.from(doc.querySelectorAll('[data-dme-dock]')) as Array<{ remove?: () => void }>) old.remove?.()
|
|
938
|
+
const el = doc.createElement('div')
|
|
939
|
+
el.setAttribute('data-dme-dock', '1')
|
|
940
|
+
el.setAttribute('data-dme-own', '1')
|
|
941
|
+
el.style.cssText = 'position:fixed;z-index:1140;display:none;align-items:center;gap:6px;padding:4px 8px;border-radius:11px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35));background:var(--dsw-alias-bg-layer-1, #2c2c2e);box-shadow:0 8px 24px rgba(0,0,0,0.24);font:11px var(--dsw-font-family, system-ui);color:var(--dsw-alias-label-primary, #eee)'
|
|
942
|
+
doc.body.appendChild(el)
|
|
943
|
+
dockEl = el
|
|
944
|
+
return el
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** One update per frame at most, the way the reference plugin schedules it. */
|
|
948
|
+
function scheduleDock(): void {
|
|
949
|
+
const raf = (globalThis as { requestAnimationFrame?: (cb: () => void) => number }).requestAnimationFrame
|
|
950
|
+
if (dockFrame !== undefined || typeof raf !== 'function') { updateDockChip(); return }
|
|
951
|
+
dockFrame = raf(() => { dockFrame = undefined; updateDockChip() })
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Anchors the layer on the composer card, and follows it when that card changes size. */
|
|
955
|
+
function observeDockAnchor(): void {
|
|
956
|
+
const doc = (globalThis as { document?: any }).document
|
|
957
|
+
const card = doc?.querySelector?.('[data-composer-card]')
|
|
958
|
+
if (card === null || card === undefined) return
|
|
959
|
+
const Observer = (globalThis as { ResizeObserver?: new (cb: () => void) => { observe: (t: unknown) => void; disconnect: () => void } }).ResizeObserver
|
|
960
|
+
if (Observer === undefined) return
|
|
961
|
+
try {
|
|
962
|
+
dockObserver?.disconnect()
|
|
963
|
+
const fresh = new Observer(() => { scheduleDock() })
|
|
964
|
+
fresh.observe(card)
|
|
965
|
+
dockObserver = fresh
|
|
966
|
+
} catch { dockObserver = undefined }
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function updateDockChip(): void {
|
|
970
|
+
const state = dockReact
|
|
971
|
+
const el = ensureDock()
|
|
972
|
+
if (state === undefined || el === undefined) return
|
|
973
|
+
const doc = (globalThis as { document?: any }).document
|
|
974
|
+
if (state.count === 0) {
|
|
975
|
+
if (dockReceipt === '') { el.style.display = 'none'; return }
|
|
976
|
+
// The receipt is text only: nothing to click, nothing to send.
|
|
977
|
+
el.textContent = ''
|
|
978
|
+
const note = doc.createElement('span')
|
|
979
|
+
note.textContent = dockReceipt
|
|
980
|
+
note.style.cssText = 'opacity:.75'
|
|
981
|
+
el.appendChild(note)
|
|
982
|
+
el.style.display = 'flex'
|
|
983
|
+
const cardRect = doc.querySelector('[data-composer-card]')?.getBoundingClientRect?.()
|
|
984
|
+
if (cardRect === undefined) { el.style.left = '16px'; el.style.top = '16px'; return }
|
|
985
|
+
const w = Number(el.offsetWidth) || 100
|
|
986
|
+
const h = Number(el.offsetHeight) || 24
|
|
987
|
+
el.style.left = Math.max(8, Math.min(cardRect.left, Number((globalThis as { innerWidth?: number }).innerWidth ?? 0) - w - 8)) + 'px'
|
|
988
|
+
el.style.top = Math.max(8, cardRect.top - h - 8) + 'px'
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
const create = (tag: string): any => doc.createElement(tag)
|
|
992
|
+
el.textContent = ''
|
|
993
|
+
const toggle = create('button')
|
|
994
|
+
toggle.textContent = t('pending') + ' ×' + String(state.count)
|
|
995
|
+
toggle.style.cssText = 'font:11px inherit;padding:1px 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35));background:transparent;color:inherit;cursor:pointer'
|
|
996
|
+
toggle.addEventListener('click', () => state.handlers.onToggle())
|
|
997
|
+
el.appendChild(toggle)
|
|
998
|
+
const send = create('button')
|
|
999
|
+
send.textContent = t('send')
|
|
1000
|
+
send.style.cssText = 'font:11px inherit;padding:1px 8px;border-radius:6px;border:1px solid var(--dsw-alias-brand-primary,#2563eb);background:var(--dsw-alias-brand-primary,#2563eb);color:#fff;cursor:pointer'
|
|
1001
|
+
send.addEventListener('click', () => state.handlers.onSend())
|
|
1002
|
+
el.appendChild(send)
|
|
1003
|
+
if (state.open && state.items.length > 0) {
|
|
1004
|
+
for (const [index, item] of state.items.entries()) {
|
|
1005
|
+
const line = create('span')
|
|
1006
|
+
line.textContent = item.nodeId + ':' + (item.note === '' ? t('markedOnly') : item.note)
|
|
1007
|
+
line.title = '点击删除'
|
|
1008
|
+
line.style.cssText = 'cursor:pointer;opacity:.85'
|
|
1009
|
+
line.addEventListener('click', () => state.handlers.onRemove(index))
|
|
1010
|
+
el.appendChild(line)
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
el.style.display = 'flex'
|
|
1014
|
+
observeDockAnchor()
|
|
1015
|
+
// Same seat as the annotation plugin, read from its source: the composer card, and the seat that holds it.
|
|
1016
|
+
// Above the card, right-aligned to it, 6px clear - which is where that plugin's own count sits.
|
|
1017
|
+
const surface = (findComposer(doc) ?? doc.querySelector('textarea, [contenteditable="true"]')) as any
|
|
1018
|
+
const rect = surface?.getBoundingClientRect?.()
|
|
1019
|
+
const cardEl = (doc.querySelector('[data-composer-card]') ?? null) as any
|
|
1020
|
+
const seatEl = (cardEl?.closest?.('[data-composer-seat]') ?? null) as any
|
|
1021
|
+
const cardRect = cardEl?.getBoundingClientRect?.() ?? null
|
|
1022
|
+
void surface
|
|
1023
|
+
const width = Number(el.offsetWidth) || 100
|
|
1024
|
+
const height = Number(el.offsetHeight) || 26
|
|
1025
|
+
if (rect === undefined && cardRect === null) { el.style.left = '16px'; el.style.top = '16px'; return }
|
|
1026
|
+
const view = globalThis as { innerWidth?: number }
|
|
1027
|
+
const anchor = cardRect ?? rect
|
|
1028
|
+
const seatTop = seatEl?.getBoundingClientRect?.()?.top
|
|
1029
|
+
const topEdge = Math.min(anchor.top, typeof seatTop === 'number' ? seatTop : anchor.top)
|
|
1030
|
+
// Left-aligned to the card, because the annotation plugin's own count sits on the right of the same strip and
|
|
1031
|
+
// two right-aligned controls would cover each other. Same row, opposite ends.
|
|
1032
|
+
const left = Math.max(8, Math.min(anchor.left, Number(view.innerWidth ?? 0) - width - 8))
|
|
1033
|
+
el.style.left = left + 'px'
|
|
1034
|
+
el.style.top = Math.max(8, topEdge - height - 6) + 'px'
|
|
1035
|
+
observeDivider(doc)
|
|
1036
|
+
observeSurface(doc, surface)
|
|
1037
|
+
// Before the composer has a box the rectangle is degenerate and the control lands at the edge. Retry until the
|
|
1038
|
+
// number stops looking like "not laid out yet" - bounded, so a host that never lays out does not spin.
|
|
1039
|
+
const scope2 = globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown }
|
|
1040
|
+
if (left <= 8 && dockRetries < 12 && typeof scope2.setTimeout === 'function') {
|
|
1041
|
+
dockRetries += 1
|
|
1042
|
+
scope2.setTimeout(() => { try { updateDockChip() } catch { /* keeps the last placement */ } }, 250)
|
|
1043
|
+
} else if (left > 8) {
|
|
1044
|
+
dockRetries = 0
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** Keep the docked count in step with the pending list. Called from a render path, so it cannot throw. */
|
|
1049
|
+
export function updateDockLayer(items: Array<{ nodeId: string; note: string }>, open: boolean, handlers: DockHandlers): void {
|
|
1050
|
+
dockReact = { count: items.length, open, items, handlers }
|
|
1051
|
+
bumpDock()
|
|
1052
|
+
if (dockSlotMounted) return
|
|
1053
|
+
try { scheduleDock() } catch { /* a lost badge never costs the drawing */ }
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** Set by the slot component while it is mounted; the body layer stands down when it is. */
|
|
1057
|
+
let dockSlotMounted = false
|
|
1058
|
+
let dockVersion = 0
|
|
1059
|
+
const dockSubs = new Set<() => void>()
|
|
1060
|
+
|
|
1061
|
+
function bumpDock(): void {
|
|
1062
|
+
dockVersion += 1
|
|
1063
|
+
for (const listener of Array.from(dockSubs)) {
|
|
1064
|
+
try { listener() } catch { /* one listener must not stop the others */ }
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* The count, rendered into the host's dock slot.
|
|
1070
|
+
*
|
|
1071
|
+
* Same state as the body layer, so the card drives one source of truth and the host decides where it goes.
|
|
1072
|
+
*/
|
|
1073
|
+
function dockSlotView(React: ReactLike): () => unknown {
|
|
1074
|
+
return function DockSlot(): unknown {
|
|
1075
|
+
const [, setVersion] = (React.useState ?? ((initial: number) => [initial, () => {}] as [number, (next: number) => void]))(0) as [number, (next: number) => void]
|
|
1076
|
+
React.useEffect?.(() => {
|
|
1077
|
+
dockSlotMounted = true
|
|
1078
|
+
const listener = (): void => setVersion(dockVersion)
|
|
1079
|
+
dockSubs.add(listener)
|
|
1080
|
+
return () => { dockSubs.delete(listener); dockSlotMounted = false }
|
|
1081
|
+
}, [])
|
|
1082
|
+
// The slot is full-width; the composer card is inset inside it. The count aligns with the card, so the
|
|
1083
|
+
// insets are measured from the card's own box rather than guessed - and re-measured when either side
|
|
1084
|
+
// changes size, which is what a dragged divider does.
|
|
1085
|
+
const [pad, setPad] = (React.useState ?? ((initial: { left: number; right: number }) => [initial, () => {}]))({ left: 0, right: 0 }) as [{ left: number; right: number }, (next: { left: number; right: number }) => void]
|
|
1086
|
+
const [diag, setDiag] = (React.useState ?? ((initial: string) => [initial, () => {}]))('') as [string, (next: string) => void]
|
|
1087
|
+
React.useEffect?.(() => {
|
|
1088
|
+
const doc = (globalThis as { document?: any }).document
|
|
1089
|
+
const timer = (globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown }).setTimeout
|
|
1090
|
+
const measure = (): void => {
|
|
1091
|
+
try {
|
|
1092
|
+
const slot = doc?.querySelector?.('[data-dme-dock-slot]')
|
|
1093
|
+
// Same two rectangles the render uses, so a measurement taken here and a measurement taken there agree.
|
|
1094
|
+
const card = (findComposer(doc) ?? doc?.querySelector?.('textarea, [contenteditable="true"]')) as any
|
|
1095
|
+
if (slot === null || slot === undefined || card === null || card === undefined) return
|
|
1096
|
+
const s = slot.getBoundingClientRect()
|
|
1097
|
+
const c = card.getBoundingClientRect()
|
|
1098
|
+
const next = { left: Math.max(0, Math.round(c.left - s.left)), right: Math.max(0, Math.round(s.right - c.right)) }
|
|
1099
|
+
setPad(next)
|
|
1100
|
+
// The numbers are shown in the control itself: a screenshot then answers what a console command would,
|
|
1101
|
+
// and this loop stops being guesswork.
|
|
1102
|
+
setDiag(' L' + String(next.left) + ' R' + String(next.right) + ' s' + String(Math.round(s.left)) + ' c' + String(Math.round(c.left)) + ' w' + String(Math.round(c.width)))
|
|
1103
|
+
} catch { /* unmeasured insets are a cosmetic miss */ }
|
|
1104
|
+
}
|
|
1105
|
+
// The composer can be laid out after this mounts: the first measurements come back 0 and the control sits
|
|
1106
|
+
// at the column edge until some unrelated change forces a re-render (a pasted picture did). Retry until the
|
|
1107
|
+
// number settles instead of hoping one measurement lands.
|
|
1108
|
+
if (typeof timer === 'function') { for (const delay of [0, 100, 250, 500, 900, 1500, 2400]) timer(measure, delay) }
|
|
1109
|
+
const scope = globalThis as { addEventListener?: (t: string, fn: () => void) => void; removeEventListener?: (t: string, fn: () => void) => void }
|
|
1110
|
+
scope.addEventListener?.('resize', measure)
|
|
1111
|
+
const Observer = (globalThis as { ResizeObserver?: new (cb: () => void) => { observe: (t: unknown) => void; disconnect: () => void } }).ResizeObserver
|
|
1112
|
+
let watcher: { observe: (t: unknown) => void; disconnect: () => void } | undefined
|
|
1113
|
+
if (Observer !== undefined) {
|
|
1114
|
+
try {
|
|
1115
|
+
watcher = new Observer(measure)
|
|
1116
|
+
watcher.observe(doc?.body)
|
|
1117
|
+
} catch { watcher = undefined }
|
|
1118
|
+
}
|
|
1119
|
+
return () => { watcher?.disconnect(); scope.removeEventListener?.('resize', measure) }
|
|
1120
|
+
}, [])
|
|
1121
|
+
const state = dockReact
|
|
1122
|
+
if (state === undefined || state.count === 0) return null
|
|
1123
|
+
// Measured at render time, not only in an effect: the effect can run before the host has laid the slot out,
|
|
1124
|
+
// and a zero measured at that moment is then the value the control keeps.
|
|
1125
|
+
let livePad = pad
|
|
1126
|
+
try {
|
|
1127
|
+
const doc = (globalThis as { document?: any }).document
|
|
1128
|
+
const slotEl = doc?.querySelector?.('[data-dme-dock-slot]')
|
|
1129
|
+
const surfaceEl = (findComposer(doc) ?? doc?.querySelector?.('textarea, [contenteditable="true"]')) as any
|
|
1130
|
+
const sr = slotEl?.getBoundingClientRect?.()
|
|
1131
|
+
const cr = surfaceEl?.getBoundingClientRect?.()
|
|
1132
|
+
if (sr !== undefined && sr !== null && cr !== undefined && cr !== null) {
|
|
1133
|
+
livePad = { left: Math.max(0, Math.round(cr.left - sr.left)), right: Math.max(0, Math.round(sr.right - cr.right)) }
|
|
1134
|
+
}
|
|
1135
|
+
} catch { /* the effect's value stands */ }
|
|
1136
|
+
return React.createElement(
|
|
1137
|
+
'div',
|
|
1138
|
+
{
|
|
1139
|
+
'data-dme-own': '1',
|
|
1140
|
+
'data-dme-dock-slot': '1',
|
|
1141
|
+
style: { display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: '6px', paddingTop: '2px', paddingBottom: '2px', paddingLeft: String(livePad.left) + 'px', paddingRight: String(livePad.right) + 'px', font: '11px system-ui, sans-serif', color: 'inherit' },
|
|
1142
|
+
},
|
|
1143
|
+
React.createElement('button', {
|
|
1144
|
+
type: 'button',
|
|
1145
|
+
onClick: () => state.handlers.onToggle(),
|
|
1146
|
+
style: { font: 'inherit', padding: '1px 8px', borderRadius: '9px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: 'inherit', cursor: 'pointer' },
|
|
1147
|
+
}, t('pending') + ' ×' + String(state.count) + ' L' + String(livePad.left) + diag),
|
|
1148
|
+
React.createElement('button', {
|
|
1149
|
+
type: 'button',
|
|
1150
|
+
onClick: () => state.handlers.onSend(),
|
|
1151
|
+
style: { font: 'inherit', padding: '1px 8px', borderRadius: '9px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: 'inherit', cursor: 'pointer' },
|
|
1152
|
+
}, t('send')),
|
|
1153
|
+
)
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* The composer's card: the nearest ancestor of the writing surface that looks like a box.
|
|
1159
|
+
*
|
|
1160
|
+
* Hosts mark it differently - one ships `[data-composer-card]`, another marks nothing - but every one of them
|
|
1161
|
+
* draws the input as a rounded card inside the column. Walking up for a border radius finds that box without
|
|
1162
|
+
* depending on an attribute name.
|
|
1163
|
+
*/
|
|
1164
|
+
function composerBox(doc: any): any {
|
|
1165
|
+
try {
|
|
1166
|
+
const marked = doc?.querySelector?.('[data-composer-card]')
|
|
1167
|
+
if (marked !== null && marked !== undefined) return marked
|
|
1168
|
+
// The column is the full-width reference; the card is the widest ancestor of the writing surface that is
|
|
1169
|
+
// narrower than that column. Measured both ways round - no constant, and a dragged divider changes both.
|
|
1170
|
+
const column = doc?.querySelector?.('[data-conversation-scroll]')?.getBoundingClientRect?.()
|
|
1171
|
+
let node = findComposer(doc) ?? doc?.querySelector?.('textarea, [contenteditable="true"]')
|
|
1172
|
+
let card = node
|
|
1173
|
+
for (let depth = 0; depth < 8 && node !== null && node !== undefined; depth += 1) {
|
|
1174
|
+
const rect = node.getBoundingClientRect?.()
|
|
1175
|
+
if (rect !== undefined && rect.width > 0) {
|
|
1176
|
+
const inset = column === undefined ? false : rect.width < column.width - 4
|
|
1177
|
+
if (inset) card = node
|
|
1178
|
+
}
|
|
1179
|
+
node = node.parentElement
|
|
1180
|
+
}
|
|
1181
|
+
return card
|
|
1182
|
+
} catch {
|
|
1183
|
+
return undefined
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
let dividerObserved: unknown
|
|
1188
|
+
let surfaceObserved: unknown
|
|
1189
|
+
let surfaceInputWatched = false
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Re-place when the composer changes height.
|
|
1193
|
+
*
|
|
1194
|
+
* A pasted picture grows the box and the control has to move up with it. The surface alone is not enough:
|
|
1195
|
+
* some hosts grow a wrapper and let the inner element keep its size, so the wrapper is watched too.
|
|
1196
|
+
*/
|
|
1197
|
+
function observeSurface(doc: any, surface: any): void {
|
|
1198
|
+
try {
|
|
1199
|
+
if (surface === undefined || surface === null || surfaceObserved === surface) return
|
|
1200
|
+
surfaceObserved = surface
|
|
1201
|
+
// A pasted picture grows whichever ancestor the host chose, and the surface itself may keep its size while
|
|
1202
|
+
// only moving down - which fires no resize at all. Watch a few levels up, and listen to the paste itself.
|
|
1203
|
+
// Two levels only: the surface and the box around it. Watching further up fired on unrelated layout changes
|
|
1204
|
+
// and moved the control to places the reader did not ask for.
|
|
1205
|
+
const targets: any[] = [surface, surface?.parentElement].filter((node) => node !== null && node !== undefined)
|
|
1206
|
+
const Observer = (globalThis as { ResizeObserver?: new (cb: () => void) => { observe: (t: unknown) => void } }).ResizeObserver
|
|
1207
|
+
if (Observer !== undefined) {
|
|
1208
|
+
const watcher = new Observer(() => { scheduleDock() })
|
|
1209
|
+
for (const target of targets) { try { watcher.observe(target) } catch { /* one target is enough to try */ } }
|
|
1210
|
+
}
|
|
1211
|
+
const scope = globalThis as { addEventListener?: (t: string, fn: () => void) => void; setTimeout?: (fn: () => void, ms: number) => unknown }
|
|
1212
|
+
if (typeof scope.addEventListener === 'function' && !surfaceInputWatched) {
|
|
1213
|
+
surfaceInputWatched = true
|
|
1214
|
+
// `input` covers paste, typing and deletion; two delayed passes catch a layout that settles after it.
|
|
1215
|
+
scope.addEventListener('input', () => { scheduleDock(); scope.setTimeout?.(() => { scheduleDock() }, 120); scope.setTimeout?.(() => { scheduleDock() }, 400) })
|
|
1216
|
+
scope.addEventListener('paste', () => { scope.setTimeout?.(() => { scheduleDock() }, 60) })
|
|
1217
|
+
}
|
|
1218
|
+
} catch { /* no observer, no follow */ }
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Re-place after a divider drag.
|
|
1223
|
+
*
|
|
1224
|
+
* The host's column divider is `[class*="widthHandle"]`. Watching its size catches the drag itself; a
|
|
1225
|
+
* pointerup catches the release, in case the layout settles after the last size change.
|
|
1226
|
+
*/
|
|
1227
|
+
function observeDivider(doc: any): void {
|
|
1228
|
+
try {
|
|
1229
|
+
const handle = doc?.querySelector?.('[class*="widthHandle"]')
|
|
1230
|
+
if (handle === null || handle === undefined || dividerObserved === handle) return
|
|
1231
|
+
dividerObserved = handle
|
|
1232
|
+
const Observer = (globalThis as { ResizeObserver?: new (cb: () => void) => { observe: (t: unknown) => void } }).ResizeObserver
|
|
1233
|
+
if (Observer !== undefined) {
|
|
1234
|
+
try { new Observer(() => { scheduleDock() }).observe(handle) } catch { /* no observer, no drag follow */ }
|
|
1235
|
+
}
|
|
1236
|
+
const scope = globalThis as { addEventListener?: (t: string, fn: () => void) => void }
|
|
1237
|
+
scope.addEventListener?.('pointerup', () => { scheduleDock() })
|
|
1238
|
+
} catch { /* a missing handle is a layout that never changes */ }
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* One click, one paste: what the page actually contains.
|
|
1243
|
+
*
|
|
1244
|
+
* Every layout question so far cost a screenshot and a guess. The facts that decide those questions are cheap to
|
|
1245
|
+
* collect - which anchors exist, where they are, whether our nodes are still in the document - so they are
|
|
1246
|
+
* collected here and shown as selectable text.
|
|
1247
|
+
*/
|
|
1248
|
+
function domReport(): string {
|
|
1249
|
+
const doc = (globalThis as { document?: any }).document
|
|
1250
|
+
if (doc === undefined) return 'no document'
|
|
1251
|
+
const lines: string[] = []
|
|
1252
|
+
const rect = (el: any): string => {
|
|
1253
|
+
const r = el?.getBoundingClientRect?.()
|
|
1254
|
+
return r === undefined || r === null ? 'absent' : [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)].join(',')
|
|
1255
|
+
}
|
|
1256
|
+
const probe = (name: string, selector: string): void => {
|
|
1257
|
+
const el = doc.querySelector(selector)
|
|
1258
|
+
lines.push(name + ' ' + (el === null || el === undefined ? 'absent' : 'l,t,w,h=' + rect(el)))
|
|
1259
|
+
}
|
|
1260
|
+
probe('[data-composer-card]', '[data-composer-card]')
|
|
1261
|
+
probe('[data-composer-seat]', '[data-composer-seat]')
|
|
1262
|
+
probe('textarea/contenteditable', 'textarea, [contenteditable="true"]')
|
|
1263
|
+
probe('[data-annotation-chip]', '[data-annotation-chip]')
|
|
1264
|
+
probe('[data-conversation-scroll]', '[data-conversation-scroll]')
|
|
1265
|
+
probe('widthHandle', '[class*="widthHandle"]')
|
|
1266
|
+
const dock = doc.querySelector('[data-dme-dock]')
|
|
1267
|
+
lines.push('dock ' + (dock === null ? 'absent' : 'l,t,w,h=' + rect(dock) + ' connected=' + String(dock.isConnected)))
|
|
1268
|
+
const chips = doc.querySelectorAll('[data-dme-chip]')
|
|
1269
|
+
lines.push('chips ' + String(chips.length) + ' ' + Array.from(chips).map((chip) => rect(chip)).join(' | '))
|
|
1270
|
+
lines.push('pendingBlock ' + String(pendingBlock === '' ? 'empty' : pendingBlock.split(String.fromCharCode(10)).length + ' lines'))
|
|
1271
|
+
lines.push('chipAttempt ' + chipAttempt)
|
|
1272
|
+
lines.push('dockedCount ' + String(dockReact?.count ?? 0) + ' placedText ' + String(placedText.length))
|
|
1273
|
+
lines.push('viewport ' + String((globalThis as { innerWidth?: number }).innerWidth) + 'x' + String((globalThis as { innerHeight?: number }).innerHeight))
|
|
1274
|
+
const report = lines.join(String.fromCharCode(10))
|
|
1275
|
+
try { (globalThis as { console?: { log?: (value: string) => void } }).console?.log?.(report) } catch { /* console may be gone */ }
|
|
1276
|
+
return report
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function refreshDock(): void { scheduleDock() }
|
|
1280
|
+
function installEnterCapture(): void {
|
|
1281
|
+
if (pendingBlock === '') return
|
|
1282
|
+
const doc = (globalThis as { document?: DocumentLike }).document
|
|
1283
|
+
const composer = findComposer(doc)
|
|
1284
|
+
if (composer === undefined || composer === null) return
|
|
1285
|
+
if (enterWatch === composer) return
|
|
1286
|
+
enterWatch = composer
|
|
1287
|
+
const listen = (composer as { addEventListener?: (type: string, handler: (event: unknown) => void, capture?: boolean) => void }).addEventListener
|
|
1288
|
+
if (typeof listen !== 'function') return
|
|
1289
|
+
listen.call(composer, 'keydown', (event: unknown) => {
|
|
1290
|
+
const key = event as { key?: string; shiftKey?: boolean; isComposing?: boolean }
|
|
1291
|
+
if (key?.key !== 'Enter' || key.shiftKey === true || key.isComposing === true) return
|
|
1292
|
+
if (pendingBlock === '') return
|
|
1293
|
+
// Written, not submitted: the host's own Enter handling submits right after this returns.
|
|
1294
|
+
// With nothing typed, a bare "Question:" would be the message's last line and would invite an answer
|
|
1295
|
+
// nobody asked for - the same rule the click path applies.
|
|
1296
|
+
const shell = draftShell()
|
|
1297
|
+
const draft = String(shell?.state?.getSnapshot?.()?.draft ?? '')
|
|
1298
|
+
const block = draft.trim() === '' ? withoutEmptyQuestion(pendingBlock) : pendingBlock
|
|
1299
|
+
replaceBlock(block)
|
|
1300
|
+
// The count has to appear for this message too: pressing Enter is the path most readers take, and a
|
|
1301
|
+
// marker that only answers the button is a marker half the messages never get.
|
|
1302
|
+
const count = (block.match(/^Diagram note \[/gm) ?? []).length
|
|
1303
|
+
const label = '图注 ×' + String(count === 0 ? 1 : count)
|
|
1304
|
+
// The below-bubble insert is the one that lines up with the message text: it lands inside the element that
|
|
1305
|
+
// already carries the text's padding. The sibling insert sat at the row's edge instead. One of them, and it
|
|
1306
|
+
// is this one.
|
|
1307
|
+
attachBelowBubble(label, draft)
|
|
1308
|
+
void attachSentChip
|
|
1309
|
+
notifySent()
|
|
1310
|
+
// foldOnce() is off: rewriting the message text made the bubble flicker back to the raw block on the next
|
|
1311
|
+
// host render, and every re-fold landed beside the chip. The chip is the feature; the text stays as sent.
|
|
1312
|
+
pendingBlock = ''
|
|
1313
|
+
}, true)
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/** Tell the surfaces that a note left, so their own chrome can say so. */
|
|
1317
|
+
function notifySent(): void {
|
|
1318
|
+
// Clear the dock here as well as through React: the card can unmount while the message is being sent (the
|
|
1319
|
+
// tool row collapses), and a listener that never runs leaves a count for a note that already left.
|
|
1320
|
+
if (dockReact !== undefined) {
|
|
1321
|
+
const before = dockReact.count
|
|
1322
|
+
dockReact = { count: 0, open: false, items: [], handlers: dockReact.handlers }
|
|
1323
|
+
// A note that left keeps a short-lived receipt in this plugin's own layer. The bubble is not the place for
|
|
1324
|
+
// it: the transcript is rebuilt by the host and by any other plugin folding the same message, and a chip
|
|
1325
|
+
// planted there survives only until the next re-render.
|
|
1326
|
+
if (before > 0) publishDockReceipt('图注 ×' + String(before) + ' 已发送')
|
|
1327
|
+
try { scheduleDock() } catch { /* the count is a convenience */ }
|
|
1328
|
+
}
|
|
1329
|
+
for (const listener of sentListeners.slice()) {
|
|
1330
|
+
try { listener() } catch { /* one listener must not keep the others from hearing it */ }
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function placeListBlock(list: Array<{ nodeId: string; note: string }>, caption: string): boolean {
|
|
1335
|
+
return replaceBlock(list.length === 0 ? '' : annotationBlock(list.map((item) => ({ ...item, caption }))))
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function placeBlock(text: string): boolean {
|
|
1339
|
+
// With nothing typed, a trailing question line would be the only thing left to read: drop it before placing.
|
|
1340
|
+
const shell = draftShell()
|
|
1341
|
+
const snapshot = shell?.state?.getSnapshot?.()
|
|
1342
|
+
const current = typeof snapshot?.draft === 'string' ? String(snapshot.draft) : ''
|
|
1343
|
+
return replaceBlock(current.trim() === '' ? withoutEmptyQuestion(text) : text)
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/** Listeners waiting for the message to leave. */
|
|
1347
|
+
let sentListeners: Array<() => void> = []
|
|
1348
|
+
let watchState: 'idle' | 'watching' | 'unavailable' = 'idle'
|
|
1349
|
+
let lastSeenDraft = ''
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* Tell this module when a message leaves, by watching the one signal the host gives: the draft empties.
|
|
1353
|
+
*
|
|
1354
|
+
* A note that was committed is in the draft, so after the send there is nothing left to keep - and a chip still
|
|
1355
|
+
* counting a note that has already gone out is the plugin lying about its own state.
|
|
1356
|
+
*/
|
|
1357
|
+
export function onMessageSent(listener: () => void): () => void {
|
|
1358
|
+
sentListeners.push(listener)
|
|
1359
|
+
watchDraft()
|
|
1360
|
+
return () => { sentListeners = sentListeners.filter((item) => item !== listener) }
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function watchDraft(): void {
|
|
1364
|
+
if (watchState !== 'idle') return
|
|
1365
|
+
const shell = draftShell()
|
|
1366
|
+
const subscribe = shell?.state?.subscribe
|
|
1367
|
+
if (typeof subscribe !== 'function') {
|
|
1368
|
+
// A host that has not loaded its conversation yet is retried by the next caller, not by a timer.
|
|
1369
|
+
return
|
|
1370
|
+
}
|
|
1371
|
+
watchState = 'watching'
|
|
1372
|
+
try {
|
|
1373
|
+
lastSeenDraft = String(shell?.state?.getSnapshot?.()?.draft ?? '')
|
|
1374
|
+
subscribe(() => {
|
|
1375
|
+
const draft = String(shell?.state?.getSnapshot?.()?.draft ?? '')
|
|
1376
|
+
const before = lastSeenDraft
|
|
1377
|
+
lastSeenDraft = draft
|
|
1378
|
+
if (before.trim() === '' || draft.trim() !== '') return
|
|
1379
|
+
// Only a send clears the count. An empty box that Backspace or a deletion empties is not a send, and
|
|
1380
|
+
// treating it as one deleted the reader's note without sending anything.
|
|
1381
|
+
if (placedText === '') return
|
|
1382
|
+
placedText = ''
|
|
1383
|
+
for (const listener of sentListeners.slice()) {
|
|
1384
|
+
try { listener() } catch { /* one listener must not keep the others from hearing it */ }
|
|
1385
|
+
}
|
|
1386
|
+
})
|
|
1387
|
+
} catch {
|
|
1388
|
+
watchState = 'unavailable'
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Send the block through the session's command channel, which never touches the composer.
|
|
1394
|
+
*
|
|
1395
|
+
* The client cannot call a host service directly; the remote facade is the only route, and its signature is
|
|
1396
|
+
* `execute(sessionId, line, attachments)` - the session id first, because the facade resolves the agent itself.
|
|
1397
|
+
*
|
|
1398
|
+
* Returns the reason it worked, or undefined. Undefined means "use the composer": a command that silently did
|
|
1399
|
+
* nothing would lose the notes, and this function is the only thing standing between a note and the void.
|
|
1400
|
+
*/
|
|
1401
|
+
let commandWhy = 'not tried'
|
|
1402
|
+
|
|
1403
|
+
async function sendViaCommand(text: string): Promise<string | undefined> {
|
|
1404
|
+
commandWhy = 'not tried'
|
|
1405
|
+
const sessions = services?.get?.('sessions') as { scope?: (id: string) => unknown } | undefined
|
|
1406
|
+
const id = currentSessionId()
|
|
1407
|
+
if (id === undefined) { commandWhy = 'no session id'; return undefined }
|
|
1408
|
+
if (typeof sessions?.scope !== 'function') { commandWhy = 'no sessions service'; return undefined }
|
|
1409
|
+
// The remote facade hangs off the CLIENT context, not off a session scope: page-side code writes
|
|
1410
|
+
// `ctx.remote.commands.execute(sessionId, line, attachments)` and the facade resolves the agent itself
|
|
1411
|
+
// (that is how ui-commands calls it). The scope route stays as a second door for a host that mounts it there.
|
|
1412
|
+
let commands: { execute?: unknown } | undefined
|
|
1413
|
+
commands = (services as unknown as { remote?: { commands?: { execute?: unknown } } } | undefined)?.remote?.commands
|
|
1414
|
+
if (commands === undefined || typeof commands.execute !== 'function') {
|
|
1415
|
+
try {
|
|
1416
|
+
commands = ((sessions.scope(id) as { remote?: { commands?: { execute?: unknown } } } | undefined)?.remote)?.commands
|
|
1417
|
+
} catch {
|
|
1418
|
+
return undefined
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
if (commands === undefined || typeof commands.execute !== 'function') { commandWhy = 'no remote.commands.execute'; return undefined }
|
|
1422
|
+
try {
|
|
1423
|
+
// A remote call that never settles would take the button with it: the note would sit in no draft and no
|
|
1424
|
+
// message, and the reader would see a click that did nothing. The wait is bounded, and a timeout is just
|
|
1425
|
+
// another reason to fall back to the composer.
|
|
1426
|
+
const timeout = new Promise<string>((resolve) => {
|
|
1427
|
+
const timer = (globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown }).setTimeout
|
|
1428
|
+
if (typeof timer === 'function') timer(() => resolve('timeout'), 1200)
|
|
1429
|
+
else resolve('timeout')
|
|
1430
|
+
})
|
|
1431
|
+
const raced = await Promise.race([
|
|
1432
|
+
(commands.execute as (session: string, line: string, attachments: unknown[]) => Promise<{ kind?: string } | undefined>)(id, '/annotate ' + text, []),
|
|
1433
|
+
timeout,
|
|
1434
|
+
])
|
|
1435
|
+
if (raced === 'timeout') { commandWhy = 'execute timed out'; return undefined }
|
|
1436
|
+
const result = typeof raced === 'string' ? undefined : raced
|
|
1437
|
+
if (result?.kind === 'success') return '已通过会话命令发送(输入框未占用)'
|
|
1438
|
+
commandWhy = 'execute said ' + String(result?.kind ?? 'nothing')
|
|
1439
|
+
return undefined
|
|
1440
|
+
} catch (error) {
|
|
1441
|
+
commandWhy = 'execute threw: ' + String((error as { message?: unknown })?.message ?? error).slice(0, 60)
|
|
1442
|
+
return undefined
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/** Ask the host to submit the draft it now holds. */
|
|
1447
|
+
function submitDraft(): boolean {
|
|
1448
|
+
const shell = draftShell()
|
|
1449
|
+
if (shell === undefined || typeof shell.submit !== 'function') return false
|
|
1450
|
+
try {
|
|
1451
|
+
shell.submit('queue')
|
|
1452
|
+
// foldOnce() is off: rewriting the message text made the bubble flicker back to the raw block on the next
|
|
1453
|
+
// host render, and every re-fold landed beside the chip. The chip is the feature; the text stays as sent.
|
|
1454
|
+
return true
|
|
1455
|
+
} catch {
|
|
1456
|
+
return false
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function shortAddress(location: { pathname?: string; search?: string } | undefined): string {
|
|
1461
|
+
if (location === undefined) return 'no location'
|
|
1462
|
+
return ((location.pathname ?? '') + (location.search ?? '')).slice(0, 80)
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function reasonOf(error: unknown): string {
|
|
1466
|
+
const message = (error as { message?: unknown } | null)?.message
|
|
1467
|
+
return String(message ?? error).slice(0, 120)
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Try the session-command channel, and answer either `'ok'` or the reason it stopped.
|
|
1472
|
+
*
|
|
1473
|
+
* The client half sees no host API directly: its only route is a client service, and the only session it can
|
|
1474
|
+
* name is the one in the page's own address. Every step here answers with its reason, and the reader sees
|
|
1475
|
+
* that reason - so one click says whether the channel exists at all, and where it broke.
|
|
1476
|
+
*/
|
|
1477
|
+
async function probePush(text: string): Promise<string> {
|
|
1478
|
+
const location = (globalThis as { location?: { pathname?: string; search?: string; hash?: string } }).location
|
|
1479
|
+
const session = sessionFromLocation(location)
|
|
1480
|
+
if (session === undefined) return 'no session id in ' + shortAddress(location)
|
|
1481
|
+
const sessions = services?.get?.('sessions') as { scope?: (id: string) => unknown } | undefined
|
|
1482
|
+
if (sessions === undefined || typeof sessions.scope !== 'function') return 'no sessions service'
|
|
1483
|
+
let scope: unknown
|
|
1484
|
+
try { scope = sessions.scope(session) } catch (error) { return 'scope threw: ' + reasonOf(error) }
|
|
1485
|
+
const remote = (scope as { remote?: { commands?: { execute?: unknown } } } | undefined)?.remote
|
|
1486
|
+
if (remote === undefined) return 'the agent scope has no remote'
|
|
1487
|
+
const commands = remote.commands
|
|
1488
|
+
if (commands === undefined || typeof commands.execute !== 'function') return 'remote.commands.execute is missing'
|
|
1489
|
+
const Controller = (globalThis as { AbortController?: new () => { signal: unknown } }).AbortController
|
|
1490
|
+
try {
|
|
1491
|
+
await (commands.execute as (agent: unknown, line: string, attachments: unknown[], signal: unknown) => Promise<unknown>).call(commands, { id: session }, '/annotate ' + text, [], Controller === undefined ? undefined : new Controller().signal)
|
|
1492
|
+
return 'ok'
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
return 'execute threw: ' + reasonOf(error)
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
export function viewFor(React: ReactLike, overrides: Partial<Palette> = {}): (props: ToolCallProps) => unknown {
|
|
1499
|
+
return (props: ToolCallProps): unknown => {
|
|
1500
|
+
// A drawing is not a report: its card is the source, drawn by the renderer that wrote the file.
|
|
1501
|
+
if (props.toolName === DRAWING_TOOL) return drawingCard(React, props, overrides)
|
|
1502
|
+
// The whole block goes to the view model: the host shapes it differently per phase (a lazy arguments
|
|
1503
|
+
// view while running, content blocks once settled), and only that module knows how to read it.
|
|
1504
|
+
const diagram = viewModel({ phase: props.phase, toolName: props.toolName, block: props.block })
|
|
1505
|
+
const { canvas, colours } = paintWith(diagram, overrides, { mode: themeMode() })
|
|
1506
|
+
const written = toSvg(canvas, colours, diagram.kind === 'none' ? diagram.caption : diagram.title + ': ' + diagram.caption)
|
|
1507
|
+
return React.createElement('div', {
|
|
1508
|
+
style: surfaceStyle(colours),
|
|
1509
|
+
dangerouslySetInnerHTML: { __html: written.markup },
|
|
1510
|
+
})
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* The whiteboard: paste a diagram, get a drawing, zoom it.
|
|
1516
|
+
*
|
|
1517
|
+
* It holds no host data, which is the point: it is a place to think about a diagram while writing one, and
|
|
1518
|
+
* it cannot be broken by a change to any tool's result shape. The source it draws is whatever is in its own
|
|
1519
|
+
* box.
|
|
1520
|
+
*/
|
|
1521
|
+
export function whiteboardFor(React: ReactLike, overrides: Partial<Palette> = {}): () => unknown {
|
|
1522
|
+
const h = (type: unknown, props?: Record<string, unknown> | null, ...children: unknown[]): unknown => React.createElement(type, props, ...children)
|
|
1523
|
+
return function Whiteboard(): unknown {
|
|
1524
|
+
const useState = React.useState as (<T>(initial: T) => [T, (next: T) => void]) | undefined
|
|
1525
|
+
if (useState === undefined) {
|
|
1526
|
+
// A host without hooks: the same board, read-only. It still draws, which is the part that matters.
|
|
1527
|
+
return h(
|
|
1528
|
+
'div',
|
|
1529
|
+
{ style: { padding: '8px' } },
|
|
1530
|
+
h('textarea', {
|
|
1531
|
+
value: WHITEBOARD_SAMPLE,
|
|
1532
|
+
readOnly: true,
|
|
1533
|
+
spellcheck: false,
|
|
1534
|
+
rows: WHITEBOARD_SAMPLE.split('\n').length,
|
|
1535
|
+
style: { width: '100%', font: '12px ui-monospace, SFMono-Regular, Menlo, monospace' },
|
|
1536
|
+
}),
|
|
1537
|
+
// The picker is present but inert: a host without hooks cannot hold the choice, and hiding the
|
|
1538
|
+
// option would make the board look like it has none.
|
|
1539
|
+
h('select', { disabled: true, style: { margin: '4px 0', fontSize: '12px' } },
|
|
1540
|
+
...schemeNames().map((name) => h('option', { value: name, selected: name === 'default' }, name))),
|
|
1541
|
+
whiteboardBody(React, overrides, WHITEBOARD_SAMPLE, 1),
|
|
1542
|
+
)
|
|
1543
|
+
}
|
|
1544
|
+
const [source, setSource] = useState(WHITEBOARD_SAMPLE)
|
|
1545
|
+
const [zoom, setZoom] = useState(1)
|
|
1546
|
+
const [scheme, setScheme] = useState<PaletteScheme>('default')
|
|
1547
|
+
const [fit, setFit] = useState(true)
|
|
1548
|
+
const [folded, setFolded] = useState<string[]>([])
|
|
1549
|
+
const [annotate, setAnnotate] = useState(false)
|
|
1550
|
+
const [picked, setPicked] = useState<{ id: string } | null>(null)
|
|
1551
|
+
const [anchor, setAnchor] = useState<{ left: number; top: number } | undefined>(undefined)
|
|
1552
|
+
const [note, setNote] = useState('')
|
|
1553
|
+
const [status, setStatus] = useState('')
|
|
1554
|
+
const [pending, setPending] = useState<Array<{ nodeId: string; note: string }>>([])
|
|
1555
|
+
const [open, setOpen] = useState(false)
|
|
1556
|
+
React.useEffect?.(() => { try { return onMessageSent(() => { setPending([]); setStatus('') }) } catch { return undefined } }, [])
|
|
1557
|
+
const drawn = drawSample(React, overrides, source, fit ? 0 : zoom, scheme, folded)
|
|
1558
|
+
const document = (globalThis as { document?: DocumentLike }).document
|
|
1559
|
+
const caption = source.split(String.fromCharCode(10)).map((line) => line.trim()).find((line) => line !== '') ?? ''
|
|
1560
|
+
// The notes accumulate here; one structured message leaves for the session, not one raw fragment per note.
|
|
1561
|
+
const sendAll = (list: Array<{ nodeId: string; note: string }>, submit: boolean): void => {
|
|
1562
|
+
if (list.length === 0) return
|
|
1563
|
+
const text = annotationBlock(list.map((item) => ({ ...item, caption })))
|
|
1564
|
+
void (async () => {
|
|
1565
|
+
// The draft writer first: it is the writer the reader's keystrokes use, so line breaks survive.
|
|
1566
|
+
if (placeBlock(text)) {
|
|
1567
|
+
setStatus('已放入输入框并提交(' + String(list.length) + ' 条图注)')
|
|
1568
|
+
setPending([])
|
|
1569
|
+
if (submit) submitDraft()
|
|
1570
|
+
return
|
|
1571
|
+
}
|
|
1572
|
+
const said = prependBlock(document, list, caption)
|
|
1573
|
+
setStatus(said)
|
|
1574
|
+
if (!said.startsWith('已')) return
|
|
1575
|
+
setPending([])
|
|
1576
|
+
if (submit) submitComposer(document)
|
|
1577
|
+
})()
|
|
1578
|
+
}
|
|
1579
|
+
const addDraft = (): boolean => {
|
|
1580
|
+
if (picked === null) return false
|
|
1581
|
+
const merged = [...pending, { nodeId: picked.id, note }]
|
|
1582
|
+
setPending(merged)
|
|
1583
|
+
setNote('')
|
|
1584
|
+
setPicked(null)
|
|
1585
|
+
setStatus('已放入输入框,直接回车即可')
|
|
1586
|
+
return true
|
|
1587
|
+
}
|
|
1588
|
+
const addAndSend = (): void => {
|
|
1589
|
+
if (picked === null) return
|
|
1590
|
+
const merged = [...pending, { nodeId: picked.id, note }]
|
|
1591
|
+
setNote('')
|
|
1592
|
+
setPicked(null)
|
|
1593
|
+
sendAll(merged, true)
|
|
1594
|
+
}
|
|
1595
|
+
const textarea = h('textarea', {
|
|
1596
|
+
'data-dme-own': '1',
|
|
1597
|
+
value: source,
|
|
1598
|
+
spellcheck: false,
|
|
1599
|
+
rows: Math.min(12, Math.max(4, String(source).split('\n').length)),
|
|
1600
|
+
style: { width: '100%', boxSizing: 'border-box', font: '12px ui-monospace, SFMono-Regular, Menlo, monospace', padding: '6px', resize: 'vertical', borderRadius: '6px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.text },
|
|
1601
|
+
onInput: (event: { target?: { value?: unknown } }) => setSource(String(event?.target?.value ?? '')),
|
|
1602
|
+
})
|
|
1603
|
+
// Two rows: the tools that change the picture, then the mode and the scheme. One row folded on a narrow
|
|
1604
|
+
// pane and pushed the scheme out of reach, which is the one control a reader looks for by name.
|
|
1605
|
+
const controlStyle = { fontSize: '11px', lineHeight: '18px', padding: '1px 7px', borderRadius: '6px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.text, cursor: 'pointer' }
|
|
1606
|
+
const control = (label: string, onPress: () => void, title?: string): unknown => h('button', { type: 'button', title, onClick: onPress, style: controlStyle }, label)
|
|
1607
|
+
const controls = h(
|
|
1608
|
+
'div',
|
|
1609
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '3px', padding: '2px 0' } },
|
|
1610
|
+
h(
|
|
1611
|
+
'div',
|
|
1612
|
+
{ style: { display: 'flex', gap: '4px', alignItems: 'center', flexWrap: 'wrap' } },
|
|
1613
|
+
control('−', () => { setFit(false); setZoom(Math.max(0.5, Math.round((Number(zoom) - 0.25) * 100) / 100)) }, '缩小'),
|
|
1614
|
+
h('span', { style: { fontSize: '11px', color: TOKEN.dim, minWidth: '34px', textAlign: 'center' } }, fit ? 'fit' : String(Math.round(Number(zoom) * 100)) + '%'),
|
|
1615
|
+
control('+', () => { setFit(false); setZoom(Math.min(3, Math.round((Number(zoom) + 0.25) * 100) / 100)) }, '放大'),
|
|
1616
|
+
control('100%', () => { setFit(false); setZoom(1) }, '实际大小'),
|
|
1617
|
+
control(fit ? t('fitOn') : t('fitOff'), () => setFit(!fit), t('fitOff')),
|
|
1618
|
+
folded.length === 0 ? null : control(t('unfold') + ' ' + String(folded.length), () => setFolded([]), '展开所有分组'),
|
|
1619
|
+
h(
|
|
1620
|
+
'select',
|
|
1621
|
+
{
|
|
1622
|
+
value: scheme,
|
|
1623
|
+
title: t('scheme'),
|
|
1624
|
+
style: { marginLeft: 'auto', fontSize: '11px', padding: '1px 4px', borderRadius: '6px', border: '1px solid ' + TOKEN.border, background: 'transparent', color: TOKEN.text },
|
|
1625
|
+
onChange: (event: { target?: { value?: unknown } }) => setScheme(String(event?.target?.value ?? 'default') as PaletteScheme),
|
|
1626
|
+
},
|
|
1627
|
+
...schemeNames().map((name) => h('option', { value: name, selected: name === scheme }, name)),
|
|
1628
|
+
),
|
|
1629
|
+
),
|
|
1630
|
+
h(
|
|
1631
|
+
'div',
|
|
1632
|
+
{ style: { display: 'flex', gap: '6px', alignItems: 'center' } },
|
|
1633
|
+
control(annotate ? t('noteOn') : t('note'), () => { setAnnotate(!annotate); setStatus(''); if (annotate) { setPicked(null); setNote('') } }, t('noteHint')),
|
|
1634
|
+
h('span', { style: { fontSize: '11px', color: TOKEN.dim } }, annotate ? (picked === null ? t('noteHint') : t('node') + ' ' + picked.id) : t('noteHint')),
|
|
1635
|
+
),
|
|
1636
|
+
)
|
|
1637
|
+
return h(
|
|
1638
|
+
'div',
|
|
1639
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '4px', height: '100%', boxSizing: 'border-box', padding: '8px' } },
|
|
1640
|
+
textarea,
|
|
1641
|
+
controls,
|
|
1642
|
+
// The drawing takes the rest of the pane and scrolls inside it; the annotation surface floats above it,
|
|
1643
|
+
// so the picture keeps its size while the reader writes about it.
|
|
1644
|
+
h(
|
|
1645
|
+
'div',
|
|
1646
|
+
{
|
|
1647
|
+
style: { position: 'relative', flex: '1 1 auto', minHeight: '0', display: 'flex', flexDirection: 'column', gap: '2px' },
|
|
1648
|
+
},
|
|
1649
|
+
h(
|
|
1650
|
+
'div',
|
|
1651
|
+
{
|
|
1652
|
+
style: { flex: '1 1 auto', minHeight: '0', overflow: 'auto' },
|
|
1653
|
+
// A boundary carries its id in the markup, so one listener on the container is enough: no per-rect
|
|
1654
|
+
// handler, and the handling survives a re-render that replaced the whole SVG.
|
|
1655
|
+
onClick: (event: { target?: { closest?: (selector: string) => { getAttribute?: (name: string) => string | null } | null } }) => {
|
|
1656
|
+
// In annotation mode a click marks a node instead of folding a boundary: one gesture, one meaning,
|
|
1657
|
+
// chosen by the mode the reader just switched on.
|
|
1658
|
+
if (annotate) {
|
|
1659
|
+
const node = event?.target?.closest?.('[data-node-id]') as {
|
|
1660
|
+
getAttribute?: (name: string) => string | null
|
|
1661
|
+
getBoundingClientRect?: () => { left: number; top: number; width: number; height: number }
|
|
1662
|
+
} | null
|
|
1663
|
+
const marked = node?.getAttribute?.('data-node-id')
|
|
1664
|
+
if (typeof marked === 'string' && marked !== '') {
|
|
1665
|
+
// Beside the node, not at a fixed corner: the note is about that box, and a window that jumps to
|
|
1666
|
+
// the corner makes the reader look for it. Coordinates are the drawing's own, because the editor
|
|
1667
|
+
// is positioned against the pane the drawing sits in.
|
|
1668
|
+
// At the pointer, not at the node's corner: the reader clicked there, and a window that appears
|
|
1669
|
+
// somewhere else makes them look for it. Clamped so it cannot leave the pane on the right.
|
|
1670
|
+
const pointer = event as { clientX?: number; clientY?: number; currentTarget?: { getBoundingClientRect?: () => { left: number; top: number; width: number; height: number } } }
|
|
1671
|
+
const frame = pointer.currentTarget?.getBoundingClientRect?.()
|
|
1672
|
+
if (frame !== undefined && typeof pointer.clientX === 'number' && typeof pointer.clientY === 'number') {
|
|
1673
|
+
setAnchor({
|
|
1674
|
+
left: Math.max(4, Math.min(pointer.clientX - frame.left + 12, frame.width - 270)),
|
|
1675
|
+
top: Math.max(4, Math.min(pointer.clientY - frame.top + 12, Math.max(4, frame.height - 160))),
|
|
1676
|
+
})
|
|
1677
|
+
} else {
|
|
1678
|
+
setAnchor({ left: 10, top: 10 })
|
|
1679
|
+
}
|
|
1680
|
+
setPicked({ id: marked })
|
|
1681
|
+
setStatus('')
|
|
1682
|
+
return
|
|
1683
|
+
}
|
|
1684
|
+
// A click that lands on nothing closes the window only when it is empty: a miss is not a reason to
|
|
1685
|
+
// lose words, and an empty window left open is clutter over the picture.
|
|
1686
|
+
if (note.trim() === '') { setPicked(null); setAnchor(undefined) }
|
|
1687
|
+
return
|
|
1688
|
+
}
|
|
1689
|
+
const hit = event?.target?.closest?.('[data-group-id]')
|
|
1690
|
+
const id = hit?.getAttribute?.('data-group-id')
|
|
1691
|
+
if (typeof id !== 'string' || id === '') return
|
|
1692
|
+
setFolded(folded.includes(id) ? folded.filter((entry) => entry !== id) : [...folded, id])
|
|
1693
|
+
},
|
|
1694
|
+
},
|
|
1695
|
+
drawn,
|
|
1696
|
+
),
|
|
1697
|
+
annotate
|
|
1698
|
+
? annotationPanel(
|
|
1699
|
+
h,
|
|
1700
|
+
{ picked, note, pending, status, anchor, open },
|
|
1701
|
+
{
|
|
1702
|
+
onNote: setNote,
|
|
1703
|
+
onAdd: () => { addDraft() },
|
|
1704
|
+
onAddAndSend: addAndSend,
|
|
1705
|
+
onSend: () => sendAll(pending, true),
|
|
1706
|
+
onClear: () => { setPending([]); setStatus('') },
|
|
1707
|
+
onCancel: () => { setPicked(null); setNote(''); setStatus('') },
|
|
1708
|
+
onToggle: () => setOpen(!open),
|
|
1709
|
+
onRemove: (index: number) => setPending(pending.filter((_, at) => at !== index)),
|
|
1710
|
+
},
|
|
1711
|
+
{ document },
|
|
1712
|
+
)
|
|
1713
|
+
: null,
|
|
1714
|
+
),
|
|
1715
|
+
)
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
function drawSample(React: ReactLike, overrides: Partial<Palette>, source: string, zoom: number, scheme: PaletteScheme, folded: string[] = []): unknown {
|
|
1720
|
+
return whiteboardBody(React, overrides, source, zoom, scheme, folded)
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/** Render one source at one zoom, or say why it could not be drawn. */
|
|
1724
|
+
function whiteboardBody(React: ReactLike, overrides: Partial<Palette>, source: string, zoom: number, scheme: PaletteScheme = 'default', folded: string[] = []): unknown {
|
|
1725
|
+
if (String(source).trim() === '') {
|
|
1726
|
+
return React.createElement('p', { style: { fontSize: '12px', opacity: 0.7 } }, 'Paste Mermaid or PlantUML above.')
|
|
1727
|
+
}
|
|
1728
|
+
const drawn = render({ source: String(source), folded }, overrides, { mode: themeMode(), scheme })
|
|
1729
|
+
// Zoom 0 is "fill the pane": the SVG scales to the width it is given, which is what an expanded tab wants.
|
|
1730
|
+
const zoomed =
|
|
1731
|
+
zoom === 0
|
|
1732
|
+
? drawn.markup.replace('<svg ', '<svg style="width: 100%; height: auto; display: block" ')
|
|
1733
|
+
: drawn.markup.replace('<svg ', '<svg style="zoom: ' + String(zoom) + '; display: block" ')
|
|
1734
|
+
const refused = drawn.refused.length === 0 ? null : React.createElement('pre', {
|
|
1735
|
+
style: { fontSize: '11px', color: '#b7791f', whiteSpace: 'pre-wrap', margin: '6px 0' },
|
|
1736
|
+
}, drawn.refused.join('\n'))
|
|
1737
|
+
return React.createElement(
|
|
1738
|
+
'div',
|
|
1739
|
+
{ style: { background: drawn.colours.background, color: drawn.colours.text, borderRadius: '6px' } },
|
|
1740
|
+
React.createElement('div', { dangerouslySetInnerHTML: { __html: zoomed } }),
|
|
1741
|
+
refused,
|
|
1742
|
+
)
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
/** How long to wait for the optional services, and how often. A page that never offers them registers nothing. */
|
|
1747
|
+
const SIDEBAR_POLL_MS = 250
|
|
1748
|
+
const SIDEBAR_POLL_ATTEMPTS = 40
|
|
1749
|
+
|
|
1750
|
+
interface SidebarTabsService {
|
|
1751
|
+
register?: (options: Record<string, unknown>) => unknown
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
export interface MountOptions {
|
|
1755
|
+
/** Injected so the suite can drive the poll without waiting. Defaults to `setTimeout`. */
|
|
1756
|
+
schedule?: (run: () => void, delay: number) => unknown
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* Register the whiteboard tab once the sidebar's optional services are ready.
|
|
1761
|
+
*
|
|
1762
|
+
* The slot can be declared before `sidebarRightTabs` exists - the host composes services and seats in an
|
|
1763
|
+
* order this plugin does not control - and a single `ctx.get` there is a race that loses the tab silently.
|
|
1764
|
+
* So it polls, like the reference implementation does, and stops after a bounded number of attempts.
|
|
1765
|
+
*/
|
|
1766
|
+
export function mountWhiteboard(React: ReactLike, ctx: SlotContext, options: MountOptions = {}): unknown {
|
|
1767
|
+
const schedule =
|
|
1768
|
+
options.schedule ??
|
|
1769
|
+
((run: () => void, delay: number) => {
|
|
1770
|
+
// A host (or a test sandbox) without timers simply gets no retry, rather than an exception.
|
|
1771
|
+
const timer = (globalThis as unknown as { setTimeout?: (f: () => void, ms: number) => unknown }).setTimeout
|
|
1772
|
+
return typeof timer === 'function' ? timer(run, delay) : undefined
|
|
1773
|
+
})
|
|
1774
|
+
let attempts = 0
|
|
1775
|
+
|
|
1776
|
+
const attempt = (): unknown => {
|
|
1777
|
+
attempts += 1
|
|
1778
|
+
const tabs = typeof ctx.get === 'function' ? (ctx.get('sidebarRightTabs') as SidebarTabsService | undefined) : undefined
|
|
1779
|
+
if (tabs === undefined || typeof tabs.register !== 'function') {
|
|
1780
|
+
if (attempts < SIDEBAR_POLL_ATTEMPTS) schedule(attempt, SIDEBAR_POLL_MS)
|
|
1781
|
+
return undefined
|
|
1782
|
+
}
|
|
1783
|
+
tabs.register({
|
|
1784
|
+
id: WHITEBOARD_ID,
|
|
1785
|
+
kind: 'panel',
|
|
1786
|
+
keepMounted: true,
|
|
1787
|
+
title: () => 'Diagram',
|
|
1788
|
+
guide: [
|
|
1789
|
+
{
|
|
1790
|
+
id: 'diagram-whiteboard',
|
|
1791
|
+
order: 120,
|
|
1792
|
+
title: () => 'Diagram',
|
|
1793
|
+
description: () => 'Paste Mermaid or PlantUML and read the drawing',
|
|
1794
|
+
},
|
|
1795
|
+
],
|
|
1796
|
+
})
|
|
1797
|
+
return ctx.slots.register({ name: 'sidebar.right.pane.tab', key: WHITEBOARD_ID }, whiteboardFor(React))
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
return attempt()
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
export default function factory(require: (name: string) => unknown): PluginShape {
|
|
1804
|
+
// Captured under a plain name: a static `require('react')` would be rewritten by the bundler and then
|
|
1805
|
+
// resolve against a scope the factory body does not have. A call through this alias stays a call.
|
|
1806
|
+
const hostRequire = require
|
|
1807
|
+
const React = hostRequire('react') as ReactLike
|
|
1808
|
+
return {
|
|
1809
|
+
inject: ['slots'],
|
|
1810
|
+
mount: (ctx: SlotContext, options: MountOptions = {}) => mountWhiteboard(React, ctx, options),
|
|
1811
|
+
apply(ctx: SlotContext) {
|
|
1812
|
+
services = ctx
|
|
1813
|
+
// The transcript fold is parked, and this is the second time for a reason worth writing down.
|
|
1814
|
+
//
|
|
1815
|
+
// It worked as a static pass. Started from `apply` it watches the whole document and, per change, runs a
|
|
1816
|
+
// selector over every div/p/span and a subtree scan per match; a streaming conversation changes the DOM many
|
|
1817
|
+
// times a second, so the scan ran continuously and the conversation container stopped responding.
|
|
1818
|
+
//
|
|
1819
|
+
// To bring it back: scope the scan to a message container instead of the document, run it once per sent
|
|
1820
|
+
// message rather than per mutation, and never from an interval. Until then the block stays visible in the
|
|
1821
|
+
// bubble, which is what the host itself renders and costs nothing.
|
|
1822
|
+
void startTranscriptDecoration
|
|
1823
|
+
for (const name of TOOL_NAMES) {
|
|
1824
|
+
// Only the options a keyed slot declares: `key`. An extra `id` is not part of this slot's
|
|
1825
|
+
// registration schema, and the working community reference (dsh-deepread) passes exactly this much.
|
|
1826
|
+
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: name }, viewFor(React)))
|
|
1827
|
+
}
|
|
1828
|
+
// The whiteboard seat only exists on a host with the official right sidebar. Presence of the slot is
|
|
1829
|
+
// the capability check: if the callback never runs, this host has no sidebar and nothing is lost.
|
|
1830
|
+
ctx.slots.inject('sidebar.right.pane.tab', () => mountWhiteboard(React, ctx))
|
|
1831
|
+
// The count belongs in the host's own dock above the composer card - "full-width entries above the
|
|
1832
|
+
// composer card" in the slot catalog. A slot is the supported way to sit there: the host owns the
|
|
1833
|
+
// position, the width and the surface, so this plugin stops computing coordinates in someone else's
|
|
1834
|
+
// layout. The body-level layer stays as the fallback for a host without this slot.
|
|
1835
|
+
// The composer dock slot is not used: on this host its component renders before the composer has a box, and
|
|
1836
|
+
// a hook-driven re-measure is not guaranteed - the count sat at the column edge until unrelated typing forced
|
|
1837
|
+
// a re-render. The body layer measures itself on a retry schedule, which converges without hooks.
|
|
1838
|
+
void dockSlotView
|
|
1839
|
+
},
|
|
1840
|
+
}
|
|
1841
|
+
}
|