dsh-select-quote 0.3.8 → 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 +1656 -296
- 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,8 +256,10 @@ ${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
265
|
摘要块(composer 与历史消息)在内容普通流里,不参与这套层带;悬停面板仍是
|
|
@@ -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;
|
|
@@ -373,7 +422,7 @@ ${atomRules}
|
|
|
373
422
|
padding: 8px 10px;
|
|
374
423
|
border: 0.5px solid var(--dsq-line);
|
|
375
424
|
border-radius: 18px;
|
|
376
|
-
background: var(--dsq-
|
|
425
|
+
background: var(--dsq-summary-bg);
|
|
377
426
|
pointer-events: auto;
|
|
378
427
|
user-select: none;
|
|
379
428
|
}
|
|
@@ -392,7 +441,9 @@ ${atomRules}
|
|
|
392
441
|
position: relative;
|
|
393
442
|
}
|
|
394
443
|
|
|
395
|
-
.
|
|
444
|
+
/* Card titles (composer summary / touch sheet) share one type scale. */
|
|
445
|
+
.dsq_summaryTitle,
|
|
446
|
+
.dsq_touchSheetTitle {
|
|
396
447
|
font-size: 14px;
|
|
397
448
|
font-weight: 500;
|
|
398
449
|
line-height: 20px;
|
|
@@ -405,18 +456,37 @@ ${atomRules}
|
|
|
405
456
|
color: var(--dsq-label-3);
|
|
406
457
|
}
|
|
407
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
|
+
|
|
408
480
|
.dsq_summaryHover {
|
|
409
481
|
/* \`display: none\` here is the closed state, so this rule must not take the
|
|
410
482
|
flex atoms — the open class below turns display back on. */
|
|
411
483
|
display: none;
|
|
412
484
|
position: fixed;
|
|
413
485
|
z-index: 14;
|
|
414
|
-
width: min(320px, calc(100vw - 16px));
|
|
415
486
|
flex-direction: column;
|
|
416
|
-
padding:
|
|
417
|
-
|
|
418
|
-
padding
|
|
419
|
-
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;
|
|
420
490
|
background: transparent;
|
|
421
491
|
pointer-events: auto;
|
|
422
492
|
opacity: 0;
|
|
@@ -432,14 +502,22 @@ ${atomRules}
|
|
|
432
502
|
display: flex;
|
|
433
503
|
}
|
|
434
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
|
+
|
|
435
514
|
.dsq_summaryHoverInner {
|
|
436
515
|
position: relative;
|
|
437
516
|
gap: 4px;
|
|
438
517
|
padding: 4px;
|
|
439
518
|
border-radius: 12px;
|
|
440
519
|
/* 不画 border:--dsq-shadow(宿主的 elevation-prominent)自带 0 0 0 .5px 的
|
|
441
|
-
|
|
442
|
-
box-shadow: var(--dsq-shadow);
|
|
520
|
+
描边环,再画一遍会叠成两条线。底色与模糊都在材质图层里,elevation 在共享规则里。 */
|
|
443
521
|
}
|
|
444
522
|
|
|
445
523
|
/* 滚动只放在列表上:材质图层是绝对定位的,留在会滚动的容器里会跟着内容跑掉,
|
|
@@ -457,7 +535,9 @@ ${atomRules}
|
|
|
457
535
|
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
|
458
536
|
.dsq_toolbar::before,
|
|
459
537
|
.dsq_commentPop::before,
|
|
460
|
-
.dsq_summaryHoverInner::before
|
|
538
|
+
.dsq_summaryHoverInner::before,
|
|
539
|
+
.dsq_touchBar::before,
|
|
540
|
+
.dsq_chipPreview::before {
|
|
461
541
|
background: var(--dsq-bg-raised);
|
|
462
542
|
backdrop-filter: none;
|
|
463
543
|
-webkit-backdrop-filter: none;
|
|
@@ -477,6 +557,9 @@ ${atomRules}
|
|
|
477
557
|
|
|
478
558
|
.dsq_summaryItemHead {
|
|
479
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;
|
|
480
563
|
}
|
|
481
564
|
|
|
482
565
|
.dsq_summaryItemBadge {
|
|
@@ -516,6 +599,9 @@ ${atomRules}
|
|
|
516
599
|
|
|
517
600
|
.dsq_summaryItemMain {
|
|
518
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;
|
|
519
605
|
}
|
|
520
606
|
|
|
521
607
|
.dsq_summaryItemButton {
|
|
@@ -524,7 +610,9 @@ ${atomRules}
|
|
|
524
610
|
text-align: left;
|
|
525
611
|
font: inherit;
|
|
526
612
|
border-radius: 8px;
|
|
527
|
-
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;
|
|
528
616
|
}
|
|
529
617
|
|
|
530
618
|
.dsq_annAnchor {
|
|
@@ -535,18 +623,36 @@ ${atomRules}
|
|
|
535
623
|
pointer-events: none;
|
|
536
624
|
}
|
|
537
625
|
|
|
538
|
-
.
|
|
626
|
+
/* Field labels shared by the hover list and the chip preview. */
|
|
627
|
+
.dsq_summaryItemLabel,
|
|
628
|
+
.dsq_chipPreviewLabel {
|
|
539
629
|
font-size: 11px;
|
|
540
630
|
line-height: 16px;
|
|
541
631
|
color: var(--dsq-label-3);
|
|
542
632
|
}
|
|
543
633
|
|
|
544
|
-
.
|
|
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 {
|
|
545
648
|
font-size: 13px;
|
|
546
|
-
line-height: 18px;
|
|
547
649
|
color: var(--dsq-label);
|
|
548
650
|
white-space: pre-wrap;
|
|
549
651
|
overflow-wrap: anywhere;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
.dsq_summaryItemText {
|
|
655
|
+
line-height: 18px;
|
|
550
656
|
display: -webkit-box;
|
|
551
657
|
-webkit-line-clamp: 3;
|
|
552
658
|
-webkit-box-orient: vertical;
|
|
@@ -557,24 +663,22 @@ ${atomRules}
|
|
|
557
663
|
color: var(--dsq-label-3);
|
|
558
664
|
}
|
|
559
665
|
|
|
560
|
-
/* Inline edit form rendered inside the hover panel item (no extra layer).
|
|
561
|
-
.
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
white-space: pre-wrap;
|
|
566
|
-
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;
|
|
567
671
|
max-height: 132px;
|
|
568
672
|
overflow-y: auto;
|
|
569
|
-
|
|
673
|
+
overscroll-behavior: contain;
|
|
570
674
|
}
|
|
571
675
|
|
|
572
|
-
|
|
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 {
|
|
573
680
|
width: 100%;
|
|
574
|
-
min-height: 72px;
|
|
575
|
-
resize: vertical;
|
|
576
681
|
border: none;
|
|
577
|
-
border-radius: 10px;
|
|
578
682
|
padding: 8px 10px;
|
|
579
683
|
font: inherit;
|
|
580
684
|
font-size: 13px;
|
|
@@ -584,44 +688,47 @@ ${atomRules}
|
|
|
584
688
|
outline: none;
|
|
585
689
|
}
|
|
586
690
|
|
|
587
|
-
.dsq_annEditInput::placeholder
|
|
691
|
+
.dsq_annEditInput::placeholder,
|
|
692
|
+
.dsq_commentInput::placeholder {
|
|
588
693
|
color: var(--dsq-label-3);
|
|
589
694
|
}
|
|
590
695
|
|
|
696
|
+
.dsq_annEditInput {
|
|
697
|
+
min-height: 72px;
|
|
698
|
+
resize: vertical;
|
|
699
|
+
border-radius: 10px;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
.dsq_commentActions,
|
|
591
703
|
.dsq_annEditActions {
|
|
592
704
|
justify-content: flex-end;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.dsq_annEditActions {
|
|
593
708
|
gap: 8px;
|
|
594
709
|
margin-top: 4px;
|
|
595
710
|
}
|
|
596
711
|
|
|
597
|
-
|
|
598
|
-
|
|
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 {
|
|
599
716
|
font: inherit;
|
|
600
717
|
font-size: 13px;
|
|
601
718
|
padding: 4px 12px;
|
|
602
719
|
border-radius: 10px;
|
|
603
720
|
}
|
|
604
721
|
|
|
722
|
+
.dsq_annEditCancel {
|
|
723
|
+
color: var(--dsq-label-2);
|
|
724
|
+
}
|
|
725
|
+
|
|
605
726
|
.dsq_annEditCancel:hover {
|
|
606
727
|
background: var(--dsq-fill-hover);
|
|
607
728
|
}
|
|
608
729
|
|
|
609
730
|
.dsq_annEditSave {
|
|
610
|
-
appearance: none;
|
|
611
|
-
border: none;
|
|
612
|
-
background: var(--dsq-accent);
|
|
613
|
-
color: #fff;
|
|
614
|
-
font: inherit;
|
|
615
|
-
font-size: 13px;
|
|
616
731
|
font-weight: 500;
|
|
617
|
-
padding: 4px 12px;
|
|
618
|
-
border-radius: 10px;
|
|
619
|
-
cursor: pointer;
|
|
620
|
-
transition: filter 0.12s;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
.dsq_annEditSave:hover {
|
|
624
|
-
background: var(--dsq-accent-400);
|
|
625
732
|
}
|
|
626
733
|
|
|
627
734
|
.dsq_annEditSave:disabled {
|
|
@@ -640,35 +747,226 @@ ${atomRules}
|
|
|
640
747
|
gap: 8px;
|
|
641
748
|
padding: 8px;
|
|
642
749
|
border-radius: 16px;
|
|
643
|
-
/*
|
|
644
|
-
box-shadow: var(--dsq-shadow);
|
|
750
|
+
/* 底色与模糊都在材质图层里,elevation 在共享规则里 */
|
|
645
751
|
/* The layer host is pointer-events:none; interactive surfaces opt back in. */
|
|
646
752
|
pointer-events: auto;
|
|
647
753
|
}
|
|
648
754
|
|
|
649
755
|
.dsq_commentInput {
|
|
650
|
-
width: 100%;
|
|
651
756
|
min-height: 64px;
|
|
652
757
|
resize: none;
|
|
653
|
-
border: none;
|
|
654
758
|
border-radius: 8px;
|
|
655
|
-
|
|
656
|
-
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.dsq_commentActions {
|
|
762
|
+
gap: 6px;
|
|
763
|
+
}
|
|
764
|
+
|
|
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 {
|
|
782
|
+
display: flex;
|
|
783
|
+
flex-direction: column;
|
|
784
|
+
gap: 6px;
|
|
785
|
+
animation: dsq-rise-in 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
.dsq_chipPreviewLeaving {
|
|
789
|
+
pointer-events: none;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
.dsq_chipPreviewLeaving .dsq_chipPreviewBody {
|
|
793
|
+
animation: dsq-rise-out 0.12s cubic-bezier(0.4, 0, 1, 1) both;
|
|
794
|
+
}
|
|
795
|
+
|
|
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 {
|
|
657
804
|
font-size: 13px;
|
|
658
|
-
|
|
805
|
+
font-weight: 600;
|
|
806
|
+
line-height: 18px;
|
|
659
807
|
color: var(--dsq-label);
|
|
660
|
-
background: var(--dsq-fill-hover);
|
|
661
|
-
outline: none;
|
|
662
808
|
}
|
|
663
809
|
|
|
664
|
-
.
|
|
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 {
|
|
665
827
|
color: var(--dsq-label-3);
|
|
666
828
|
}
|
|
667
829
|
|
|
668
|
-
.
|
|
669
|
-
|
|
670
|
-
justify-content:
|
|
671
|
-
gap:
|
|
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;
|
|
837
|
+
font: inherit;
|
|
838
|
+
font-size: 12px;
|
|
839
|
+
font-weight: 500;
|
|
840
|
+
line-height: 1;
|
|
841
|
+
white-space: nowrap;
|
|
842
|
+
}
|
|
843
|
+
|
|
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;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
.dsq_touchBar {
|
|
861
|
+
z-index: 60;
|
|
862
|
+
gap: 4px;
|
|
863
|
+
padding: 6px;
|
|
864
|
+
border-radius: 16px;
|
|
865
|
+
}
|
|
866
|
+
|
|
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;
|
|
878
|
+
font-size: 11px;
|
|
879
|
+
line-height: 14px;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
.dsq_touchAction span {
|
|
883
|
+
max-width: 100%;
|
|
884
|
+
overflow: hidden;
|
|
885
|
+
text-overflow: ellipsis;
|
|
886
|
+
white-space: nowrap;
|
|
887
|
+
}
|
|
888
|
+
|
|
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;
|
|
936
|
+
display: -webkit-box;
|
|
937
|
+
-webkit-line-clamp: 2;
|
|
938
|
+
-webkit-box-orient: vertical;
|
|
939
|
+
overflow: hidden;
|
|
940
|
+
}
|
|
941
|
+
|
|
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);
|
|
672
970
|
}
|
|
673
971
|
|
|
674
972
|
/* Transcript summary — the same block as the composer's, right-aligned under
|
|
@@ -682,8 +980,12 @@ ${atomRules}
|
|
|
682
980
|
|
|
683
981
|
.dsq_tSummary .dsq_summary {
|
|
684
982
|
width: auto;
|
|
685
|
-
min-width:
|
|
983
|
+
min-width: 160px;
|
|
686
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;
|
|
687
989
|
}
|
|
688
990
|
|
|
689
991
|
/* Replacement user bubble (quote block stripped from display). */
|
|
@@ -780,13 +1082,74 @@ ${atomRules}
|
|
|
780
1082
|
}
|
|
781
1083
|
|
|
782
1084
|
.dsq_annMark:hover {
|
|
783
|
-
background: var(--dsq-
|
|
1085
|
+
background: var(--dsq-mark-hover);
|
|
784
1086
|
}
|
|
785
1087
|
|
|
786
1088
|
.dsq_annMarkFlash {
|
|
787
1089
|
animation: dsq-ann-flash 1.4s ease-out;
|
|
788
1090
|
}
|
|
789
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
|
+
|
|
790
1153
|
/* Layer 4 — host surface: keyframes, theme flips, and the two rules that reach
|
|
791
1154
|
into product-owned DOM. */
|
|
792
1155
|
|
|
@@ -794,10 +1157,12 @@ ${atomRules}
|
|
|
794
1157
|
0% {
|
|
795
1158
|
background: var(--dsq-mark-color);
|
|
796
1159
|
color: #fff;
|
|
1160
|
+
box-shadow: 0 0 0 3px var(--dsq-mark-tint);
|
|
797
1161
|
}
|
|
798
1162
|
100% {
|
|
799
1163
|
background: var(--dsq-mark-tint);
|
|
800
1164
|
color: inherit;
|
|
1165
|
+
box-shadow: 0 0 0 0 transparent;
|
|
801
1166
|
}
|
|
802
1167
|
}
|
|
803
1168
|
|
|
@@ -805,9 +1170,10 @@ ${atomRules}
|
|
|
805
1170
|
--dsq-* alias follows it. Only values we own ourselves need restating. */
|
|
806
1171
|
body[data-ds-dark-theme] .dsq_annMark {
|
|
807
1172
|
--dsq-mark-tint: var(--dsq-accent-tint-dark);
|
|
1173
|
+
--dsq-mark-hover: var(--dsw-static-blue-800, #1e40af);
|
|
808
1174
|
}
|
|
809
1175
|
|
|
810
|
-
/* Reserve room for the floating
|
|
1176
|
+
/* Reserve room for the floating annotation summary inside the composer card, so the
|
|
811
1177
|
attachment rail and the editor flow below them instead of being covered. */
|
|
812
1178
|
.dsq_cardPad {
|
|
813
1179
|
padding-top: var(--dsq-quote-pad, 88px) !important;
|
|
@@ -838,7 +1204,10 @@ const styles = {
|
|
|
838
1204
|
summaryBody: "dsq_summaryBody",
|
|
839
1205
|
summaryTitle: "dsq_summaryTitle",
|
|
840
1206
|
summaryHint: "dsq_summaryHint",
|
|
1207
|
+
summaryHintPointer: "dsq_summaryHintPointer",
|
|
1208
|
+
summaryHintTouch: "dsq_summaryHintTouch",
|
|
841
1209
|
summaryHover: "dsq_summaryHover",
|
|
1210
|
+
summaryHoverLeaving: "dsq_summaryHoverLeaving",
|
|
842
1211
|
summaryItem: "dsq_summaryItem",
|
|
843
1212
|
summaryItemLabel: "dsq_summaryItemLabel",
|
|
844
1213
|
summaryItemText: "dsq_summaryItemText",
|
|
@@ -846,7 +1215,7 @@ const styles = {
|
|
|
846
1215
|
summaryItemMain: "dsq_summaryItemMain",
|
|
847
1216
|
summaryItemHead: "dsq_summaryItemHead",
|
|
848
1217
|
summaryItemTextEmpty: "dsq_summaryItemTextEmpty",
|
|
849
|
-
|
|
1218
|
+
summaryItemTextEdit: "dsq_summaryItemTextEdit",
|
|
850
1219
|
annEditInput: "dsq_annEditInput",
|
|
851
1220
|
annEditActions: "dsq_annEditActions",
|
|
852
1221
|
annEditCancel: "dsq_annEditCancel",
|
|
@@ -861,9 +1230,32 @@ const styles = {
|
|
|
861
1230
|
annAnchor: "dsq_annAnchor",
|
|
862
1231
|
annMark: "dsq_annMark",
|
|
863
1232
|
annMarkFlash: "dsq_annMarkFlash",
|
|
1233
|
+
annMessageFlash: "dsq_annMessageFlash",
|
|
864
1234
|
commentPop: "dsq_commentPop",
|
|
865
1235
|
commentInput: "dsq_commentInput",
|
|
866
1236
|
commentActions: "dsq_commentActions",
|
|
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",
|
|
867
1259
|
tSummary: "dsq_tSummary",
|
|
868
1260
|
userRow: "dsq_userRow",
|
|
869
1261
|
userStack: "dsq_userStack",
|
|
@@ -877,7 +1269,7 @@ const styles = {
|
|
|
877
1269
|
* it as stable character offsets over the message's own text.
|
|
878
1270
|
*
|
|
879
1271
|
* Offsets are computed over "clean text": the flow item's text nodes with the
|
|
880
|
-
* plugin's own chrome (summary
|
|
1272
|
+
* plugin's own chrome (summary blocks, directive chips, anchors) skipped. Marks
|
|
881
1273
|
* are transparent — their text still counts — so an offset captured before a
|
|
882
1274
|
* mark exists stays valid after decoration.
|
|
883
1275
|
*/
|
|
@@ -1085,16 +1477,15 @@ function decorateBySearch(item, job) {
|
|
|
1085
1477
|
const full = cleanTextOf(item);
|
|
1086
1478
|
const needle = job.text.trim();
|
|
1087
1479
|
if (needle.length < 2) return false;
|
|
1088
|
-
const patterns = [needle, needle.split(/\s+/).map(escapeRegExp).join("\\s*")];
|
|
1089
|
-
for (const pattern of patterns) {
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
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;
|
|
1098
1489
|
}
|
|
1099
1490
|
}
|
|
1100
1491
|
return false;
|
|
@@ -1106,6 +1497,19 @@ function decorateJob(item, job) {
|
|
|
1106
1497
|
if (decorateAtOffsets(item, job)) return true;
|
|
1107
1498
|
return decorateBySearch(item, job);
|
|
1108
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
|
+
}
|
|
1109
1513
|
/** Apply every registered job whose message is currently rendered. */
|
|
1110
1514
|
function applyAnnotationMarks(root) {
|
|
1111
1515
|
if (jobs.size === 0) return;
|
|
@@ -1113,10 +1517,15 @@ function applyAnnotationMarks(root) {
|
|
|
1113
1517
|
if (!scope) return;
|
|
1114
1518
|
for (const job of [...jobs.values()]) {
|
|
1115
1519
|
const items = flowItems(job.messageId, scope);
|
|
1520
|
+
let decorated = false;
|
|
1116
1521
|
for (const item of items) {
|
|
1117
|
-
if (item
|
|
1522
|
+
if (refreshMarkIndex(item, job)) {
|
|
1523
|
+
decorated = true;
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
if (decorated) continue;
|
|
1118
1527
|
if (job.messageId === "" && !item.textContent?.includes(job.text.trim().slice(0, 16))) continue;
|
|
1119
|
-
if (decorateJob(item, job))
|
|
1528
|
+
if (decorateJob(item, job)) decorated = true;
|
|
1120
1529
|
}
|
|
1121
1530
|
}
|
|
1122
1531
|
}
|
|
@@ -1172,6 +1581,7 @@ function watchAnnotationMarks(root) {
|
|
|
1172
1581
|
applyAnnotationMarks(el);
|
|
1173
1582
|
let frame = 0;
|
|
1174
1583
|
const schedule = () => {
|
|
1584
|
+
if (jobs.size === 0) return;
|
|
1175
1585
|
if (frame !== 0) return;
|
|
1176
1586
|
frame = window.requestAnimationFrame(() => {
|
|
1177
1587
|
frame = 0;
|
|
@@ -1390,43 +1800,52 @@ function composeAnnotatedMessage(draft, annotations) {
|
|
|
1390
1800
|
const rest = stripDraftMarker(draft).replace(/^\s+/, "");
|
|
1391
1801
|
if (annotations.length === 0) return rest;
|
|
1392
1802
|
const payload = annotations.map(toWireItem);
|
|
1803
|
+
const json = JSON.stringify(payload).replace(/</g, "\\u003c");
|
|
1393
1804
|
const block = [
|
|
1394
1805
|
PROTOCOL_HEADER,
|
|
1395
1806
|
"",
|
|
1396
1807
|
ANNOTATIONS_BLOCK_OPEN,
|
|
1397
|
-
|
|
1808
|
+
json,
|
|
1398
1809
|
ANNOTATIONS_BLOCK_CLOSE
|
|
1399
1810
|
].join("\n");
|
|
1400
1811
|
return rest ? `${block}\n\n${rest}` : `${block}\n\n`;
|
|
1401
1812
|
}
|
|
1402
1813
|
/** Remove `# Response annotations:` instructional prose before the JSON block. */
|
|
1403
1814
|
function stripProtocolHeader(prefix) {
|
|
1404
|
-
|
|
1815
|
+
const marker = prefix.indexOf("# Response annotations:");
|
|
1816
|
+
if (marker >= 0) return prefix.slice(0, marker).replace(/\s+$/, "");
|
|
1405
1817
|
return prefix.replace(/\s+$/, "");
|
|
1406
1818
|
}
|
|
1407
1819
|
function blockParts(text) {
|
|
1408
1820
|
const open = text.indexOf(ANNOTATIONS_BLOCK_OPEN);
|
|
1409
1821
|
if (open < 0) return null;
|
|
1410
|
-
const
|
|
1411
|
-
|
|
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;
|
|
1412
1838
|
return {
|
|
1413
1839
|
before: stripProtocolHeader(text.slice(0, open)),
|
|
1414
|
-
after: text.slice(
|
|
1415
|
-
json: text.slice(open + 22, close).trim()
|
|
1840
|
+
after: text.slice(firstClose + 23).replace(/^\s+/, "")
|
|
1416
1841
|
};
|
|
1417
1842
|
}
|
|
1418
1843
|
function joinedRest(parts) {
|
|
1419
1844
|
return [parts.before, parts.after].filter((part) => part.length > 0).join("\n\n").trim();
|
|
1420
1845
|
}
|
|
1421
|
-
/** Wire payload as an array; null when the block is not
|
|
1846
|
+
/** Wire payload as an array; null when the block is not an array. */
|
|
1422
1847
|
function parseWire(parts) {
|
|
1423
|
-
|
|
1424
|
-
try {
|
|
1425
|
-
parsed = JSON.parse(parts.json);
|
|
1426
|
-
} catch {
|
|
1427
|
-
return null;
|
|
1428
|
-
}
|
|
1429
|
-
return Array.isArray(parsed) ? parsed : null;
|
|
1848
|
+
return Array.isArray(parts.parsed) ? parts.parsed : null;
|
|
1430
1849
|
}
|
|
1431
1850
|
/** Every source an item carries; falls back to the selected text itself. */
|
|
1432
1851
|
function sourcesOf(record, body) {
|
|
@@ -1453,6 +1872,9 @@ function wireBody(entry) {
|
|
|
1453
1872
|
/**
|
|
1454
1873
|
* Read plugin annotations out of a durable message. Tolerates the JSON block
|
|
1455
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.
|
|
1456
1878
|
*/
|
|
1457
1879
|
function scanAnnotatedMessage(text) {
|
|
1458
1880
|
const parts = blockParts(text);
|
|
@@ -1460,12 +1882,12 @@ function scanAnnotatedMessage(text) {
|
|
|
1460
1882
|
annotations: [],
|
|
1461
1883
|
rest: text
|
|
1462
1884
|
};
|
|
1885
|
+
const rest = joinedRest(parts);
|
|
1463
1886
|
const parsed = parseWire(parts);
|
|
1464
1887
|
if (parsed === null) return {
|
|
1465
1888
|
annotations: [],
|
|
1466
|
-
rest
|
|
1889
|
+
rest
|
|
1467
1890
|
};
|
|
1468
|
-
const rest = joinedRest(parts);
|
|
1469
1891
|
const annotations = [];
|
|
1470
1892
|
for (const entry of parsed) {
|
|
1471
1893
|
const item = wireBody(entry);
|
|
@@ -1686,18 +2108,63 @@ function adoptPersistedAnnotations(sessionId) {
|
|
|
1686
2108
|
if (orphaned.length > 0) bySession.delete(MEMORY_KEY);
|
|
1687
2109
|
notify();
|
|
1688
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
|
+
}
|
|
1689
2156
|
function annotationTitle(annotation) {
|
|
1690
2157
|
return previewTitle(annotation.text);
|
|
1691
2158
|
}
|
|
1692
2159
|
//#endregion
|
|
1693
2160
|
//#region src/client/dom/popoverPlacement.ts
|
|
1694
|
-
const GAP = 8;
|
|
2161
|
+
const GAP$1 = 8;
|
|
1695
2162
|
const MARGIN = 8;
|
|
1696
2163
|
/**
|
|
1697
2164
|
* Place `panel` near `anchor`, flipping vertical side when the preferred side
|
|
1698
2165
|
* lacks room. Keeps the panel inside `bounds` (the viewport by default).
|
|
1699
2166
|
*/
|
|
1700
|
-
function placePopover(anchor, panel, preferred = "bottom", gap = GAP, bounds) {
|
|
2167
|
+
function placePopover(anchor, panel, preferred = "bottom", gap = GAP$1, bounds) {
|
|
1701
2168
|
const box = bounds ?? {
|
|
1702
2169
|
top: 0,
|
|
1703
2170
|
left: 0,
|
|
@@ -1784,6 +2251,7 @@ const zh = {
|
|
|
1784
2251
|
"common.selectedText": "选中的文本",
|
|
1785
2252
|
"common.commentPlaceholder": "添加可选评论…",
|
|
1786
2253
|
"common.cancel": "取消",
|
|
2254
|
+
"common.close": "关闭",
|
|
1787
2255
|
"common.save": "保存",
|
|
1788
2256
|
"toolbar.aria": "划词操作",
|
|
1789
2257
|
"toolbar.commentHint": "添加批注…",
|
|
@@ -1795,20 +2263,25 @@ const zh = {
|
|
|
1795
2263
|
"toolbar.skipComment": "暂不评论",
|
|
1796
2264
|
"toolbar.addComment": "添加评论",
|
|
1797
2265
|
"summary.hint": "悬停查看批注",
|
|
2266
|
+
"summary.hintTouch": "点按查看批注",
|
|
1798
2267
|
"summary.dialogAria": "{count} 条划词批注详情",
|
|
1799
2268
|
"summary.edit": "编辑批注 {index}",
|
|
1800
2269
|
"summary.remove": "移除批注 {index}",
|
|
1801
2270
|
"summary.userComment": "用户评论",
|
|
1802
2271
|
"summary.noComment": "未添加评论",
|
|
1803
|
-
"summary.aria.other": "{count}
|
|
2272
|
+
"summary.aria.other": "{count} 条划词批注。展开查看详情。",
|
|
1804
2273
|
"summary.title.other": "{count} 条批注",
|
|
1805
2274
|
"summary.removeAll": "移除全部划词批注",
|
|
1806
|
-
"chip.annotation": "批注 {index}"
|
|
2275
|
+
"chip.annotation": "批注 {index}",
|
|
2276
|
+
"chip.previewAria": "批注预览",
|
|
2277
|
+
"chip.jump": "原文",
|
|
2278
|
+
"chip.jumpAria": "跳转到原文"
|
|
1807
2279
|
};
|
|
1808
2280
|
const en = {
|
|
1809
2281
|
"common.selectedText": "Selected text",
|
|
1810
2282
|
"common.commentPlaceholder": "Add an optional comment…",
|
|
1811
2283
|
"common.cancel": "Cancel",
|
|
2284
|
+
"common.close": "Close",
|
|
1812
2285
|
"common.save": "Save",
|
|
1813
2286
|
"toolbar.aria": "Selection actions",
|
|
1814
2287
|
"toolbar.commentHint": "Add annotation…",
|
|
@@ -1820,17 +2293,21 @@ const en = {
|
|
|
1820
2293
|
"toolbar.skipComment": "Skip comment",
|
|
1821
2294
|
"toolbar.addComment": "Add comment",
|
|
1822
2295
|
"summary.hint": "Hover to view annotations",
|
|
2296
|
+
"summary.hintTouch": "Tap to view annotations",
|
|
1823
2297
|
"summary.dialogAria": "Annotation details: {count}",
|
|
1824
2298
|
"summary.edit": "Edit annotation {index}",
|
|
1825
2299
|
"summary.remove": "Remove annotation {index}",
|
|
1826
2300
|
"summary.userComment": "User comment",
|
|
1827
2301
|
"summary.noComment": "No comment added",
|
|
1828
|
-
"summary.aria.one": "{count} annotation.
|
|
1829
|
-
"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.",
|
|
1830
2304
|
"summary.title.one": "{count} annotation",
|
|
1831
2305
|
"summary.title.other": "{count} annotations",
|
|
1832
2306
|
"summary.removeAll": "Remove all annotations",
|
|
1833
|
-
"chip.annotation": "Annotation {index}"
|
|
2307
|
+
"chip.annotation": "Annotation {index}",
|
|
2308
|
+
"chip.previewAria": "Annotation preview",
|
|
2309
|
+
"chip.jump": "Original",
|
|
2310
|
+
"chip.jumpAria": "Jump to original"
|
|
1834
2311
|
};
|
|
1835
2312
|
/**
|
|
1836
2313
|
* Russian is not a shipped host locale: it is added through
|
|
@@ -1841,6 +2318,7 @@ const ru = {
|
|
|
1841
2318
|
"common.selectedText": "Выделенный текст",
|
|
1842
2319
|
"common.commentPlaceholder": "Добавить необязательный комментарий…",
|
|
1843
2320
|
"common.cancel": "Отмена",
|
|
2321
|
+
"common.close": "Закрыть",
|
|
1844
2322
|
"common.save": "Сохранить",
|
|
1845
2323
|
"toolbar.aria": "Действия с выделенным текстом",
|
|
1846
2324
|
"toolbar.commentHint": "Добавить аннотацию…",
|
|
@@ -1852,21 +2330,25 @@ const ru = {
|
|
|
1852
2330
|
"toolbar.skipComment": "Без комментария",
|
|
1853
2331
|
"toolbar.addComment": "Добавить комментарий",
|
|
1854
2332
|
"summary.hint": "Наведите, чтобы увидеть аннотации",
|
|
2333
|
+
"summary.hintTouch": "Нажмите, чтобы увидеть аннотации",
|
|
1855
2334
|
"summary.dialogAria": "Детали аннотаций: {count}",
|
|
1856
2335
|
"summary.edit": "Изменить аннотацию {index}",
|
|
1857
2336
|
"summary.remove": "Удалить аннотацию {index}",
|
|
1858
2337
|
"summary.userComment": "Комментарий пользователя",
|
|
1859
2338
|
"summary.noComment": "Комментарий не добавлен",
|
|
1860
|
-
"summary.aria.one": "{count} аннотация.
|
|
1861
|
-
"summary.aria.few": "{count} аннотации.
|
|
1862
|
-
"summary.aria.many": "{count} аннотаций.
|
|
1863
|
-
"summary.aria.other": "{count} аннотаций.
|
|
2339
|
+
"summary.aria.one": "{count} аннотация. Откройте, чтобы увидеть подробности.",
|
|
2340
|
+
"summary.aria.few": "{count} аннотации. Откройте, чтобы увидеть подробности.",
|
|
2341
|
+
"summary.aria.many": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
|
|
2342
|
+
"summary.aria.other": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
|
|
1864
2343
|
"summary.title.one": "{count} аннотация",
|
|
1865
2344
|
"summary.title.few": "{count} аннотации",
|
|
1866
2345
|
"summary.title.many": "{count} аннотаций",
|
|
1867
2346
|
"summary.title.other": "{count} аннотаций",
|
|
1868
2347
|
"summary.removeAll": "Удалить все аннотации",
|
|
1869
|
-
"chip.annotation": "Аннотация {index}"
|
|
2348
|
+
"chip.annotation": "Аннотация {index}",
|
|
2349
|
+
"chip.previewAria": "Просмотр аннотации",
|
|
2350
|
+
"chip.jump": "Оригинал",
|
|
2351
|
+
"chip.jumpAria": "Перейти к оригиналу"
|
|
1870
2352
|
};
|
|
1871
2353
|
/** Both shipped host locales, required together by the typed `register()`. */
|
|
1872
2354
|
const SHIPPED_DICTS = Object.freeze({
|
|
@@ -1948,10 +2430,18 @@ function useLocale() {
|
|
|
1948
2430
|
}
|
|
1949
2431
|
//#endregion
|
|
1950
2432
|
//#region src/client/ui/AnnotationCardView.tsx
|
|
1951
|
-
/**
|
|
1952
|
-
const CLOSE_DELAY =
|
|
2433
|
+
/** Short grace for crossing the 12px bridge; the exit starts right after it. */
|
|
2434
|
+
const CLOSE_DELAY$1 = 40;
|
|
1953
2435
|
/** After a viewport change, wait this long before position-checking the pointer. */
|
|
1954
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
|
+
}
|
|
1955
2445
|
/**
|
|
1956
2446
|
* Qoder-style summary with a placement-aware hover list.
|
|
1957
2447
|
*
|
|
@@ -1971,21 +2461,31 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
1971
2461
|
const settleTimerRef = (0, react.useRef)(null);
|
|
1972
2462
|
const lastPointerRef = (0, react.useRef)(null);
|
|
1973
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)();
|
|
1974
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);
|
|
1975
2473
|
const [editing, setEditing] = (0, react.useState)(null);
|
|
1976
2474
|
const [editDraft, setEditDraft] = (0, react.useState)("");
|
|
1977
2475
|
const place = (0, react.useCallback)(() => {
|
|
1978
2476
|
const root = rootRef.current;
|
|
1979
2477
|
const pop = popRef.current;
|
|
1980
2478
|
if (!root || !pop) return;
|
|
1981
|
-
const
|
|
1982
|
-
|
|
1983
|
-
|
|
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
|
|
1984
2484
|
}, "top", 0);
|
|
1985
2485
|
pop.style.top = `${top}px`;
|
|
1986
2486
|
pop.style.left = `${left}px`;
|
|
1987
|
-
pop.style.paddingTop = side === "bottom" ?
|
|
1988
|
-
pop.style.paddingBottom = side === "top" ?
|
|
2487
|
+
pop.style.paddingTop = side === "bottom" ? `${BRIDGE}px` : "0";
|
|
2488
|
+
pop.style.paddingBottom = side === "top" ? `${BRIDGE}px` : "0";
|
|
1989
2489
|
pop.dataset.side = side;
|
|
1990
2490
|
pop.style.opacity = "1";
|
|
1991
2491
|
}, []);
|
|
@@ -2003,11 +2503,81 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2003
2503
|
cancelClose();
|
|
2004
2504
|
setOpen(false);
|
|
2005
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]);
|
|
2006
2529
|
const scheduleClose = (0, react.useCallback)(() => {
|
|
2007
2530
|
cancelClose();
|
|
2008
2531
|
if (editing !== null) return;
|
|
2009
|
-
closeTimerRef.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY);
|
|
2532
|
+
closeTimerRef.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY$1);
|
|
2010
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]);
|
|
2011
2581
|
/** True when the last seen pointer sits on the summary or the panel. */
|
|
2012
2582
|
const pointerInsideCluster = (0, react.useCallback)(() => {
|
|
2013
2583
|
const point = lastPointerRef.current;
|
|
@@ -2030,6 +2600,11 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2030
2600
|
items,
|
|
2031
2601
|
editing
|
|
2032
2602
|
]);
|
|
2603
|
+
(0, react.useLayoutEffect)(() => {
|
|
2604
|
+
if (!open || !enterPanelRef.current) return;
|
|
2605
|
+
enterPanelRef.current = false;
|
|
2606
|
+
focusPanel("first");
|
|
2607
|
+
}, [open, focusPanel]);
|
|
2033
2608
|
(0, react.useEffect)(() => {
|
|
2034
2609
|
if (!open) return;
|
|
2035
2610
|
const track = (event) => {
|
|
@@ -2084,31 +2659,98 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2084
2659
|
const onPointerEnter = (0, react.useCallback)(() => {
|
|
2085
2660
|
openPop();
|
|
2086
2661
|
}, [openPop]);
|
|
2087
|
-
const onPointerLeave = (0, react.useCallback)(() => {
|
|
2088
|
-
scheduleClose();
|
|
2089
|
-
}, [scheduleClose]);
|
|
2090
2662
|
const onBlur = (0, react.useCallback)((e) => {
|
|
2663
|
+
if (editing !== null) return;
|
|
2091
2664
|
const next = e.relatedTarget;
|
|
2092
2665
|
if (next instanceof Node) {
|
|
2093
2666
|
if (rootRef.current?.contains(next)) return;
|
|
2094
2667
|
if (popRef.current?.contains(next)) return;
|
|
2095
2668
|
}
|
|
2096
2669
|
closePop();
|
|
2097
|
-
}, [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]);
|
|
2098
2676
|
const onKeyDown = (0, react.useCallback)((e) => {
|
|
2099
2677
|
if (e.key === "Escape") {
|
|
2678
|
+
e.preventDefault();
|
|
2100
2679
|
if (editing !== null) setEditing(null);
|
|
2101
|
-
else
|
|
2680
|
+
else closeAndRestoreFocus();
|
|
2681
|
+
return;
|
|
2102
2682
|
}
|
|
2103
|
-
|
|
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();
|
|
2712
|
+
if (editing !== null) setEditing(null);
|
|
2713
|
+
else closeAndRestoreFocus();
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
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
|
+
]);
|
|
2104
2743
|
const startEdit = (0, react.useCallback)((index) => {
|
|
2105
|
-
|
|
2106
|
-
|
|
2744
|
+
const item = items[index];
|
|
2745
|
+
if (!item) return;
|
|
2746
|
+
setEditing(editKeyOf(item, index));
|
|
2747
|
+
setEditDraft(item.comment ?? "");
|
|
2107
2748
|
}, [items]);
|
|
2108
2749
|
const commitEdit = (0, react.useCallback)(() => {
|
|
2109
2750
|
if (editing === null) return;
|
|
2110
|
-
const
|
|
2111
|
-
|
|
2751
|
+
const index = items.findIndex((item, i) => editKeyOf(item, i) === editing);
|
|
2752
|
+
const item = items[index];
|
|
2753
|
+
if (item) onItemEdit?.(index, item, editDraft);
|
|
2112
2754
|
setEditing(null);
|
|
2113
2755
|
}, [
|
|
2114
2756
|
editing,
|
|
@@ -2116,19 +2758,22 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2116
2758
|
editDraft,
|
|
2117
2759
|
onItemEdit
|
|
2118
2760
|
]);
|
|
2119
|
-
const panel = open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2761
|
+
const panel = open || renderPanel ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2120
2762
|
ref: popRef,
|
|
2121
|
-
|
|
2763
|
+
id: panelId,
|
|
2764
|
+
className: cx(styles.summaryHover, styles.summaryHoverOpen, closingPanel && !open && styles.summaryHoverLeaving),
|
|
2122
2765
|
role: "dialog",
|
|
2123
2766
|
"aria-label": t("summary.dialogAria", { count }),
|
|
2124
2767
|
onPointerEnter: openPop,
|
|
2125
2768
|
onPointerLeave,
|
|
2769
|
+
onKeyDown: onPanelKeyDown,
|
|
2126
2770
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2127
2771
|
className: cx(A.col, styles.summaryHoverInner),
|
|
2128
2772
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2129
2773
|
className: cx(A.col, styles.summaryList),
|
|
2130
2774
|
children: items.map((item, i) => {
|
|
2131
|
-
const
|
|
2775
|
+
const itemKey = editKeyOf(item, i);
|
|
2776
|
+
const isEditing = editing === itemKey;
|
|
2132
2777
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2133
2778
|
className: styles.summaryItem,
|
|
2134
2779
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -2164,7 +2809,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2164
2809
|
className: styles.summaryItemMain,
|
|
2165
2810
|
children: [
|
|
2166
2811
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2167
|
-
className: styles.
|
|
2812
|
+
className: cx(styles.summaryItemText, styles.summaryItemTextEdit),
|
|
2168
2813
|
children: item.text
|
|
2169
2814
|
}),
|
|
2170
2815
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -2180,6 +2825,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2180
2825
|
onKeyDown: (e) => {
|
|
2181
2826
|
if (e.key === "Escape") {
|
|
2182
2827
|
e.preventDefault();
|
|
2828
|
+
e.stopPropagation();
|
|
2183
2829
|
setEditing(null);
|
|
2184
2830
|
return;
|
|
2185
2831
|
}
|
|
@@ -2198,7 +2844,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2198
2844
|
children: t("common.cancel")
|
|
2199
2845
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2200
2846
|
type: "button",
|
|
2201
|
-
className: styles.annEditSave,
|
|
2847
|
+
className: cx(A.plain, styles.annEditSave),
|
|
2202
2848
|
onClick: commitEdit,
|
|
2203
2849
|
children: t("common.save")
|
|
2204
2850
|
})]
|
|
@@ -2241,9 +2887,12 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2241
2887
|
"data-selection-annotation-summary": "true",
|
|
2242
2888
|
"aria-label": t("summary.aria", { count }),
|
|
2243
2889
|
"aria-expanded": open,
|
|
2890
|
+
"aria-haspopup": "dialog",
|
|
2891
|
+
"aria-controls": open ? panelId : void 0,
|
|
2244
2892
|
onPointerEnter,
|
|
2245
2893
|
onPointerLeave,
|
|
2246
|
-
|
|
2894
|
+
onPointerUp: onSummaryPointerUp,
|
|
2895
|
+
onFocus: onSummaryFocus,
|
|
2247
2896
|
onBlur,
|
|
2248
2897
|
onKeyDown,
|
|
2249
2898
|
children: [
|
|
@@ -2254,13 +2903,20 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2254
2903
|
}),
|
|
2255
2904
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2256
2905
|
className: cx(A.grow, styles.summaryBody),
|
|
2257
|
-
children: [
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
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
|
+
]
|
|
2264
2920
|
}),
|
|
2265
2921
|
onRemoveAll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2266
2922
|
type: "button",
|
|
@@ -2321,8 +2977,7 @@ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded, foldingRef
|
|
|
2321
2977
|
onFolded();
|
|
2322
2978
|
};
|
|
2323
2979
|
const onKeyDown = (event) => {
|
|
2324
|
-
if (event
|
|
2325
|
-
if (event.isComposing) return;
|
|
2980
|
+
if (!isSubmitKey(event)) return;
|
|
2326
2981
|
const target = event.target;
|
|
2327
2982
|
if (!(target instanceof HTMLElement) || !target.isContentEditable) return;
|
|
2328
2983
|
const card = composerCardOf(target);
|
|
@@ -2545,10 +3200,35 @@ const QuoteCard = AnnotationPanel;
|
|
|
2545
3200
|
const HOST_ATTR = "data-dsq-layer";
|
|
2546
3201
|
/** Below the composer seat (sticky, z-index 7); above in-content stickies (6). */
|
|
2547
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;
|
|
2548
3209
|
const layers = /* @__PURE__ */ new WeakMap();
|
|
2549
3210
|
/** flow item → layer, so scrolling does not re-walk the ancestor chain. */
|
|
2550
3211
|
const itemLayers = /* @__PURE__ */ new WeakMap();
|
|
2551
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
|
+
}
|
|
2552
3232
|
/**
|
|
2553
3233
|
* Top edge of the composer seat, in viewport coordinates.
|
|
2554
3234
|
*
|
|
@@ -2558,11 +3238,8 @@ let fallback = null;
|
|
|
2558
3238
|
* parks popovers under the input box — the bottom edge must be the composer.
|
|
2559
3239
|
* In the regular layout the seat sits below the scroller, so it changes nothing.
|
|
2560
3240
|
*/
|
|
2561
|
-
function composerTop() {
|
|
2562
|
-
|
|
2563
|
-
const seat = document.querySelector("[data-composer-seat]") ?? document.querySelector("[data-composer-card]");
|
|
2564
|
-
if (seat === null) return null;
|
|
2565
|
-
return seat.getBoundingClientRect().top;
|
|
3241
|
+
function composerTop(scroller) {
|
|
3242
|
+
return composerSeatFor(scroller)?.getBoundingClientRect().top ?? null;
|
|
2566
3243
|
}
|
|
2567
3244
|
function contentCoords(scroller, left, top) {
|
|
2568
3245
|
const rect = scroller.getBoundingClientRect();
|
|
@@ -2589,7 +3266,7 @@ function layerFor(scroller) {
|
|
|
2589
3266
|
const rect = scroller.getBoundingClientRect();
|
|
2590
3267
|
const top = rect.top + scroller.clientTop;
|
|
2591
3268
|
const rawBottom = top + scroller.clientHeight;
|
|
2592
|
-
const cover = composerTop();
|
|
3269
|
+
const cover = composerTop(scroller);
|
|
2593
3270
|
const bottom = cover !== null && cover > top && cover < rawBottom ? cover : rawBottom;
|
|
2594
3271
|
return new DOMRect(rect.left + scroller.clientLeft, top, scroller.clientWidth, Math.max(0, bottom - top));
|
|
2595
3272
|
},
|
|
@@ -2600,6 +3277,9 @@ function layerFor(scroller) {
|
|
|
2600
3277
|
left: left + rect.left + scroller.clientLeft - scroller.scrollLeft,
|
|
2601
3278
|
top: top + rect.top + scroller.clientTop - scroller.scrollTop
|
|
2602
3279
|
};
|
|
3280
|
+
},
|
|
3281
|
+
setAboveComposer: (above) => {
|
|
3282
|
+
host.style.zIndex = String(above ? HOST_Z_ABOVE_COMPOSER : HOST_Z);
|
|
2603
3283
|
}
|
|
2604
3284
|
};
|
|
2605
3285
|
layers.set(scroller, layer);
|
|
@@ -2616,7 +3296,7 @@ function fallbackLayer() {
|
|
|
2616
3296
|
host,
|
|
2617
3297
|
scroller: document.body,
|
|
2618
3298
|
bounds: () => {
|
|
2619
|
-
const bottom = composerTop() ?? window.innerHeight;
|
|
3299
|
+
const bottom = composerTop(null) ?? window.innerHeight;
|
|
2620
3300
|
return new DOMRect(0, 0, window.innerWidth, Math.max(0, bottom));
|
|
2621
3301
|
},
|
|
2622
3302
|
toContent: (left, top) => ({
|
|
@@ -2626,7 +3306,10 @@ function fallbackLayer() {
|
|
|
2626
3306
|
fromContent: (left, top) => ({
|
|
2627
3307
|
left,
|
|
2628
3308
|
top
|
|
2629
|
-
})
|
|
3309
|
+
}),
|
|
3310
|
+
setAboveComposer: (above) => {
|
|
3311
|
+
host.style.zIndex = String(above ? HOST_Z_ABOVE_COMPOSER : HOST_Z);
|
|
3312
|
+
}
|
|
2630
3313
|
};
|
|
2631
3314
|
return fallback;
|
|
2632
3315
|
}
|
|
@@ -2811,6 +3494,69 @@ function linkTarget(text, anchorHref) {
|
|
|
2811
3494
|
return selectionUrl(text) ?? (anchorHref ? selectionUrl(anchorHref) : null);
|
|
2812
3495
|
}
|
|
2813
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
|
|
2814
3560
|
//#region src/client/SelectionToolbar.tsx
|
|
2815
3561
|
/** Cap for the auto-growing comment box; beyond this it scrolls internally. */
|
|
2816
3562
|
const COMMENT_MAX_HEIGHT = 220;
|
|
@@ -2827,6 +3573,8 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2827
3573
|
activeRef.current = active;
|
|
2828
3574
|
/** Annotation saved when the comment step opened; the box only edits it. */
|
|
2829
3575
|
const pendingIdRef = (0, react.useRef)(null);
|
|
3576
|
+
const touch = useCoarsePointer();
|
|
3577
|
+
const viewport = useVisualViewport(touch);
|
|
2830
3578
|
(0, react.useEffect)(() => {
|
|
2831
3579
|
ensureToolbarStyles();
|
|
2832
3580
|
if (sessionId) bindAnnotationSession(sessionId);
|
|
@@ -2944,6 +3692,7 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2944
3692
|
refresh
|
|
2945
3693
|
]);
|
|
2946
3694
|
const placeCommentPop = (0, react.useCallback)(() => {
|
|
3695
|
+
if (touch) return;
|
|
2947
3696
|
const snapshot = activeRef.current;
|
|
2948
3697
|
const layer = snapshot?.layer;
|
|
2949
3698
|
const toolbar = toolbarRef.current;
|
|
@@ -2988,33 +3737,46 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2988
3737
|
const toolbarAt = layer.toContent(leftView, topView);
|
|
2989
3738
|
toolbar.style.left = `${toolbarAt.left}px`;
|
|
2990
3739
|
toolbar.style.top = `${toolbarAt.top}px`;
|
|
2991
|
-
}, []);
|
|
3740
|
+
}, [touch]);
|
|
2992
3741
|
(0, react.useLayoutEffect)(() => {
|
|
2993
|
-
if (phase === "comment") placeCommentPop();
|
|
3742
|
+
if (phase === "comment" && !touch) placeCommentPop();
|
|
2994
3743
|
}, [
|
|
2995
3744
|
active,
|
|
2996
3745
|
phase,
|
|
2997
|
-
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
|
|
2998
3758
|
]);
|
|
2999
3759
|
(0, react.useEffect)(() => {
|
|
3000
|
-
if (phase === "comment") commentRef.current?.focus();
|
|
3001
|
-
}, [phase]);
|
|
3760
|
+
if (phase === "comment" && !touch) commentRef.current?.focus();
|
|
3761
|
+
}, [phase, touch]);
|
|
3002
3762
|
(0, react.useEffect)(() => {
|
|
3763
|
+
if (touch) return;
|
|
3003
3764
|
const onResize = () => placeCommentPop();
|
|
3004
3765
|
window.addEventListener("resize", onResize);
|
|
3005
3766
|
return () => window.removeEventListener("resize", onResize);
|
|
3006
|
-
}, [placeCommentPop]);
|
|
3767
|
+
}, [placeCommentPop, touch]);
|
|
3007
3768
|
(0, react.useLayoutEffect)(() => {
|
|
3008
3769
|
if (phase !== "comment") return;
|
|
3009
3770
|
const area = commentRef.current;
|
|
3010
3771
|
if (!area) return;
|
|
3011
3772
|
area.style.height = "auto";
|
|
3012
3773
|
area.style.height = `${Math.min(area.scrollHeight, COMMENT_MAX_HEIGHT)}px`;
|
|
3013
|
-
placeCommentPop();
|
|
3774
|
+
if (!touch) placeCommentPop();
|
|
3014
3775
|
}, [
|
|
3015
3776
|
phase,
|
|
3016
3777
|
comment,
|
|
3017
|
-
placeCommentPop
|
|
3778
|
+
placeCommentPop,
|
|
3779
|
+
touch
|
|
3018
3780
|
]);
|
|
3019
3781
|
const handleToolbarPointerDown = (0, react.useCallback)((event) => {
|
|
3020
3782
|
event.preventDefault();
|
|
@@ -3066,14 +3828,147 @@ function SelectionToolbar({ sessionId }) {
|
|
|
3066
3828
|
else pendingIdRef.current = null;
|
|
3067
3829
|
clearNativeSelection();
|
|
3068
3830
|
hide();
|
|
3069
|
-
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();
|
|
3070
3841
|
}, [flushComment, hide]);
|
|
3071
3842
|
if (!active) return null;
|
|
3072
3843
|
const toolbarStyle = {
|
|
3073
3844
|
left: active.left,
|
|
3074
3845
|
top: active.top
|
|
3075
3846
|
};
|
|
3076
|
-
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
|
+
}
|
|
3077
3972
|
return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
3078
3973
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3079
3974
|
ref: toolbarRef,
|
|
@@ -3095,45 +3990,9 @@ function SelectionToolbar({ sessionId }) {
|
|
|
3095
3990
|
},
|
|
3096
3991
|
children: t("toolbar.commentHint")
|
|
3097
3992
|
})
|
|
3098
|
-
}) : /* @__PURE__ */ (0, react_jsx_runtime.
|
|
3993
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3099
3994
|
className: cx(A.inlineRow, styles.toolbarInner),
|
|
3100
|
-
children:
|
|
3101
|
-
link ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
3102
|
-
type: "button",
|
|
3103
|
-
className: cx(A.plain, A.inlineRow, styles.button),
|
|
3104
|
-
onClick: handleOpenLink,
|
|
3105
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
|
|
3106
|
-
name: "external-link",
|
|
3107
|
-
size: 14
|
|
3108
|
-
}), t("toolbar.openLink")]
|
|
3109
|
-
}) : null,
|
|
3110
|
-
link ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3111
|
-
className: styles.divider,
|
|
3112
|
-
"aria-hidden": "true"
|
|
3113
|
-
}) : null,
|
|
3114
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
3115
|
-
type: "button",
|
|
3116
|
-
className: cx(A.plain, A.inlineRow, styles.button),
|
|
3117
|
-
onClick: () => void handleCopy(),
|
|
3118
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
|
|
3119
|
-
name: "copy",
|
|
3120
|
-
size: 14
|
|
3121
|
-
}), t("toolbar.copy")]
|
|
3122
|
-
}),
|
|
3123
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3124
|
-
className: styles.divider,
|
|
3125
|
-
"aria-hidden": "true"
|
|
3126
|
-
}),
|
|
3127
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
3128
|
-
type: "button",
|
|
3129
|
-
className: cx(A.plain, A.inlineRow, styles.button),
|
|
3130
|
-
onClick: openComment,
|
|
3131
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
|
|
3132
|
-
name: "plus",
|
|
3133
|
-
size: 14
|
|
3134
|
-
}), t("toolbar.addToTask")]
|
|
3135
|
-
})
|
|
3136
|
-
]
|
|
3995
|
+
children: actionRow(false)
|
|
3137
3996
|
})
|
|
3138
3997
|
}),
|
|
3139
3998
|
phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -3142,39 +4001,7 @@ function SelectionToolbar({ sessionId }) {
|
|
|
3142
4001
|
role: "dialog",
|
|
3143
4002
|
"aria-label": t("toolbar.commentDialogAria"),
|
|
3144
4003
|
onPointerDown: handleToolbarPointerDown,
|
|
3145
|
-
children: [
|
|
3146
|
-
ref: commentRef,
|
|
3147
|
-
className: cx(A.box, styles.commentInput),
|
|
3148
|
-
placeholder: t("common.commentPlaceholder"),
|
|
3149
|
-
value: comment,
|
|
3150
|
-
onChange: (e) => setComment(e.target.value),
|
|
3151
|
-
onKeyDown: (e) => {
|
|
3152
|
-
if (e.key === "Escape") {
|
|
3153
|
-
e.preventDefault();
|
|
3154
|
-
pendingIdRef.current = null;
|
|
3155
|
-
hide();
|
|
3156
|
-
return;
|
|
3157
|
-
}
|
|
3158
|
-
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
3159
|
-
e.preventDefault();
|
|
3160
|
-
finishComment(true);
|
|
3161
|
-
}
|
|
3162
|
-
}
|
|
3163
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3164
|
-
className: styles.commentActions,
|
|
3165
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3166
|
-
type: "button",
|
|
3167
|
-
className: cx(A.plain, A.inlineRow, styles.button),
|
|
3168
|
-
onClick: () => finishComment(false),
|
|
3169
|
-
children: t("toolbar.skipComment")
|
|
3170
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3171
|
-
type: "button",
|
|
3172
|
-
className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
|
|
3173
|
-
disabled: !comment.trim(),
|
|
3174
|
-
onClick: () => finishComment(true),
|
|
3175
|
-
children: t("toolbar.addComment")
|
|
3176
|
-
})]
|
|
3177
|
-
})]
|
|
4004
|
+
children: [commentField, commentActions]
|
|
3178
4005
|
}) : null,
|
|
3179
4006
|
status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3180
4007
|
className: styles.status,
|
|
@@ -3185,6 +4012,423 @@ function SelectionToolbar({ sessionId }) {
|
|
|
3185
4012
|
] }), active.layer.host);
|
|
3186
4013
|
}
|
|
3187
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
|
|
3188
4432
|
//#region src/client/dom/decorateAnnotationDirectives.ts
|
|
3189
4433
|
/**
|
|
3190
4434
|
* Turn model-written `:dsh-annotation{index="N"}` into clickable chips.
|
|
@@ -3197,7 +4441,7 @@ function decorateTextNode(node) {
|
|
|
3197
4441
|
if (!value || !value.includes(":dsh-annotation{")) return;
|
|
3198
4442
|
const parent = node.parentElement;
|
|
3199
4443
|
if (!parent || parent.closest(`[data-${DECO}]`)) return;
|
|
3200
|
-
if (parent.closest("pre, code, [data-dsq-toolbar]")) return;
|
|
4444
|
+
if (parent.closest("pre, code, [data-dsq-toolbar], [data-dsq-chip-preview]")) return;
|
|
3201
4445
|
if (parent.closest("textarea, input, [data-lexical-editor=\"true\"]")) return;
|
|
3202
4446
|
const frag = document.createDocumentFragment();
|
|
3203
4447
|
let last = 0;
|
|
@@ -3214,10 +4458,7 @@ function decorateTextNode(node) {
|
|
|
3214
4458
|
chip.setAttribute("data-dsq-deco", "true");
|
|
3215
4459
|
chip.dataset[DECO_KEY] = String(index);
|
|
3216
4460
|
chip.textContent = t("chip.annotation", { index });
|
|
3217
|
-
chip
|
|
3218
|
-
const mark = findAnnotationMarkBefore(index, chip);
|
|
3219
|
-
if (mark) flashAnnotationMark(mark);
|
|
3220
|
-
});
|
|
4461
|
+
bindChipPreview(chip, index);
|
|
3221
4462
|
frag.append(chip);
|
|
3222
4463
|
last = m.index + m[0].length;
|
|
3223
4464
|
}
|
|
@@ -3247,6 +4488,7 @@ function decorateAnnotationDirectives(root) {
|
|
|
3247
4488
|
* literal, so `decorateTextNode()` returns before it can touch the DOM.
|
|
3248
4489
|
*/
|
|
3249
4490
|
function refreshDirectiveChips(root) {
|
|
4491
|
+
closeChipPreview(false);
|
|
3250
4492
|
const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
|
|
3251
4493
|
if (!el) return;
|
|
3252
4494
|
for (const chip of el.querySelectorAll(`[data-${DECO}]`)) {
|
|
@@ -3279,66 +4521,142 @@ function watchAnnotationDirectives(root) {
|
|
|
3279
4521
|
//#endregion
|
|
3280
4522
|
//#region src/client/dom/stripSubmissionEcho.ts
|
|
3281
4523
|
/**
|
|
3282
|
-
* Strip the annotation protocol from
|
|
4524
|
+
* Strip the annotation protocol from host surfaces that paint the raw draft.
|
|
3283
4525
|
*
|
|
3284
4526
|
* On submit the host paints an optimistic bubble (`[data-submission-echo]`)
|
|
3285
4527
|
* from the composer draft — which, at that instant, is the protocol block the
|
|
3286
|
-
* send gesture just folded in.
|
|
3287
|
-
*
|
|
3288
|
-
*
|
|
3289
|
-
*
|
|
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.
|
|
3290
4534
|
*
|
|
3291
4535
|
* The durable message keeps the block (the model needs it). This is display
|
|
3292
|
-
* only: the echo's
|
|
3293
|
-
*
|
|
3294
|
-
*
|
|
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.
|
|
3295
4540
|
*/
|
|
3296
4541
|
const ECHO_SELECTOR = "[data-submission-echo]";
|
|
3297
|
-
const
|
|
3298
|
-
|
|
3299
|
-
function
|
|
3300
|
-
|
|
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("*"))) {
|
|
3301
4552
|
if (el.children.length > 0) continue;
|
|
3302
|
-
|
|
3303
|
-
if (text.includes("<response-annotations>") && text.includes("</response-annotations>")) return el;
|
|
4553
|
+
if (carriesProtocol(el.textContent ?? "")) runs.push(el);
|
|
3304
4554
|
}
|
|
3305
|
-
return
|
|
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));
|
|
3306
4586
|
}
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
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;
|
|
3315
4602
|
}
|
|
3316
4603
|
/**
|
|
3317
|
-
* Watch for
|
|
3318
|
-
* them. Runs once immediately, then on
|
|
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.
|
|
3319
4607
|
*/
|
|
3320
4608
|
function watchSubmissionEcho(root) {
|
|
3321
4609
|
const scope = root ?? (typeof document === "undefined" ? void 0 : document.body);
|
|
3322
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
|
+
};
|
|
3323
4616
|
const sweep = () => {
|
|
3324
|
-
for (const
|
|
4617
|
+
for (const surface of Array.from(scope.querySelectorAll(SURFACE_SELECTOR))) stripSurface(surface);
|
|
3325
4618
|
};
|
|
3326
4619
|
sweep();
|
|
3327
4620
|
if (typeof MutationObserver === "undefined") return () => {};
|
|
4621
|
+
const pending = /* @__PURE__ */ new Set();
|
|
3328
4622
|
let frame = 0;
|
|
3329
|
-
const
|
|
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;
|
|
3330
4645
|
if (frame !== 0) return;
|
|
3331
4646
|
frame = window.requestAnimationFrame(() => {
|
|
3332
4647
|
frame = 0;
|
|
3333
|
-
|
|
4648
|
+
for (const surface of pending) stripSurface(surface);
|
|
4649
|
+
pending.clear();
|
|
3334
4650
|
});
|
|
3335
4651
|
});
|
|
3336
4652
|
observer.observe(scope, {
|
|
3337
4653
|
childList: true,
|
|
3338
|
-
subtree: true
|
|
4654
|
+
subtree: true,
|
|
4655
|
+
characterData: true
|
|
3339
4656
|
});
|
|
3340
4657
|
return () => {
|
|
3341
4658
|
observer.disconnect();
|
|
4659
|
+
pending.clear();
|
|
3342
4660
|
if (frame !== 0) window.cancelAnimationFrame(frame);
|
|
3343
4661
|
};
|
|
3344
4662
|
}
|
|
@@ -3370,6 +4688,12 @@ function TranscriptQuoteCard({ node }) {
|
|
|
3370
4688
|
endOffset: quote.endOffset
|
|
3371
4689
|
})));
|
|
3372
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]);
|
|
3373
4697
|
const markIdOf = (0, react.useCallback)((index) => {
|
|
3374
4698
|
const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
|
|
3375
4699
|
return ownerKey ? `dqsm_${ownerKey}_${index}` : void 0;
|
|
@@ -3438,9 +4762,9 @@ function imageAttachments(raw) {
|
|
|
3438
4762
|
* block is removed from the text blocks, everything else (attachments, file
|
|
3439
4763
|
* cards, reference labels, time, copy, and any plugin that portals into the
|
|
3440
4764
|
* actions row, e.g. dsh-rewind's ↶) stays host-owned and follows host changes.
|
|
3441
|
-
* The annotation
|
|
4765
|
+
* The annotation summary is the only extra DOM, prepended above the host row.
|
|
3442
4766
|
*
|
|
3443
|
-
*
|
|
4767
|
+
* The summary must live inside the `user` node view rather than on a
|
|
3444
4768
|
* `select-quote` Chat node: `conversation.chat.node` is gated by the shipped
|
|
3445
4769
|
* `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
|
|
3446
4770
|
* turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail), and any
|
|
@@ -3448,10 +4772,10 @@ function imageAttachments(raw) {
|
|
|
3448
4772
|
*/
|
|
3449
4773
|
function UserMessageDisplay(props) {
|
|
3450
4774
|
ensureToolbarStyles();
|
|
3451
|
-
const { node,
|
|
4775
|
+
const { node, hostNodeOf, ...rest } = props;
|
|
3452
4776
|
const raw = contentBlocksToText(node.data?.content);
|
|
3453
4777
|
const scanned = scanAnnotatedMessage(raw);
|
|
3454
|
-
const text = scanned.
|
|
4778
|
+
const text = displayTextWithoutQuote(scanned.rest).trim();
|
|
3455
4779
|
const quotes = scanned.annotations;
|
|
3456
4780
|
const rewritten = quotes.length > 0 || text !== raw.trim();
|
|
3457
4781
|
const displayNode = (0, react.useMemo)(() => rewritten ? {
|
|
@@ -3466,7 +4790,7 @@ function UserMessageDisplay(props) {
|
|
|
3466
4790
|
text
|
|
3467
4791
|
]);
|
|
3468
4792
|
const cards = quotes.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }) : null;
|
|
3469
|
-
const Host =
|
|
4793
|
+
const Host = hostNodeOf?.();
|
|
3470
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, {
|
|
3471
4795
|
...rest,
|
|
3472
4796
|
node: displayNode
|
|
@@ -3496,6 +4820,27 @@ function UserMessageDisplay(props) {
|
|
|
3496
4820
|
})] });
|
|
3497
4821
|
}
|
|
3498
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;
|
|
4842
|
+
}
|
|
4843
|
+
//#endregion
|
|
3499
4844
|
//#region src/client/state/annotation-persistence.ts
|
|
3500
4845
|
/** localStorage prefix; a session id is appended as `.<sessionId>`. */
|
|
3501
4846
|
const ANNOTATION_KEY_PREFIX = "dsh.select-quote.annotations";
|
|
@@ -3652,7 +4997,7 @@ function createAnnotationPersistence() {
|
|
|
3652
4997
|
* Selection toolbar is root-scoped (`shell.overlay`) so transcript selections
|
|
3653
4998
|
* are always observed. Annotation summary stays on the session composer.
|
|
3654
4999
|
*
|
|
3655
|
-
* History
|
|
5000
|
+
* History summary renders **inside** the `user` Chat node view, not on a
|
|
3656
5001
|
* `select-quote` Chat node. `conversation.chat.node` is gated by the shipped
|
|
3657
5002
|
* `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
|
|
3658
5003
|
* turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail): any other
|
|
@@ -3696,7 +5041,20 @@ function apply(ctx) {
|
|
|
3696
5041
|
const port = createAnnotationPersistence();
|
|
3697
5042
|
setAnnotationPersistence(port);
|
|
3698
5043
|
port.prune();
|
|
3699
|
-
|
|
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
|
+
};
|
|
3700
5058
|
}, "dsh-select-quote: annotation persistence");
|
|
3701
5059
|
ctx.slots.inject("shell.overlay", () => {
|
|
3702
5060
|
ctx.slots.register({
|
|
@@ -3719,28 +5077,30 @@ function apply(ctx) {
|
|
|
3719
5077
|
order: 1
|
|
3720
5078
|
}, SessionBridge);
|
|
3721
5079
|
});
|
|
3722
|
-
const
|
|
3723
|
-
|
|
3724
|
-
return (candidates.find((entry) => componentName(entry.component) === "UserMessageNodeView") ?? candidates[0])?.component;
|
|
5080
|
+
const hostNodeOf = (key, self) => {
|
|
5081
|
+
return pickHostNode(typeof ctx.slots.entries === "function" ? ctx.slots.entries("conversation.chat.node") : [], self, key);
|
|
3725
5082
|
};
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
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", () => {
|
|
3731
5096
|
ctx.slots.register({
|
|
3732
5097
|
name: "conversation.chat.node",
|
|
3733
|
-
key
|
|
5098
|
+
key,
|
|
3734
5099
|
priority: -10,
|
|
3735
5100
|
locale: "chat"
|
|
3736
|
-
},
|
|
5101
|
+
}, View);
|
|
3737
5102
|
});
|
|
3738
5103
|
}
|
|
3739
|
-
/** Component name across memo/forwardRef/plain function shapes. */
|
|
3740
|
-
function componentName(component) {
|
|
3741
|
-
const candidate = component;
|
|
3742
|
-
return candidate?.type?.name ?? candidate?.name ?? candidate?.displayName;
|
|
3743
|
-
}
|
|
3744
5104
|
/** Invisible session-scoped bridge that publishes sessionId for the root toolbar. */
|
|
3745
5105
|
function SessionBridge({ sessionId }) {
|
|
3746
5106
|
bindAnnotationSession(sessionId);
|