dsh-select-quote 0.3.7 → 0.3.9
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/README.md +21 -19
- package/cordis.patch.yml +2 -1
- package/lib/client.js +1937 -576
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -28,6 +28,20 @@ function composerCardOf(node) {
|
|
|
28
28
|
function willSubmit(card, draft) {
|
|
29
29
|
return draft.trim().length > 0 || card.querySelector("img") !== null;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* True for the Enter gestures the host submits: plain Enter, Ctrl+Enter, and
|
|
33
|
+
* Meta+Enter. Mirrors the host composer's Enter command: Shift+Enter inserts
|
|
34
|
+
* a newline; Alt, AltGraph, the two modifiers together, and IME composition
|
|
35
|
+
* are ignored. Used to fold annotations for every real submit gesture.
|
|
36
|
+
*/
|
|
37
|
+
function isSubmitKey(event) {
|
|
38
|
+
if (event.key !== "Enter") return false;
|
|
39
|
+
if (event.shiftKey || event.altKey) return false;
|
|
40
|
+
if (event.ctrlKey && event.metaKey) return false;
|
|
41
|
+
if (event.isComposing) return false;
|
|
42
|
+
if (event.getModifierState?.("AltGraph") === true) return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
31
45
|
/** Focus the resident composer editor and park the caret at the end. */
|
|
32
46
|
function focusComposer() {
|
|
33
47
|
const editor = document.querySelector("[data-lexical-editor=\"true\"]") ?? document.querySelector("[contenteditable=\"true\"][data-lexical-editor]") ?? lastEditable();
|
|
@@ -78,7 +92,7 @@ const ATOMS = {
|
|
|
78
92
|
};
|
|
79
93
|
const toKebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
80
94
|
const atomRules = Object.entries(ATOMS).map(([name, declarations]) => `.dsq_a_${toKebab(name)} { ${declarations} }`).join("\n");
|
|
81
|
-
/** Atom class names, for `cx(styles.
|
|
95
|
+
/** Atom class names, for `cx(styles.summary, A.row)`. */
|
|
82
96
|
const A = Object.fromEntries(Object.keys(ATOMS).map((name) => [name, `dsq_a_${toKebab(name)}`]));
|
|
83
97
|
/** Join class parts, dropping the falsy ones so a variant can be `cond && A.x`. */
|
|
84
98
|
function cx(...parts) {
|
|
@@ -99,6 +113,7 @@ body {
|
|
|
99
113
|
--dsq-line: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
|
|
100
114
|
--dsq-line-divider: var(--dsw-alias-border-l4, rgba(0, 0, 0, 0.12));
|
|
101
115
|
--dsq-focus-ring: var(--dsw-alias-button-primary-fill, #1677ff);
|
|
116
|
+
--dsq-focus-ring-soft: color-mix(in srgb, var(--dsq-accent) 50%, transparent);
|
|
102
117
|
--dsq-fill-hover: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
|
|
103
118
|
--dsq-label: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
|
|
104
119
|
--dsq-label-inverted: var(--dsw-alias-label-primary-inverted, #fff);
|
|
@@ -108,11 +123,14 @@ body {
|
|
|
108
123
|
--dsq-bubble: var(--dsw-specific-bubble, rgba(0, 0, 0, 0.05));
|
|
109
124
|
--dsq-menu: var(--dsw-menu-surface-fill, rgba(248, 249, 250, .58));
|
|
110
125
|
--dsq-menu-backdrop: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
|
|
126
|
+
--dsq-scrim: rgba(0, 0, 0, 0.32);
|
|
111
127
|
--dsq-accent-400: var(--dsw-static-blue-400, #1677ff);
|
|
112
128
|
--dsq-accent: var(--dsw-static-blue-500, #3b82f6);
|
|
113
129
|
--dsq-accent-tint: var(--dsw-static-blue-50p, #eaf3ff);
|
|
114
130
|
--dsq-accent-tint-hover: var(--dsw-static-blue-75, #e5f0ff);
|
|
115
131
|
--dsq-accent-tint-dark: var(--dsw-static-blue-950, #172554);
|
|
132
|
+
--dsq-summary-bg: var(--dsw-alias-bg-layer-2, #fff);
|
|
133
|
+
--dsq-mark-hover: var(--dsw-static-blue-75, #e5f0ff);
|
|
116
134
|
--dsq-neutral-50: var(--dsw-static-neutral-50, #fafafa);
|
|
117
135
|
--dsq-neutral-100: var(--dsw-static-neutral-100, #f5f5f5);
|
|
118
136
|
--dsq-neutral-800: var(--dsw-static-neutral-800, #292929);
|
|
@@ -170,6 +188,17 @@ ${atomRules}
|
|
|
170
188
|
}
|
|
171
189
|
}
|
|
172
190
|
|
|
191
|
+
/* Exit of the chip preview: the same 4px travel, reversed. Transform only, so
|
|
192
|
+
the material layer keeps sampling its backdrop until the node is removed. */
|
|
193
|
+
@keyframes dsq-rise-out {
|
|
194
|
+
from {
|
|
195
|
+
transform: translateY(0);
|
|
196
|
+
}
|
|
197
|
+
to {
|
|
198
|
+
transform: translateY(4px);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
173
202
|
/* 宿主菜单材质(官方 material 图层同款):填充走 --dsw-menu-surface-fill,模糊走
|
|
174
203
|
--dsw-menu-backdrop-filter(恒为 blur(40px) saturate(150%)),亮暗自动翻。
|
|
175
204
|
画在 z-index:-1 的独立图层上,内容在它之上:模糊只取身后的页面,不会把
|
|
@@ -178,7 +207,9 @@ ${atomRules}
|
|
|
178
207
|
菜单用;这里按需求用 surface-fill,所以我们的浮层会比宿主自己的菜单更透。 */
|
|
179
208
|
.dsq_toolbar::before,
|
|
180
209
|
.dsq_commentPop::before,
|
|
181
|
-
.dsq_summaryHoverInner::before
|
|
210
|
+
.dsq_summaryHoverInner::before,
|
|
211
|
+
.dsq_touchBar::before,
|
|
212
|
+
.dsq_chipPreview::before {
|
|
182
213
|
content: "";
|
|
183
214
|
position: absolute;
|
|
184
215
|
inset: 0;
|
|
@@ -201,9 +232,15 @@ ${atomRules}
|
|
|
201
232
|
整条链才在浮层元素上取值。 */
|
|
202
233
|
.dsq_toolbar,
|
|
203
234
|
.dsq_commentPop,
|
|
204
|
-
.dsq_summaryHoverInner
|
|
235
|
+
.dsq_summaryHoverInner,
|
|
236
|
+
.dsq_touchBar,
|
|
237
|
+
.dsq_touchSheet,
|
|
238
|
+
.dsq_chipPreview {
|
|
205
239
|
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
|
|
206
240
|
--dsq-shadow: var(--dsw-elevation-prominent, 0 0 0 .5px rgba(0, 0, 0, 0.04), 0 3px 8px rgba(0, 0, 0, 0.04), 0 0 20px rgba(0, 0, 0, 0.05));
|
|
241
|
+
/* Every surface above takes the same elevation; declaring it once here is
|
|
242
|
+
what keeps the six component blocks free of a copied box-shadow. */
|
|
243
|
+
box-shadow: var(--dsq-shadow);
|
|
207
244
|
}
|
|
208
245
|
|
|
209
246
|
/* 浮层挂载与层带(2026-10-20 定,改前先读):
|
|
@@ -219,12 +256,14 @@ ${atomRules}
|
|
|
219
256
|
现在把两件事交回宿主布局:
|
|
220
257
|
- 滚动容器负责裁剪:浮层随内容上移,到容器上边缘就被裁掉,自然滑进 header
|
|
221
258
|
下面,和正文一致;
|
|
222
|
-
- host 的 z-index
|
|
259
|
+
- host 的 z-index 平时 6(见 floating-layer.ts):低于 composer 的 7,高于
|
|
223
260
|
内容里吸顶元素的 6(同 z 时 host 追加在滚动容器末尾,DOM 次序压过它们)。
|
|
261
|
+
评论步骤打开期间抬到 8:摘要卡在 composer 座位里(z 7),会伸进滚动区,
|
|
262
|
+
6 的评论泡会被它盖住;关闭评论即还原,空闲工具条仍不压输入框。
|
|
224
263
|
host 内部次序:status 3 > toolbar 2 > commentPop 1。
|
|
225
264
|
|
|
226
|
-
|
|
227
|
-
(原因见 AnnotationCardView 里 contain:layout 的说明)。 */
|
|
265
|
+
摘要块(composer 与历史消息)在内容普通流里,不参与这套层带;悬停面板仍是
|
|
266
|
+
body 级 fixed(原因见 AnnotationCardView 里 contain:layout 的说明)。 */
|
|
228
267
|
|
|
229
268
|
.dsq_toolbar {
|
|
230
269
|
position: absolute;
|
|
@@ -241,8 +280,7 @@ ${atomRules}
|
|
|
241
280
|
由它勾勒轮廓,所以这里不要再画 border,否则描边会和环叠成两条线。 */
|
|
242
281
|
border: 0;
|
|
243
282
|
border-radius: 12px;
|
|
244
|
-
/*
|
|
245
|
-
box-shadow: var(--dsq-shadow);
|
|
283
|
+
/* 底色与模糊都在上面的材质图层里,这里不再画背景;elevation 在共享规则里 */
|
|
246
284
|
pointer-events: auto;
|
|
247
285
|
/* A/B:scale 入场放在容器上(材质图层归它所有)。关键帧自带 translate(-50%,-100%),
|
|
248
286
|
所以缩放不会破坏居中。若真实客户端里磨砂在放大期间消失,改回内容层方案。 */
|
|
@@ -261,10 +299,9 @@ ${atomRules}
|
|
|
261
299
|
animation-name: dsq-toolbar-in-popover-top;
|
|
262
300
|
}
|
|
263
301
|
|
|
264
|
-
/*
|
|
302
|
+
/* 按钮行:形状由 A.inlineRow 提供,这里只留间距;入场动画在容器上
|
|
303
|
+
(见 .dsq_toolbar 的 A/B 说明)。 */
|
|
265
304
|
.dsq_toolbarInner {
|
|
266
|
-
display: inline-flex;
|
|
267
|
-
align-items: center;
|
|
268
305
|
gap: 2px;
|
|
269
306
|
}
|
|
270
307
|
|
|
@@ -282,11 +319,17 @@ ${atomRules}
|
|
|
282
319
|
background: var(--dsq-fill-hover);
|
|
283
320
|
}
|
|
284
321
|
|
|
285
|
-
.
|
|
322
|
+
/* Accent-filled actions: comment commit, annotation save, chip jump. Only the
|
|
323
|
+
metrics differ; fill and hover live here once. */
|
|
324
|
+
.dsq_buttonAddComment,
|
|
325
|
+
.dsq_annEditSave,
|
|
326
|
+
.dsq_chipPreviewJump {
|
|
286
327
|
background: var(--dsq-accent);
|
|
287
|
-
color
|
|
328
|
+
color: #fff;
|
|
288
329
|
}
|
|
289
|
-
.dsq_buttonAddComment:hover
|
|
330
|
+
.dsq_buttonAddComment:hover,
|
|
331
|
+
.dsq_annEditSave:hover,
|
|
332
|
+
.dsq_chipPreviewJump:hover {
|
|
290
333
|
background: var(--dsq-accent-400);
|
|
291
334
|
}
|
|
292
335
|
|
|
@@ -306,10 +349,10 @@ ${atomRules}
|
|
|
306
349
|
margin: 0 2px;
|
|
307
350
|
}
|
|
308
351
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
352
|
+
/* Toast pill: the desktop status and the touch status share the whole skin;
|
|
353
|
+
only where they anchor differs. */
|
|
354
|
+
.dsq_status,
|
|
355
|
+
.dsq_touchStatus {
|
|
313
356
|
padding: 4px 10px;
|
|
314
357
|
border-radius: 999px;
|
|
315
358
|
background: var(--dsq-bg-toast);
|
|
@@ -320,6 +363,12 @@ ${atomRules}
|
|
|
320
363
|
white-space: nowrap;
|
|
321
364
|
}
|
|
322
365
|
|
|
366
|
+
.dsq_status {
|
|
367
|
+
position: absolute;
|
|
368
|
+
z-index: 3;
|
|
369
|
+
transform: translate(-50%, 8px);
|
|
370
|
+
}
|
|
371
|
+
|
|
323
372
|
/* Annotation stack — inside the composer card (input.overlay), ~1/5 width. */
|
|
324
373
|
.dsq_cardStack {
|
|
325
374
|
position: absolute;
|
|
@@ -334,78 +383,8 @@ ${atomRules}
|
|
|
334
383
|
pointer-events: auto;
|
|
335
384
|
}
|
|
336
385
|
|
|
337
|
-
/*
|
|
338
|
-
|
|
339
|
-
numbers where the transcript really differs.
|
|
340
|
-
Geometry mirrors the product's own file card (\`.nyYjTG_file\` in
|
|
341
|
-
ui-deliverables): hairline border, neutral fill, 18px radius, fixed 72px row. */
|
|
342
|
-
.dsq_card {
|
|
343
|
-
--dsq-card-fill: var(--dsq-neutral-50);
|
|
344
|
-
--dsq-card-hover: var(--dsq-neutral-100);
|
|
345
|
-
flex: 0 0 auto;
|
|
346
|
-
gap: 10px;
|
|
347
|
-
width: fit-content;
|
|
348
|
-
max-width: calc(25% - 5px);
|
|
349
|
-
min-width: 0;
|
|
350
|
-
height: 64px;
|
|
351
|
-
margin: 0;
|
|
352
|
-
padding: 10px;
|
|
353
|
-
border: 0.5px solid var(--dsq-line);
|
|
354
|
-
border-radius: 18px;
|
|
355
|
-
background: var(--dsq-card-fill);
|
|
356
|
-
color: var(--dsq-label);
|
|
357
|
-
transition: background-color 0.12s;
|
|
358
|
-
position: relative;
|
|
359
|
-
overflow: hidden;
|
|
360
|
-
user-select: none;
|
|
361
|
-
pointer-events: auto;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
.dsq_card:hover {
|
|
365
|
-
background: var(--dsq-card-hover);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/* Scaled down from the reference 48px: the composer card is width-capped at a
|
|
369
|
-
quarter of the input, so 48px would leave no room for the quote text. */
|
|
370
|
-
.dsq_cardIcon {
|
|
371
|
-
z-index: 2;
|
|
372
|
-
width: 36px;
|
|
373
|
-
height: 36px;
|
|
374
|
-
border: 0.5px solid var(--dsq-line);
|
|
375
|
-
border-radius: 12px;
|
|
376
|
-
background: var(--dsq-card-fill);
|
|
377
|
-
color: var(--dsq-link);
|
|
378
|
-
font-size: 12px;
|
|
379
|
-
font-weight: 600;
|
|
380
|
-
letter-spacing: -0.02em;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
.dsq_cardBody {
|
|
384
|
-
z-index: 2;
|
|
385
|
-
justify-content: center;
|
|
386
|
-
gap: 2px;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/* Keeps the ellipsised title clear of the corner remove button, which only the
|
|
390
|
-
composer stack renders. */
|
|
391
|
-
.dsq_cardStack .dsq_cardBody {
|
|
392
|
-
padding-right: 14px;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
.dsq_cardTitle {
|
|
396
|
-
font-size: 14px;
|
|
397
|
-
font-weight: 500;
|
|
398
|
-
line-height: 22px;
|
|
399
|
-
color: var(--dsq-label);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
.dsq_cardSubtitle {
|
|
403
|
-
font-size: 12px;
|
|
404
|
-
line-height: 18px;
|
|
405
|
-
color: var(--dsq-label-3);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/* Corner remove button: hidden until the card is hovered or focused. */
|
|
386
|
+
/* Corner remove-all button on the summary block: hidden until the block is
|
|
387
|
+
hovered or focused. */
|
|
409
388
|
.dsq_cardClose {
|
|
410
389
|
position: absolute;
|
|
411
390
|
top: 4px;
|
|
@@ -422,8 +401,6 @@ ${atomRules}
|
|
|
422
401
|
transition: opacity 0.12s, background-color 0.12s;
|
|
423
402
|
}
|
|
424
403
|
|
|
425
|
-
.dsq_card:hover .dsq_cardClose,
|
|
426
|
-
.dsq_card:focus-within .dsq_cardClose,
|
|
427
404
|
.dsq_summary:hover .dsq_cardClose,
|
|
428
405
|
.dsq_summary:focus-within .dsq_cardClose {
|
|
429
406
|
opacity: 1;
|
|
@@ -435,33 +412,6 @@ ${atomRules}
|
|
|
435
412
|
color: var(--dsq-label);
|
|
436
413
|
}
|
|
437
414
|
|
|
438
|
-
/* Secondary corner action (edit comment). */
|
|
439
|
-
.dsq_cardAction {
|
|
440
|
-
position: absolute;
|
|
441
|
-
top: 4px;
|
|
442
|
-
right: 28px;
|
|
443
|
-
z-index: 3;
|
|
444
|
-
width: 20px;
|
|
445
|
-
height: 20px;
|
|
446
|
-
border-radius: 6px;
|
|
447
|
-
color: var(--dsq-label-3);
|
|
448
|
-
font-size: 12px;
|
|
449
|
-
line-height: 1;
|
|
450
|
-
opacity: 0;
|
|
451
|
-
pointer-events: none;
|
|
452
|
-
transition: opacity 0.12s, background-color 0.12s;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
.dsq_card:hover .dsq_cardAction,
|
|
456
|
-
.dsq_card:focus-within .dsq_cardAction {
|
|
457
|
-
opacity: 1;
|
|
458
|
-
pointer-events: auto;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
.dsq_cardAction:hover {
|
|
462
|
-
background: var(--dsq-fill-hover);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
415
|
/* Qoder-style annotation summary (“N 条批注”) inside the composer. */
|
|
466
416
|
.dsq_summary {
|
|
467
417
|
position: relative;
|
|
@@ -472,7 +422,7 @@ ${atomRules}
|
|
|
472
422
|
padding: 8px 10px;
|
|
473
423
|
border: 0.5px solid var(--dsq-line);
|
|
474
424
|
border-radius: 18px;
|
|
475
|
-
background: var(--dsq-
|
|
425
|
+
background: var(--dsq-summary-bg);
|
|
476
426
|
pointer-events: auto;
|
|
477
427
|
user-select: none;
|
|
478
428
|
}
|
|
@@ -491,7 +441,9 @@ ${atomRules}
|
|
|
491
441
|
position: relative;
|
|
492
442
|
}
|
|
493
443
|
|
|
494
|
-
.
|
|
444
|
+
/* Card titles (composer summary / touch sheet) share one type scale. */
|
|
445
|
+
.dsq_summaryTitle,
|
|
446
|
+
.dsq_touchSheetTitle {
|
|
495
447
|
font-size: 14px;
|
|
496
448
|
font-weight: 500;
|
|
497
449
|
line-height: 20px;
|
|
@@ -504,18 +456,37 @@ ${atomRules}
|
|
|
504
456
|
color: var(--dsq-label-3);
|
|
505
457
|
}
|
|
506
458
|
|
|
459
|
+
/* Hover wording is wrong on touch; show one line, not both. */
|
|
460
|
+
.dsq_summaryHintTouch {
|
|
461
|
+
display: none;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
@media (hover: none), (pointer: coarse) {
|
|
465
|
+
.dsq_summaryHintPointer {
|
|
466
|
+
display: none;
|
|
467
|
+
}
|
|
468
|
+
.dsq_summaryHintTouch {
|
|
469
|
+
display: block;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/* The hover panel and the chip preview are the same floating width; keeping it
|
|
474
|
+
in one rule means a width tweak lands on both. */
|
|
475
|
+
.dsq_summaryHover,
|
|
476
|
+
.dsq_chipPreview {
|
|
477
|
+
width: min(320px, calc(100vw - 16px));
|
|
478
|
+
}
|
|
479
|
+
|
|
507
480
|
.dsq_summaryHover {
|
|
508
481
|
/* \`display: none\` here is the closed state, so this rule must not take the
|
|
509
482
|
flex atoms — the open class below turns display back on. */
|
|
510
483
|
display: none;
|
|
511
484
|
position: fixed;
|
|
512
485
|
z-index: 14;
|
|
513
|
-
width: min(320px, calc(100vw - 16px));
|
|
514
486
|
flex-direction: column;
|
|
515
|
-
padding:
|
|
516
|
-
|
|
517
|
-
padding
|
|
518
|
-
padding-bottom: 12px;
|
|
487
|
+
/* No side padding: the visible card must share the summary's left edge. The
|
|
488
|
+
transparent hover bridge is added by place() only on the facing side. */
|
|
489
|
+
padding: 0;
|
|
519
490
|
background: transparent;
|
|
520
491
|
pointer-events: auto;
|
|
521
492
|
opacity: 0;
|
|
@@ -531,14 +502,22 @@ ${atomRules}
|
|
|
531
502
|
display: flex;
|
|
532
503
|
}
|
|
533
504
|
|
|
505
|
+
/* Keep the closing panel mounted briefly; the list reverses its rise. */
|
|
506
|
+
.dsq_summaryHoverLeaving {
|
|
507
|
+
pointer-events: none;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.dsq_summaryHoverLeaving .dsq_summaryList {
|
|
511
|
+
animation: dsq-rise-out 0.12s cubic-bezier(0.4, 0, 1, 1) both;
|
|
512
|
+
}
|
|
513
|
+
|
|
534
514
|
.dsq_summaryHoverInner {
|
|
535
515
|
position: relative;
|
|
536
516
|
gap: 4px;
|
|
537
517
|
padding: 4px;
|
|
538
518
|
border-radius: 12px;
|
|
539
519
|
/* 不画 border:--dsq-shadow(宿主的 elevation-prominent)自带 0 0 0 .5px 的
|
|
540
|
-
|
|
541
|
-
box-shadow: var(--dsq-shadow);
|
|
520
|
+
描边环,再画一遍会叠成两条线。底色与模糊都在材质图层里,elevation 在共享规则里。 */
|
|
542
521
|
}
|
|
543
522
|
|
|
544
523
|
/* 滚动只放在列表上:材质图层是绝对定位的,留在会滚动的容器里会跟着内容跑掉,
|
|
@@ -556,7 +535,9 @@ ${atomRules}
|
|
|
556
535
|
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
|
557
536
|
.dsq_toolbar::before,
|
|
558
537
|
.dsq_commentPop::before,
|
|
559
|
-
.dsq_summaryHoverInner::before
|
|
538
|
+
.dsq_summaryHoverInner::before,
|
|
539
|
+
.dsq_touchBar::before,
|
|
540
|
+
.dsq_chipPreview::before {
|
|
560
541
|
background: var(--dsq-bg-raised);
|
|
561
542
|
backdrop-filter: none;
|
|
562
543
|
-webkit-backdrop-filter: none;
|
|
@@ -576,6 +557,9 @@ ${atomRules}
|
|
|
576
557
|
|
|
577
558
|
.dsq_summaryItemHead {
|
|
578
559
|
gap: 6px;
|
|
560
|
+
/* Normal mode's action buttons set the row height; keep it in edit mode too
|
|
561
|
+
so the badge and the label never move when the actions disappear. */
|
|
562
|
+
min-height: 26px;
|
|
579
563
|
}
|
|
580
564
|
|
|
581
565
|
.dsq_summaryItemBadge {
|
|
@@ -615,6 +599,9 @@ ${atomRules}
|
|
|
615
599
|
|
|
616
600
|
.dsq_summaryItemMain {
|
|
617
601
|
min-width: 0;
|
|
602
|
+
/* The lead-in lives on the shared wrapper, not on the normal-mode button, so
|
|
603
|
+
edit mode has no wrapper to compensate for. */
|
|
604
|
+
padding-top: 2px;
|
|
618
605
|
}
|
|
619
606
|
|
|
620
607
|
.dsq_summaryItemButton {
|
|
@@ -623,7 +610,9 @@ ${atomRules}
|
|
|
623
610
|
text-align: left;
|
|
624
611
|
font: inherit;
|
|
625
612
|
border-radius: 8px;
|
|
626
|
-
padding: 2px
|
|
613
|
+
/* No padding: the 2px lead-in is on .dsq_summaryItemMain, and the UA's
|
|
614
|
+
default button padding would shift the quote sideways and down. */
|
|
615
|
+
padding: 0;
|
|
627
616
|
}
|
|
628
617
|
|
|
629
618
|
.dsq_annAnchor {
|
|
@@ -634,18 +623,36 @@ ${atomRules}
|
|
|
634
623
|
pointer-events: none;
|
|
635
624
|
}
|
|
636
625
|
|
|
637
|
-
.
|
|
626
|
+
/* Field labels shared by the hover list and the chip preview. */
|
|
627
|
+
.dsq_summaryItemLabel,
|
|
628
|
+
.dsq_chipPreviewLabel {
|
|
638
629
|
font-size: 11px;
|
|
639
630
|
line-height: 16px;
|
|
640
631
|
color: var(--dsq-label-3);
|
|
641
632
|
}
|
|
642
633
|
|
|
643
|
-
.
|
|
634
|
+
.dsq_summaryItemLabel {
|
|
635
|
+
margin-top: 8px;
|
|
636
|
+
margin-bottom: 4px;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/* The header label shares the class but sits in a centered flex row. */
|
|
640
|
+
.dsq_summaryItemHead .dsq_summaryItemLabel {
|
|
641
|
+
margin: 0;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/* Quote/comment body type is shared; only the box behavior differs. */
|
|
645
|
+
.dsq_summaryItemText,
|
|
646
|
+
.dsq_chipPreviewQuote,
|
|
647
|
+
.dsq_chipPreviewComment {
|
|
644
648
|
font-size: 13px;
|
|
645
|
-
line-height: 18px;
|
|
646
649
|
color: var(--dsq-label);
|
|
647
650
|
white-space: pre-wrap;
|
|
648
651
|
overflow-wrap: anywhere;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
.dsq_summaryItemText {
|
|
655
|
+
line-height: 18px;
|
|
649
656
|
display: -webkit-box;
|
|
650
657
|
-webkit-line-clamp: 3;
|
|
651
658
|
-webkit-box-orient: vertical;
|
|
@@ -656,24 +663,22 @@ ${atomRules}
|
|
|
656
663
|
color: var(--dsq-label-3);
|
|
657
664
|
}
|
|
658
665
|
|
|
659
|
-
/* Inline edit form rendered inside the hover panel item (no extra layer).
|
|
660
|
-
.
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
white-space: pre-wrap;
|
|
665
|
-
overflow-wrap: anywhere;
|
|
666
|
+
/* Inline edit form rendered inside the hover panel item (no extra layer).
|
|
667
|
+
Shares .dsq_summaryItemText with the read view, but shows the full quote
|
|
668
|
+
instead of clamp-reading it. */
|
|
669
|
+
.dsq_summaryItemTextEdit {
|
|
670
|
+
display: block;
|
|
666
671
|
max-height: 132px;
|
|
667
672
|
overflow-y: auto;
|
|
668
|
-
|
|
673
|
+
overscroll-behavior: contain;
|
|
669
674
|
}
|
|
670
675
|
|
|
671
|
-
|
|
676
|
+
/* Textarea skin shared by the annotation editor and the comment popover; the
|
|
677
|
+
variants below only set min-height, resize and radius. */
|
|
678
|
+
.dsq_annEditInput,
|
|
679
|
+
.dsq_commentInput {
|
|
672
680
|
width: 100%;
|
|
673
|
-
min-height: 72px;
|
|
674
|
-
resize: vertical;
|
|
675
681
|
border: none;
|
|
676
|
-
border-radius: 10px;
|
|
677
682
|
padding: 8px 10px;
|
|
678
683
|
font: inherit;
|
|
679
684
|
font-size: 13px;
|
|
@@ -683,44 +688,47 @@ ${atomRules}
|
|
|
683
688
|
outline: none;
|
|
684
689
|
}
|
|
685
690
|
|
|
686
|
-
.dsq_annEditInput::placeholder
|
|
691
|
+
.dsq_annEditInput::placeholder,
|
|
692
|
+
.dsq_commentInput::placeholder {
|
|
687
693
|
color: var(--dsq-label-3);
|
|
688
694
|
}
|
|
689
695
|
|
|
696
|
+
.dsq_annEditInput {
|
|
697
|
+
min-height: 72px;
|
|
698
|
+
resize: vertical;
|
|
699
|
+
border-radius: 10px;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
.dsq_commentActions,
|
|
690
703
|
.dsq_annEditActions {
|
|
691
704
|
justify-content: flex-end;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.dsq_annEditActions {
|
|
692
708
|
gap: 8px;
|
|
693
709
|
margin-top: 4px;
|
|
694
710
|
}
|
|
695
711
|
|
|
696
|
-
|
|
697
|
-
|
|
712
|
+
/* Footer buttons share type and metrics; the save fill comes from the shared
|
|
713
|
+
accent-action rule above. */
|
|
714
|
+
.dsq_annEditCancel,
|
|
715
|
+
.dsq_annEditSave {
|
|
698
716
|
font: inherit;
|
|
699
717
|
font-size: 13px;
|
|
700
718
|
padding: 4px 12px;
|
|
701
719
|
border-radius: 10px;
|
|
702
720
|
}
|
|
703
721
|
|
|
722
|
+
.dsq_annEditCancel {
|
|
723
|
+
color: var(--dsq-label-2);
|
|
724
|
+
}
|
|
725
|
+
|
|
704
726
|
.dsq_annEditCancel:hover {
|
|
705
727
|
background: var(--dsq-fill-hover);
|
|
706
728
|
}
|
|
707
729
|
|
|
708
730
|
.dsq_annEditSave {
|
|
709
|
-
appearance: none;
|
|
710
|
-
border: none;
|
|
711
|
-
background: var(--dsq-accent);
|
|
712
|
-
color: #fff;
|
|
713
|
-
font: inherit;
|
|
714
|
-
font-size: 13px;
|
|
715
731
|
font-weight: 500;
|
|
716
|
-
padding: 4px 12px;
|
|
717
|
-
border-radius: 10px;
|
|
718
|
-
cursor: pointer;
|
|
719
|
-
transition: filter 0.12s;
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
.dsq_annEditSave:hover {
|
|
723
|
-
background: var(--dsq-accent-400);
|
|
724
732
|
}
|
|
725
733
|
|
|
726
734
|
.dsq_annEditSave:disabled {
|
|
@@ -739,129 +747,245 @@ ${atomRules}
|
|
|
739
747
|
gap: 8px;
|
|
740
748
|
padding: 8px;
|
|
741
749
|
border-radius: 16px;
|
|
742
|
-
/*
|
|
743
|
-
box-shadow: var(--dsq-shadow);
|
|
750
|
+
/* 底色与模糊都在材质图层里,elevation 在共享规则里 */
|
|
744
751
|
/* The layer host is pointer-events:none; interactive surfaces opt back in. */
|
|
745
752
|
pointer-events: auto;
|
|
746
753
|
}
|
|
747
754
|
|
|
748
755
|
.dsq_commentInput {
|
|
749
|
-
width: 100%;
|
|
750
756
|
min-height: 64px;
|
|
751
757
|
resize: none;
|
|
752
|
-
border: none;
|
|
753
758
|
border-radius: 8px;
|
|
754
|
-
padding: 8px 10px;
|
|
755
|
-
font: inherit;
|
|
756
|
-
font-size: 13px;
|
|
757
|
-
line-height: 20px;
|
|
758
|
-
color: var(--dsq-label);
|
|
759
|
-
background: var(--dsq-fill-hover);
|
|
760
|
-
outline: none;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
.dsq_commentInput::placeholder {
|
|
764
|
-
color: var(--dsq-label-3);
|
|
765
759
|
}
|
|
766
760
|
|
|
767
761
|
.dsq_commentActions {
|
|
768
|
-
display: flex;
|
|
769
|
-
justify-content: flex-end;
|
|
770
762
|
gap: 6px;
|
|
771
763
|
}
|
|
772
764
|
|
|
773
|
-
/*
|
|
774
|
-
|
|
765
|
+
/* Preview for a model-written annotation chip. Body-level fixed (the chat
|
|
766
|
+
surface's contain: layout would otherwise capture it); hover and click
|
|
767
|
+
share this one surface, and the button owns the jump. */
|
|
768
|
+
.dsq_chipPreview {
|
|
769
|
+
position: fixed;
|
|
770
|
+
z-index: 50;
|
|
771
|
+
box-sizing: border-box;
|
|
772
|
+
max-height: min(420px, calc(100vh - 32px));
|
|
773
|
+
padding: 10px;
|
|
774
|
+
border-radius: 14px;
|
|
775
|
+
pointer-events: auto;
|
|
776
|
+
overflow: hidden;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/* Same rise as the annotation card's hover list; on the content, not on the
|
|
780
|
+
material layer, so backdrop sampling stays put. */
|
|
781
|
+
.dsq_chipPreviewBody {
|
|
775
782
|
display: flex;
|
|
776
|
-
flex-direction:
|
|
777
|
-
flex-wrap: wrap;
|
|
778
|
-
justify-content: flex-end;
|
|
779
|
-
align-items: stretch;
|
|
783
|
+
flex-direction: column;
|
|
780
784
|
gap: 6px;
|
|
781
|
-
|
|
782
|
-
/* Cards lay out horizontally and wrap onto the next row when they run out of
|
|
783
|
-
width. Each card carries the selected text plus the user's comment, so they
|
|
784
|
-
stay compact and stay on the user-bubble side (right). */
|
|
785
|
-
max-width: 100%;
|
|
786
|
-
margin-left: auto;
|
|
787
|
-
margin-right: 0;
|
|
785
|
+
animation: dsq-rise-in 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
788
786
|
}
|
|
789
787
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
comment, so the fixed height and single-line clamps are lifted on \`.dsq_tCardButton\`. */
|
|
793
|
-
.dsq_tCard {
|
|
794
|
-
max-width: min(calc(var(--dsq-chat-width) * 0.72), 520px);
|
|
795
|
-
margin-left: auto;
|
|
788
|
+
.dsq_chipPreviewLeaving {
|
|
789
|
+
pointer-events: none;
|
|
796
790
|
}
|
|
797
791
|
|
|
798
|
-
.
|
|
799
|
-
|
|
800
|
-
height: 40px;
|
|
801
|
-
font-size: 14px;
|
|
792
|
+
.dsq_chipPreviewLeaving .dsq_chipPreviewBody {
|
|
793
|
+
animation: dsq-rise-out 0.12s cubic-bezier(0.4, 0, 1, 1) both;
|
|
802
794
|
}
|
|
803
795
|
|
|
804
|
-
.
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
796
|
+
/* Header rows: title on the left, the single action on the right. */
|
|
797
|
+
.dsq_chipPreviewHead,
|
|
798
|
+
.dsq_touchSheetHead {
|
|
799
|
+
justify-content: space-between;
|
|
800
|
+
gap: 8px;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
.dsq_chipPreviewTitle {
|
|
804
|
+
font-size: 13px;
|
|
805
|
+
font-weight: 600;
|
|
806
|
+
line-height: 18px;
|
|
807
|
+
color: var(--dsq-label);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
.dsq_chipPreviewLabel {
|
|
811
|
+
margin-top: 2px;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
.dsq_chipPreviewQuote,
|
|
815
|
+
.dsq_chipPreviewComment {
|
|
816
|
+
line-height: 19px;
|
|
817
|
+
max-height: 108px;
|
|
818
|
+
overflow-y: auto;
|
|
819
|
+
overscroll-behavior: contain;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
.dsq_chipPreviewComment {
|
|
823
|
+
color: var(--dsq-label-2);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
.dsq_chipPreviewCommentEmpty {
|
|
827
|
+
color: var(--dsq-label-3);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
.dsq_chipPreviewJump {
|
|
831
|
+
flex: none;
|
|
832
|
+
justify-content: center;
|
|
833
|
+
gap: 4px;
|
|
834
|
+
min-height: 28px;
|
|
835
|
+
padding: 0 9px;
|
|
836
|
+
border-radius: 7px;
|
|
811
837
|
font: inherit;
|
|
812
|
-
|
|
813
|
-
|
|
838
|
+
font-size: 12px;
|
|
839
|
+
font-weight: 500;
|
|
840
|
+
line-height: 1;
|
|
841
|
+
white-space: nowrap;
|
|
814
842
|
}
|
|
815
843
|
|
|
816
|
-
.
|
|
817
|
-
|
|
818
|
-
|
|
844
|
+
/* Touch selection chrome. Fixed to the visual viewport and mounted on body:
|
|
845
|
+
the OS selection menu owns the space around the selected text, and the soft
|
|
846
|
+
keyboard owns the bottom, so neither content coordinates nor the scroller's
|
|
847
|
+
clipping can place this correctly. Only rendered on coarse pointers. */
|
|
848
|
+
.dsq_touchBar,
|
|
849
|
+
.dsq_touchSheet {
|
|
850
|
+
position: fixed;
|
|
851
|
+
left: 50%;
|
|
852
|
+
right: auto;
|
|
853
|
+
box-sizing: border-box;
|
|
854
|
+
width: min(520px, calc(100vw - 16px));
|
|
855
|
+
transform: translateX(-50%);
|
|
856
|
+
touch-action: manipulation;
|
|
857
|
+
pointer-events: auto;
|
|
819
858
|
}
|
|
820
859
|
|
|
821
|
-
.
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
gap: 8px;
|
|
827
|
-
padding: 8px;
|
|
828
|
-
border-radius: 14px;
|
|
860
|
+
.dsq_touchBar {
|
|
861
|
+
z-index: 60;
|
|
862
|
+
gap: 4px;
|
|
863
|
+
padding: 6px;
|
|
864
|
+
border-radius: 16px;
|
|
829
865
|
}
|
|
830
866
|
|
|
831
|
-
.
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
867
|
+
.dsq_touchAction {
|
|
868
|
+
flex: 1 1 0;
|
|
869
|
+
min-width: 0;
|
|
870
|
+
min-height: 48px;
|
|
871
|
+
align-items: center;
|
|
872
|
+
justify-content: center;
|
|
873
|
+
gap: 3px;
|
|
874
|
+
padding: 6px 4px;
|
|
875
|
+
border-radius: 10px;
|
|
876
|
+
color: var(--dsq-label);
|
|
877
|
+
font: inherit;
|
|
835
878
|
font-size: 11px;
|
|
879
|
+
line-height: 14px;
|
|
836
880
|
}
|
|
837
881
|
|
|
838
|
-
.
|
|
839
|
-
|
|
840
|
-
-webkit-line-clamp: 2;
|
|
841
|
-
-webkit-box-orient: vertical;
|
|
882
|
+
.dsq_touchAction span {
|
|
883
|
+
max-width: 100%;
|
|
842
884
|
overflow: hidden;
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
font-size: 13px;
|
|
846
|
-
line-height: 18px;
|
|
885
|
+
text-overflow: ellipsis;
|
|
886
|
+
white-space: nowrap;
|
|
847
887
|
}
|
|
848
888
|
|
|
849
|
-
.
|
|
889
|
+
.dsq_touchAction:active {
|
|
890
|
+
background: var(--dsq-fill-hover);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
.dsq_touchScrim {
|
|
894
|
+
position: fixed;
|
|
895
|
+
inset: 0;
|
|
896
|
+
z-index: 61;
|
|
897
|
+
background: var(--dsq-scrim);
|
|
898
|
+
touch-action: none;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
.dsq_touchSheet {
|
|
902
|
+
z-index: 62;
|
|
903
|
+
gap: 10px;
|
|
904
|
+
padding: 12px;
|
|
905
|
+
border-radius: 20px;
|
|
906
|
+
background: var(--dsq-bg-raised);
|
|
907
|
+
overflow-y: auto;
|
|
908
|
+
overscroll-behavior: contain;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
.dsq_touchSheetClose {
|
|
912
|
+
flex: none;
|
|
913
|
+
width: 44px;
|
|
914
|
+
height: 44px;
|
|
915
|
+
margin: -8px -8px -8px 0;
|
|
916
|
+
border-radius: 12px;
|
|
917
|
+
color: var(--dsq-label-2);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
.dsq_touchSheetClose:active {
|
|
921
|
+
background: var(--dsq-fill-hover);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/* Keep the selected words visible after the sheet covers the page: on touch the
|
|
925
|
+
reader has no hover to re-orient, so the sheet carries its own quote. */
|
|
926
|
+
.dsq_touchQuote {
|
|
927
|
+
border-left: 2px solid var(--dsq-accent);
|
|
928
|
+
border-radius: 6px;
|
|
929
|
+
padding: 6px 8px;
|
|
930
|
+
background: var(--dsq-fill-hover);
|
|
931
|
+
color: var(--dsq-label-2);
|
|
932
|
+
font-size: 12px;
|
|
933
|
+
line-height: 18px;
|
|
934
|
+
white-space: pre-wrap;
|
|
935
|
+
overflow-wrap: anywhere;
|
|
850
936
|
display: -webkit-box;
|
|
851
|
-
-webkit-line-clamp:
|
|
937
|
+
-webkit-line-clamp: 2;
|
|
852
938
|
-webkit-box-orient: vertical;
|
|
853
939
|
overflow: hidden;
|
|
854
|
-
white-space: pre-wrap;
|
|
855
|
-
overflow-wrap: anywhere;
|
|
856
|
-
margin-top: 2px;
|
|
857
|
-
font-size: 12px;
|
|
858
|
-
line-height: 16px;
|
|
859
940
|
}
|
|
860
941
|
|
|
861
|
-
/*
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
942
|
+
/* 16px keeps iOS from zooming the page when the field is focused. */
|
|
943
|
+
.dsq_touchComment {
|
|
944
|
+
min-height: 88px;
|
|
945
|
+
font-size: 16px;
|
|
946
|
+
line-height: 22px;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
.dsq_touchActions {
|
|
950
|
+
gap: 8px;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
.dsq_touchActions .dsq_button {
|
|
954
|
+
flex: 1;
|
|
955
|
+
min-height: 44px;
|
|
956
|
+
justify-content: center;
|
|
957
|
+
font-size: 14px;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
.dsq_touchSecondary {
|
|
961
|
+
background: var(--dsq-fill-hover);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
.dsq_touchStatus {
|
|
965
|
+
position: fixed;
|
|
966
|
+
left: 50%;
|
|
967
|
+
z-index: 63;
|
|
968
|
+
transform: translateX(-50%);
|
|
969
|
+
max-width: calc(100vw - 32px);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/* Transcript summary — the same block as the composer's, right-aligned under
|
|
973
|
+
the user bubble. One block per message; the hover list carries every
|
|
974
|
+
annotation with its selected text and comment. */
|
|
975
|
+
.dsq_tSummary {
|
|
976
|
+
display: flex;
|
|
977
|
+
justify-content: flex-end;
|
|
978
|
+
margin: 4px 0 8px;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
.dsq_tSummary .dsq_summary {
|
|
982
|
+
width: auto;
|
|
983
|
+
min-width: 160px;
|
|
984
|
+
max-width: min(calc(var(--dsq-chat-width) * 0.72), 520px);
|
|
985
|
+
/* In the composer the card must clear .dsq_cardStack (12); in the transcript
|
|
986
|
+
it is plain content and must stay under the floating layer's host (6/8),
|
|
987
|
+
otherwise a hovered comment popover is painted beneath it. */
|
|
988
|
+
z-index: 1;
|
|
865
989
|
}
|
|
866
990
|
|
|
867
991
|
/* Replacement user bubble (quote block stripped from display). */
|
|
@@ -892,6 +1016,12 @@ ${atomRules}
|
|
|
892
1016
|
max-width: 100%;
|
|
893
1017
|
}
|
|
894
1018
|
|
|
1019
|
+
.dsq_userImages img {
|
|
1020
|
+
display: block;
|
|
1021
|
+
max-width: 100%;
|
|
1022
|
+
border-radius: 12px;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
895
1025
|
/* Model-written :dsh-annotation{index="N"} chips. */
|
|
896
1026
|
.dsq_annDirective {
|
|
897
1027
|
appearance: none;
|
|
@@ -952,13 +1082,74 @@ ${atomRules}
|
|
|
952
1082
|
}
|
|
953
1083
|
|
|
954
1084
|
.dsq_annMark:hover {
|
|
955
|
-
background: var(--dsq-
|
|
1085
|
+
background: var(--dsq-mark-hover);
|
|
956
1086
|
}
|
|
957
1087
|
|
|
958
1088
|
.dsq_annMarkFlash {
|
|
959
1089
|
animation: dsq-ann-flash 1.4s ease-out;
|
|
960
1090
|
}
|
|
961
1091
|
|
|
1092
|
+
/* Fallback jump target when the source mark is no longer in the DOM: flash the
|
|
1093
|
+
message that owns it instead of scrolling nowhere. */
|
|
1094
|
+
.dsq_annMessageFlash {
|
|
1095
|
+
animation: dsq-ann-message-flash 1.4s ease-out;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
@keyframes dsq-ann-message-flash {
|
|
1099
|
+
0% {
|
|
1100
|
+
box-shadow: inset 0 0 0 2px var(--dsq-accent);
|
|
1101
|
+
}
|
|
1102
|
+
100% {
|
|
1103
|
+
box-shadow: inset 0 0 0 2px transparent;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/* Keyboard focus is a first-class state for the toolbar, summary and panel. */
|
|
1108
|
+
.dsq_summary:focus-visible,
|
|
1109
|
+
.dsq_button:focus-visible,
|
|
1110
|
+
.dsq_touchAction:focus-visible,
|
|
1111
|
+
.dsq_touchSheetClose:focus-visible,
|
|
1112
|
+
.dsq_chipPreviewJump:focus-visible,
|
|
1113
|
+
.dsq_annMark:focus-visible,
|
|
1114
|
+
.dsq_summaryItemAction:focus-visible,
|
|
1115
|
+
.dsq_summaryItemButton:focus-visible,
|
|
1116
|
+
.dsq_cardClose:focus-visible,
|
|
1117
|
+
.dsq_annEditCancel:focus-visible,
|
|
1118
|
+
.dsq_annEditSave:focus-visible {
|
|
1119
|
+
outline: 2px solid var(--dsq-focus-ring);
|
|
1120
|
+
outline-offset: 2px;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
.dsq_commentInput:focus-visible,
|
|
1124
|
+
.dsq_annEditInput:focus-visible {
|
|
1125
|
+
/* The host's button fill is monochrome (black on light, white on dark); a
|
|
1126
|
+
half-transparent accent ring reads as focus without looking like a border. */
|
|
1127
|
+
outline: 2px solid var(--dsq-focus-ring-soft);
|
|
1128
|
+
outline-offset: 1px;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/* Touch: 44px minimum targets. Icon-only corners keep their visual size and
|
|
1132
|
+
grow an invisible hit area; toolbar actions grow in place. */
|
|
1133
|
+
@media (hover: none), (pointer: coarse) {
|
|
1134
|
+
.dsq_button,
|
|
1135
|
+
.dsq_annEditCancel,
|
|
1136
|
+
.dsq_annEditSave {
|
|
1137
|
+
min-height: 44px;
|
|
1138
|
+
}
|
|
1139
|
+
.dsq_summaryItemAction {
|
|
1140
|
+
width: 44px;
|
|
1141
|
+
height: 44px;
|
|
1142
|
+
}
|
|
1143
|
+
.dsq_cardClose::after {
|
|
1144
|
+
content: "";
|
|
1145
|
+
position: absolute;
|
|
1146
|
+
inset: -12px;
|
|
1147
|
+
}
|
|
1148
|
+
.dsq_chipPreviewJump {
|
|
1149
|
+
min-height: 44px;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
962
1153
|
/* Layer 4 — host surface: keyframes, theme flips, and the two rules that reach
|
|
963
1154
|
into product-owned DOM. */
|
|
964
1155
|
|
|
@@ -966,26 +1157,23 @@ ${atomRules}
|
|
|
966
1157
|
0% {
|
|
967
1158
|
background: var(--dsq-mark-color);
|
|
968
1159
|
color: #fff;
|
|
1160
|
+
box-shadow: 0 0 0 3px var(--dsq-mark-tint);
|
|
969
1161
|
}
|
|
970
1162
|
100% {
|
|
971
1163
|
background: var(--dsq-mark-tint);
|
|
972
1164
|
color: inherit;
|
|
1165
|
+
box-shadow: 0 0 0 0 transparent;
|
|
973
1166
|
}
|
|
974
1167
|
}
|
|
975
1168
|
|
|
976
1169
|
/* The product flips its palette on body[data-ds-dark-theme], and every
|
|
977
|
-
--dsq-* alias follows it. Only values we own ourselves need restating
|
|
978
|
-
transcript card carries .dsq_card too, so one selector covers both. */
|
|
979
|
-
body[data-ds-dark-theme] .dsq_card {
|
|
980
|
-
--dsq-card-fill: var(--dsq-neutral-850);
|
|
981
|
-
--dsq-card-hover: var(--dsq-neutral-800);
|
|
982
|
-
}
|
|
983
|
-
|
|
1170
|
+
--dsq-* alias follows it. Only values we own ourselves need restating. */
|
|
984
1171
|
body[data-ds-dark-theme] .dsq_annMark {
|
|
985
1172
|
--dsq-mark-tint: var(--dsq-accent-tint-dark);
|
|
1173
|
+
--dsq-mark-hover: var(--dsw-static-blue-800, #1e40af);
|
|
986
1174
|
}
|
|
987
1175
|
|
|
988
|
-
/* Reserve room for the floating
|
|
1176
|
+
/* Reserve room for the floating annotation summary inside the composer card, so the
|
|
989
1177
|
attachment rail and the editor flow below them instead of being covered. */
|
|
990
1178
|
.dsq_cardPad {
|
|
991
1179
|
padding-top: var(--dsq-quote-pad, 88px) !important;
|
|
@@ -1009,20 +1197,17 @@ const styles = {
|
|
|
1009
1197
|
divider: "dsq_divider",
|
|
1010
1198
|
status: "dsq_status",
|
|
1011
1199
|
cardStack: "dsq_cardStack",
|
|
1012
|
-
card: "dsq_card",
|
|
1013
|
-
cardIcon: "dsq_cardIcon",
|
|
1014
|
-
cardBody: "dsq_cardBody",
|
|
1015
|
-
cardTitle: "dsq_cardTitle",
|
|
1016
|
-
cardSubtitle: "dsq_cardSubtitle",
|
|
1017
1200
|
cardClose: "dsq_cardClose",
|
|
1018
1201
|
cardPad: "dsq_cardPad",
|
|
1019
|
-
cardAction: "dsq_cardAction",
|
|
1020
1202
|
summary: "dsq_summary",
|
|
1021
1203
|
summaryIcon: "dsq_summaryIcon",
|
|
1022
1204
|
summaryBody: "dsq_summaryBody",
|
|
1023
1205
|
summaryTitle: "dsq_summaryTitle",
|
|
1024
1206
|
summaryHint: "dsq_summaryHint",
|
|
1207
|
+
summaryHintPointer: "dsq_summaryHintPointer",
|
|
1208
|
+
summaryHintTouch: "dsq_summaryHintTouch",
|
|
1025
1209
|
summaryHover: "dsq_summaryHover",
|
|
1210
|
+
summaryHoverLeaving: "dsq_summaryHoverLeaving",
|
|
1026
1211
|
summaryItem: "dsq_summaryItem",
|
|
1027
1212
|
summaryItemLabel: "dsq_summaryItemLabel",
|
|
1028
1213
|
summaryItemText: "dsq_summaryItemText",
|
|
@@ -1030,7 +1215,7 @@ const styles = {
|
|
|
1030
1215
|
summaryItemMain: "dsq_summaryItemMain",
|
|
1031
1216
|
summaryItemHead: "dsq_summaryItemHead",
|
|
1032
1217
|
summaryItemTextEmpty: "dsq_summaryItemTextEmpty",
|
|
1033
|
-
|
|
1218
|
+
summaryItemTextEdit: "dsq_summaryItemTextEdit",
|
|
1034
1219
|
annEditInput: "dsq_annEditInput",
|
|
1035
1220
|
annEditActions: "dsq_annEditActions",
|
|
1036
1221
|
annEditCancel: "dsq_annEditCancel",
|
|
@@ -1045,13 +1230,33 @@ const styles = {
|
|
|
1045
1230
|
annAnchor: "dsq_annAnchor",
|
|
1046
1231
|
annMark: "dsq_annMark",
|
|
1047
1232
|
annMarkFlash: "dsq_annMarkFlash",
|
|
1233
|
+
annMessageFlash: "dsq_annMessageFlash",
|
|
1048
1234
|
commentPop: "dsq_commentPop",
|
|
1049
1235
|
commentInput: "dsq_commentInput",
|
|
1050
1236
|
commentActions: "dsq_commentActions",
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1237
|
+
chipPreview: "dsq_chipPreview",
|
|
1238
|
+
chipPreviewBody: "dsq_chipPreviewBody",
|
|
1239
|
+
chipPreviewLeaving: "dsq_chipPreviewLeaving",
|
|
1240
|
+
chipPreviewHead: "dsq_chipPreviewHead",
|
|
1241
|
+
chipPreviewTitle: "dsq_chipPreviewTitle",
|
|
1242
|
+
chipPreviewLabel: "dsq_chipPreviewLabel",
|
|
1243
|
+
chipPreviewQuote: "dsq_chipPreviewQuote",
|
|
1244
|
+
chipPreviewComment: "dsq_chipPreviewComment",
|
|
1245
|
+
chipPreviewCommentEmpty: "dsq_chipPreviewCommentEmpty",
|
|
1246
|
+
chipPreviewJump: "dsq_chipPreviewJump",
|
|
1247
|
+
touchBar: "dsq_touchBar",
|
|
1248
|
+
touchAction: "dsq_touchAction",
|
|
1249
|
+
touchScrim: "dsq_touchScrim",
|
|
1250
|
+
touchSheet: "dsq_touchSheet",
|
|
1251
|
+
touchSheetHead: "dsq_touchSheetHead",
|
|
1252
|
+
touchSheetTitle: "dsq_touchSheetTitle",
|
|
1253
|
+
touchSheetClose: "dsq_touchSheetClose",
|
|
1254
|
+
touchQuote: "dsq_touchQuote",
|
|
1255
|
+
touchComment: "dsq_touchComment",
|
|
1256
|
+
touchActions: "dsq_touchActions",
|
|
1257
|
+
touchSecondary: "dsq_touchSecondary",
|
|
1258
|
+
touchStatus: "dsq_touchStatus",
|
|
1259
|
+
tSummary: "dsq_tSummary",
|
|
1055
1260
|
userRow: "dsq_userRow",
|
|
1056
1261
|
userStack: "dsq_userStack",
|
|
1057
1262
|
userImages: "dsq_userImages",
|
|
@@ -1064,7 +1269,7 @@ const styles = {
|
|
|
1064
1269
|
* it as stable character offsets over the message's own text.
|
|
1065
1270
|
*
|
|
1066
1271
|
* Offsets are computed over "clean text": the flow item's text nodes with the
|
|
1067
|
-
* plugin's own chrome (summary
|
|
1272
|
+
* plugin's own chrome (summary blocks, directive chips, anchors) skipped. Marks
|
|
1068
1273
|
* are transparent — their text still counts — so an offset captured before a
|
|
1069
1274
|
* mark exists stays valid after decoration.
|
|
1070
1275
|
*/
|
|
@@ -1272,16 +1477,15 @@ function decorateBySearch(item, job) {
|
|
|
1272
1477
|
const full = cleanTextOf(item);
|
|
1273
1478
|
const needle = job.text.trim();
|
|
1274
1479
|
if (needle.length < 2) return false;
|
|
1275
|
-
const patterns = [needle, needle.split(/\s+/).map(escapeRegExp).join("\\s*")];
|
|
1276
|
-
for (const pattern of patterns) {
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
at = full.indexOf(pattern, at + 1);
|
|
1480
|
+
const patterns = [new RegExp(escapeRegExp(needle), "g"), new RegExp(needle.split(/\s+/).map(escapeRegExp).join("\\s*"), "g")];
|
|
1481
|
+
for (const pattern of patterns) for (const match of full.matchAll(pattern)) {
|
|
1482
|
+
const at = match.index ?? 0;
|
|
1483
|
+
const length = match[0].length;
|
|
1484
|
+
if (length === 0) continue;
|
|
1485
|
+
const located = cleanTextRange(item, at, at + length);
|
|
1486
|
+
if (located) {
|
|
1487
|
+
const pieces = overlappingEntries(located.entries, located.from, located.to);
|
|
1488
|
+
if (pieces.length > 0 && !isMarked(pieces) && wrapRange(item, job, located.from, located.to)) return true;
|
|
1285
1489
|
}
|
|
1286
1490
|
}
|
|
1287
1491
|
return false;
|
|
@@ -1293,6 +1497,19 @@ function decorateJob(item, job) {
|
|
|
1293
1497
|
if (decorateAtOffsets(item, job)) return true;
|
|
1294
1498
|
return decorateBySearch(item, job);
|
|
1295
1499
|
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Existing marks are kept, but their ordinal is owned by the job: removing an
|
|
1502
|
+
* earlier annotation renumbers the rest without re-wrapping the text. Only the
|
|
1503
|
+
* final fragment carries the badge.
|
|
1504
|
+
*/
|
|
1505
|
+
function refreshMarkIndex(item, job) {
|
|
1506
|
+
const marks = Array.from(item.querySelectorAll(`[${MARK_ATTR}="${attrValue(job.markId)}"]`));
|
|
1507
|
+
if (marks.length === 0) return false;
|
|
1508
|
+
const last = marks[marks.length - 1];
|
|
1509
|
+
const next = String(job.index);
|
|
1510
|
+
if (last.getAttribute(INDEX_ATTR) !== next) last.setAttribute(INDEX_ATTR, next);
|
|
1511
|
+
return true;
|
|
1512
|
+
}
|
|
1296
1513
|
/** Apply every registered job whose message is currently rendered. */
|
|
1297
1514
|
function applyAnnotationMarks(root) {
|
|
1298
1515
|
if (jobs.size === 0) return;
|
|
@@ -1300,10 +1517,15 @@ function applyAnnotationMarks(root) {
|
|
|
1300
1517
|
if (!scope) return;
|
|
1301
1518
|
for (const job of [...jobs.values()]) {
|
|
1302
1519
|
const items = flowItems(job.messageId, scope);
|
|
1520
|
+
let decorated = false;
|
|
1303
1521
|
for (const item of items) {
|
|
1304
|
-
if (item
|
|
1522
|
+
if (refreshMarkIndex(item, job)) {
|
|
1523
|
+
decorated = true;
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
if (decorated) continue;
|
|
1305
1527
|
if (job.messageId === "" && !item.textContent?.includes(job.text.trim().slice(0, 16))) continue;
|
|
1306
|
-
if (decorateJob(item, job))
|
|
1528
|
+
if (decorateJob(item, job)) decorated = true;
|
|
1307
1529
|
}
|
|
1308
1530
|
}
|
|
1309
1531
|
}
|
|
@@ -1359,6 +1581,7 @@ function watchAnnotationMarks(root) {
|
|
|
1359
1581
|
applyAnnotationMarks(el);
|
|
1360
1582
|
let frame = 0;
|
|
1361
1583
|
const schedule = () => {
|
|
1584
|
+
if (jobs.size === 0) return;
|
|
1362
1585
|
if (frame !== 0) return;
|
|
1363
1586
|
frame = window.requestAnimationFrame(() => {
|
|
1364
1587
|
frame = 0;
|
|
@@ -1577,61 +1800,106 @@ function composeAnnotatedMessage(draft, annotations) {
|
|
|
1577
1800
|
const rest = stripDraftMarker(draft).replace(/^\s+/, "");
|
|
1578
1801
|
if (annotations.length === 0) return rest;
|
|
1579
1802
|
const payload = annotations.map(toWireItem);
|
|
1803
|
+
const json = JSON.stringify(payload).replace(/</g, "\\u003c");
|
|
1580
1804
|
const block = [
|
|
1581
1805
|
PROTOCOL_HEADER,
|
|
1582
1806
|
"",
|
|
1583
1807
|
ANNOTATIONS_BLOCK_OPEN,
|
|
1584
|
-
|
|
1808
|
+
json,
|
|
1585
1809
|
ANNOTATIONS_BLOCK_CLOSE
|
|
1586
1810
|
].join("\n");
|
|
1587
1811
|
return rest ? `${block}\n\n${rest}` : `${block}\n\n`;
|
|
1588
1812
|
}
|
|
1589
1813
|
/** Remove `# Response annotations:` instructional prose before the JSON block. */
|
|
1590
1814
|
function stripProtocolHeader(prefix) {
|
|
1591
|
-
|
|
1815
|
+
const marker = prefix.indexOf("# Response annotations:");
|
|
1816
|
+
if (marker >= 0) return prefix.slice(0, marker).replace(/\s+$/, "");
|
|
1592
1817
|
return prefix.replace(/\s+$/, "");
|
|
1593
1818
|
}
|
|
1819
|
+
function blockParts(text) {
|
|
1820
|
+
const open = text.indexOf(ANNOTATIONS_BLOCK_OPEN);
|
|
1821
|
+
if (open < 0) return null;
|
|
1822
|
+
const payloadStart = open + 22;
|
|
1823
|
+
const firstClose = text.indexOf(ANNOTATIONS_BLOCK_CLOSE, payloadStart);
|
|
1824
|
+
let close = firstClose;
|
|
1825
|
+
while (close >= 0) {
|
|
1826
|
+
const json = text.slice(payloadStart, close).trim();
|
|
1827
|
+
try {
|
|
1828
|
+
return {
|
|
1829
|
+
before: stripProtocolHeader(text.slice(0, open)),
|
|
1830
|
+
after: text.slice(close + 23).replace(/^\s+/, ""),
|
|
1831
|
+
parsed: JSON.parse(json)
|
|
1832
|
+
};
|
|
1833
|
+
} catch {
|
|
1834
|
+
close = text.indexOf(ANNOTATIONS_BLOCK_CLOSE, close + 23);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
if (firstClose < 0) return null;
|
|
1838
|
+
return {
|
|
1839
|
+
before: stripProtocolHeader(text.slice(0, open)),
|
|
1840
|
+
after: text.slice(firstClose + 23).replace(/^\s+/, "")
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
function joinedRest(parts) {
|
|
1844
|
+
return [parts.before, parts.after].filter((part) => part.length > 0).join("\n\n").trim();
|
|
1845
|
+
}
|
|
1846
|
+
/** Wire payload as an array; null when the block is not an array. */
|
|
1847
|
+
function parseWire(parts) {
|
|
1848
|
+
return Array.isArray(parts.parsed) ? parts.parsed : null;
|
|
1849
|
+
}
|
|
1850
|
+
/** Every source an item carries; falls back to the selected text itself. */
|
|
1851
|
+
function sourcesOf(record, body) {
|
|
1852
|
+
const single = record.source;
|
|
1853
|
+
if (single !== null && typeof single === "object" && typeof single.text === "string") return [single];
|
|
1854
|
+
if (Array.isArray(record.sources)) {
|
|
1855
|
+
const list = record.sources.filter((item) => item !== null && typeof item === "object" && typeof item.text === "string");
|
|
1856
|
+
if (list.length > 0) return list;
|
|
1857
|
+
}
|
|
1858
|
+
return [{ text: body }];
|
|
1859
|
+
}
|
|
1860
|
+
/** One usable wire item; null for entries without a selected text. */
|
|
1861
|
+
function wireBody(entry) {
|
|
1862
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
1863
|
+
const record = entry;
|
|
1864
|
+
const body = typeof record.text === "string" ? record.text.trim() : "";
|
|
1865
|
+
if (!body) return null;
|
|
1866
|
+
return {
|
|
1867
|
+
body,
|
|
1868
|
+
comment: typeof record.annotation === "string" ? record.annotation : "",
|
|
1869
|
+
sources: sourcesOf(record, body)
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1594
1872
|
/**
|
|
1595
1873
|
* Read plugin annotations out of a durable message. Tolerates the JSON block
|
|
1596
1874
|
* and strips it from `rest` so the bubble can show only the user's question.
|
|
1875
|
+
* A complete block with a malformed or non-array payload yields no
|
|
1876
|
+
* annotations but is still stripped — the transcript never shows protocol
|
|
1877
|
+
* text, valid or not.
|
|
1597
1878
|
*/
|
|
1598
1879
|
function scanAnnotatedMessage(text) {
|
|
1599
|
-
const
|
|
1600
|
-
|
|
1601
|
-
if (open < 0 || close < 0 || close < open) return {
|
|
1880
|
+
const parts = blockParts(text);
|
|
1881
|
+
if (parts === null) return {
|
|
1602
1882
|
annotations: [],
|
|
1603
1883
|
rest: text
|
|
1604
1884
|
};
|
|
1605
|
-
const
|
|
1606
|
-
const
|
|
1607
|
-
|
|
1608
|
-
let parsed;
|
|
1609
|
-
try {
|
|
1610
|
-
parsed = JSON.parse(jsonText);
|
|
1611
|
-
} catch {
|
|
1612
|
-
return {
|
|
1613
|
-
annotations: [],
|
|
1614
|
-
rest: text.replace(/^\s+/, "")
|
|
1615
|
-
};
|
|
1616
|
-
}
|
|
1617
|
-
if (!Array.isArray(parsed)) return {
|
|
1885
|
+
const rest = joinedRest(parts);
|
|
1886
|
+
const parsed = parseWire(parts);
|
|
1887
|
+
if (parsed === null) return {
|
|
1618
1888
|
annotations: [],
|
|
1619
1889
|
rest
|
|
1620
1890
|
};
|
|
1621
1891
|
const annotations = [];
|
|
1622
1892
|
for (const entry of parsed) {
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
const
|
|
1626
|
-
if (!body) continue;
|
|
1627
|
-
const source = record.source && typeof record.source === "object" ? record.source : Array.isArray(record.sources) ? record.sources.find((item) => item && typeof item === "object") : void 0;
|
|
1893
|
+
const item = wireBody(entry);
|
|
1894
|
+
if (item === null) continue;
|
|
1895
|
+
const source = item.sources[0];
|
|
1628
1896
|
const messageId = typeof source?.messageId === "string" ? source.messageId : void 0;
|
|
1629
1897
|
const startOffset = typeof source?.startOffset === "number" ? source.startOffset : void 0;
|
|
1630
1898
|
const endOffset = typeof source?.endOffset === "number" ? source.endOffset : void 0;
|
|
1631
1899
|
annotations.push({
|
|
1632
|
-
title: previewTitle(body),
|
|
1633
|
-
text: body,
|
|
1634
|
-
comment:
|
|
1900
|
+
title: previewTitle(item.body),
|
|
1901
|
+
text: item.body,
|
|
1902
|
+
comment: item.comment,
|
|
1635
1903
|
...messageId ? { messageId } : {},
|
|
1636
1904
|
...startOffset !== void 0 ? { startOffset } : {},
|
|
1637
1905
|
...endOffset !== void 0 ? { endOffset } : {}
|
|
@@ -1642,6 +1910,33 @@ function scanAnnotatedMessage(text) {
|
|
|
1642
1910
|
rest
|
|
1643
1911
|
};
|
|
1644
1912
|
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Inverse of `composeAnnotatedMessage` for text that is about to live in the
|
|
1915
|
+
* composer: dsh-rewind refills a withdrawn message verbatim, protocol block
|
|
1916
|
+
* included. Returns the pending annotations and the user's own text, or null
|
|
1917
|
+
* when `text` carries no complete, valid block — a draft that merely mentions
|
|
1918
|
+
* the markers must never be edited away.
|
|
1919
|
+
*/
|
|
1920
|
+
function readComposedDraft(text) {
|
|
1921
|
+
const parts = blockParts(text);
|
|
1922
|
+
if (parts === null) return null;
|
|
1923
|
+
const parsed = parseWire(parts);
|
|
1924
|
+
if (parsed === null || parsed.length === 0) return null;
|
|
1925
|
+
const annotations = [];
|
|
1926
|
+
for (const entry of parsed) {
|
|
1927
|
+
const item = wireBody(entry);
|
|
1928
|
+
if (item !== null) annotations.push({
|
|
1929
|
+
text: item.body,
|
|
1930
|
+
comment: item.comment,
|
|
1931
|
+
sources: item.sources
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
if (annotations.length === 0) return null;
|
|
1935
|
+
return {
|
|
1936
|
+
annotations,
|
|
1937
|
+
rest: joinedRest(parts)
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1645
1940
|
//#endregion
|
|
1646
1941
|
//#region src/client/runtime.ts
|
|
1647
1942
|
/**
|
|
@@ -1689,6 +1984,9 @@ function persist(partitionKey) {
|
|
|
1689
1984
|
if (!persistence || partitionKey === MEMORY_KEY) return;
|
|
1690
1985
|
persistence.save(partitionKey, bySession.get(partitionKey) ?? EMPTY$1);
|
|
1691
1986
|
}
|
|
1987
|
+
function newAnnotationId() {
|
|
1988
|
+
return `ann_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1989
|
+
}
|
|
1692
1990
|
/** Append one selection; an identical text+comment pair is reused. */
|
|
1693
1991
|
function saveAnnotation(sessionId, input) {
|
|
1694
1992
|
const k = partition(sessionId);
|
|
@@ -1697,7 +1995,7 @@ function saveAnnotation(sessionId, input) {
|
|
|
1697
1995
|
const duplicate = current.find((item) => item.text === input.text && (item.comment ?? "") === comment);
|
|
1698
1996
|
if (duplicate) return duplicate;
|
|
1699
1997
|
const annotation = {
|
|
1700
|
-
id:
|
|
1998
|
+
id: newAnnotationId(),
|
|
1701
1999
|
text: input.text,
|
|
1702
2000
|
...comment ? { comment } : {},
|
|
1703
2001
|
sources: input.sources?.length ? input.sources : [{ text: input.text }]
|
|
@@ -1707,6 +2005,36 @@ function saveAnnotation(sessionId, input) {
|
|
|
1707
2005
|
notify();
|
|
1708
2006
|
return annotation;
|
|
1709
2007
|
}
|
|
2008
|
+
/**
|
|
2009
|
+
* Re-adopt annotations lifted out of a composed message.
|
|
2010
|
+
*
|
|
2011
|
+
* dsh-rewind puts a withdrawn message back into the composer verbatim, so the
|
|
2012
|
+
* protocol block arrives there too; the composer half parses it and hands the
|
|
2013
|
+
* items here. Same text+comment pairs already pending win, so a repeated
|
|
2014
|
+
* restore never duplicates a card. Returns how many items were added.
|
|
2015
|
+
*/
|
|
2016
|
+
function restoreAnnotations(sessionId, items) {
|
|
2017
|
+
const k = partition(sessionId);
|
|
2018
|
+
const current = bySession.get(k) ?? EMPTY$1;
|
|
2019
|
+
const next = [...current];
|
|
2020
|
+
for (const item of items) {
|
|
2021
|
+
const text = item.text.trim();
|
|
2022
|
+
if (!text) continue;
|
|
2023
|
+
const comment = item.comment?.trim() ?? "";
|
|
2024
|
+
if (next.some((entry) => entry.text === text && (entry.comment ?? "") === comment)) continue;
|
|
2025
|
+
next.push({
|
|
2026
|
+
id: newAnnotationId(),
|
|
2027
|
+
text,
|
|
2028
|
+
...comment ? { comment } : {},
|
|
2029
|
+
sources: item.sources.length > 0 ? item.sources : [{ text }]
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
if (next.length === current.length) return 0;
|
|
2033
|
+
bySession.set(k, next);
|
|
2034
|
+
persist(k);
|
|
2035
|
+
notify();
|
|
2036
|
+
return next.length - current.length;
|
|
2037
|
+
}
|
|
1710
2038
|
function getAnnotations(sessionId) {
|
|
1711
2039
|
return bySession.get(partition(sessionId)) ?? EMPTY$1;
|
|
1712
2040
|
}
|
|
@@ -1780,18 +2108,63 @@ function adoptPersistedAnnotations(sessionId) {
|
|
|
1780
2108
|
if (orphaned.length > 0) bySession.delete(MEMORY_KEY);
|
|
1781
2109
|
notify();
|
|
1782
2110
|
}
|
|
2111
|
+
/** Same ids in the same order, with the only mutable field (comment) equal. */
|
|
2112
|
+
function sameAnnotations(a, b) {
|
|
2113
|
+
return a.length === b.length && a.every((item, i) => {
|
|
2114
|
+
const other = b[i];
|
|
2115
|
+
return other !== void 0 && item.id === other.id && (item.comment ?? "") === (other.comment ?? "");
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Re-read a session after another tab wrote its storage key.
|
|
2120
|
+
*
|
|
2121
|
+
* Storage is the shared source of truth: every local mutation persists
|
|
2122
|
+
* synchronously, so this tab's memory can only be stale relative to it. The
|
|
2123
|
+
* stored array replaces the local one (last writer wins) — merging would
|
|
2124
|
+
* resurrect annotations the other tab already sent and cleared.
|
|
2125
|
+
*
|
|
2126
|
+
* `null` is the `localStorage.clear()` event: reload every partition this tab
|
|
2127
|
+
* holds. Sessions that are neither cached nor active are ignored; binding the
|
|
2128
|
+
* session later reads storage anyway.
|
|
2129
|
+
*/
|
|
2130
|
+
function adoptStorageAnnotations(sessionId) {
|
|
2131
|
+
if (!persistence) return;
|
|
2132
|
+
if (sessionId === null) {
|
|
2133
|
+
let changed = false;
|
|
2134
|
+
for (const key of [...bySession.keys()]) {
|
|
2135
|
+
if (key === MEMORY_KEY) continue;
|
|
2136
|
+
const stored = persistence.load(key);
|
|
2137
|
+
const current = bySession.get(key);
|
|
2138
|
+
if (stored.length === 0) {
|
|
2139
|
+
bySession.delete(key);
|
|
2140
|
+
changed = true;
|
|
2141
|
+
} else if (current === void 0 || !sameAnnotations(stored, current)) {
|
|
2142
|
+
bySession.set(key, [...stored]);
|
|
2143
|
+
changed = true;
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
if (changed) notify();
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
if (!bySession.has(sessionId) && sessionId !== runtime.sessionId) return;
|
|
2150
|
+
const stored = persistence.load(sessionId);
|
|
2151
|
+
if (sameAnnotations(stored, bySession.get(sessionId) ?? EMPTY$1)) return;
|
|
2152
|
+
if (stored.length === 0) bySession.delete(sessionId);
|
|
2153
|
+
else bySession.set(sessionId, [...stored]);
|
|
2154
|
+
notify();
|
|
2155
|
+
}
|
|
1783
2156
|
function annotationTitle(annotation) {
|
|
1784
2157
|
return previewTitle(annotation.text);
|
|
1785
2158
|
}
|
|
1786
2159
|
//#endregion
|
|
1787
2160
|
//#region src/client/dom/popoverPlacement.ts
|
|
1788
|
-
const GAP = 8;
|
|
2161
|
+
const GAP$1 = 8;
|
|
1789
2162
|
const MARGIN = 8;
|
|
1790
2163
|
/**
|
|
1791
2164
|
* Place `panel` near `anchor`, flipping vertical side when the preferred side
|
|
1792
2165
|
* lacks room. Keeps the panel inside `bounds` (the viewport by default).
|
|
1793
2166
|
*/
|
|
1794
|
-
function placePopover(anchor, panel, preferred = "bottom", gap = GAP, bounds) {
|
|
2167
|
+
function placePopover(anchor, panel, preferred = "bottom", gap = GAP$1, bounds) {
|
|
1795
2168
|
const box = bounds ?? {
|
|
1796
2169
|
top: 0,
|
|
1797
2170
|
left: 0,
|
|
@@ -1878,6 +2251,7 @@ const zh = {
|
|
|
1878
2251
|
"common.selectedText": "选中的文本",
|
|
1879
2252
|
"common.commentPlaceholder": "添加可选评论…",
|
|
1880
2253
|
"common.cancel": "取消",
|
|
2254
|
+
"common.close": "关闭",
|
|
1881
2255
|
"common.save": "保存",
|
|
1882
2256
|
"toolbar.aria": "划词操作",
|
|
1883
2257
|
"toolbar.commentHint": "添加批注…",
|
|
@@ -1888,26 +2262,26 @@ const zh = {
|
|
|
1888
2262
|
"toolbar.commentDialogAria": "添加可选评论",
|
|
1889
2263
|
"toolbar.skipComment": "暂不评论",
|
|
1890
2264
|
"toolbar.addComment": "添加评论",
|
|
1891
|
-
"card.commentSubtitle": "评论 · {comment}",
|
|
1892
|
-
"card.ariaIndexed": "批注 {index}",
|
|
1893
|
-
"card.ariaPlain": "划词批注",
|
|
1894
|
-
"card.editComment": "编辑评论",
|
|
1895
|
-
"card.remove": "移除批注",
|
|
1896
2265
|
"summary.hint": "悬停查看批注",
|
|
2266
|
+
"summary.hintTouch": "点按查看批注",
|
|
1897
2267
|
"summary.dialogAria": "{count} 条划词批注详情",
|
|
1898
2268
|
"summary.edit": "编辑批注 {index}",
|
|
1899
2269
|
"summary.remove": "移除批注 {index}",
|
|
1900
2270
|
"summary.userComment": "用户评论",
|
|
1901
2271
|
"summary.noComment": "未添加评论",
|
|
1902
|
-
"summary.aria.other": "{count}
|
|
2272
|
+
"summary.aria.other": "{count} 条划词批注。展开查看详情。",
|
|
1903
2273
|
"summary.title.other": "{count} 条批注",
|
|
1904
2274
|
"summary.removeAll": "移除全部划词批注",
|
|
1905
|
-
"chip.annotation": "批注 {index}"
|
|
2275
|
+
"chip.annotation": "批注 {index}",
|
|
2276
|
+
"chip.previewAria": "批注预览",
|
|
2277
|
+
"chip.jump": "原文",
|
|
2278
|
+
"chip.jumpAria": "跳转到原文"
|
|
1906
2279
|
};
|
|
1907
2280
|
const en = {
|
|
1908
2281
|
"common.selectedText": "Selected text",
|
|
1909
2282
|
"common.commentPlaceholder": "Add an optional comment…",
|
|
1910
2283
|
"common.cancel": "Cancel",
|
|
2284
|
+
"common.close": "Close",
|
|
1911
2285
|
"common.save": "Save",
|
|
1912
2286
|
"toolbar.aria": "Selection actions",
|
|
1913
2287
|
"toolbar.commentHint": "Add annotation…",
|
|
@@ -1918,23 +2292,22 @@ const en = {
|
|
|
1918
2292
|
"toolbar.commentDialogAria": "Add an optional comment",
|
|
1919
2293
|
"toolbar.skipComment": "Skip comment",
|
|
1920
2294
|
"toolbar.addComment": "Add comment",
|
|
1921
|
-
"card.commentSubtitle": "Comment · {comment}",
|
|
1922
|
-
"card.ariaIndexed": "Annotation {index}",
|
|
1923
|
-
"card.ariaPlain": "Selection annotation",
|
|
1924
|
-
"card.editComment": "Edit comment",
|
|
1925
|
-
"card.remove": "Remove annotation",
|
|
1926
2295
|
"summary.hint": "Hover to view annotations",
|
|
2296
|
+
"summary.hintTouch": "Tap to view annotations",
|
|
1927
2297
|
"summary.dialogAria": "Annotation details: {count}",
|
|
1928
2298
|
"summary.edit": "Edit annotation {index}",
|
|
1929
2299
|
"summary.remove": "Remove annotation {index}",
|
|
1930
2300
|
"summary.userComment": "User comment",
|
|
1931
2301
|
"summary.noComment": "No comment added",
|
|
1932
|
-
"summary.aria.one": "{count} annotation.
|
|
1933
|
-
"summary.aria.other": "{count} annotations.
|
|
2302
|
+
"summary.aria.one": "{count} annotation. Open to view details.",
|
|
2303
|
+
"summary.aria.other": "{count} annotations. Open to view details.",
|
|
1934
2304
|
"summary.title.one": "{count} annotation",
|
|
1935
2305
|
"summary.title.other": "{count} annotations",
|
|
1936
2306
|
"summary.removeAll": "Remove all annotations",
|
|
1937
|
-
"chip.annotation": "Annotation {index}"
|
|
2307
|
+
"chip.annotation": "Annotation {index}",
|
|
2308
|
+
"chip.previewAria": "Annotation preview",
|
|
2309
|
+
"chip.jump": "Original",
|
|
2310
|
+
"chip.jumpAria": "Jump to original"
|
|
1938
2311
|
};
|
|
1939
2312
|
/**
|
|
1940
2313
|
* Russian is not a shipped host locale: it is added through
|
|
@@ -1945,6 +2318,7 @@ const ru = {
|
|
|
1945
2318
|
"common.selectedText": "Выделенный текст",
|
|
1946
2319
|
"common.commentPlaceholder": "Добавить необязательный комментарий…",
|
|
1947
2320
|
"common.cancel": "Отмена",
|
|
2321
|
+
"common.close": "Закрыть",
|
|
1948
2322
|
"common.save": "Сохранить",
|
|
1949
2323
|
"toolbar.aria": "Действия с выделенным текстом",
|
|
1950
2324
|
"toolbar.commentHint": "Добавить аннотацию…",
|
|
@@ -1955,27 +2329,26 @@ const ru = {
|
|
|
1955
2329
|
"toolbar.commentDialogAria": "Добавить необязательный комментарий",
|
|
1956
2330
|
"toolbar.skipComment": "Без комментария",
|
|
1957
2331
|
"toolbar.addComment": "Добавить комментарий",
|
|
1958
|
-
"card.commentSubtitle": "Комментарий · {comment}",
|
|
1959
|
-
"card.ariaIndexed": "Аннотация {index}",
|
|
1960
|
-
"card.ariaPlain": "Аннотация к выделенному тексту",
|
|
1961
|
-
"card.editComment": "Изменить комментарий",
|
|
1962
|
-
"card.remove": "Удалить аннотацию",
|
|
1963
2332
|
"summary.hint": "Наведите, чтобы увидеть аннотации",
|
|
2333
|
+
"summary.hintTouch": "Нажмите, чтобы увидеть аннотации",
|
|
1964
2334
|
"summary.dialogAria": "Детали аннотаций: {count}",
|
|
1965
2335
|
"summary.edit": "Изменить аннотацию {index}",
|
|
1966
2336
|
"summary.remove": "Удалить аннотацию {index}",
|
|
1967
2337
|
"summary.userComment": "Комментарий пользователя",
|
|
1968
2338
|
"summary.noComment": "Комментарий не добавлен",
|
|
1969
|
-
"summary.aria.one": "{count} аннотация.
|
|
1970
|
-
"summary.aria.few": "{count} аннотации.
|
|
1971
|
-
"summary.aria.many": "{count} аннотаций.
|
|
1972
|
-
"summary.aria.other": "{count} аннотаций.
|
|
2339
|
+
"summary.aria.one": "{count} аннотация. Откройте, чтобы увидеть подробности.",
|
|
2340
|
+
"summary.aria.few": "{count} аннотации. Откройте, чтобы увидеть подробности.",
|
|
2341
|
+
"summary.aria.many": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
|
|
2342
|
+
"summary.aria.other": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
|
|
1973
2343
|
"summary.title.one": "{count} аннотация",
|
|
1974
2344
|
"summary.title.few": "{count} аннотации",
|
|
1975
2345
|
"summary.title.many": "{count} аннотаций",
|
|
1976
2346
|
"summary.title.other": "{count} аннотаций",
|
|
1977
2347
|
"summary.removeAll": "Удалить все аннотации",
|
|
1978
|
-
"chip.annotation": "Аннотация {index}"
|
|
2348
|
+
"chip.annotation": "Аннотация {index}",
|
|
2349
|
+
"chip.previewAria": "Просмотр аннотации",
|
|
2350
|
+
"chip.jump": "Оригинал",
|
|
2351
|
+
"chip.jumpAria": "Перейти к оригиналу"
|
|
1979
2352
|
};
|
|
1980
2353
|
/** Both shipped host locales, required together by the typed `register()`. */
|
|
1981
2354
|
const SHIPPED_DICTS = Object.freeze({
|
|
@@ -2057,56 +2430,18 @@ function useLocale() {
|
|
|
2057
2430
|
}
|
|
2058
2431
|
//#endregion
|
|
2059
2432
|
//#region src/client/ui/AnnotationCardView.tsx
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
const composer = variant === "composer";
|
|
2063
|
-
const subtitle = comment ? composer ? t("card.commentSubtitle", { comment }) : comment : composer ? t("common.selectedText") : t("summary.noComment");
|
|
2064
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2065
|
-
className: cx(A.box, A.row, styles.card, !composer && styles.tCard),
|
|
2066
|
-
role: "group",
|
|
2067
|
-
"aria-label": index ? t("card.ariaIndexed", { index }) : t("card.ariaPlain"),
|
|
2068
|
-
children: [
|
|
2069
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2070
|
-
className: cx(A.keep, A.gridMid, styles.cardIcon, !composer && styles.tCardIcon),
|
|
2071
|
-
"aria-hidden": "true",
|
|
2072
|
-
children: index ? String(index) : "AI"
|
|
2073
|
-
}),
|
|
2074
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2075
|
-
className: cx(A.grow, A.col, styles.cardBody),
|
|
2076
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2077
|
-
className: cx(A.ell, styles.cardTitle),
|
|
2078
|
-
title: text,
|
|
2079
|
-
children: title
|
|
2080
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2081
|
-
className: cx(A.ell, styles.cardSubtitle),
|
|
2082
|
-
...comment ? {
|
|
2083
|
-
title: comment,
|
|
2084
|
-
"data-dsq-has-comment": "true"
|
|
2085
|
-
} : {},
|
|
2086
|
-
children: subtitle
|
|
2087
|
-
})]
|
|
2088
|
-
}),
|
|
2089
|
-
composer && onEditComment ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2090
|
-
type: "button",
|
|
2091
|
-
className: cx(A.plain, A.gridMid, styles.cardAction),
|
|
2092
|
-
"aria-label": t("card.editComment"),
|
|
2093
|
-
onClick: onEditComment,
|
|
2094
|
-
children: "✎"
|
|
2095
|
-
}) : null,
|
|
2096
|
-
composer && onRemove ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2097
|
-
type: "button",
|
|
2098
|
-
className: cx(A.plain, A.gridMid, styles.cardClose),
|
|
2099
|
-
"aria-label": t("card.remove"),
|
|
2100
|
-
onClick: onRemove,
|
|
2101
|
-
children: "×"
|
|
2102
|
-
}) : null
|
|
2103
|
-
]
|
|
2104
|
-
});
|
|
2105
|
-
}
|
|
2106
|
-
/** Grace period so pointer transit between the summary and the panel never closes it. */
|
|
2107
|
-
const CLOSE_DELAY = 320;
|
|
2433
|
+
/** Short grace for crossing the 12px bridge; the exit starts right after it. */
|
|
2434
|
+
const CLOSE_DELAY$1 = 40;
|
|
2108
2435
|
/** After a viewport change, wait this long before position-checking the pointer. */
|
|
2109
2436
|
const SETTLE_DELAY = 600;
|
|
2437
|
+
/** Transparent strip on the facing side; the pointer crosses it in transit. */
|
|
2438
|
+
const BRIDGE = 12;
|
|
2439
|
+
/** Matches the list's leaving keyframe; the panel unmounts after it. */
|
|
2440
|
+
const EXIT_MS$1 = 120;
|
|
2441
|
+
/** Stable edit target: item id, with an index fallback for id-less callers. */
|
|
2442
|
+
function editKeyOf(item, index) {
|
|
2443
|
+
return item?.id ?? `index:${index}`;
|
|
2444
|
+
}
|
|
2110
2445
|
/**
|
|
2111
2446
|
* Qoder-style summary with a placement-aware hover list.
|
|
2112
2447
|
*
|
|
@@ -2126,21 +2461,31 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2126
2461
|
const settleTimerRef = (0, react.useRef)(null);
|
|
2127
2462
|
const lastPointerRef = (0, react.useRef)(null);
|
|
2128
2463
|
const editInputRef = (0, react.useRef)(null);
|
|
2464
|
+
/** Set while keyboard focus is being restored after a deliberate close. */
|
|
2465
|
+
const focusGuardRef = (0, react.useRef)(false);
|
|
2466
|
+
/** Set by keyboard open; the effect focuses the panel once it is mounted. */
|
|
2467
|
+
const enterPanelRef = (0, react.useRef)(false);
|
|
2468
|
+
const panelId = (0, react.useId)();
|
|
2129
2469
|
const [open, setOpen] = (0, react.useState)(false);
|
|
2470
|
+
const [renderPanel, setRenderPanel] = (0, react.useState)(false);
|
|
2471
|
+
const [closingPanel, setClosingPanel] = (0, react.useState)(false);
|
|
2472
|
+
const exitTimerRef = (0, react.useRef)(null);
|
|
2130
2473
|
const [editing, setEditing] = (0, react.useState)(null);
|
|
2131
2474
|
const [editDraft, setEditDraft] = (0, react.useState)("");
|
|
2132
2475
|
const place = (0, react.useCallback)(() => {
|
|
2133
2476
|
const root = rootRef.current;
|
|
2134
2477
|
const pop = popRef.current;
|
|
2135
2478
|
if (!root || !pop) return;
|
|
2136
|
-
const
|
|
2137
|
-
|
|
2138
|
-
|
|
2479
|
+
const anchor = root.getBoundingClientRect();
|
|
2480
|
+
const inner = pop.firstElementChild instanceof HTMLElement ? pop.firstElementChild : null;
|
|
2481
|
+
const { top, left, side } = placePopover(anchor, {
|
|
2482
|
+
width: inner?.offsetWidth || pop.offsetWidth || 300,
|
|
2483
|
+
height: (inner?.offsetHeight || pop.offsetHeight || 220) + BRIDGE
|
|
2139
2484
|
}, "top", 0);
|
|
2140
2485
|
pop.style.top = `${top}px`;
|
|
2141
2486
|
pop.style.left = `${left}px`;
|
|
2142
|
-
pop.style.paddingTop = side === "bottom" ?
|
|
2143
|
-
pop.style.paddingBottom = side === "top" ?
|
|
2487
|
+
pop.style.paddingTop = side === "bottom" ? `${BRIDGE}px` : "0";
|
|
2488
|
+
pop.style.paddingBottom = side === "top" ? `${BRIDGE}px` : "0";
|
|
2144
2489
|
pop.dataset.side = side;
|
|
2145
2490
|
pop.style.opacity = "1";
|
|
2146
2491
|
}, []);
|
|
@@ -2158,11 +2503,81 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2158
2503
|
cancelClose();
|
|
2159
2504
|
setOpen(false);
|
|
2160
2505
|
}, [cancelClose]);
|
|
2506
|
+
/** Keyboard-focusable controls in the open panel, in DOM order. */
|
|
2507
|
+
const focusables = (0, react.useCallback)(() => {
|
|
2508
|
+
const pop = popRef.current;
|
|
2509
|
+
if (!pop) return [];
|
|
2510
|
+
return Array.from(pop.querySelectorAll("button, textarea, [href], [tabindex]:not([tabindex=\"-1\"])")).filter((el) => !el.hasAttribute("disabled") && el.getClientRects().length > 0);
|
|
2511
|
+
}, []);
|
|
2512
|
+
const focusPanel = (0, react.useCallback)((edge = "first") => {
|
|
2513
|
+
const list = focusables();
|
|
2514
|
+
(edge === "first" ? list[0] : list[list.length - 1])?.focus({ preventScroll: true });
|
|
2515
|
+
}, [focusables]);
|
|
2516
|
+
/** Close the panel and put focus back on the summary without re-opening it. */
|
|
2517
|
+
const closeAndRestoreFocus = (0, react.useCallback)(() => {
|
|
2518
|
+
focusGuardRef.current = true;
|
|
2519
|
+
closePop();
|
|
2520
|
+
rootRef.current?.focus({ preventScroll: true });
|
|
2521
|
+
window.setTimeout(() => {
|
|
2522
|
+
focusGuardRef.current = false;
|
|
2523
|
+
}, 0);
|
|
2524
|
+
}, [closePop]);
|
|
2525
|
+
const openAndEnterPanel = (0, react.useCallback)(() => {
|
|
2526
|
+
enterPanelRef.current = true;
|
|
2527
|
+
openPop();
|
|
2528
|
+
}, [openPop]);
|
|
2161
2529
|
const scheduleClose = (0, react.useCallback)(() => {
|
|
2162
2530
|
cancelClose();
|
|
2163
2531
|
if (editing !== null) return;
|
|
2164
|
-
closeTimerRef.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY);
|
|
2532
|
+
closeTimerRef.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY$1);
|
|
2165
2533
|
}, [cancelClose, editing]);
|
|
2534
|
+
/** Touch pointers emit pointerleave on lift; only hover pointers auto-close. */
|
|
2535
|
+
const onPointerLeave = (0, react.useCallback)((event) => {
|
|
2536
|
+
if (event?.pointerType === "touch") return;
|
|
2537
|
+
scheduleClose();
|
|
2538
|
+
}, [scheduleClose]);
|
|
2539
|
+
const onSummaryPointerUp = (0, react.useCallback)((event) => {
|
|
2540
|
+
if (event.pointerType !== "touch") return;
|
|
2541
|
+
const target = event.target;
|
|
2542
|
+
if (target instanceof Element && target.closest("button, textarea") !== null) return;
|
|
2543
|
+
openPop();
|
|
2544
|
+
}, [openPop]);
|
|
2545
|
+
(0, react.useEffect)(() => {
|
|
2546
|
+
if (!open) return;
|
|
2547
|
+
const onPointerDown = (event) => {
|
|
2548
|
+
const target = event.target;
|
|
2549
|
+
if (!(target instanceof Node)) return;
|
|
2550
|
+
if (rootRef.current?.contains(target) || popRef.current?.contains(target)) return;
|
|
2551
|
+
if (editing !== null) return;
|
|
2552
|
+
setOpen(false);
|
|
2553
|
+
};
|
|
2554
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
2555
|
+
return () => document.removeEventListener("pointerdown", onPointerDown, true);
|
|
2556
|
+
}, [open, editing]);
|
|
2557
|
+
(0, react.useLayoutEffect)(() => {
|
|
2558
|
+
if (open) {
|
|
2559
|
+
if (exitTimerRef.current !== null) {
|
|
2560
|
+
window.clearTimeout(exitTimerRef.current);
|
|
2561
|
+
exitTimerRef.current = null;
|
|
2562
|
+
}
|
|
2563
|
+
setClosingPanel(false);
|
|
2564
|
+
setRenderPanel(true);
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
if (!renderPanel) return;
|
|
2568
|
+
setClosingPanel(true);
|
|
2569
|
+
exitTimerRef.current = window.setTimeout(() => {
|
|
2570
|
+
exitTimerRef.current = null;
|
|
2571
|
+
setRenderPanel(false);
|
|
2572
|
+
setClosingPanel(false);
|
|
2573
|
+
}, EXIT_MS$1);
|
|
2574
|
+
return () => {
|
|
2575
|
+
if (exitTimerRef.current !== null) {
|
|
2576
|
+
window.clearTimeout(exitTimerRef.current);
|
|
2577
|
+
exitTimerRef.current = null;
|
|
2578
|
+
}
|
|
2579
|
+
};
|
|
2580
|
+
}, [open, renderPanel]);
|
|
2166
2581
|
/** True when the last seen pointer sits on the summary or the panel. */
|
|
2167
2582
|
const pointerInsideCluster = (0, react.useCallback)(() => {
|
|
2168
2583
|
const point = lastPointerRef.current;
|
|
@@ -2185,6 +2600,11 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2185
2600
|
items,
|
|
2186
2601
|
editing
|
|
2187
2602
|
]);
|
|
2603
|
+
(0, react.useLayoutEffect)(() => {
|
|
2604
|
+
if (!open || !enterPanelRef.current) return;
|
|
2605
|
+
enterPanelRef.current = false;
|
|
2606
|
+
focusPanel("first");
|
|
2607
|
+
}, [open, focusPanel]);
|
|
2188
2608
|
(0, react.useEffect)(() => {
|
|
2189
2609
|
if (!open) return;
|
|
2190
2610
|
const track = (event) => {
|
|
@@ -2239,31 +2659,98 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2239
2659
|
const onPointerEnter = (0, react.useCallback)(() => {
|
|
2240
2660
|
openPop();
|
|
2241
2661
|
}, [openPop]);
|
|
2242
|
-
const onPointerLeave = (0, react.useCallback)(() => {
|
|
2243
|
-
scheduleClose();
|
|
2244
|
-
}, [scheduleClose]);
|
|
2245
2662
|
const onBlur = (0, react.useCallback)((e) => {
|
|
2663
|
+
if (editing !== null) return;
|
|
2246
2664
|
const next = e.relatedTarget;
|
|
2247
2665
|
if (next instanceof Node) {
|
|
2248
2666
|
if (rootRef.current?.contains(next)) return;
|
|
2249
2667
|
if (popRef.current?.contains(next)) return;
|
|
2250
2668
|
}
|
|
2251
2669
|
closePop();
|
|
2252
|
-
}, [closePop]);
|
|
2670
|
+
}, [closePop, editing]);
|
|
2671
|
+
/** Keyboard focus opens the panel; mouse/touch focus does not. */
|
|
2672
|
+
const onSummaryFocus = (0, react.useCallback)(() => {
|
|
2673
|
+
if (focusGuardRef.current) return;
|
|
2674
|
+
if (rootRef.current?.matches(":focus-visible")) openPop();
|
|
2675
|
+
}, [openPop]);
|
|
2253
2676
|
const onKeyDown = (0, react.useCallback)((e) => {
|
|
2254
2677
|
if (e.key === "Escape") {
|
|
2678
|
+
e.preventDefault();
|
|
2679
|
+
if (editing !== null) setEditing(null);
|
|
2680
|
+
else closeAndRestoreFocus();
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
2684
|
+
if (e.target !== e.currentTarget) return;
|
|
2685
|
+
e.preventDefault();
|
|
2686
|
+
if (open) closePop();
|
|
2687
|
+
else openAndEnterPanel();
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
if (e.key === "ArrowDown" && e.target === e.currentTarget) {
|
|
2691
|
+
e.preventDefault();
|
|
2692
|
+
if (open) focusPanel("first");
|
|
2693
|
+
else openAndEnterPanel();
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
if (e.key === "Tab" && open && !e.shiftKey && e.target === e.currentTarget) {
|
|
2697
|
+
e.preventDefault();
|
|
2698
|
+
focusPanel("first");
|
|
2699
|
+
}
|
|
2700
|
+
}, [
|
|
2701
|
+
closeAndRestoreFocus,
|
|
2702
|
+
closePop,
|
|
2703
|
+
editing,
|
|
2704
|
+
focusPanel,
|
|
2705
|
+
open,
|
|
2706
|
+
openAndEnterPanel
|
|
2707
|
+
]);
|
|
2708
|
+
const onPanelKeyDown = (0, react.useCallback)((e) => {
|
|
2709
|
+
if (e.key === "Escape") {
|
|
2710
|
+
e.preventDefault();
|
|
2711
|
+
e.stopPropagation();
|
|
2255
2712
|
if (editing !== null) setEditing(null);
|
|
2256
|
-
else
|
|
2713
|
+
else closeAndRestoreFocus();
|
|
2714
|
+
return;
|
|
2257
2715
|
}
|
|
2258
|
-
|
|
2716
|
+
if (e.key === "Tab" || e.key === "ArrowDown" || e.key === "ArrowUp") e.stopPropagation();
|
|
2717
|
+
const list = focusables();
|
|
2718
|
+
if (list.length === 0) return;
|
|
2719
|
+
const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
2720
|
+
const index = active === null ? -1 : list.indexOf(active);
|
|
2721
|
+
if (e.key === "Tab") {
|
|
2722
|
+
if (list.length === 1) {
|
|
2723
|
+
e.preventDefault();
|
|
2724
|
+
list[0].focus();
|
|
2725
|
+
} else if (!e.shiftKey && index === list.length - 1) {
|
|
2726
|
+
e.preventDefault();
|
|
2727
|
+
list[0].focus();
|
|
2728
|
+
} else if (e.shiftKey && index <= 0) {
|
|
2729
|
+
e.preventDefault();
|
|
2730
|
+
list[list.length - 1].focus();
|
|
2731
|
+
}
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
|
2735
|
+
if (e.target instanceof HTMLTextAreaElement) return;
|
|
2736
|
+
e.preventDefault();
|
|
2737
|
+
list[(index + (e.key === "ArrowDown" ? 1 : -1) + list.length) % list.length]?.focus({ preventScroll: true });
|
|
2738
|
+
}, [
|
|
2739
|
+
closeAndRestoreFocus,
|
|
2740
|
+
editing,
|
|
2741
|
+
focusables
|
|
2742
|
+
]);
|
|
2259
2743
|
const startEdit = (0, react.useCallback)((index) => {
|
|
2260
|
-
|
|
2261
|
-
|
|
2744
|
+
const item = items[index];
|
|
2745
|
+
if (!item) return;
|
|
2746
|
+
setEditing(editKeyOf(item, index));
|
|
2747
|
+
setEditDraft(item.comment ?? "");
|
|
2262
2748
|
}, [items]);
|
|
2263
2749
|
const commitEdit = (0, react.useCallback)(() => {
|
|
2264
2750
|
if (editing === null) return;
|
|
2265
|
-
const
|
|
2266
|
-
|
|
2751
|
+
const index = items.findIndex((item, i) => editKeyOf(item, i) === editing);
|
|
2752
|
+
const item = items[index];
|
|
2753
|
+
if (item) onItemEdit?.(index, item, editDraft);
|
|
2267
2754
|
setEditing(null);
|
|
2268
2755
|
}, [
|
|
2269
2756
|
editing,
|
|
@@ -2271,19 +2758,22 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2271
2758
|
editDraft,
|
|
2272
2759
|
onItemEdit
|
|
2273
2760
|
]);
|
|
2274
|
-
const panel = open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2761
|
+
const panel = open || renderPanel ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2275
2762
|
ref: popRef,
|
|
2276
|
-
|
|
2763
|
+
id: panelId,
|
|
2764
|
+
className: cx(styles.summaryHover, styles.summaryHoverOpen, closingPanel && !open && styles.summaryHoverLeaving),
|
|
2277
2765
|
role: "dialog",
|
|
2278
2766
|
"aria-label": t("summary.dialogAria", { count }),
|
|
2279
2767
|
onPointerEnter: openPop,
|
|
2280
2768
|
onPointerLeave,
|
|
2769
|
+
onKeyDown: onPanelKeyDown,
|
|
2281
2770
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2282
2771
|
className: cx(A.col, styles.summaryHoverInner),
|
|
2283
2772
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2284
2773
|
className: cx(A.col, styles.summaryList),
|
|
2285
2774
|
children: items.map((item, i) => {
|
|
2286
|
-
const
|
|
2775
|
+
const itemKey = editKeyOf(item, i);
|
|
2776
|
+
const isEditing = editing === itemKey;
|
|
2287
2777
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2288
2778
|
className: styles.summaryItem,
|
|
2289
2779
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -2319,7 +2809,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2319
2809
|
className: styles.summaryItemMain,
|
|
2320
2810
|
children: [
|
|
2321
2811
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2322
|
-
className: styles.
|
|
2812
|
+
className: cx(styles.summaryItemText, styles.summaryItemTextEdit),
|
|
2323
2813
|
children: item.text
|
|
2324
2814
|
}),
|
|
2325
2815
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -2335,6 +2825,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2335
2825
|
onKeyDown: (e) => {
|
|
2336
2826
|
if (e.key === "Escape") {
|
|
2337
2827
|
e.preventDefault();
|
|
2828
|
+
e.stopPropagation();
|
|
2338
2829
|
setEditing(null);
|
|
2339
2830
|
return;
|
|
2340
2831
|
}
|
|
@@ -2353,7 +2844,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2353
2844
|
children: t("common.cancel")
|
|
2354
2845
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2355
2846
|
type: "button",
|
|
2356
|
-
className: styles.annEditSave,
|
|
2847
|
+
className: cx(A.plain, styles.annEditSave),
|
|
2357
2848
|
onClick: commitEdit,
|
|
2358
2849
|
children: t("common.save")
|
|
2359
2850
|
})]
|
|
@@ -2396,9 +2887,12 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2396
2887
|
"data-selection-annotation-summary": "true",
|
|
2397
2888
|
"aria-label": t("summary.aria", { count }),
|
|
2398
2889
|
"aria-expanded": open,
|
|
2890
|
+
"aria-haspopup": "dialog",
|
|
2891
|
+
"aria-controls": open ? panelId : void 0,
|
|
2399
2892
|
onPointerEnter,
|
|
2400
2893
|
onPointerLeave,
|
|
2401
|
-
|
|
2894
|
+
onPointerUp: onSummaryPointerUp,
|
|
2895
|
+
onFocus: onSummaryFocus,
|
|
2402
2896
|
onBlur,
|
|
2403
2897
|
onKeyDown,
|
|
2404
2898
|
children: [
|
|
@@ -2409,13 +2903,20 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2409
2903
|
}),
|
|
2410
2904
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2411
2905
|
className: cx(A.grow, styles.summaryBody),
|
|
2412
|
-
children: [
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2906
|
+
children: [
|
|
2907
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2908
|
+
className: styles.summaryTitle,
|
|
2909
|
+
children: t("summary.title", { count })
|
|
2910
|
+
}),
|
|
2911
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2912
|
+
className: cx(styles.summaryHint, styles.summaryHintPointer),
|
|
2913
|
+
children: t("summary.hint")
|
|
2914
|
+
}),
|
|
2915
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2916
|
+
className: cx(styles.summaryHint, styles.summaryHintTouch),
|
|
2917
|
+
children: t("summary.hintTouch")
|
|
2918
|
+
})
|
|
2919
|
+
]
|
|
2419
2920
|
}),
|
|
2420
2921
|
onRemoveAll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2421
2922
|
type: "button",
|
|
@@ -2464,19 +2965,19 @@ function clearDraftMarker(draft, setDraft) {
|
|
|
2464
2965
|
* composer, or the primary send button) so the composer's submit path carries
|
|
2465
2966
|
* the JSON protocol block.
|
|
2466
2967
|
*/
|
|
2467
|
-
function useSendIntercept({ itemCount, draftRef, setDraft, onFolded }) {
|
|
2968
|
+
function useSendIntercept({ itemCount, draftRef, setDraft, onFolded, foldingRef }) {
|
|
2468
2969
|
(0, react.useEffect)(() => {
|
|
2469
2970
|
if (itemCount === 0) return;
|
|
2470
2971
|
const inject = () => {
|
|
2471
2972
|
const current = getAnnotations();
|
|
2472
2973
|
if (current.length === 0) return;
|
|
2974
|
+
if (foldingRef !== void 0) foldingRef.current = true;
|
|
2473
2975
|
setDraft(composeAnnotatedMessage(draftRef.current, current));
|
|
2474
2976
|
clearAnnotations();
|
|
2475
2977
|
onFolded();
|
|
2476
2978
|
};
|
|
2477
2979
|
const onKeyDown = (event) => {
|
|
2478
|
-
if (event
|
|
2479
|
-
if (event.isComposing) return;
|
|
2980
|
+
if (!isSubmitKey(event)) return;
|
|
2480
2981
|
const target = event.target;
|
|
2481
2982
|
if (!(target instanceof HTMLElement) || !target.isContentEditable) return;
|
|
2482
2983
|
const card = composerCardOf(target);
|
|
@@ -2505,7 +3006,8 @@ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded }) {
|
|
|
2505
3006
|
itemCount,
|
|
2506
3007
|
draftRef,
|
|
2507
3008
|
setDraft,
|
|
2508
|
-
onFolded
|
|
3009
|
+
onFolded,
|
|
3010
|
+
foldingRef
|
|
2509
3011
|
]);
|
|
2510
3012
|
}
|
|
2511
3013
|
//#endregion
|
|
@@ -2550,6 +3052,7 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
|
|
|
2550
3052
|
const draftRef = (0, react.useRef)(draft);
|
|
2551
3053
|
const actionsRef = (0, react.useRef)(inputActions);
|
|
2552
3054
|
const sessionRef = (0, react.useRef)(sessionId);
|
|
3055
|
+
const foldingRef = (0, react.useRef)(false);
|
|
2553
3056
|
const composing = useComposing();
|
|
2554
3057
|
draftRef.current = draft;
|
|
2555
3058
|
actionsRef.current = inputActions;
|
|
@@ -2595,8 +3098,26 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
|
|
|
2595
3098
|
itemCount: items.length,
|
|
2596
3099
|
draftRef,
|
|
2597
3100
|
setDraft,
|
|
2598
|
-
onFolded: () => setItems(EMPTY)
|
|
3101
|
+
onFolded: () => setItems(EMPTY),
|
|
3102
|
+
foldingRef
|
|
2599
3103
|
});
|
|
3104
|
+
(0, react.useLayoutEffect)(() => {
|
|
3105
|
+
if (!draft.includes("<response-annotations>")) {
|
|
3106
|
+
foldingRef.current = false;
|
|
3107
|
+
return;
|
|
3108
|
+
}
|
|
3109
|
+
if (foldingRef.current || composing.current) return;
|
|
3110
|
+
const restored = readComposedDraft(draft);
|
|
3111
|
+
if (restored === null) return;
|
|
3112
|
+
foldingRef.current = true;
|
|
3113
|
+
restoreAnnotations(sessionId, restored.annotations);
|
|
3114
|
+
setDraft(restored.rest);
|
|
3115
|
+
}, [
|
|
3116
|
+
draft,
|
|
3117
|
+
sessionId,
|
|
3118
|
+
setDraft,
|
|
3119
|
+
composing
|
|
3120
|
+
]);
|
|
2600
3121
|
(0, react.useEffect)(() => {
|
|
2601
3122
|
if (composing.current) return;
|
|
2602
3123
|
if (items.length === 0) {
|
|
@@ -2679,10 +3200,47 @@ const QuoteCard = AnnotationPanel;
|
|
|
2679
3200
|
const HOST_ATTR = "data-dsq-layer";
|
|
2680
3201
|
/** Below the composer seat (sticky, z-index 7); above in-content stickies (6). */
|
|
2681
3202
|
const HOST_Z = 6;
|
|
3203
|
+
/**
|
|
3204
|
+
* Raised while the comment step is open. The composer summary card lives in
|
|
3205
|
+
* the seat (z 7) and can extend over the scroller area; the popover, clipped
|
|
3206
|
+
* to the scroller, would otherwise paint underneath it.
|
|
3207
|
+
*/
|
|
3208
|
+
const HOST_Z_ABOVE_COMPOSER = 8;
|
|
2682
3209
|
const layers = /* @__PURE__ */ new WeakMap();
|
|
2683
3210
|
/** flow item → layer, so scrolling does not re-walk the ancestor chain. */
|
|
2684
3211
|
const itemLayers = /* @__PURE__ */ new WeakMap();
|
|
2685
3212
|
let fallback = null;
|
|
3213
|
+
const SEAT_SELECTOR = "[data-composer-seat]";
|
|
3214
|
+
const CARD_SELECTOR = "[data-composer-card]";
|
|
3215
|
+
/**
|
|
3216
|
+
* The composer belonging to `scroller`, or the document's first one when the
|
|
3217
|
+
* scroller has no seat in its ancestor chain.
|
|
3218
|
+
*
|
|
3219
|
+
* The desktop layout puts the sticky seat outside the scroll container; a
|
|
3220
|
+
* document-wide lookup picks the first chat on the page, which is wrong when
|
|
3221
|
+
* more than one conversation is mounted. Walk out from the scroller and take
|
|
3222
|
+
* the first seat inside the nearest ancestor that has one.
|
|
3223
|
+
*/
|
|
3224
|
+
function composerSeatFor(scroller) {
|
|
3225
|
+
if (typeof document === "undefined") return null;
|
|
3226
|
+
if (scroller !== null) for (let el = scroller; el !== null && el !== document.body; el = el.parentElement) {
|
|
3227
|
+
const seat = el.querySelector(SEAT_SELECTOR) ?? el.querySelector(CARD_SELECTOR);
|
|
3228
|
+
if (seat !== null) return seat;
|
|
3229
|
+
}
|
|
3230
|
+
return document.querySelector(SEAT_SELECTOR) ?? document.querySelector(CARD_SELECTOR);
|
|
3231
|
+
}
|
|
3232
|
+
/**
|
|
3233
|
+
* Top edge of the composer seat, in viewport coordinates.
|
|
3234
|
+
*
|
|
3235
|
+
* In the desktop (overlay) layout the composer is `position: sticky` over the
|
|
3236
|
+
* scroller and stacks above this layer (z 7 vs 6), so the scroller's own box
|
|
3237
|
+
* extends **behind** it. Placement that stops at the scroller box therefore
|
|
3238
|
+
* parks popovers under the input box — the bottom edge must be the composer.
|
|
3239
|
+
* In the regular layout the seat sits below the scroller, so it changes nothing.
|
|
3240
|
+
*/
|
|
3241
|
+
function composerTop(scroller) {
|
|
3242
|
+
return composerSeatFor(scroller)?.getBoundingClientRect().top ?? null;
|
|
3243
|
+
}
|
|
2686
3244
|
function contentCoords(scroller, left, top) {
|
|
2687
3245
|
const rect = scroller.getBoundingClientRect();
|
|
2688
3246
|
return {
|
|
@@ -2706,7 +3264,11 @@ function layerFor(scroller) {
|
|
|
2706
3264
|
scroller,
|
|
2707
3265
|
bounds: () => {
|
|
2708
3266
|
const rect = scroller.getBoundingClientRect();
|
|
2709
|
-
|
|
3267
|
+
const top = rect.top + scroller.clientTop;
|
|
3268
|
+
const rawBottom = top + scroller.clientHeight;
|
|
3269
|
+
const cover = composerTop(scroller);
|
|
3270
|
+
const bottom = cover !== null && cover > top && cover < rawBottom ? cover : rawBottom;
|
|
3271
|
+
return new DOMRect(rect.left + scroller.clientLeft, top, scroller.clientWidth, Math.max(0, bottom - top));
|
|
2710
3272
|
},
|
|
2711
3273
|
toContent: (left, top) => contentCoords(scroller, left, top),
|
|
2712
3274
|
fromContent: (left, top) => {
|
|
@@ -2715,6 +3277,9 @@ function layerFor(scroller) {
|
|
|
2715
3277
|
left: left + rect.left + scroller.clientLeft - scroller.scrollLeft,
|
|
2716
3278
|
top: top + rect.top + scroller.clientTop - scroller.scrollTop
|
|
2717
3279
|
};
|
|
3280
|
+
},
|
|
3281
|
+
setAboveComposer: (above) => {
|
|
3282
|
+
host.style.zIndex = String(above ? HOST_Z_ABOVE_COMPOSER : HOST_Z);
|
|
2718
3283
|
}
|
|
2719
3284
|
};
|
|
2720
3285
|
layers.set(scroller, layer);
|
|
@@ -2730,7 +3295,10 @@ function fallbackLayer() {
|
|
|
2730
3295
|
fallback = {
|
|
2731
3296
|
host,
|
|
2732
3297
|
scroller: document.body,
|
|
2733
|
-
bounds: () =>
|
|
3298
|
+
bounds: () => {
|
|
3299
|
+
const bottom = composerTop(null) ?? window.innerHeight;
|
|
3300
|
+
return new DOMRect(0, 0, window.innerWidth, Math.max(0, bottom));
|
|
3301
|
+
},
|
|
2734
3302
|
toContent: (left, top) => ({
|
|
2735
3303
|
left,
|
|
2736
3304
|
top
|
|
@@ -2738,7 +3306,10 @@ function fallbackLayer() {
|
|
|
2738
3306
|
fromContent: (left, top) => ({
|
|
2739
3307
|
left,
|
|
2740
3308
|
top
|
|
2741
|
-
})
|
|
3309
|
+
}),
|
|
3310
|
+
setAboveComposer: (above) => {
|
|
3311
|
+
host.style.zIndex = String(above ? HOST_Z_ABOVE_COMPOSER : HOST_Z);
|
|
3312
|
+
}
|
|
2742
3313
|
};
|
|
2743
3314
|
return fallback;
|
|
2744
3315
|
}
|
|
@@ -2923,6 +3494,69 @@ function linkTarget(text, anchorHref) {
|
|
|
2923
3494
|
return selectionUrl(text) ?? (anchorHref ? selectionUrl(anchorHref) : null);
|
|
2924
3495
|
}
|
|
2925
3496
|
//#endregion
|
|
3497
|
+
//#region src/client/ui/touch.ts
|
|
3498
|
+
/**
|
|
3499
|
+
* The same query the coarse-pointer CSS uses. Keeping JS and CSS on one
|
|
3500
|
+
* criterion means a device never gets the bottom bar with desktop positioning
|
|
3501
|
+
* (or the reverse).
|
|
3502
|
+
*/
|
|
3503
|
+
const COARSE_POINTER = "(hover: none), (pointer: coarse)";
|
|
3504
|
+
function coarsePointer() {
|
|
3505
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
|
3506
|
+
return window.matchMedia(COARSE_POINTER).matches;
|
|
3507
|
+
}
|
|
3508
|
+
function useCoarsePointer() {
|
|
3509
|
+
const [coarse, setCoarse] = (0, react.useState)(coarsePointer);
|
|
3510
|
+
(0, react.useEffect)(() => {
|
|
3511
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
3512
|
+
const query = window.matchMedia(COARSE_POINTER);
|
|
3513
|
+
const onChange = (event) => setCoarse(event.matches);
|
|
3514
|
+
setCoarse(query.matches);
|
|
3515
|
+
query.addEventListener("change", onChange);
|
|
3516
|
+
return () => query.removeEventListener("change", onChange);
|
|
3517
|
+
}, []);
|
|
3518
|
+
return coarse;
|
|
3519
|
+
}
|
|
3520
|
+
function measure() {
|
|
3521
|
+
if (typeof window === "undefined") return {
|
|
3522
|
+
inset: 0,
|
|
3523
|
+
height: 0
|
|
3524
|
+
};
|
|
3525
|
+
const viewport = window.visualViewport;
|
|
3526
|
+
const layout = document.documentElement.clientHeight || window.innerHeight;
|
|
3527
|
+
if (!viewport) return {
|
|
3528
|
+
inset: 0,
|
|
3529
|
+
height: window.innerHeight
|
|
3530
|
+
};
|
|
3531
|
+
return {
|
|
3532
|
+
inset: Math.max(0, Math.round(layout - viewport.height - viewport.offsetTop)),
|
|
3533
|
+
height: Math.round(viewport.height)
|
|
3534
|
+
};
|
|
3535
|
+
}
|
|
3536
|
+
/**
|
|
3537
|
+
* Ride above the soft keyboard. `innerHeight` alone is not enough: iOS keeps the
|
|
3538
|
+
* layout viewport at full height and only shrinks the visual viewport, while
|
|
3539
|
+
* Android resizes the layout viewport. The inset is zero in the Android case
|
|
3540
|
+
* because the fixed element then already sits above the keyboard.
|
|
3541
|
+
*/
|
|
3542
|
+
function useVisualViewport(enabled) {
|
|
3543
|
+
const [metrics, setMetrics] = (0, react.useState)(measure);
|
|
3544
|
+
(0, react.useEffect)(() => {
|
|
3545
|
+
if (!enabled || typeof window === "undefined") return;
|
|
3546
|
+
const update = () => setMetrics(measure());
|
|
3547
|
+
update();
|
|
3548
|
+
window.visualViewport?.addEventListener("resize", update);
|
|
3549
|
+
window.visualViewport?.addEventListener("scroll", update);
|
|
3550
|
+
window.addEventListener("resize", update);
|
|
3551
|
+
return () => {
|
|
3552
|
+
window.visualViewport?.removeEventListener("resize", update);
|
|
3553
|
+
window.visualViewport?.removeEventListener("scroll", update);
|
|
3554
|
+
window.removeEventListener("resize", update);
|
|
3555
|
+
};
|
|
3556
|
+
}, [enabled]);
|
|
3557
|
+
return metrics;
|
|
3558
|
+
}
|
|
3559
|
+
//#endregion
|
|
2926
3560
|
//#region src/client/SelectionToolbar.tsx
|
|
2927
3561
|
/** Cap for the auto-growing comment box; beyond this it scrolls internally. */
|
|
2928
3562
|
const COMMENT_MAX_HEIGHT = 220;
|
|
@@ -2939,6 +3573,8 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2939
3573
|
activeRef.current = active;
|
|
2940
3574
|
/** Annotation saved when the comment step opened; the box only edits it. */
|
|
2941
3575
|
const pendingIdRef = (0, react.useRef)(null);
|
|
3576
|
+
const touch = useCoarsePointer();
|
|
3577
|
+
const viewport = useVisualViewport(touch);
|
|
2942
3578
|
(0, react.useEffect)(() => {
|
|
2943
3579
|
ensureToolbarStyles();
|
|
2944
3580
|
if (sessionId) bindAnnotationSession(sessionId);
|
|
@@ -3056,6 +3692,7 @@ function SelectionToolbar({ sessionId }) {
|
|
|
3056
3692
|
refresh
|
|
3057
3693
|
]);
|
|
3058
3694
|
const placeCommentPop = (0, react.useCallback)(() => {
|
|
3695
|
+
if (touch) return;
|
|
3059
3696
|
const snapshot = activeRef.current;
|
|
3060
3697
|
const layer = snapshot?.layer;
|
|
3061
3698
|
const toolbar = toolbarRef.current;
|
|
@@ -3100,33 +3737,46 @@ function SelectionToolbar({ sessionId }) {
|
|
|
3100
3737
|
const toolbarAt = layer.toContent(leftView, topView);
|
|
3101
3738
|
toolbar.style.left = `${toolbarAt.left}px`;
|
|
3102
3739
|
toolbar.style.top = `${toolbarAt.top}px`;
|
|
3103
|
-
}, []);
|
|
3740
|
+
}, [touch]);
|
|
3104
3741
|
(0, react.useLayoutEffect)(() => {
|
|
3105
|
-
if (phase === "comment") placeCommentPop();
|
|
3742
|
+
if (phase === "comment" && !touch) placeCommentPop();
|
|
3106
3743
|
}, [
|
|
3107
3744
|
active,
|
|
3108
3745
|
phase,
|
|
3109
|
-
placeCommentPop
|
|
3746
|
+
placeCommentPop,
|
|
3747
|
+
touch
|
|
3748
|
+
]);
|
|
3749
|
+
const layer = active?.layer;
|
|
3750
|
+
(0, react.useLayoutEffect)(() => {
|
|
3751
|
+
if (!layer || touch) return;
|
|
3752
|
+
layer.setAboveComposer(phase === "comment");
|
|
3753
|
+
return () => layer.setAboveComposer(false);
|
|
3754
|
+
}, [
|
|
3755
|
+
layer,
|
|
3756
|
+
phase,
|
|
3757
|
+
touch
|
|
3110
3758
|
]);
|
|
3111
3759
|
(0, react.useEffect)(() => {
|
|
3112
|
-
if (phase === "comment") commentRef.current?.focus();
|
|
3113
|
-
}, [phase]);
|
|
3760
|
+
if (phase === "comment" && !touch) commentRef.current?.focus();
|
|
3761
|
+
}, [phase, touch]);
|
|
3114
3762
|
(0, react.useEffect)(() => {
|
|
3763
|
+
if (touch) return;
|
|
3115
3764
|
const onResize = () => placeCommentPop();
|
|
3116
3765
|
window.addEventListener("resize", onResize);
|
|
3117
3766
|
return () => window.removeEventListener("resize", onResize);
|
|
3118
|
-
}, [placeCommentPop]);
|
|
3767
|
+
}, [placeCommentPop, touch]);
|
|
3119
3768
|
(0, react.useLayoutEffect)(() => {
|
|
3120
3769
|
if (phase !== "comment") return;
|
|
3121
3770
|
const area = commentRef.current;
|
|
3122
3771
|
if (!area) return;
|
|
3123
3772
|
area.style.height = "auto";
|
|
3124
3773
|
area.style.height = `${Math.min(area.scrollHeight, COMMENT_MAX_HEIGHT)}px`;
|
|
3125
|
-
placeCommentPop();
|
|
3774
|
+
if (!touch) placeCommentPop();
|
|
3126
3775
|
}, [
|
|
3127
3776
|
phase,
|
|
3128
3777
|
comment,
|
|
3129
|
-
placeCommentPop
|
|
3778
|
+
placeCommentPop,
|
|
3779
|
+
touch
|
|
3130
3780
|
]);
|
|
3131
3781
|
const handleToolbarPointerDown = (0, react.useCallback)((event) => {
|
|
3132
3782
|
event.preventDefault();
|
|
@@ -3178,14 +3828,147 @@ function SelectionToolbar({ sessionId }) {
|
|
|
3178
3828
|
else pendingIdRef.current = null;
|
|
3179
3829
|
clearNativeSelection();
|
|
3180
3830
|
hide();
|
|
3181
|
-
window.setTimeout(focusComposer, 16);
|
|
3831
|
+
if (!touch) window.setTimeout(focusComposer, 16);
|
|
3832
|
+
}, [
|
|
3833
|
+
flushComment,
|
|
3834
|
+
hide,
|
|
3835
|
+
touch
|
|
3836
|
+
]);
|
|
3837
|
+
/** Dismiss the sheet without discarding what was typed (desktop outside-click). */
|
|
3838
|
+
const closeCommentSheet = (0, react.useCallback)(() => {
|
|
3839
|
+
flushComment();
|
|
3840
|
+
hide();
|
|
3182
3841
|
}, [flushComment, hide]);
|
|
3183
3842
|
if (!active) return null;
|
|
3184
3843
|
const toolbarStyle = {
|
|
3185
3844
|
left: active.left,
|
|
3186
3845
|
top: active.top
|
|
3187
3846
|
};
|
|
3188
|
-
const
|
|
3847
|
+
const actions = [
|
|
3848
|
+
...linkTarget(active.text, active.anchorHref) ? [{
|
|
3849
|
+
key: "open",
|
|
3850
|
+
icon: "external-link",
|
|
3851
|
+
label: t("toolbar.openLink"),
|
|
3852
|
+
onClick: handleOpenLink
|
|
3853
|
+
}] : [],
|
|
3854
|
+
{
|
|
3855
|
+
key: "copy",
|
|
3856
|
+
icon: "copy",
|
|
3857
|
+
label: t("toolbar.copy"),
|
|
3858
|
+
onClick: () => void handleCopy()
|
|
3859
|
+
},
|
|
3860
|
+
{
|
|
3861
|
+
key: "add",
|
|
3862
|
+
icon: "plus",
|
|
3863
|
+
label: t("toolbar.addToTask"),
|
|
3864
|
+
onClick: openComment
|
|
3865
|
+
}
|
|
3866
|
+
];
|
|
3867
|
+
const actionRow = (stacked) => actions.map((action, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [!stacked && index > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3868
|
+
className: styles.divider,
|
|
3869
|
+
"aria-hidden": "true"
|
|
3870
|
+
}) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
3871
|
+
type: "button",
|
|
3872
|
+
className: cx(A.plain, stacked ? A.col : A.inlineRow, stacked ? styles.touchAction : styles.button),
|
|
3873
|
+
onClick: action.onClick,
|
|
3874
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
|
|
3875
|
+
name: action.icon,
|
|
3876
|
+
size: stacked ? 18 : 14
|
|
3877
|
+
}), stacked ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: action.label }) : action.label]
|
|
3878
|
+
})] }, action.key));
|
|
3879
|
+
const commentField = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
|
|
3880
|
+
ref: commentRef,
|
|
3881
|
+
className: cx(A.box, styles.commentInput, touch && styles.touchComment),
|
|
3882
|
+
placeholder: t("common.commentPlaceholder"),
|
|
3883
|
+
value: comment,
|
|
3884
|
+
onChange: (e) => setComment(e.target.value),
|
|
3885
|
+
onKeyDown: (e) => {
|
|
3886
|
+
if (e.key === "Escape") {
|
|
3887
|
+
e.preventDefault();
|
|
3888
|
+
pendingIdRef.current = null;
|
|
3889
|
+
hide();
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
3893
|
+
e.preventDefault();
|
|
3894
|
+
finishComment(true);
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
});
|
|
3898
|
+
const commentActions = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3899
|
+
className: cx(A.row, styles.commentActions, touch && styles.touchActions),
|
|
3900
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3901
|
+
type: "button",
|
|
3902
|
+
className: cx(A.plain, A.inlineRow, styles.button, touch && styles.touchSecondary),
|
|
3903
|
+
onClick: () => finishComment(false),
|
|
3904
|
+
children: t("toolbar.skipComment")
|
|
3905
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3906
|
+
type: "button",
|
|
3907
|
+
className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
|
|
3908
|
+
disabled: !comment.trim(),
|
|
3909
|
+
onClick: () => finishComment(true),
|
|
3910
|
+
children: t("toolbar.addComment")
|
|
3911
|
+
})]
|
|
3912
|
+
});
|
|
3913
|
+
if (touch) {
|
|
3914
|
+
const bottom = `calc(${viewport.inset}px + 8px + env(safe-area-inset-bottom, 0px))`;
|
|
3915
|
+
return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
3916
|
+
phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3917
|
+
ref: toolbarRef,
|
|
3918
|
+
className: cx(A.row, styles.touchBar),
|
|
3919
|
+
style: { bottom },
|
|
3920
|
+
role: "toolbar",
|
|
3921
|
+
"aria-label": t("toolbar.aria"),
|
|
3922
|
+
"data-dsq-toolbar": "true",
|
|
3923
|
+
"data-touch": "true",
|
|
3924
|
+
onPointerDown: handleToolbarPointerDown,
|
|
3925
|
+
children: actionRow(true)
|
|
3926
|
+
}) : null,
|
|
3927
|
+
phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3928
|
+
className: styles.touchScrim,
|
|
3929
|
+
"aria-hidden": "true"
|
|
3930
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3931
|
+
ref: commentWrapRef,
|
|
3932
|
+
className: cx(A.col, styles.touchSheet),
|
|
3933
|
+
style: {
|
|
3934
|
+
bottom,
|
|
3935
|
+
maxHeight: `${Math.max(240, Math.round(viewport.height * .72))}px`
|
|
3936
|
+
},
|
|
3937
|
+
role: "dialog",
|
|
3938
|
+
"aria-label": t("toolbar.commentDialogAria"),
|
|
3939
|
+
children: [
|
|
3940
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3941
|
+
className: cx(A.row, styles.touchSheetHead),
|
|
3942
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3943
|
+
className: styles.touchSheetTitle,
|
|
3944
|
+
children: t("toolbar.commentHint")
|
|
3945
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3946
|
+
type: "button",
|
|
3947
|
+
className: cx(A.plain, A.gridMid, styles.touchSheetClose),
|
|
3948
|
+
"aria-label": t("common.close"),
|
|
3949
|
+
onClick: closeCommentSheet,
|
|
3950
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
|
|
3951
|
+
name: "x",
|
|
3952
|
+
size: 18
|
|
3953
|
+
})
|
|
3954
|
+
})]
|
|
3955
|
+
}),
|
|
3956
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3957
|
+
className: styles.touchQuote,
|
|
3958
|
+
children: active.text
|
|
3959
|
+
}),
|
|
3960
|
+
commentField,
|
|
3961
|
+
commentActions
|
|
3962
|
+
]
|
|
3963
|
+
})] }) : null,
|
|
3964
|
+
status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3965
|
+
className: styles.touchStatus,
|
|
3966
|
+
style: { bottom: `calc(${viewport.inset}px + 72px + env(safe-area-inset-bottom, 0px))` },
|
|
3967
|
+
role: "status",
|
|
3968
|
+
children: status
|
|
3969
|
+
}) : null
|
|
3970
|
+
] }), document.body);
|
|
3971
|
+
}
|
|
3189
3972
|
return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
3190
3973
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3191
3974
|
ref: toolbarRef,
|
|
@@ -3207,45 +3990,9 @@ function SelectionToolbar({ sessionId }) {
|
|
|
3207
3990
|
},
|
|
3208
3991
|
children: t("toolbar.commentHint")
|
|
3209
3992
|
})
|
|
3210
|
-
}) : /* @__PURE__ */ (0, react_jsx_runtime.
|
|
3993
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3211
3994
|
className: cx(A.inlineRow, styles.toolbarInner),
|
|
3212
|
-
children:
|
|
3213
|
-
link ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
3214
|
-
type: "button",
|
|
3215
|
-
className: cx(A.plain, A.inlineRow, styles.button),
|
|
3216
|
-
onClick: handleOpenLink,
|
|
3217
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
|
|
3218
|
-
name: "external-link",
|
|
3219
|
-
size: 14
|
|
3220
|
-
}), t("toolbar.openLink")]
|
|
3221
|
-
}) : null,
|
|
3222
|
-
link ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3223
|
-
className: styles.divider,
|
|
3224
|
-
"aria-hidden": "true"
|
|
3225
|
-
}) : null,
|
|
3226
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
3227
|
-
type: "button",
|
|
3228
|
-
className: cx(A.plain, A.inlineRow, styles.button),
|
|
3229
|
-
onClick: () => void handleCopy(),
|
|
3230
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
|
|
3231
|
-
name: "copy",
|
|
3232
|
-
size: 14
|
|
3233
|
-
}), t("toolbar.copy")]
|
|
3234
|
-
}),
|
|
3235
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3236
|
-
className: styles.divider,
|
|
3237
|
-
"aria-hidden": "true"
|
|
3238
|
-
}),
|
|
3239
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
3240
|
-
type: "button",
|
|
3241
|
-
className: cx(A.plain, A.inlineRow, styles.button),
|
|
3242
|
-
onClick: openComment,
|
|
3243
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
|
|
3244
|
-
name: "plus",
|
|
3245
|
-
size: 14
|
|
3246
|
-
}), t("toolbar.addToTask")]
|
|
3247
|
-
})
|
|
3248
|
-
]
|
|
3995
|
+
children: actionRow(false)
|
|
3249
3996
|
})
|
|
3250
3997
|
}),
|
|
3251
3998
|
phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -3254,39 +4001,7 @@ function SelectionToolbar({ sessionId }) {
|
|
|
3254
4001
|
role: "dialog",
|
|
3255
4002
|
"aria-label": t("toolbar.commentDialogAria"),
|
|
3256
4003
|
onPointerDown: handleToolbarPointerDown,
|
|
3257
|
-
children: [
|
|
3258
|
-
ref: commentRef,
|
|
3259
|
-
className: cx(A.box, styles.commentInput),
|
|
3260
|
-
placeholder: t("common.commentPlaceholder"),
|
|
3261
|
-
value: comment,
|
|
3262
|
-
onChange: (e) => setComment(e.target.value),
|
|
3263
|
-
onKeyDown: (e) => {
|
|
3264
|
-
if (e.key === "Escape") {
|
|
3265
|
-
e.preventDefault();
|
|
3266
|
-
pendingIdRef.current = null;
|
|
3267
|
-
hide();
|
|
3268
|
-
return;
|
|
3269
|
-
}
|
|
3270
|
-
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
3271
|
-
e.preventDefault();
|
|
3272
|
-
finishComment(true);
|
|
3273
|
-
}
|
|
3274
|
-
}
|
|
3275
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3276
|
-
className: styles.commentActions,
|
|
3277
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3278
|
-
type: "button",
|
|
3279
|
-
className: cx(A.plain, A.inlineRow, styles.button),
|
|
3280
|
-
onClick: () => finishComment(false),
|
|
3281
|
-
children: t("toolbar.skipComment")
|
|
3282
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3283
|
-
type: "button",
|
|
3284
|
-
className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
|
|
3285
|
-
disabled: !comment.trim(),
|
|
3286
|
-
onClick: () => finishComment(true),
|
|
3287
|
-
children: t("toolbar.addComment")
|
|
3288
|
-
})]
|
|
3289
|
-
})]
|
|
4004
|
+
children: [commentField, commentActions]
|
|
3290
4005
|
}) : null,
|
|
3291
4006
|
status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3292
4007
|
className: styles.status,
|
|
@@ -3297,6 +4012,423 @@ function SelectionToolbar({ sessionId }) {
|
|
|
3297
4012
|
] }), active.layer.host);
|
|
3298
4013
|
}
|
|
3299
4014
|
//#endregion
|
|
4015
|
+
//#region src/client/transcript/quote-registry.ts
|
|
4016
|
+
/**
|
|
4017
|
+
* Quotes rendered for a user message, keyed by that message's flow key.
|
|
4018
|
+
*
|
|
4019
|
+
* The durable annotation data lives in the user turn, but the chip that refers
|
|
4020
|
+
* to it lives in the assistant reply. The chip decorator walks the DOM; this
|
|
4021
|
+
* registry is how it resolves an ordinal back to the original text and comment
|
|
4022
|
+
* without re-parsing the protocol or exposing JSON in the DOM.
|
|
4023
|
+
*/
|
|
4024
|
+
const quotesByOwner = /* @__PURE__ */ new Map();
|
|
4025
|
+
function registerMessageQuotes(ownerKey, quotes) {
|
|
4026
|
+
quotesByOwner.set(ownerKey, quotes);
|
|
4027
|
+
return () => {
|
|
4028
|
+
if (quotesByOwner.get(ownerKey) === quotes) quotesByOwner.delete(ownerKey);
|
|
4029
|
+
};
|
|
4030
|
+
}
|
|
4031
|
+
function messageQuotes(ownerKey) {
|
|
4032
|
+
return quotesByOwner.get(ownerKey);
|
|
4033
|
+
}
|
|
4034
|
+
//#endregion
|
|
4035
|
+
//#region src/client/dom/chipPreview.ts
|
|
4036
|
+
/**
|
|
4037
|
+
* Hover/focus/click preview for `:dsh-annotation{index="N"}` chips.
|
|
4038
|
+
*
|
|
4039
|
+
* The chip decorator is plain DOM, so the preview is too. One body-level
|
|
4040
|
+
* preview is reused for every chip: `contain: layout` surfaces in the
|
|
4041
|
+
* transcript would otherwise become the containing block of a fixed popover.
|
|
4042
|
+
*
|
|
4043
|
+
* Clicking the chip no longer jumps: it pins the preview, and the preview's
|
|
4044
|
+
* button owns the jump (same contract on touch, where there is no hover).
|
|
4045
|
+
*/
|
|
4046
|
+
const HOVER_DELAY = 250;
|
|
4047
|
+
/** Just enough for the pointer to cross the 8px gap into the preview. */
|
|
4048
|
+
const CLOSE_DELAY = 40;
|
|
4049
|
+
const GAP = 8;
|
|
4050
|
+
/** Matches the leaving keyframe; the node is detached after it finishes. */
|
|
4051
|
+
const EXIT_MS = 120;
|
|
4052
|
+
const JUMP_ICON = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M7 7h10v10\"/><path d=\"M7 17 17 7\"/></svg>";
|
|
4053
|
+
let preview = null;
|
|
4054
|
+
let jumpButton = null;
|
|
4055
|
+
let activeChip = null;
|
|
4056
|
+
let activeMark = null;
|
|
4057
|
+
let activeOwnerKey = null;
|
|
4058
|
+
let activeMessageId = null;
|
|
4059
|
+
let activeText = "";
|
|
4060
|
+
let activeIndex = 0;
|
|
4061
|
+
let pinned = false;
|
|
4062
|
+
let showTimer = 0;
|
|
4063
|
+
let hideTimer = 0;
|
|
4064
|
+
let frame = 0;
|
|
4065
|
+
let suppressFocusOpen = false;
|
|
4066
|
+
let observer = null;
|
|
4067
|
+
let onDocPointerDown = null;
|
|
4068
|
+
let onScroll = null;
|
|
4069
|
+
let onResize = null;
|
|
4070
|
+
let leavingBox = null;
|
|
4071
|
+
let leavingTimer = 0;
|
|
4072
|
+
function cancelTimers() {
|
|
4073
|
+
if (showTimer !== 0) window.clearTimeout(showTimer);
|
|
4074
|
+
if (hideTimer !== 0) window.clearTimeout(hideTimer);
|
|
4075
|
+
showTimer = 0;
|
|
4076
|
+
hideTimer = 0;
|
|
4077
|
+
}
|
|
4078
|
+
/** A new preview replaces any panel still animating out. */
|
|
4079
|
+
function flushLeavingPreview() {
|
|
4080
|
+
if (leavingTimer !== 0) {
|
|
4081
|
+
window.clearTimeout(leavingTimer);
|
|
4082
|
+
leavingTimer = 0;
|
|
4083
|
+
}
|
|
4084
|
+
leavingBox?.remove();
|
|
4085
|
+
leavingBox = null;
|
|
4086
|
+
}
|
|
4087
|
+
function animatePreviewOut(box) {
|
|
4088
|
+
flushLeavingPreview();
|
|
4089
|
+
const active = document.activeElement;
|
|
4090
|
+
if (active instanceof HTMLElement && box.contains(active)) active.blur();
|
|
4091
|
+
box.classList.add(styles.chipPreviewLeaving);
|
|
4092
|
+
box.setAttribute("aria-hidden", "true");
|
|
4093
|
+
leavingBox = box;
|
|
4094
|
+
leavingTimer = window.setTimeout(() => {
|
|
4095
|
+
leavingTimer = 0;
|
|
4096
|
+
if (leavingBox === box) leavingBox = null;
|
|
4097
|
+
box.remove();
|
|
4098
|
+
}, EXIT_MS);
|
|
4099
|
+
}
|
|
4100
|
+
/**
|
|
4101
|
+
* The annotated text can sit in an earlier assistant turn while the quotes were
|
|
4102
|
+
* serialized into the user turn that sent them. The mark id is built by
|
|
4103
|
+
* `TranscriptQuoteCard` as `dqsm_<ownerFlowKey>_<zeroBasedIndex>`, so it — not
|
|
4104
|
+
* the mark's physical ancestor — names the message that owns the quotes.
|
|
4105
|
+
*/
|
|
4106
|
+
function quotesOwnerOf(mark) {
|
|
4107
|
+
if (!mark) return null;
|
|
4108
|
+
const id = mark.getAttribute("data-dsq-ann-mark") ?? "";
|
|
4109
|
+
const match = /^dqsm_(.+)_\d+$/.exec(id);
|
|
4110
|
+
return match ? match[1] : null;
|
|
4111
|
+
}
|
|
4112
|
+
/** The quote card for the user turn that sent the annotation, before the chip. */
|
|
4113
|
+
function quotesOwnerBefore(chip, index) {
|
|
4114
|
+
const cards = [...document.querySelectorAll(".dsq_tSummary")];
|
|
4115
|
+
for (let i = cards.length - 1; i >= 0; i -= 1) {
|
|
4116
|
+
const card = cards[i];
|
|
4117
|
+
if ((card.compareDocumentPosition(chip) & Node.DOCUMENT_POSITION_FOLLOWING) === 0) continue;
|
|
4118
|
+
const ownerKey = card.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
|
|
4119
|
+
if (ownerKey && messageQuotes(ownerKey)?.[index - 1]) return ownerKey;
|
|
4120
|
+
}
|
|
4121
|
+
return null;
|
|
4122
|
+
}
|
|
4123
|
+
function markByOwner(ownerKey, index) {
|
|
4124
|
+
const id = `dqsm_${ownerKey}_${index - 1}`;
|
|
4125
|
+
const marks = [...document.querySelectorAll("[data-dsq-ann-mark]")].filter((mark) => mark.getAttribute("data-dsq-ann-mark") === id);
|
|
4126
|
+
return marks.find((mark) => mark.getAttribute("data-dsq-ann-index") === String(index)) ?? marks[0] ?? null;
|
|
4127
|
+
}
|
|
4128
|
+
function flowItem(messageId) {
|
|
4129
|
+
for (const item of document.querySelectorAll("[data-chat-flow-key]")) if (item.getAttribute("data-chat-flow-key") === messageId) return item;
|
|
4130
|
+
return null;
|
|
4131
|
+
}
|
|
4132
|
+
function quoteCardFor(ownerKey) {
|
|
4133
|
+
for (const card of document.querySelectorAll(".dsq_tSummary")) if (card.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key") === ownerKey) return card;
|
|
4134
|
+
return null;
|
|
4135
|
+
}
|
|
4136
|
+
function isVisible(el) {
|
|
4137
|
+
if (!el?.isConnected) return false;
|
|
4138
|
+
const rect = el.getBoundingClientRect();
|
|
4139
|
+
return rect.width > 0 && rect.height > 0;
|
|
4140
|
+
}
|
|
4141
|
+
function flashMessage(el) {
|
|
4142
|
+
el.classList.add(styles.annMessageFlash);
|
|
4143
|
+
window.setTimeout(() => el.classList.remove(styles.annMessageFlash), 1400);
|
|
4144
|
+
}
|
|
4145
|
+
function cancelClose() {
|
|
4146
|
+
if (hideTimer !== 0) window.clearTimeout(hideTimer);
|
|
4147
|
+
hideTimer = 0;
|
|
4148
|
+
}
|
|
4149
|
+
function scheduleClose() {
|
|
4150
|
+
cancelClose();
|
|
4151
|
+
if (pinned) return;
|
|
4152
|
+
hideTimer = window.setTimeout(() => closeChipPreview(false), CLOSE_DELAY);
|
|
4153
|
+
}
|
|
4154
|
+
function scheduleShow(chip, index) {
|
|
4155
|
+
cancelTimers();
|
|
4156
|
+
showTimer = window.setTimeout(() => showChipPreview(chip, index, false), HOVER_DELAY);
|
|
4157
|
+
}
|
|
4158
|
+
function scheduleReposition() {
|
|
4159
|
+
if (frame !== 0) return;
|
|
4160
|
+
frame = window.requestAnimationFrame(() => {
|
|
4161
|
+
frame = 0;
|
|
4162
|
+
reposition();
|
|
4163
|
+
});
|
|
4164
|
+
}
|
|
4165
|
+
function reposition() {
|
|
4166
|
+
if (!preview || !activeChip) return;
|
|
4167
|
+
if (!activeChip.isConnected) {
|
|
4168
|
+
closeChipPreview(false);
|
|
4169
|
+
return;
|
|
4170
|
+
}
|
|
4171
|
+
const anchor = activeChip.getBoundingClientRect();
|
|
4172
|
+
if (anchor.bottom < 0 || anchor.top > window.innerHeight) {
|
|
4173
|
+
closeChipPreview(false);
|
|
4174
|
+
return;
|
|
4175
|
+
}
|
|
4176
|
+
const { top, left } = placePopover(anchor, {
|
|
4177
|
+
width: preview.offsetWidth || 320,
|
|
4178
|
+
height: preview.offsetHeight || 180
|
|
4179
|
+
}, "top", GAP);
|
|
4180
|
+
preview.style.top = `${top}px`;
|
|
4181
|
+
preview.style.left = `${left}px`;
|
|
4182
|
+
}
|
|
4183
|
+
function appendLabel(parent, text) {
|
|
4184
|
+
const label = document.createElement("div");
|
|
4185
|
+
label.className = styles.chipPreviewLabel;
|
|
4186
|
+
label.textContent = text;
|
|
4187
|
+
parent.append(label);
|
|
4188
|
+
}
|
|
4189
|
+
function appendText(parent, className, text) {
|
|
4190
|
+
const node = document.createElement("div");
|
|
4191
|
+
node.className = className;
|
|
4192
|
+
node.textContent = text;
|
|
4193
|
+
parent.append(node);
|
|
4194
|
+
}
|
|
4195
|
+
function jumpToMark(event) {
|
|
4196
|
+
const ownerKey = activeOwnerKey;
|
|
4197
|
+
const messageId = activeMessageId;
|
|
4198
|
+
const text = activeText;
|
|
4199
|
+
const index = activeIndex;
|
|
4200
|
+
const keyboard = event.detail === 0;
|
|
4201
|
+
let mark = activeMark?.isConnected ? activeMark : null;
|
|
4202
|
+
if (!mark && ownerKey) mark = markByOwner(ownerKey, index);
|
|
4203
|
+
const card = ownerKey ? quoteCardFor(ownerKey) : null;
|
|
4204
|
+
const ownerItem = ownerKey ? flowItem(ownerKey) : null;
|
|
4205
|
+
const sourceItem = messageId ? flowItem(messageId) : null;
|
|
4206
|
+
closeChipPreview(false);
|
|
4207
|
+
if (isVisible(mark)) {
|
|
4208
|
+
mark.tabIndex = -1;
|
|
4209
|
+
flashAnnotationMark(mark);
|
|
4210
|
+
if (keyboard) window.setTimeout(() => {
|
|
4211
|
+
if (mark.isConnected) mark.focus({ preventScroll: true });
|
|
4212
|
+
}, 450);
|
|
4213
|
+
return;
|
|
4214
|
+
}
|
|
4215
|
+
const fallback = isVisible(card) ? card : ownerItem ?? sourceItem;
|
|
4216
|
+
if (fallback) {
|
|
4217
|
+
fallback.scrollIntoView({
|
|
4218
|
+
block: "center",
|
|
4219
|
+
behavior: "smooth"
|
|
4220
|
+
});
|
|
4221
|
+
flashMessage(fallback);
|
|
4222
|
+
if (keyboard) window.setTimeout(() => {
|
|
4223
|
+
if (!fallback.isConnected) return;
|
|
4224
|
+
const next = fallback.querySelector("button, [tabindex]") ?? fallback;
|
|
4225
|
+
if (next === fallback) fallback.tabIndex = -1;
|
|
4226
|
+
next.focus({ preventScroll: true });
|
|
4227
|
+
}, 450);
|
|
4228
|
+
return;
|
|
4229
|
+
}
|
|
4230
|
+
if (text) scrollToAnnotation(index, text);
|
|
4231
|
+
}
|
|
4232
|
+
function buildPreview(index, quoteText, comment) {
|
|
4233
|
+
const box = document.createElement("div");
|
|
4234
|
+
box.className = cx(A.col, styles.chipPreview);
|
|
4235
|
+
box.setAttribute("data-dsq-chip-preview", "true");
|
|
4236
|
+
box.setAttribute("role", "dialog");
|
|
4237
|
+
box.setAttribute("aria-label", t("chip.previewAria"));
|
|
4238
|
+
const body = document.createElement("div");
|
|
4239
|
+
body.className = styles.chipPreviewBody;
|
|
4240
|
+
body.style.animation = "none";
|
|
4241
|
+
const head = document.createElement("div");
|
|
4242
|
+
head.className = cx(A.row, styles.chipPreviewHead);
|
|
4243
|
+
const title = document.createElement("span");
|
|
4244
|
+
title.className = styles.chipPreviewTitle;
|
|
4245
|
+
title.textContent = t("chip.annotation", { index });
|
|
4246
|
+
jumpButton = document.createElement("button");
|
|
4247
|
+
jumpButton.type = "button";
|
|
4248
|
+
jumpButton.className = cx(A.plain, A.inlineRow, styles.chipPreviewJump);
|
|
4249
|
+
jumpButton.setAttribute("data-dsq-chip-jump", "true");
|
|
4250
|
+
jumpButton.setAttribute("aria-label", t("chip.jumpAria"));
|
|
4251
|
+
jumpButton.title = t("chip.jumpAria");
|
|
4252
|
+
jumpButton.insertAdjacentHTML("afterbegin", JUMP_ICON);
|
|
4253
|
+
const jumpLabel = document.createElement("span");
|
|
4254
|
+
jumpLabel.textContent = t("chip.jump");
|
|
4255
|
+
jumpButton.append(jumpLabel);
|
|
4256
|
+
jumpButton.addEventListener("click", jumpToMark);
|
|
4257
|
+
head.append(title, jumpButton);
|
|
4258
|
+
body.append(head);
|
|
4259
|
+
appendLabel(body, t("common.selectedText"));
|
|
4260
|
+
appendText(body, styles.chipPreviewQuote, quoteText);
|
|
4261
|
+
if (comment !== null) {
|
|
4262
|
+
appendLabel(body, t("summary.userComment"));
|
|
4263
|
+
appendText(body, cx(styles.chipPreviewComment, comment === "" && styles.chipPreviewCommentEmpty), comment === "" ? t("summary.noComment") : comment);
|
|
4264
|
+
}
|
|
4265
|
+
box.append(body);
|
|
4266
|
+
box.addEventListener("pointerenter", cancelClose);
|
|
4267
|
+
box.addEventListener("pointerleave", (event) => {
|
|
4268
|
+
if (event.pointerType === "touch") return;
|
|
4269
|
+
scheduleClose();
|
|
4270
|
+
});
|
|
4271
|
+
box.addEventListener("focusout", (event) => {
|
|
4272
|
+
const next = event.relatedTarget;
|
|
4273
|
+
if (next instanceof Node && (box.contains(next) || activeChip?.contains(next))) return;
|
|
4274
|
+
closeChipPreview(false);
|
|
4275
|
+
});
|
|
4276
|
+
box.addEventListener("keydown", (event) => {
|
|
4277
|
+
if (event.key !== "Escape") return;
|
|
4278
|
+
event.preventDefault();
|
|
4279
|
+
event.stopPropagation();
|
|
4280
|
+
closeChipPreview(true);
|
|
4281
|
+
});
|
|
4282
|
+
return box;
|
|
4283
|
+
}
|
|
4284
|
+
function showChipPreview(chip, index, nextPinned) {
|
|
4285
|
+
cancelTimers();
|
|
4286
|
+
if (!chip.isConnected) return;
|
|
4287
|
+
let mark = findAnnotationMarkBefore(index, chip);
|
|
4288
|
+
let ownerKey = quotesOwnerOf(mark) ?? mark?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key") ?? null;
|
|
4289
|
+
let quote = ownerKey ? messageQuotes(ownerKey)?.[index - 1] : void 0;
|
|
4290
|
+
if (!quote) {
|
|
4291
|
+
const fallbackKey = quotesOwnerBefore(chip, index);
|
|
4292
|
+
if (fallbackKey) {
|
|
4293
|
+
ownerKey = fallbackKey;
|
|
4294
|
+
quote = messageQuotes(fallbackKey)?.[index - 1];
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
if (ownerKey) {
|
|
4298
|
+
const exact = markByOwner(ownerKey, index);
|
|
4299
|
+
if (exact) mark = exact;
|
|
4300
|
+
else if (quotesOwnerOf(mark) !== ownerKey) mark = null;
|
|
4301
|
+
}
|
|
4302
|
+
const text = (quote?.text ?? mark?.textContent ?? "").trim();
|
|
4303
|
+
if (text === "") return;
|
|
4304
|
+
if (activeChip === chip && preview) {
|
|
4305
|
+
pinned = pinned || nextPinned;
|
|
4306
|
+
return;
|
|
4307
|
+
}
|
|
4308
|
+
closeChipPreview(false);
|
|
4309
|
+
flushLeavingPreview();
|
|
4310
|
+
activeChip = chip;
|
|
4311
|
+
activeMark = mark;
|
|
4312
|
+
activeOwnerKey = ownerKey;
|
|
4313
|
+
activeMessageId = quote?.messageId ?? null;
|
|
4314
|
+
activeText = text;
|
|
4315
|
+
activeIndex = index;
|
|
4316
|
+
pinned = nextPinned;
|
|
4317
|
+
const box = buildPreview(index, text, quote ? (quote.comment ?? "").trim() : null);
|
|
4318
|
+
preview = box;
|
|
4319
|
+
box.style.visibility = "hidden";
|
|
4320
|
+
document.body.append(box);
|
|
4321
|
+
const panel = {
|
|
4322
|
+
width: box.offsetWidth || 320,
|
|
4323
|
+
height: box.offsetHeight || 180
|
|
4324
|
+
};
|
|
4325
|
+
const { top, left } = placePopover(chip.getBoundingClientRect(), panel, "top", GAP);
|
|
4326
|
+
box.style.top = `${top}px`;
|
|
4327
|
+
box.style.left = `${left}px`;
|
|
4328
|
+
box.style.visibility = "";
|
|
4329
|
+
const animated = box.querySelector(`.${styles.chipPreviewBody}`);
|
|
4330
|
+
if (animated) animated.style.animation = "";
|
|
4331
|
+
onDocPointerDown = (event) => {
|
|
4332
|
+
const target = event.target;
|
|
4333
|
+
if (!(target instanceof Node)) return;
|
|
4334
|
+
if (box.contains(target) || activeChip?.contains(target)) return;
|
|
4335
|
+
closeChipPreview(false);
|
|
4336
|
+
};
|
|
4337
|
+
document.addEventListener("pointerdown", onDocPointerDown, true);
|
|
4338
|
+
onScroll = scheduleReposition;
|
|
4339
|
+
document.addEventListener("scroll", onScroll, true);
|
|
4340
|
+
onResize = scheduleReposition;
|
|
4341
|
+
window.addEventListener("resize", onResize);
|
|
4342
|
+
observer = new MutationObserver(() => {
|
|
4343
|
+
if (activeChip && !activeChip.isConnected) closeChipPreview(false);
|
|
4344
|
+
});
|
|
4345
|
+
observer.observe(document.body, {
|
|
4346
|
+
childList: true,
|
|
4347
|
+
subtree: true
|
|
4348
|
+
});
|
|
4349
|
+
}
|
|
4350
|
+
/** Close the preview. `restoreFocus` is for a deliberate Escape. */
|
|
4351
|
+
function closeChipPreview(restoreFocus) {
|
|
4352
|
+
const box = preview;
|
|
4353
|
+
const chip = activeChip;
|
|
4354
|
+
cancelTimers();
|
|
4355
|
+
if (frame !== 0) {
|
|
4356
|
+
window.cancelAnimationFrame(frame);
|
|
4357
|
+
frame = 0;
|
|
4358
|
+
}
|
|
4359
|
+
if (observer) {
|
|
4360
|
+
observer.disconnect();
|
|
4361
|
+
observer = null;
|
|
4362
|
+
}
|
|
4363
|
+
if (onDocPointerDown) {
|
|
4364
|
+
document.removeEventListener("pointerdown", onDocPointerDown, true);
|
|
4365
|
+
onDocPointerDown = null;
|
|
4366
|
+
}
|
|
4367
|
+
if (onScroll) {
|
|
4368
|
+
document.removeEventListener("scroll", onScroll, true);
|
|
4369
|
+
onScroll = null;
|
|
4370
|
+
}
|
|
4371
|
+
if (onResize) {
|
|
4372
|
+
window.removeEventListener("resize", onResize);
|
|
4373
|
+
onResize = null;
|
|
4374
|
+
}
|
|
4375
|
+
preview = null;
|
|
4376
|
+
jumpButton = null;
|
|
4377
|
+
activeChip = null;
|
|
4378
|
+
activeMark = null;
|
|
4379
|
+
activeOwnerKey = null;
|
|
4380
|
+
activeMessageId = null;
|
|
4381
|
+
activeText = "";
|
|
4382
|
+
activeIndex = 0;
|
|
4383
|
+
pinned = false;
|
|
4384
|
+
if (restoreFocus && chip?.isConnected) {
|
|
4385
|
+
suppressFocusOpen = true;
|
|
4386
|
+
chip.focus({ preventScroll: true });
|
|
4387
|
+
}
|
|
4388
|
+
if (box) animatePreviewOut(box);
|
|
4389
|
+
suppressFocusOpen = false;
|
|
4390
|
+
}
|
|
4391
|
+
/** Bind the preview to a freshly decorated chip. Safe to call twice. */
|
|
4392
|
+
function bindChipPreview(chip, index) {
|
|
4393
|
+
if (chip.dataset.dsqChipPreviewBound === "true") return;
|
|
4394
|
+
chip.dataset.dsqChipPreviewBound = "true";
|
|
4395
|
+
chip.addEventListener("pointerenter", (event) => {
|
|
4396
|
+
if (event.pointerType === "touch") return;
|
|
4397
|
+
if (activeChip === chip && preview) {
|
|
4398
|
+
cancelClose();
|
|
4399
|
+
return;
|
|
4400
|
+
}
|
|
4401
|
+
scheduleShow(chip, index);
|
|
4402
|
+
});
|
|
4403
|
+
chip.addEventListener("pointerleave", (event) => {
|
|
4404
|
+
if (event.pointerType === "touch") return;
|
|
4405
|
+
scheduleClose();
|
|
4406
|
+
});
|
|
4407
|
+
chip.addEventListener("focus", () => {
|
|
4408
|
+
if (suppressFocusOpen) return;
|
|
4409
|
+
showChipPreview(chip, index, true);
|
|
4410
|
+
});
|
|
4411
|
+
chip.addEventListener("blur", (event) => {
|
|
4412
|
+
const next = event.relatedTarget;
|
|
4413
|
+
if (next instanceof Node && (preview?.contains(next) || chip.contains(next))) return;
|
|
4414
|
+
closeChipPreview(false);
|
|
4415
|
+
});
|
|
4416
|
+
chip.addEventListener("click", () => showChipPreview(chip, index, true));
|
|
4417
|
+
chip.addEventListener("keydown", (event) => {
|
|
4418
|
+
if (event.key === "Escape") {
|
|
4419
|
+
if (activeChip !== chip) return;
|
|
4420
|
+
event.preventDefault();
|
|
4421
|
+
closeChipPreview(true);
|
|
4422
|
+
return;
|
|
4423
|
+
}
|
|
4424
|
+
if (event.key === "ArrowDown") {
|
|
4425
|
+
event.preventDefault();
|
|
4426
|
+
showChipPreview(chip, index, true);
|
|
4427
|
+
jumpButton?.focus();
|
|
4428
|
+
}
|
|
4429
|
+
});
|
|
4430
|
+
}
|
|
4431
|
+
//#endregion
|
|
3300
4432
|
//#region src/client/dom/decorateAnnotationDirectives.ts
|
|
3301
4433
|
/**
|
|
3302
4434
|
* Turn model-written `:dsh-annotation{index="N"}` into clickable chips.
|
|
@@ -3309,7 +4441,7 @@ function decorateTextNode(node) {
|
|
|
3309
4441
|
if (!value || !value.includes(":dsh-annotation{")) return;
|
|
3310
4442
|
const parent = node.parentElement;
|
|
3311
4443
|
if (!parent || parent.closest(`[data-${DECO}]`)) return;
|
|
3312
|
-
if (parent.closest("pre, code, [data-dsq-toolbar]")) return;
|
|
4444
|
+
if (parent.closest("pre, code, [data-dsq-toolbar], [data-dsq-chip-preview]")) return;
|
|
3313
4445
|
if (parent.closest("textarea, input, [data-lexical-editor=\"true\"]")) return;
|
|
3314
4446
|
const frag = document.createDocumentFragment();
|
|
3315
4447
|
let last = 0;
|
|
@@ -3326,10 +4458,7 @@ function decorateTextNode(node) {
|
|
|
3326
4458
|
chip.setAttribute("data-dsq-deco", "true");
|
|
3327
4459
|
chip.dataset[DECO_KEY] = String(index);
|
|
3328
4460
|
chip.textContent = t("chip.annotation", { index });
|
|
3329
|
-
chip
|
|
3330
|
-
const mark = findAnnotationMarkBefore(index, chip);
|
|
3331
|
-
if (mark) flashAnnotationMark(mark);
|
|
3332
|
-
});
|
|
4461
|
+
bindChipPreview(chip, index);
|
|
3333
4462
|
frag.append(chip);
|
|
3334
4463
|
last = m.index + m[0].length;
|
|
3335
4464
|
}
|
|
@@ -3359,6 +4488,7 @@ function decorateAnnotationDirectives(root) {
|
|
|
3359
4488
|
* literal, so `decorateTextNode()` returns before it can touch the DOM.
|
|
3360
4489
|
*/
|
|
3361
4490
|
function refreshDirectiveChips(root) {
|
|
4491
|
+
closeChipPreview(false);
|
|
3362
4492
|
const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
|
|
3363
4493
|
if (!el) return;
|
|
3364
4494
|
for (const chip of el.querySelectorAll(`[data-${DECO}]`)) {
|
|
@@ -3389,16 +4519,158 @@ function watchAnnotationDirectives(root) {
|
|
|
3389
4519
|
return () => observer.disconnect();
|
|
3390
4520
|
}
|
|
3391
4521
|
//#endregion
|
|
4522
|
+
//#region src/client/dom/stripSubmissionEcho.ts
|
|
4523
|
+
/**
|
|
4524
|
+
* Strip the annotation protocol from host surfaces that paint the raw draft.
|
|
4525
|
+
*
|
|
4526
|
+
* On submit the host paints an optimistic bubble (`[data-submission-echo]`)
|
|
4527
|
+
* from the composer draft — which, at that instant, is the protocol block the
|
|
4528
|
+
* send gesture just folded in. A message submitted while a turn is running is
|
|
4529
|
+
* also previewed in the queue dock (`[data-queue-dock]`) from the same wire
|
|
4530
|
+
* content. Both are rendered by the chat flow itself, not by a
|
|
4531
|
+
* `conversation.chat.node` registration, so `UserMessageDisplay` never sees
|
|
4532
|
+
* them; without this, the raw JSON sits on screen until (or unless) a durable
|
|
4533
|
+
* message replaces it.
|
|
4534
|
+
*
|
|
4535
|
+
* The durable message keeps the block (the model needs it). This is display
|
|
4536
|
+
* only: the echo's protocol run is rewritten in place, and the queue preview's
|
|
4537
|
+
* word-split text container is rebuilt to the user's own text. The echo row
|
|
4538
|
+
* itself is never rebuilt (it may carry session chips); a truncated block is
|
|
4539
|
+
* left untouched, so nothing but our own protocol is edited.
|
|
4540
|
+
*/
|
|
4541
|
+
const ECHO_SELECTOR = "[data-submission-echo]";
|
|
4542
|
+
const QUEUE_DOCK_SELECTOR = "[data-queue-dock]";
|
|
4543
|
+
const SURFACE_SELECTOR = `${ECHO_SELECTOR}, ${QUEUE_DOCK_SELECTOR}`;
|
|
4544
|
+
function carriesProtocol(text) {
|
|
4545
|
+
return text.includes("<response-annotations>") && text.includes("</response-annotations>");
|
|
4546
|
+
}
|
|
4547
|
+
/** Deepest runs that carry both markers. A queue dock may carry one per row. */
|
|
4548
|
+
function protocolRuns(row) {
|
|
4549
|
+
if (row.children.length === 0) return carriesProtocol(row.textContent ?? "") ? [row] : [];
|
|
4550
|
+
const runs = [];
|
|
4551
|
+
for (const el of Array.from(row.querySelectorAll("*"))) {
|
|
4552
|
+
if (el.children.length > 0) continue;
|
|
4553
|
+
if (carriesProtocol(el.textContent ?? "")) runs.push(el);
|
|
4554
|
+
}
|
|
4555
|
+
return runs;
|
|
4556
|
+
}
|
|
4557
|
+
/**
|
|
4558
|
+
* Rewrite one surface's protocol runs in place.
|
|
4559
|
+
*
|
|
4560
|
+
* Idempotent: once stripped a run no longer carries both markers, so a later
|
|
4561
|
+
* sweep (after the host re-renders the surface) is a no-op rather than a
|
|
4562
|
+
* reason to remember the node forever.
|
|
4563
|
+
*/
|
|
4564
|
+
function stripEchoRow(row) {
|
|
4565
|
+
let stripped = false;
|
|
4566
|
+
for (const run of protocolRuns(row)) {
|
|
4567
|
+
const original = run.textContent ?? "";
|
|
4568
|
+
const scan = scanAnnotatedMessage(original);
|
|
4569
|
+
if (scan.rest === original) continue;
|
|
4570
|
+
run.textContent = scan.rest;
|
|
4571
|
+
stripped = true;
|
|
4572
|
+
}
|
|
4573
|
+
return stripped;
|
|
4574
|
+
}
|
|
4575
|
+
/**
|
|
4576
|
+
* Innermost text containers whose text carries a complete block.
|
|
4577
|
+
*
|
|
4578
|
+
* Queue previews go through the host's `conversation.user.text` factory, which
|
|
4579
|
+
* splits the draft into per-word spans; the open and close markers therefore
|
|
4580
|
+
* never share a leaf. The innermost ancestor holding both is the preview's
|
|
4581
|
+
* text container — rewriting that one leaves the row's icon and actions alone.
|
|
4582
|
+
*/
|
|
4583
|
+
function textContainers(surface) {
|
|
4584
|
+
const all = Array.from(surface.querySelectorAll("*")).filter((el) => el.children.length > 0 && carriesProtocol(el.textContent ?? ""));
|
|
4585
|
+
return all.filter((el) => !all.some((other) => other !== el && el.contains?.(other) === true));
|
|
4586
|
+
}
|
|
4587
|
+
/**
|
|
4588
|
+
* Rewrite each queued row's text container in place. Unlike the echo, the
|
|
4589
|
+
* preview is a word-split run with no chips or attachments inside it, so
|
|
4590
|
+
* rebuilding that container is safe; the row itself is never touched.
|
|
4591
|
+
*/
|
|
4592
|
+
function stripQueueDock(dock) {
|
|
4593
|
+
let stripped = false;
|
|
4594
|
+
for (const container of textContainers(dock)) {
|
|
4595
|
+
const original = container.textContent ?? "";
|
|
4596
|
+
const scan = scanAnnotatedMessage(original);
|
|
4597
|
+
if (scan.rest === original) continue;
|
|
4598
|
+
container.textContent = scan.rest;
|
|
4599
|
+
stripped = true;
|
|
4600
|
+
}
|
|
4601
|
+
return stripped;
|
|
4602
|
+
}
|
|
4603
|
+
/**
|
|
4604
|
+
* Watch for host surfaces that paint the composed draft and rewrite the
|
|
4605
|
+
* protocol block out of them. Runs once immediately, then only on mutations
|
|
4606
|
+
* that can affect one of those surfaces.
|
|
4607
|
+
*/
|
|
4608
|
+
function watchSubmissionEcho(root) {
|
|
4609
|
+
const scope = root ?? (typeof document === "undefined" ? void 0 : document.body);
|
|
4610
|
+
if (!scope) return () => {};
|
|
4611
|
+
const stripSurface = (surface) => {
|
|
4612
|
+
const node = surface;
|
|
4613
|
+
if (surface.matches(QUEUE_DOCK_SELECTOR)) stripQueueDock(node);
|
|
4614
|
+
else stripEchoRow(node);
|
|
4615
|
+
};
|
|
4616
|
+
const sweep = () => {
|
|
4617
|
+
for (const surface of Array.from(scope.querySelectorAll(SURFACE_SELECTOR))) stripSurface(surface);
|
|
4618
|
+
};
|
|
4619
|
+
sweep();
|
|
4620
|
+
if (typeof MutationObserver === "undefined") return () => {};
|
|
4621
|
+
const pending = /* @__PURE__ */ new Set();
|
|
4622
|
+
let frame = 0;
|
|
4623
|
+
const collect = (records) => {
|
|
4624
|
+
for (const record of records) {
|
|
4625
|
+
if (record.type === "characterData") {
|
|
4626
|
+
const row = record.target.parentElement?.closest(SURFACE_SELECTOR);
|
|
4627
|
+
if (row) pending.add(row);
|
|
4628
|
+
continue;
|
|
4629
|
+
}
|
|
4630
|
+
for (const node of Array.from(record.addedNodes)) {
|
|
4631
|
+
const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
4632
|
+
if (el === null) continue;
|
|
4633
|
+
const row = el.closest(SURFACE_SELECTOR);
|
|
4634
|
+
if (row) {
|
|
4635
|
+
pending.add(row);
|
|
4636
|
+
continue;
|
|
4637
|
+
}
|
|
4638
|
+
for (const inner of Array.from(el.querySelectorAll(SURFACE_SELECTOR))) pending.add(inner);
|
|
4639
|
+
}
|
|
4640
|
+
}
|
|
4641
|
+
};
|
|
4642
|
+
const observer = new MutationObserver((records) => {
|
|
4643
|
+
collect(records);
|
|
4644
|
+
if (pending.size === 0) return;
|
|
4645
|
+
if (frame !== 0) return;
|
|
4646
|
+
frame = window.requestAnimationFrame(() => {
|
|
4647
|
+
frame = 0;
|
|
4648
|
+
for (const surface of pending) stripSurface(surface);
|
|
4649
|
+
pending.clear();
|
|
4650
|
+
});
|
|
4651
|
+
});
|
|
4652
|
+
observer.observe(scope, {
|
|
4653
|
+
childList: true,
|
|
4654
|
+
subtree: true,
|
|
4655
|
+
characterData: true
|
|
4656
|
+
});
|
|
4657
|
+
return () => {
|
|
4658
|
+
observer.disconnect();
|
|
4659
|
+
pending.clear();
|
|
4660
|
+
if (frame !== 0) window.cancelAnimationFrame(frame);
|
|
4661
|
+
};
|
|
4662
|
+
}
|
|
4663
|
+
//#endregion
|
|
3392
4664
|
//#region src/client/transcript/TranscriptQuoteCard.tsx
|
|
3393
4665
|
/**
|
|
3394
|
-
* History-side
|
|
4666
|
+
* History-side summary for one user message's annotations.
|
|
3395
4667
|
*
|
|
3396
|
-
* One
|
|
3397
|
-
*
|
|
3398
|
-
*
|
|
3399
|
-
*
|
|
3400
|
-
*
|
|
3401
|
-
* refers to.
|
|
4668
|
+
* One block per message, not one card per annotation: the block carries the
|
|
4669
|
+
* count and the hover list shows every annotation with its selected text and
|
|
4670
|
+
* comment. That is the same shape the composer uses for pending selections, so
|
|
4671
|
+
* a message reads the same before and after it is sent. Clicking an item
|
|
4672
|
+
* scrolls back to the marked text in the source message; the ordinal badge
|
|
4673
|
+
* matches the `:dsh-annotation{index="N"}` the model refers to.
|
|
3402
4674
|
*/
|
|
3403
4675
|
function TranscriptQuoteCard({ node }) {
|
|
3404
4676
|
ensureToolbarStyles();
|
|
@@ -3416,98 +4688,157 @@ function TranscriptQuoteCard({ node }) {
|
|
|
3416
4688
|
endOffset: quote.endOffset
|
|
3417
4689
|
})));
|
|
3418
4690
|
}, [quotes]);
|
|
4691
|
+
(0, react.useEffect)(() => {
|
|
4692
|
+
if (quotes.length === 0) return;
|
|
4693
|
+
const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
|
|
4694
|
+
if (!ownerKey) return;
|
|
4695
|
+
return registerMessageQuotes(ownerKey, quotes);
|
|
4696
|
+
}, [quotes]);
|
|
3419
4697
|
const markIdOf = (0, react.useCallback)((index) => {
|
|
3420
4698
|
const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
|
|
3421
4699
|
return ownerKey ? `dqsm_${ownerKey}_${index}` : void 0;
|
|
3422
4700
|
}, []);
|
|
3423
|
-
const onSelect = (0, react.useCallback)((index,
|
|
3424
|
-
|
|
3425
|
-
|
|
4701
|
+
const onSelect = (0, react.useCallback)((index, _item) => {
|
|
4702
|
+
const quote = quotes[index];
|
|
4703
|
+
if (quote === void 0) return;
|
|
4704
|
+
scrollToAnnotation(index + 1, quote.text, markIdOf(index));
|
|
4705
|
+
}, [markIdOf, quotes]);
|
|
3426
4706
|
if (quotes.length === 0) return null;
|
|
3427
4707
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3428
4708
|
ref: stackRef,
|
|
3429
|
-
className: styles.
|
|
4709
|
+
className: styles.tSummary,
|
|
3430
4710
|
"data-dsq-deco": "true",
|
|
3431
|
-
children:
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
"aria-label": quote.title,
|
|
3436
|
-
onClick: () => onSelect(index, quote.text),
|
|
3437
|
-
onKeyDown: (event) => {
|
|
3438
|
-
if (event.key !== "Enter" && event.key !== " ") return;
|
|
3439
|
-
event.preventDefault();
|
|
3440
|
-
onSelect(index, quote.text);
|
|
3441
|
-
},
|
|
3442
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnnotationCardView, {
|
|
3443
|
-
variant: "transcript",
|
|
4711
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnnotationSummary, {
|
|
4712
|
+
count: quotes.length,
|
|
4713
|
+
items: quotes.map((quote, index) => ({
|
|
4714
|
+
id: `dqsm-${index}`,
|
|
3444
4715
|
title: quote.title,
|
|
3445
4716
|
text: quote.text,
|
|
3446
|
-
comment: quote.comment
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
}
|
|
4717
|
+
...quote.comment ? { comment: quote.comment } : {}
|
|
4718
|
+
})),
|
|
4719
|
+
onItemSelect: onSelect
|
|
4720
|
+
})
|
|
3450
4721
|
});
|
|
3451
4722
|
}
|
|
3452
4723
|
//#endregion
|
|
3453
4724
|
//#region src/client/transcript/UserMessageDisplay.tsx
|
|
3454
|
-
function
|
|
3455
|
-
if (
|
|
4725
|
+
function isTextBlock(block) {
|
|
4726
|
+
if (block === null || typeof block !== "object") return false;
|
|
4727
|
+
const candidate = block;
|
|
4728
|
+
if ((candidate.type === "image" || candidate.type === "file") && candidate.attachment !== void 0) return false;
|
|
4729
|
+
return candidate.type === "text" || typeof candidate.text === "string";
|
|
4730
|
+
}
|
|
4731
|
+
/**
|
|
4732
|
+
* Replace all text blocks with one stripped text block, keeping attachments and
|
|
4733
|
+
* unknown blocks in place. The host's `contentParts` joins text blocks and
|
|
4734
|
+
* lifts attachments out anyway, so one text block is the same message content
|
|
4735
|
+
* with the protocol block gone.
|
|
4736
|
+
*/
|
|
4737
|
+
function strippedContent(raw, text) {
|
|
4738
|
+
const rest = Array.isArray(raw) ? raw.filter((block) => !isTextBlock(block)) : [];
|
|
4739
|
+
if (text) rest.unshift({
|
|
4740
|
+
type: "text",
|
|
4741
|
+
text
|
|
4742
|
+
});
|
|
4743
|
+
return rest;
|
|
4744
|
+
}
|
|
4745
|
+
/** Image attachments for the factory-less fallback bubble. */
|
|
4746
|
+
function imageAttachments(raw) {
|
|
4747
|
+
if (!Array.isArray(raw)) return [];
|
|
3456
4748
|
const images = [];
|
|
3457
|
-
for (const block of
|
|
4749
|
+
for (const block of raw) {
|
|
3458
4750
|
if (block === null || typeof block !== "object") continue;
|
|
3459
4751
|
const candidate = block;
|
|
3460
|
-
if (candidate.type
|
|
3461
|
-
images.push({ attachment: candidate.attachment });
|
|
4752
|
+
if (candidate.type === "image" && candidate.attachment !== void 0) images.push(candidate.attachment);
|
|
3462
4753
|
}
|
|
3463
4754
|
return images;
|
|
3464
4755
|
}
|
|
3465
4756
|
/**
|
|
3466
4757
|
* Replacement for the built-in `user` Chat node view.
|
|
3467
4758
|
*
|
|
3468
|
-
*
|
|
3469
|
-
*
|
|
3470
|
-
*
|
|
4759
|
+
* It does **not** redraw the bubble or its actions row. It resolves the host's
|
|
4760
|
+
* own `UserMessageNodeView` from `ctx.slots.entries('conversation.chat.node')`
|
|
4761
|
+
* and renders that component with a rewritten `node`: the annotation protocol
|
|
4762
|
+
* block is removed from the text blocks, everything else (attachments, file
|
|
4763
|
+
* cards, reference labels, time, copy, and any plugin that portals into the
|
|
4764
|
+
* actions row, e.g. dsh-rewind's ↶) stays host-owned and follows host changes.
|
|
4765
|
+
* The annotation summary is the only extra DOM, prepended above the host row.
|
|
3471
4766
|
*
|
|
3472
|
-
*
|
|
3473
|
-
* `conversation.chat.node` is gated by the shipped
|
|
3474
|
-
* allowlist (system-prompt, user, steering,
|
|
3475
|
-
* turn-error, turn-max-tokens, turn-tail)
|
|
3476
|
-
*
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
* that survives turn folding.
|
|
3480
|
-
*/
|
|
3481
|
-
function UserMessageDisplay({ node, renderMessageImages }) {
|
|
4767
|
+
* The summary must live inside the `user` node view rather than on a
|
|
4768
|
+
* `select-quote` Chat node: `conversation.chat.node` is gated by the shipped
|
|
4769
|
+
* `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
|
|
4770
|
+
* turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail), and any
|
|
4771
|
+
* other kind is `hidden` when the completed turn's process disclosure folds.
|
|
4772
|
+
*/
|
|
4773
|
+
function UserMessageDisplay(props) {
|
|
3482
4774
|
ensureToolbarStyles();
|
|
4775
|
+
const { node, hostNodeOf, ...rest } = props;
|
|
3483
4776
|
const raw = contentBlocksToText(node.data?.content);
|
|
3484
4777
|
const scanned = scanAnnotatedMessage(raw);
|
|
3485
|
-
const text = scanned.
|
|
4778
|
+
const text = displayTextWithoutQuote(scanned.rest).trim();
|
|
3486
4779
|
const quotes = scanned.annotations;
|
|
3487
|
-
const
|
|
3488
|
-
|
|
3489
|
-
|
|
4780
|
+
const rewritten = quotes.length > 0 || text !== raw.trim();
|
|
4781
|
+
const displayNode = (0, react.useMemo)(() => rewritten ? {
|
|
4782
|
+
...node,
|
|
4783
|
+
data: {
|
|
4784
|
+
...node.data,
|
|
4785
|
+
content: strippedContent(node.data?.content, text)
|
|
4786
|
+
}
|
|
4787
|
+
} : node, [
|
|
4788
|
+
node,
|
|
4789
|
+
rewritten,
|
|
4790
|
+
text
|
|
4791
|
+
]);
|
|
4792
|
+
const cards = quotes.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }) : null;
|
|
4793
|
+
const Host = hostNodeOf?.();
|
|
4794
|
+
if (Host !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [cards, /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Host, {
|
|
4795
|
+
...rest,
|
|
4796
|
+
node: displayNode
|
|
4797
|
+
})] });
|
|
4798
|
+
const images = imageAttachments(node.data?.content);
|
|
4799
|
+
const loadImage = props.loadImage;
|
|
4800
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [cards, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3490
4801
|
className: cx(A.colEnd, styles.userRow),
|
|
3491
4802
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3492
4803
|
className: cx(A.colEnd, styles.userStack),
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
]
|
|
4804
|
+
"data-dsq-fallback-content": "true",
|
|
4805
|
+
children: [images.length > 0 && loadImage !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4806
|
+
className: cx(A.colEnd, styles.userImages),
|
|
4807
|
+
"data-message-attachments": true,
|
|
4808
|
+
children: images.map((attachment, index) => {
|
|
4809
|
+
const url = loadImage(attachment);
|
|
4810
|
+
return url === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
|
|
4811
|
+
src: url,
|
|
4812
|
+
alt: ""
|
|
4813
|
+
}, `image-${index}`);
|
|
4814
|
+
})
|
|
4815
|
+
}) : null, text ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4816
|
+
className: styles.userBubble,
|
|
4817
|
+
children: text
|
|
4818
|
+
}) : null]
|
|
3509
4819
|
})
|
|
3510
|
-
});
|
|
4820
|
+
})] });
|
|
4821
|
+
}
|
|
4822
|
+
//#endregion
|
|
4823
|
+
//#region src/client/transcript/host-user.ts
|
|
4824
|
+
const HOST_NODE_NAME = "UserMessageNodeView";
|
|
4825
|
+
/** Component name across memo/forwardRef/plain function shapes. */
|
|
4826
|
+
function componentName(component) {
|
|
4827
|
+
const candidate = component;
|
|
4828
|
+
return candidate?.type?.name ?? candidate?.render?.name ?? candidate?.name ?? candidate?.displayName;
|
|
4829
|
+
}
|
|
4830
|
+
/**
|
|
4831
|
+
* The host view registered for `key`, or undefined when it cannot be
|
|
4832
|
+
* identified safely.
|
|
4833
|
+
*
|
|
4834
|
+
* A renamed host leaves one non-self entry for that key, which is still
|
|
4835
|
+
* almost certainly the host; more than one is ambiguous, so callers should
|
|
4836
|
+
* show the hostless fallback instead of delegating to an arbitrary plugin's
|
|
4837
|
+
* wrapper.
|
|
4838
|
+
*/
|
|
4839
|
+
function pickHostNode(entries, self, key) {
|
|
4840
|
+
const candidates = entries.filter((entry) => entry.options?.key === key && entry.component !== void 0 && entry.component !== self);
|
|
4841
|
+
return (candidates.find((entry) => componentName(entry.component) === HOST_NODE_NAME) ?? (candidates.length === 1 ? candidates[0] : void 0))?.component;
|
|
3511
4842
|
}
|
|
3512
4843
|
//#endregion
|
|
3513
4844
|
//#region src/client/state/annotation-persistence.ts
|
|
@@ -3666,7 +4997,7 @@ function createAnnotationPersistence() {
|
|
|
3666
4997
|
* Selection toolbar is root-scoped (`shell.overlay`) so transcript selections
|
|
3667
4998
|
* are always observed. Annotation summary stays on the session composer.
|
|
3668
4999
|
*
|
|
3669
|
-
* History
|
|
5000
|
+
* History summary renders **inside** the `user` Chat node view, not on a
|
|
3670
5001
|
* `select-quote` Chat node. `conversation.chat.node` is gated by the shipped
|
|
3671
5002
|
* `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
|
|
3672
5003
|
* turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail): any other
|
|
@@ -3701,6 +5032,7 @@ function apply(ctx) {
|
|
|
3701
5032
|
ctx.effect(() => subscribeLocale(() => refreshDirectiveChips()), "dsh-select-quote: chip language");
|
|
3702
5033
|
ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
|
|
3703
5034
|
ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
|
|
5035
|
+
ctx.effect(() => watchSubmissionEcho(), "dsh-select-quote: submission echo");
|
|
3704
5036
|
ctx.effect(() => {
|
|
3705
5037
|
syncPendingAnnotationJobs(getAnnotations());
|
|
3706
5038
|
return subscribeAnnotations(() => syncPendingAnnotationJobs(getAnnotations()));
|
|
@@ -3709,7 +5041,20 @@ function apply(ctx) {
|
|
|
3709
5041
|
const port = createAnnotationPersistence();
|
|
3710
5042
|
setAnnotationPersistence(port);
|
|
3711
5043
|
port.prune();
|
|
3712
|
-
|
|
5044
|
+
const onStorage = (event) => {
|
|
5045
|
+
if (event.storageArea !== null && event.storageArea !== window.localStorage) return;
|
|
5046
|
+
if (event.key === null) {
|
|
5047
|
+
adoptStorageAnnotations(null);
|
|
5048
|
+
return;
|
|
5049
|
+
}
|
|
5050
|
+
const sessionId = sessionIdOfKey(event.key);
|
|
5051
|
+
if (sessionId !== null) adoptStorageAnnotations(sessionId);
|
|
5052
|
+
};
|
|
5053
|
+
window.addEventListener("storage", onStorage);
|
|
5054
|
+
return () => {
|
|
5055
|
+
window.removeEventListener("storage", onStorage);
|
|
5056
|
+
setAnnotationPersistence(null);
|
|
5057
|
+
};
|
|
3713
5058
|
}, "dsh-select-quote: annotation persistence");
|
|
3714
5059
|
ctx.slots.inject("shell.overlay", () => {
|
|
3715
5060
|
ctx.slots.register({
|
|
@@ -3732,12 +5077,28 @@ function apply(ctx) {
|
|
|
3732
5077
|
order: 1
|
|
3733
5078
|
}, SessionBridge);
|
|
3734
5079
|
});
|
|
3735
|
-
|
|
5080
|
+
const hostNodeOf = (key, self) => {
|
|
5081
|
+
return pickHostNode(typeof ctx.slots.entries === "function" ? ctx.slots.entries("conversation.chat.node") : [], self, key);
|
|
5082
|
+
};
|
|
5083
|
+
function UserNode(props) {
|
|
5084
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMessageDisplay, {
|
|
5085
|
+
...props,
|
|
5086
|
+
hostNodeOf: () => hostNodeOf("user", UserNode)
|
|
5087
|
+
});
|
|
5088
|
+
}
|
|
5089
|
+
function SteeringNode(props) {
|
|
5090
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMessageDisplay, {
|
|
5091
|
+
...props,
|
|
5092
|
+
hostNodeOf: () => hostNodeOf("steering", SteeringNode)
|
|
5093
|
+
});
|
|
5094
|
+
}
|
|
5095
|
+
for (const [key, View] of [["user", UserNode], ["steering", SteeringNode]]) ctx.slots.inject("conversation.chat.node", () => {
|
|
3736
5096
|
ctx.slots.register({
|
|
3737
5097
|
name: "conversation.chat.node",
|
|
3738
|
-
key
|
|
3739
|
-
priority: -10
|
|
3740
|
-
|
|
5098
|
+
key,
|
|
5099
|
+
priority: -10,
|
|
5100
|
+
locale: "chat"
|
|
5101
|
+
}, View);
|
|
3741
5102
|
});
|
|
3742
5103
|
}
|
|
3743
5104
|
/** Invisible session-scoped bridge that publishes sessionId for the root toolbar. */
|