dsh-select-quote 0.3.8 → 0.3.10
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 +52 -21
- package/cordis.patch.yml +2 -1
- package/lib/client.js +3186 -589
- package/lib/index.js +911 -2
- package/package.json +8 -2
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) {
|
|
@@ -96,9 +110,16 @@ body {
|
|
|
96
110
|
--dsq-font-delta: var(--dsh-content-font-delta, 0px);
|
|
97
111
|
--dsq-bg-raised: var(--dsw-alias-bg-layer-1, #fff);
|
|
98
112
|
--dsq-bg-toast: var(--dsw-alias-bg-layer-3, rgba(0, 0, 0, 0.78));
|
|
113
|
+
/* The settings switch mirrors the Host's own switch: neutral track when off,
|
|
114
|
+
brand fill when on, and a thumb that inverts with the theme. */
|
|
115
|
+
--dsq-switch-off: var(--dsw-alias-border-l3, rgba(0, 0, 0, 0.12));
|
|
116
|
+
--dsq-switch-on: var(--dsw-alias-brand-primary, #0f1115);
|
|
117
|
+
--dsq-switch-thumb-off: var(--dsw-alias-switch-thumb, #fff);
|
|
118
|
+
--dsq-switch-thumb-on: var(--dsw-alias-label-primary-foreground, #fff);
|
|
99
119
|
--dsq-line: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
|
|
100
120
|
--dsq-line-divider: var(--dsw-alias-border-l4, rgba(0, 0, 0, 0.12));
|
|
101
121
|
--dsq-focus-ring: var(--dsw-alias-button-primary-fill, #1677ff);
|
|
122
|
+
--dsq-focus-ring-soft: color-mix(in srgb, var(--dsq-accent) 50%, transparent);
|
|
102
123
|
--dsq-fill-hover: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
|
|
103
124
|
--dsq-label: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
|
|
104
125
|
--dsq-label-inverted: var(--dsw-alias-label-primary-inverted, #fff);
|
|
@@ -108,11 +129,14 @@ body {
|
|
|
108
129
|
--dsq-bubble: var(--dsw-specific-bubble, rgba(0, 0, 0, 0.05));
|
|
109
130
|
--dsq-menu: var(--dsw-menu-surface-fill, rgba(248, 249, 250, .58));
|
|
110
131
|
--dsq-menu-backdrop: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
|
|
132
|
+
--dsq-scrim: rgba(0, 0, 0, 0.32);
|
|
111
133
|
--dsq-accent-400: var(--dsw-static-blue-400, #1677ff);
|
|
112
134
|
--dsq-accent: var(--dsw-static-blue-500, #3b82f6);
|
|
113
135
|
--dsq-accent-tint: var(--dsw-static-blue-50p, #eaf3ff);
|
|
114
136
|
--dsq-accent-tint-hover: var(--dsw-static-blue-75, #e5f0ff);
|
|
115
137
|
--dsq-accent-tint-dark: var(--dsw-static-blue-950, #172554);
|
|
138
|
+
--dsq-summary-bg: var(--dsw-alias-bg-layer-2, #fff);
|
|
139
|
+
--dsq-mark-hover: var(--dsw-static-blue-75, #e5f0ff);
|
|
116
140
|
--dsq-neutral-50: var(--dsw-static-neutral-50, #fafafa);
|
|
117
141
|
--dsq-neutral-100: var(--dsw-static-neutral-100, #f5f5f5);
|
|
118
142
|
--dsq-neutral-800: var(--dsw-static-neutral-800, #292929);
|
|
@@ -160,6 +184,26 @@ ${atomRules}
|
|
|
160
184
|
}
|
|
161
185
|
}
|
|
162
186
|
|
|
187
|
+
/* Right-click / shortcut menu: transform only, for the same reason as the
|
|
188
|
+
toolbar — an animated opacity would cut the material layer's backdrop. */
|
|
189
|
+
@keyframes dsq-menu-in {
|
|
190
|
+
from {
|
|
191
|
+
transform: translateY(4px) scale(0.97);
|
|
192
|
+
}
|
|
193
|
+
to {
|
|
194
|
+
transform: translateY(0) scale(1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
@keyframes dsq-menu-in-up {
|
|
199
|
+
from {
|
|
200
|
+
transform: translateY(-4px) scale(0.97);
|
|
201
|
+
}
|
|
202
|
+
to {
|
|
203
|
+
transform: translateY(0) scale(1);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
163
207
|
/* 内容层的 rise:悬停面板的列表在用;工具条改用容器 scale 后这条在这里待命。 */
|
|
164
208
|
@keyframes dsq-rise-in {
|
|
165
209
|
from {
|
|
@@ -170,15 +214,29 @@ ${atomRules}
|
|
|
170
214
|
}
|
|
171
215
|
}
|
|
172
216
|
|
|
217
|
+
/* Exit of the chip preview: the same 4px travel, reversed. Transform only, so
|
|
218
|
+
the material layer keeps sampling its backdrop until the node is removed. */
|
|
219
|
+
@keyframes dsq-rise-out {
|
|
220
|
+
from {
|
|
221
|
+
transform: translateY(0);
|
|
222
|
+
}
|
|
223
|
+
to {
|
|
224
|
+
transform: translateY(4px);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
173
228
|
/* 宿主菜单材质(官方 material 图层同款):填充走 --dsw-menu-surface-fill,模糊走
|
|
174
229
|
--dsw-menu-backdrop-filter(恒为 blur(40px) saturate(150%)),亮暗自动翻。
|
|
175
230
|
画在 z-index:-1 的独立图层上,内容在它之上:模糊只取身后的页面,不会把
|
|
176
|
-
|
|
231
|
+
面板里的文字一起糊掉。所有浮层共用这一条。
|
|
177
232
|
注意:macOS 上宿主另有一个 --dsw-specific-menu(覆写成 94% 不透明)给它的
|
|
178
233
|
菜单用;这里按需求用 surface-fill,所以我们的浮层会比宿主自己的菜单更透。 */
|
|
179
234
|
.dsq_toolbar::before,
|
|
180
235
|
.dsq_commentPop::before,
|
|
181
|
-
.dsq_summaryHoverInner::before
|
|
236
|
+
.dsq_summaryHoverInner::before,
|
|
237
|
+
.dsq_touchBar::before,
|
|
238
|
+
.dsq_chipPreview::before,
|
|
239
|
+
.dsq_ctxMenu::before {
|
|
182
240
|
content: "";
|
|
183
241
|
position: absolute;
|
|
184
242
|
inset: 0;
|
|
@@ -193,7 +251,7 @@ ${atomRules}
|
|
|
193
251
|
/* 覆层不描边:elevation-prominent 自带的那圈 0 0 0 .5px(--dsw-elevation-stroke-color,
|
|
194
252
|
默认 border-l4)对磨砂覆层是多余的——边缘自带材质分界,再勾一道反而抢视觉,覆写成
|
|
195
253
|
更淡的 border-l1。
|
|
196
|
-
两个坑,改之前先读:①
|
|
254
|
+
两个坑,改之前先读:① 这两行必须写在浮层自己的选择器上。写在 body 上会随继承
|
|
197
255
|
影响页面里其它依赖默认值的面板(宿主带 [data-menu-material] 的菜单有自己的覆写,
|
|
198
256
|
不受影响)。② --dsq-shadow 也必须在这儿声明,不能留在 body:自定义属性是在**声明处**
|
|
199
257
|
展开的,--dsq-shadow 留在 body 上时 var(--dsw-elevation-prominent) 会在 body 就被
|
|
@@ -201,9 +259,16 @@ ${atomRules}
|
|
|
201
259
|
整条链才在浮层元素上取值。 */
|
|
202
260
|
.dsq_toolbar,
|
|
203
261
|
.dsq_commentPop,
|
|
204
|
-
.dsq_summaryHoverInner
|
|
262
|
+
.dsq_summaryHoverInner,
|
|
263
|
+
.dsq_touchBar,
|
|
264
|
+
.dsq_touchSheet,
|
|
265
|
+
.dsq_chipPreview,
|
|
266
|
+
.dsq_ctxMenu {
|
|
205
267
|
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
|
|
206
268
|
--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));
|
|
269
|
+
/* Every surface above takes the same elevation; declaring it once here is
|
|
270
|
+
what keeps the component blocks free of a copied box-shadow. */
|
|
271
|
+
box-shadow: var(--dsq-shadow);
|
|
207
272
|
}
|
|
208
273
|
|
|
209
274
|
/* 浮层挂载与层带(2026-10-20 定,改前先读):
|
|
@@ -219,8 +284,10 @@ ${atomRules}
|
|
|
219
284
|
现在把两件事交回宿主布局:
|
|
220
285
|
- 滚动容器负责裁剪:浮层随内容上移,到容器上边缘就被裁掉,自然滑进 header
|
|
221
286
|
下面,和正文一致;
|
|
222
|
-
- host 的 z-index
|
|
287
|
+
- host 的 z-index 平时 6(见 floating-layer.ts):低于 composer 的 7,高于
|
|
223
288
|
内容里吸顶元素的 6(同 z 时 host 追加在滚动容器末尾,DOM 次序压过它们)。
|
|
289
|
+
评论步骤打开期间抬到 8:摘要卡在 composer 座位里(z 7),会伸进滚动区,
|
|
290
|
+
6 的评论泡会被它盖住;关闭评论即还原,空闲工具条仍不压输入框。
|
|
224
291
|
host 内部次序:status 3 > toolbar 2 > commentPop 1。
|
|
225
292
|
|
|
226
293
|
摘要块(composer 与历史消息)在内容普通流里,不参与这套层带;悬停面板仍是
|
|
@@ -241,8 +308,7 @@ ${atomRules}
|
|
|
241
308
|
由它勾勒轮廓,所以这里不要再画 border,否则描边会和环叠成两条线。 */
|
|
242
309
|
border: 0;
|
|
243
310
|
border-radius: 12px;
|
|
244
|
-
/*
|
|
245
|
-
box-shadow: var(--dsq-shadow);
|
|
311
|
+
/* 底色与模糊都在上面的材质图层里,这里不再画背景;elevation 在共享规则里 */
|
|
246
312
|
pointer-events: auto;
|
|
247
313
|
/* A/B:scale 入场放在容器上(材质图层归它所有)。关键帧自带 translate(-50%,-100%),
|
|
248
314
|
所以缩放不会破坏居中。若真实客户端里磨砂在放大期间消失,改回内容层方案。 */
|
|
@@ -261,10 +327,9 @@ ${atomRules}
|
|
|
261
327
|
animation-name: dsq-toolbar-in-popover-top;
|
|
262
328
|
}
|
|
263
329
|
|
|
264
|
-
/*
|
|
330
|
+
/* 按钮行:形状由 A.inlineRow 提供,这里只留间距;入场动画在容器上
|
|
331
|
+
(见 .dsq_toolbar 的 A/B 说明)。 */
|
|
265
332
|
.dsq_toolbarInner {
|
|
266
|
-
display: inline-flex;
|
|
267
|
-
align-items: center;
|
|
268
333
|
gap: 2px;
|
|
269
334
|
}
|
|
270
335
|
|
|
@@ -282,11 +347,17 @@ ${atomRules}
|
|
|
282
347
|
background: var(--dsq-fill-hover);
|
|
283
348
|
}
|
|
284
349
|
|
|
285
|
-
.
|
|
350
|
+
/* Accent-filled actions: comment commit, annotation save, chip jump. Only the
|
|
351
|
+
metrics differ; fill and hover live here once. */
|
|
352
|
+
.dsq_buttonAddComment,
|
|
353
|
+
.dsq_annEditSave,
|
|
354
|
+
.dsq_chipPreviewJump {
|
|
286
355
|
background: var(--dsq-accent);
|
|
287
|
-
color
|
|
356
|
+
color: #fff;
|
|
288
357
|
}
|
|
289
|
-
.dsq_buttonAddComment:hover
|
|
358
|
+
.dsq_buttonAddComment:hover,
|
|
359
|
+
.dsq_annEditSave:hover,
|
|
360
|
+
.dsq_chipPreviewJump:hover {
|
|
290
361
|
background: var(--dsq-accent-400);
|
|
291
362
|
}
|
|
292
363
|
|
|
@@ -306,10 +377,10 @@ ${atomRules}
|
|
|
306
377
|
margin: 0 2px;
|
|
307
378
|
}
|
|
308
379
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
380
|
+
/* Toast pill: the desktop status and the touch status share the whole skin;
|
|
381
|
+
only where they anchor differs. */
|
|
382
|
+
.dsq_status,
|
|
383
|
+
.dsq_touchStatus {
|
|
313
384
|
padding: 4px 10px;
|
|
314
385
|
border-radius: 999px;
|
|
315
386
|
background: var(--dsq-bg-toast);
|
|
@@ -320,6 +391,66 @@ ${atomRules}
|
|
|
320
391
|
white-space: nowrap;
|
|
321
392
|
}
|
|
322
393
|
|
|
394
|
+
.dsq_status {
|
|
395
|
+
position: absolute;
|
|
396
|
+
z-index: 3;
|
|
397
|
+
transform: translate(-50%, 8px);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* Right-click / shortcut menu. Body-level fixed: it must escape the
|
|
401
|
+
transcript's clipping. It closes on scroll instead of following the text,
|
|
402
|
+
so it never points at a line that has moved away. */
|
|
403
|
+
.dsq_ctxMenu {
|
|
404
|
+
position: fixed;
|
|
405
|
+
z-index: 64;
|
|
406
|
+
width: max-content;
|
|
407
|
+
min-width: 172px;
|
|
408
|
+
max-width: min(280px, calc(100vw - 16px));
|
|
409
|
+
padding: 4px;
|
|
410
|
+
border: 0;
|
|
411
|
+
border-radius: 12px;
|
|
412
|
+
outline: none;
|
|
413
|
+
animation: dsq-menu-in 0.14s cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
414
|
+
transform-origin: top left;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.dsq_ctxMenu[data-side='top'] {
|
|
418
|
+
animation-name: dsq-menu-in-up;
|
|
419
|
+
transform-origin: bottom left;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.dsq_ctxItem {
|
|
423
|
+
width: 100%;
|
|
424
|
+
gap: 8px;
|
|
425
|
+
padding: 7px 10px;
|
|
426
|
+
border-radius: 8px;
|
|
427
|
+
color: var(--dsq-label);
|
|
428
|
+
font: inherit;
|
|
429
|
+
font-size: 13px;
|
|
430
|
+
line-height: 18px;
|
|
431
|
+
text-align: left;
|
|
432
|
+
white-space: nowrap;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.dsq_ctxItem svg {
|
|
436
|
+
flex: none;
|
|
437
|
+
color: var(--dsq-label-2);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
.dsq_ctxItem:hover,
|
|
441
|
+
.dsq_ctxItem:focus {
|
|
442
|
+
background: var(--dsq-fill-hover);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
.dsq_ctxItem:focus {
|
|
446
|
+
outline: none;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.dsq_ctxItem:focus-visible {
|
|
450
|
+
outline: 2px solid var(--dsq-focus-ring);
|
|
451
|
+
outline-offset: -2px;
|
|
452
|
+
}
|
|
453
|
+
|
|
323
454
|
/* Annotation stack — inside the composer card (input.overlay), ~1/5 width. */
|
|
324
455
|
.dsq_cardStack {
|
|
325
456
|
position: absolute;
|
|
@@ -373,7 +504,7 @@ ${atomRules}
|
|
|
373
504
|
padding: 8px 10px;
|
|
374
505
|
border: 0.5px solid var(--dsq-line);
|
|
375
506
|
border-radius: 18px;
|
|
376
|
-
background: var(--dsq-
|
|
507
|
+
background: var(--dsq-summary-bg);
|
|
377
508
|
pointer-events: auto;
|
|
378
509
|
user-select: none;
|
|
379
510
|
}
|
|
@@ -392,7 +523,9 @@ ${atomRules}
|
|
|
392
523
|
position: relative;
|
|
393
524
|
}
|
|
394
525
|
|
|
395
|
-
.
|
|
526
|
+
/* Card titles (composer summary / touch sheet) share one type scale. */
|
|
527
|
+
.dsq_summaryTitle,
|
|
528
|
+
.dsq_touchSheetTitle {
|
|
396
529
|
font-size: 14px;
|
|
397
530
|
font-weight: 500;
|
|
398
531
|
line-height: 20px;
|
|
@@ -405,18 +538,37 @@ ${atomRules}
|
|
|
405
538
|
color: var(--dsq-label-3);
|
|
406
539
|
}
|
|
407
540
|
|
|
541
|
+
/* Hover wording is wrong on touch; show one line, not both. */
|
|
542
|
+
.dsq_summaryHintTouch {
|
|
543
|
+
display: none;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
@media (hover: none), (pointer: coarse) {
|
|
547
|
+
.dsq_summaryHintPointer {
|
|
548
|
+
display: none;
|
|
549
|
+
}
|
|
550
|
+
.dsq_summaryHintTouch {
|
|
551
|
+
display: block;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/* The hover panel and the chip preview are the same floating width; keeping it
|
|
556
|
+
in one rule means a width tweak lands on both. */
|
|
557
|
+
.dsq_summaryHover,
|
|
558
|
+
.dsq_chipPreview {
|
|
559
|
+
width: min(320px, calc(100vw - 16px));
|
|
560
|
+
}
|
|
561
|
+
|
|
408
562
|
.dsq_summaryHover {
|
|
409
563
|
/* \`display: none\` here is the closed state, so this rule must not take the
|
|
410
564
|
flex atoms — the open class below turns display back on. */
|
|
411
565
|
display: none;
|
|
412
566
|
position: fixed;
|
|
413
567
|
z-index: 14;
|
|
414
|
-
width: min(320px, calc(100vw - 16px));
|
|
415
568
|
flex-direction: column;
|
|
416
|
-
padding:
|
|
417
|
-
|
|
418
|
-
padding
|
|
419
|
-
padding-bottom: 12px;
|
|
569
|
+
/* No side padding: the visible card must share the summary's left edge. The
|
|
570
|
+
transparent hover bridge is added by place() only on the facing side. */
|
|
571
|
+
padding: 0;
|
|
420
572
|
background: transparent;
|
|
421
573
|
pointer-events: auto;
|
|
422
574
|
opacity: 0;
|
|
@@ -432,14 +584,22 @@ ${atomRules}
|
|
|
432
584
|
display: flex;
|
|
433
585
|
}
|
|
434
586
|
|
|
587
|
+
/* Keep the closing panel mounted briefly; the list reverses its rise. */
|
|
588
|
+
.dsq_summaryHoverLeaving {
|
|
589
|
+
pointer-events: none;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
.dsq_summaryHoverLeaving .dsq_summaryList {
|
|
593
|
+
animation: dsq-rise-out 0.12s cubic-bezier(0.4, 0, 1, 1) both;
|
|
594
|
+
}
|
|
595
|
+
|
|
435
596
|
.dsq_summaryHoverInner {
|
|
436
597
|
position: relative;
|
|
437
598
|
gap: 4px;
|
|
438
599
|
padding: 4px;
|
|
439
600
|
border-radius: 12px;
|
|
440
601
|
/* 不画 border:--dsq-shadow(宿主的 elevation-prominent)自带 0 0 0 .5px 的
|
|
441
|
-
|
|
442
|
-
box-shadow: var(--dsq-shadow);
|
|
602
|
+
描边环,再画一遍会叠成两条线。底色与模糊都在材质图层里,elevation 在共享规则里。 */
|
|
443
603
|
}
|
|
444
604
|
|
|
445
605
|
/* 滚动只放在列表上:材质图层是绝对定位的,留在会滚动的容器里会跟着内容跑掉,
|
|
@@ -457,7 +617,10 @@ ${atomRules}
|
|
|
457
617
|
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
|
458
618
|
.dsq_toolbar::before,
|
|
459
619
|
.dsq_commentPop::before,
|
|
460
|
-
.dsq_summaryHoverInner::before
|
|
620
|
+
.dsq_summaryHoverInner::before,
|
|
621
|
+
.dsq_touchBar::before,
|
|
622
|
+
.dsq_chipPreview::before,
|
|
623
|
+
.dsq_ctxMenu::before {
|
|
461
624
|
background: var(--dsq-bg-raised);
|
|
462
625
|
backdrop-filter: none;
|
|
463
626
|
-webkit-backdrop-filter: none;
|
|
@@ -477,6 +640,9 @@ ${atomRules}
|
|
|
477
640
|
|
|
478
641
|
.dsq_summaryItemHead {
|
|
479
642
|
gap: 6px;
|
|
643
|
+
/* Normal mode's action buttons set the row height; keep it in edit mode too
|
|
644
|
+
so the badge and the label never move when the actions disappear. */
|
|
645
|
+
min-height: 26px;
|
|
480
646
|
}
|
|
481
647
|
|
|
482
648
|
.dsq_summaryItemBadge {
|
|
@@ -516,6 +682,9 @@ ${atomRules}
|
|
|
516
682
|
|
|
517
683
|
.dsq_summaryItemMain {
|
|
518
684
|
min-width: 0;
|
|
685
|
+
/* The lead-in lives on the shared wrapper, not on the normal-mode button, so
|
|
686
|
+
edit mode has no wrapper to compensate for. */
|
|
687
|
+
padding-top: 2px;
|
|
519
688
|
}
|
|
520
689
|
|
|
521
690
|
.dsq_summaryItemButton {
|
|
@@ -524,7 +693,9 @@ ${atomRules}
|
|
|
524
693
|
text-align: left;
|
|
525
694
|
font: inherit;
|
|
526
695
|
border-radius: 8px;
|
|
527
|
-
padding: 2px
|
|
696
|
+
/* No padding: the 2px lead-in is on .dsq_summaryItemMain, and the UA's
|
|
697
|
+
default button padding would shift the quote sideways and down. */
|
|
698
|
+
padding: 0;
|
|
528
699
|
}
|
|
529
700
|
|
|
530
701
|
.dsq_annAnchor {
|
|
@@ -535,18 +706,36 @@ ${atomRules}
|
|
|
535
706
|
pointer-events: none;
|
|
536
707
|
}
|
|
537
708
|
|
|
538
|
-
.
|
|
709
|
+
/* Field labels shared by the hover list and the chip preview. */
|
|
710
|
+
.dsq_summaryItemLabel,
|
|
711
|
+
.dsq_chipPreviewLabel {
|
|
539
712
|
font-size: 11px;
|
|
540
713
|
line-height: 16px;
|
|
541
714
|
color: var(--dsq-label-3);
|
|
542
715
|
}
|
|
543
716
|
|
|
544
|
-
.
|
|
717
|
+
.dsq_summaryItemLabel {
|
|
718
|
+
margin-top: 8px;
|
|
719
|
+
margin-bottom: 4px;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/* The header label shares the class but sits in a centered flex row. */
|
|
723
|
+
.dsq_summaryItemHead .dsq_summaryItemLabel {
|
|
724
|
+
margin: 0;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/* Quote/comment body type is shared; only the box behavior differs. */
|
|
728
|
+
.dsq_summaryItemText,
|
|
729
|
+
.dsq_chipPreviewQuote,
|
|
730
|
+
.dsq_chipPreviewComment {
|
|
545
731
|
font-size: 13px;
|
|
546
|
-
line-height: 18px;
|
|
547
732
|
color: var(--dsq-label);
|
|
548
733
|
white-space: pre-wrap;
|
|
549
734
|
overflow-wrap: anywhere;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
.dsq_summaryItemText {
|
|
738
|
+
line-height: 18px;
|
|
550
739
|
display: -webkit-box;
|
|
551
740
|
-webkit-line-clamp: 3;
|
|
552
741
|
-webkit-box-orient: vertical;
|
|
@@ -557,24 +746,22 @@ ${atomRules}
|
|
|
557
746
|
color: var(--dsq-label-3);
|
|
558
747
|
}
|
|
559
748
|
|
|
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;
|
|
749
|
+
/* Inline edit form rendered inside the hover panel item (no extra layer).
|
|
750
|
+
Shares .dsq_summaryItemText with the read view, but shows the full quote
|
|
751
|
+
instead of clamp-reading it. */
|
|
752
|
+
.dsq_summaryItemTextEdit {
|
|
753
|
+
display: block;
|
|
567
754
|
max-height: 132px;
|
|
568
755
|
overflow-y: auto;
|
|
569
|
-
|
|
756
|
+
overscroll-behavior: contain;
|
|
570
757
|
}
|
|
571
758
|
|
|
572
|
-
|
|
759
|
+
/* Textarea skin shared by the annotation editor and the comment popover; the
|
|
760
|
+
variants below only set min-height, resize and radius. */
|
|
761
|
+
.dsq_annEditInput,
|
|
762
|
+
.dsq_commentInput {
|
|
573
763
|
width: 100%;
|
|
574
|
-
min-height: 72px;
|
|
575
|
-
resize: vertical;
|
|
576
764
|
border: none;
|
|
577
|
-
border-radius: 10px;
|
|
578
765
|
padding: 8px 10px;
|
|
579
766
|
font: inherit;
|
|
580
767
|
font-size: 13px;
|
|
@@ -584,44 +771,47 @@ ${atomRules}
|
|
|
584
771
|
outline: none;
|
|
585
772
|
}
|
|
586
773
|
|
|
587
|
-
.dsq_annEditInput::placeholder
|
|
774
|
+
.dsq_annEditInput::placeholder,
|
|
775
|
+
.dsq_commentInput::placeholder {
|
|
588
776
|
color: var(--dsq-label-3);
|
|
589
777
|
}
|
|
590
778
|
|
|
779
|
+
.dsq_annEditInput {
|
|
780
|
+
min-height: 72px;
|
|
781
|
+
resize: vertical;
|
|
782
|
+
border-radius: 10px;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
.dsq_commentActions,
|
|
591
786
|
.dsq_annEditActions {
|
|
592
787
|
justify-content: flex-end;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
.dsq_annEditActions {
|
|
593
791
|
gap: 8px;
|
|
594
792
|
margin-top: 4px;
|
|
595
793
|
}
|
|
596
794
|
|
|
597
|
-
|
|
598
|
-
|
|
795
|
+
/* Footer buttons share type and metrics; the save fill comes from the shared
|
|
796
|
+
accent-action rule above. */
|
|
797
|
+
.dsq_annEditCancel,
|
|
798
|
+
.dsq_annEditSave {
|
|
599
799
|
font: inherit;
|
|
600
800
|
font-size: 13px;
|
|
601
801
|
padding: 4px 12px;
|
|
602
802
|
border-radius: 10px;
|
|
603
803
|
}
|
|
604
804
|
|
|
805
|
+
.dsq_annEditCancel {
|
|
806
|
+
color: var(--dsq-label-2);
|
|
807
|
+
}
|
|
808
|
+
|
|
605
809
|
.dsq_annEditCancel:hover {
|
|
606
810
|
background: var(--dsq-fill-hover);
|
|
607
811
|
}
|
|
608
812
|
|
|
609
813
|
.dsq_annEditSave {
|
|
610
|
-
appearance: none;
|
|
611
|
-
border: none;
|
|
612
|
-
background: var(--dsq-accent);
|
|
613
|
-
color: #fff;
|
|
614
|
-
font: inherit;
|
|
615
|
-
font-size: 13px;
|
|
616
814
|
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
815
|
}
|
|
626
816
|
|
|
627
817
|
.dsq_annEditSave:disabled {
|
|
@@ -640,130 +830,332 @@ ${atomRules}
|
|
|
640
830
|
gap: 8px;
|
|
641
831
|
padding: 8px;
|
|
642
832
|
border-radius: 16px;
|
|
643
|
-
/*
|
|
644
|
-
box-shadow: var(--dsq-shadow);
|
|
833
|
+
/* 底色与模糊都在材质图层里,elevation 在共享规则里 */
|
|
645
834
|
/* The layer host is pointer-events:none; interactive surfaces opt back in. */
|
|
646
835
|
pointer-events: auto;
|
|
647
836
|
}
|
|
648
837
|
|
|
649
838
|
.dsq_commentInput {
|
|
650
|
-
width: 100%;
|
|
651
839
|
min-height: 64px;
|
|
652
840
|
resize: none;
|
|
653
|
-
border: none;
|
|
654
841
|
border-radius: 8px;
|
|
655
|
-
padding: 8px 10px;
|
|
656
|
-
font: inherit;
|
|
657
|
-
font-size: 13px;
|
|
658
|
-
line-height: 20px;
|
|
659
|
-
color: var(--dsq-label);
|
|
660
|
-
background: var(--dsq-fill-hover);
|
|
661
|
-
outline: none;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
.dsq_commentInput::placeholder {
|
|
665
|
-
color: var(--dsq-label-3);
|
|
666
842
|
}
|
|
667
843
|
|
|
668
844
|
.dsq_commentActions {
|
|
669
|
-
display: flex;
|
|
670
|
-
justify-content: flex-end;
|
|
671
845
|
gap: 6px;
|
|
672
846
|
}
|
|
673
847
|
|
|
674
|
-
/*
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
.
|
|
848
|
+
/* Preview for a model-written annotation chip. Body-level fixed (the chat
|
|
849
|
+
surface's contain: layout would otherwise capture it); hover and click
|
|
850
|
+
share this one surface, and the button owns the jump. */
|
|
851
|
+
.dsq_chipPreview {
|
|
852
|
+
position: fixed;
|
|
853
|
+
z-index: 50;
|
|
854
|
+
box-sizing: border-box;
|
|
855
|
+
max-height: min(420px, calc(100vh - 32px));
|
|
856
|
+
padding: 10px;
|
|
857
|
+
border-radius: 14px;
|
|
858
|
+
pointer-events: auto;
|
|
859
|
+
overflow: hidden;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/* Same rise as the annotation card's hover list; on the content, not on the
|
|
863
|
+
material layer, so backdrop sampling stays put. */
|
|
864
|
+
.dsq_chipPreviewBody {
|
|
678
865
|
display: flex;
|
|
679
|
-
|
|
680
|
-
|
|
866
|
+
flex-direction: column;
|
|
867
|
+
gap: 6px;
|
|
868
|
+
animation: dsq-rise-in 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
681
869
|
}
|
|
682
870
|
|
|
683
|
-
.
|
|
684
|
-
|
|
685
|
-
min-width: 220px;
|
|
686
|
-
max-width: min(calc(var(--dsq-chat-width) * 0.72), 520px);
|
|
871
|
+
.dsq_chipPreviewLeaving {
|
|
872
|
+
pointer-events: none;
|
|
687
873
|
}
|
|
688
874
|
|
|
689
|
-
|
|
690
|
-
.
|
|
691
|
-
gap: 6px;
|
|
875
|
+
.dsq_chipPreviewLeaving .dsq_chipPreviewBody {
|
|
876
|
+
animation: dsq-rise-out 0.12s cubic-bezier(0.4, 0, 1, 1) both;
|
|
692
877
|
}
|
|
693
878
|
|
|
694
|
-
.
|
|
695
|
-
|
|
696
|
-
|
|
879
|
+
/* Header rows: title on the left, the single action on the right. */
|
|
880
|
+
.dsq_chipPreviewHead,
|
|
881
|
+
.dsq_touchSheetHead {
|
|
882
|
+
justify-content: space-between;
|
|
697
883
|
gap: 8px;
|
|
698
884
|
}
|
|
699
885
|
|
|
700
|
-
.
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
background: var(--dsq-bubble);
|
|
886
|
+
.dsq_chipPreviewTitle {
|
|
887
|
+
font-size: 13px;
|
|
888
|
+
font-weight: 600;
|
|
889
|
+
line-height: 18px;
|
|
705
890
|
color: var(--dsq-label);
|
|
706
|
-
font-size: var(--dsq-font-size);
|
|
707
|
-
line-height: calc(22px + var(--dsq-font-delta));
|
|
708
|
-
white-space: pre-wrap;
|
|
709
|
-
word-break: break-word;
|
|
710
891
|
}
|
|
711
892
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
max-width: 100%;
|
|
893
|
+
.dsq_chipPreviewLabel {
|
|
894
|
+
margin-top: 2px;
|
|
715
895
|
}
|
|
716
896
|
|
|
717
|
-
.
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
897
|
+
.dsq_chipPreviewQuote,
|
|
898
|
+
.dsq_chipPreviewComment {
|
|
899
|
+
line-height: 19px;
|
|
900
|
+
max-height: 108px;
|
|
901
|
+
overflow-y: auto;
|
|
902
|
+
overscroll-behavior: contain;
|
|
721
903
|
}
|
|
722
904
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
905
|
+
.dsq_chipPreviewComment {
|
|
906
|
+
color: var(--dsq-label-2);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
.dsq_chipPreviewCommentEmpty {
|
|
910
|
+
color: var(--dsq-label-3);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
.dsq_chipPreviewJump {
|
|
914
|
+
flex: none;
|
|
915
|
+
justify-content: center;
|
|
916
|
+
gap: 4px;
|
|
917
|
+
min-height: 28px;
|
|
918
|
+
padding: 0 9px;
|
|
919
|
+
border-radius: 7px;
|
|
732
920
|
font: inherit;
|
|
733
921
|
font-size: 12px;
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
922
|
+
font-weight: 500;
|
|
923
|
+
line-height: 1;
|
|
924
|
+
white-space: nowrap;
|
|
737
925
|
}
|
|
738
926
|
|
|
739
|
-
/*
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
927
|
+
/* Touch selection chrome. Fixed to the visual viewport and mounted on body:
|
|
928
|
+
the OS selection menu owns the space around the selected text, and the soft
|
|
929
|
+
keyboard owns the bottom, so neither content coordinates nor the scroller's
|
|
930
|
+
clipping can place this correctly. Only rendered on coarse pointers. */
|
|
931
|
+
.dsq_touchBar,
|
|
932
|
+
.dsq_touchSheet {
|
|
933
|
+
position: fixed;
|
|
934
|
+
left: 50%;
|
|
935
|
+
right: auto;
|
|
936
|
+
box-sizing: border-box;
|
|
937
|
+
width: min(520px, calc(100vw - 16px));
|
|
938
|
+
transform: translateX(-50%);
|
|
939
|
+
touch-action: manipulation;
|
|
940
|
+
pointer-events: auto;
|
|
753
941
|
}
|
|
754
942
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
.
|
|
763
|
-
|
|
943
|
+
.dsq_touchBar {
|
|
944
|
+
z-index: 60;
|
|
945
|
+
gap: 4px;
|
|
946
|
+
padding: 6px;
|
|
947
|
+
border-radius: 16px;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
.dsq_touchAction {
|
|
951
|
+
flex: 1 1 0;
|
|
952
|
+
min-width: 0;
|
|
953
|
+
min-height: 48px;
|
|
954
|
+
align-items: center;
|
|
955
|
+
justify-content: center;
|
|
956
|
+
gap: 3px;
|
|
957
|
+
padding: 6px 4px;
|
|
958
|
+
border-radius: 10px;
|
|
959
|
+
color: var(--dsq-label);
|
|
960
|
+
font: inherit;
|
|
961
|
+
font-size: 11px;
|
|
962
|
+
line-height: 14px;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
.dsq_touchAction span {
|
|
966
|
+
max-width: 100%;
|
|
967
|
+
overflow: hidden;
|
|
968
|
+
text-overflow: ellipsis;
|
|
969
|
+
white-space: nowrap;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
.dsq_touchAction:active {
|
|
973
|
+
background: var(--dsq-fill-hover);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
.dsq_touchScrim {
|
|
977
|
+
position: fixed;
|
|
978
|
+
inset: 0;
|
|
979
|
+
z-index: 61;
|
|
980
|
+
background: var(--dsq-scrim);
|
|
981
|
+
touch-action: none;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
.dsq_touchSheet {
|
|
985
|
+
z-index: 62;
|
|
986
|
+
gap: 10px;
|
|
987
|
+
padding: 12px;
|
|
988
|
+
border-radius: 20px;
|
|
989
|
+
background: var(--dsq-bg-raised);
|
|
990
|
+
overflow-y: auto;
|
|
991
|
+
overscroll-behavior: contain;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
.dsq_touchSheetClose {
|
|
995
|
+
flex: none;
|
|
996
|
+
width: 44px;
|
|
997
|
+
height: 44px;
|
|
998
|
+
margin: -8px -8px -8px 0;
|
|
999
|
+
border-radius: 12px;
|
|
1000
|
+
color: var(--dsq-label-2);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
.dsq_touchSheetClose:active {
|
|
1004
|
+
background: var(--dsq-fill-hover);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/* Keep the selected words visible after the sheet covers the page: on touch the
|
|
1008
|
+
reader has no hover to re-orient, so the sheet carries its own quote. */
|
|
1009
|
+
.dsq_touchQuote {
|
|
1010
|
+
border-left: 2px solid var(--dsq-accent);
|
|
1011
|
+
border-radius: 6px;
|
|
1012
|
+
padding: 6px 8px;
|
|
1013
|
+
background: var(--dsq-fill-hover);
|
|
1014
|
+
color: var(--dsq-label-2);
|
|
1015
|
+
font-size: 12px;
|
|
1016
|
+
line-height: 18px;
|
|
1017
|
+
white-space: pre-wrap;
|
|
1018
|
+
overflow-wrap: anywhere;
|
|
1019
|
+
display: -webkit-box;
|
|
1020
|
+
-webkit-line-clamp: 2;
|
|
1021
|
+
-webkit-box-orient: vertical;
|
|
1022
|
+
overflow: hidden;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/* 16px keeps iOS from zooming the page when the field is focused. */
|
|
1026
|
+
.dsq_touchComment {
|
|
1027
|
+
min-height: 88px;
|
|
1028
|
+
font-size: 16px;
|
|
1029
|
+
line-height: 22px;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
.dsq_touchActions {
|
|
1033
|
+
gap: 8px;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
.dsq_touchActions .dsq_button {
|
|
1037
|
+
flex: 1;
|
|
1038
|
+
min-height: 44px;
|
|
1039
|
+
justify-content: center;
|
|
1040
|
+
font-size: 14px;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
.dsq_touchSecondary {
|
|
1044
|
+
background: var(--dsq-fill-hover);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
.dsq_touchStatus {
|
|
1048
|
+
position: fixed;
|
|
1049
|
+
left: 50%;
|
|
1050
|
+
z-index: 63;
|
|
1051
|
+
transform: translateX(-50%);
|
|
1052
|
+
max-width: calc(100vw - 32px);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/* Transcript summary — the same block as the composer's, right-aligned under
|
|
1056
|
+
the user bubble. One block per message; the hover list carries every
|
|
1057
|
+
annotation with its selected text and comment. */
|
|
1058
|
+
.dsq_tSummary {
|
|
1059
|
+
display: flex;
|
|
1060
|
+
justify-content: flex-end;
|
|
1061
|
+
margin: 4px 0 8px;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
.dsq_tSummary .dsq_summary {
|
|
1065
|
+
width: auto;
|
|
1066
|
+
min-width: 160px;
|
|
1067
|
+
max-width: min(calc(var(--dsq-chat-width) * 0.72), 520px);
|
|
1068
|
+
/* In the composer the card must clear .dsq_cardStack (12); in the transcript
|
|
1069
|
+
it is plain content and must stay under the floating layer's host (6/8),
|
|
1070
|
+
otherwise a hovered comment popover is painted beneath it. */
|
|
1071
|
+
z-index: 1;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/* Replacement user bubble (quote block stripped from display). */
|
|
1075
|
+
.dsq_userRow {
|
|
1076
|
+
gap: 6px;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
.dsq_userStack {
|
|
1080
|
+
min-width: 0;
|
|
1081
|
+
max-width: min(calc(var(--dsq-chat-width) * 0.702), 82%);
|
|
1082
|
+
gap: 8px;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
.dsq_userBubble {
|
|
1086
|
+
max-width: 100%;
|
|
1087
|
+
padding: 10px 16px;
|
|
1088
|
+
border-radius: 22px;
|
|
1089
|
+
background: var(--dsq-bubble);
|
|
1090
|
+
color: var(--dsq-label);
|
|
1091
|
+
font-size: var(--dsq-font-size);
|
|
1092
|
+
line-height: calc(22px + var(--dsq-font-delta));
|
|
1093
|
+
white-space: pre-wrap;
|
|
1094
|
+
word-break: break-word;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/* Durable message images of a replacement user bubble. */
|
|
1098
|
+
.dsq_userImages {
|
|
1099
|
+
max-width: 100%;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
.dsq_userImages img {
|
|
1103
|
+
display: block;
|
|
1104
|
+
max-width: 100%;
|
|
1105
|
+
border-radius: 12px;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/* Model-written :dsh-annotation{index="N"} chips. */
|
|
1109
|
+
.dsq_annDirective {
|
|
1110
|
+
appearance: none;
|
|
1111
|
+
margin: 0 2px;
|
|
1112
|
+
padding: 0 6px;
|
|
1113
|
+
border: 0.5px solid var(--dsq-line);
|
|
1114
|
+
border-radius: 6px;
|
|
1115
|
+
background: var(--dsq-fill-hover);
|
|
1116
|
+
color: var(--dsq-link);
|
|
1117
|
+
font: inherit;
|
|
1118
|
+
font-size: 12px;
|
|
1119
|
+
line-height: 18px;
|
|
1120
|
+
cursor: pointer;
|
|
1121
|
+
vertical-align: baseline;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/* Inline annotation mark in message bodies: highlight underline + ordinal badge. */
|
|
1125
|
+
.dsq_annMark {
|
|
1126
|
+
--dsq-mark-color: var(--dsq-accent);
|
|
1127
|
+
--dsq-mark-tint: var(--dsq-accent-tint);
|
|
1128
|
+
position: relative;
|
|
1129
|
+
background: var(--dsq-mark-tint);
|
|
1130
|
+
text-decoration: underline;
|
|
1131
|
+
text-decoration-style: dashed;
|
|
1132
|
+
text-decoration-color: var(--dsq-mark-color);
|
|
1133
|
+
text-decoration-thickness: 2px;
|
|
1134
|
+
text-underline-offset: 2px;
|
|
1135
|
+
border-radius: 2px;
|
|
1136
|
+
scroll-margin-block: 96px;
|
|
1137
|
+
transition: background-color 0.12s;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/* Ordinal as a superscript corner badge at the END of the marked text, so it
|
|
1141
|
+
never sits between the reader and the sentence. Only the last fragment of a
|
|
1142
|
+
multi-fragment mark carries the index, so the number appears exactly once.
|
|
1143
|
+
Absolute so the badge adds no inline advance width: annotating never reflows
|
|
1144
|
+
the paragraph or moves following words. Bottom (not top) anchors to the last
|
|
1145
|
+
fragment: for a mark spanning several lines the containing block is the union
|
|
1146
|
+
of the first and last fragments, so its bottom edge is the last line.
|
|
1147
|
+
BADGE_BOTTOM / BADGE_HEIGHT / the 2px margin in annotate-text.ts mirror the
|
|
1148
|
+
numbers below for collision measurement. */
|
|
1149
|
+
.dsq_annMark[data-dsq-ann-index]::after {
|
|
1150
|
+
content: attr(data-dsq-ann-index);
|
|
764
1151
|
position: absolute;
|
|
765
1152
|
bottom: 8px;
|
|
766
1153
|
left: 100%;
|
|
1154
|
+
/* Above the native selection highlight and the background of a later mark:
|
|
1155
|
+
both otherwise paint over the badge when their ranges meet. */
|
|
1156
|
+
z-index: 2;
|
|
1157
|
+
/* JS shifts colliding badges right/up; the resting position is unchanged. */
|
|
1158
|
+
transform: translate(var(--dsq-ann-badge-x, 0px), var(--dsq-ann-badge-y, 0px));
|
|
767
1159
|
display: grid;
|
|
768
1160
|
place-items: center;
|
|
769
1161
|
min-width: 15px;
|
|
@@ -780,13 +1172,270 @@ ${atomRules}
|
|
|
780
1172
|
}
|
|
781
1173
|
|
|
782
1174
|
.dsq_annMark:hover {
|
|
783
|
-
background: var(--dsq-
|
|
1175
|
+
background: var(--dsq-mark-hover);
|
|
784
1176
|
}
|
|
785
1177
|
|
|
786
1178
|
.dsq_annMarkFlash {
|
|
787
1179
|
animation: dsq-ann-flash 1.4s ease-out;
|
|
788
1180
|
}
|
|
789
1181
|
|
|
1182
|
+
/* Fallback jump target when the source mark is no longer in the DOM: flash the
|
|
1183
|
+
message that owns it instead of scrolling nowhere. */
|
|
1184
|
+
.dsq_annMessageFlash {
|
|
1185
|
+
animation: dsq-ann-message-flash 1.4s ease-out;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
@keyframes dsq-ann-message-flash {
|
|
1189
|
+
0% {
|
|
1190
|
+
box-shadow: inset 0 0 0 2px var(--dsq-accent);
|
|
1191
|
+
}
|
|
1192
|
+
100% {
|
|
1193
|
+
box-shadow: inset 0 0 0 2px transparent;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/* Keyboard focus is a first-class state for the toolbar, summary and panel. */
|
|
1198
|
+
.dsq_summary:focus-visible,
|
|
1199
|
+
.dsq_button:focus-visible,
|
|
1200
|
+
.dsq_touchAction:focus-visible,
|
|
1201
|
+
.dsq_touchSheetClose:focus-visible,
|
|
1202
|
+
.dsq_chipPreviewJump:focus-visible,
|
|
1203
|
+
.dsq_annMark:focus-visible,
|
|
1204
|
+
.dsq_summaryItemAction:focus-visible,
|
|
1205
|
+
.dsq_summaryItemButton:focus-visible,
|
|
1206
|
+
.dsq_cardClose:focus-visible,
|
|
1207
|
+
.dsq_annEditCancel:focus-visible,
|
|
1208
|
+
.dsq_annEditSave:focus-visible {
|
|
1209
|
+
outline: 2px solid var(--dsq-focus-ring);
|
|
1210
|
+
outline-offset: 2px;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
.dsq_commentInput:focus-visible,
|
|
1214
|
+
.dsq_annEditInput:focus-visible {
|
|
1215
|
+
/* The host's button fill is monochrome (black on light, white on dark); a
|
|
1216
|
+
half-transparent accent ring reads as focus without looking like a border. */
|
|
1217
|
+
outline: 2px solid var(--dsq-focus-ring-soft);
|
|
1218
|
+
outline-offset: 1px;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/* Touch: 44px minimum targets. Icon-only corners keep their visual size and
|
|
1222
|
+
grow an invisible hit area; toolbar actions grow in place. */
|
|
1223
|
+
@media (hover: none), (pointer: coarse) {
|
|
1224
|
+
.dsq_button,
|
|
1225
|
+
.dsq_annEditCancel,
|
|
1226
|
+
.dsq_annEditSave {
|
|
1227
|
+
min-height: 44px;
|
|
1228
|
+
}
|
|
1229
|
+
.dsq_summaryItemAction {
|
|
1230
|
+
width: 44px;
|
|
1231
|
+
height: 44px;
|
|
1232
|
+
}
|
|
1233
|
+
.dsq_cardClose::after {
|
|
1234
|
+
content: "";
|
|
1235
|
+
position: absolute;
|
|
1236
|
+
inset: -12px;
|
|
1237
|
+
}
|
|
1238
|
+
.dsq_chipPreviewJump {
|
|
1239
|
+
min-height: 44px;
|
|
1240
|
+
}
|
|
1241
|
+
.dsq_segOption {
|
|
1242
|
+
min-height: 44px;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/* Settings row (plugin page): an inline segmented control. The page owns the
|
|
1247
|
+
write, so there is no save button — each pick is committed immediately. */
|
|
1248
|
+
.dsq_settings {
|
|
1249
|
+
display: flex;
|
|
1250
|
+
flex-direction: column;
|
|
1251
|
+
gap: 10px;
|
|
1252
|
+
padding: 2px 0;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
.dsq_settingsSection {
|
|
1256
|
+
display: flex;
|
|
1257
|
+
flex-direction: column;
|
|
1258
|
+
gap: 10px;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
.dsq_settingsCopy {
|
|
1262
|
+
display: flex;
|
|
1263
|
+
flex-direction: column;
|
|
1264
|
+
gap: 3px;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
.dsq_settingsLabel {
|
|
1268
|
+
color: var(--dsq-label);
|
|
1269
|
+
font-size: 13px;
|
|
1270
|
+
font-weight: 500;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
.dsq_settingsHint {
|
|
1274
|
+
color: var(--dsq-label-3);
|
|
1275
|
+
font-size: 12px;
|
|
1276
|
+
line-height: 1.5;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/* Shortcut reference under the switch: the two commands and the keycaps the
|
|
1280
|
+
Host currently has in effect, so a rebind shows up here without a reload. */
|
|
1281
|
+
.dsq_settingsShortcuts {
|
|
1282
|
+
display: flex;
|
|
1283
|
+
flex-direction: column;
|
|
1284
|
+
gap: 6px;
|
|
1285
|
+
margin: 0;
|
|
1286
|
+
padding: 0;
|
|
1287
|
+
list-style: none;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
.dsq_settingsShortcutRow {
|
|
1291
|
+
display: flex;
|
|
1292
|
+
align-items: center;
|
|
1293
|
+
justify-content: space-between;
|
|
1294
|
+
gap: 12px;
|
|
1295
|
+
min-height: 22px;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
.dsq_settingsShortcutName {
|
|
1299
|
+
min-width: 0;
|
|
1300
|
+
color: var(--dsq-label-2);
|
|
1301
|
+
font-size: 12px;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
.dsq_settingsShortcutKeys {
|
|
1305
|
+
flex: none;
|
|
1306
|
+
color: var(--dsq-label-3);
|
|
1307
|
+
font-size: 12px;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
.dsq_keycap {
|
|
1311
|
+
display: inline-block;
|
|
1312
|
+
min-width: 18px;
|
|
1313
|
+
padding: 1px 6px;
|
|
1314
|
+
border: 1px solid var(--dsq-line);
|
|
1315
|
+
border-radius: 5px;
|
|
1316
|
+
background: var(--dsq-fill-hover);
|
|
1317
|
+
color: var(--dsq-label-2);
|
|
1318
|
+
font: inherit;
|
|
1319
|
+
font-size: 11px;
|
|
1320
|
+
line-height: 18px;
|
|
1321
|
+
text-align: center;
|
|
1322
|
+
white-space: nowrap;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
.dsq_seg {
|
|
1326
|
+
display: inline-flex;
|
|
1327
|
+
align-self: flex-start;
|
|
1328
|
+
gap: 2px;
|
|
1329
|
+
padding: 2px;
|
|
1330
|
+
border-radius: 8px;
|
|
1331
|
+
background: var(--dsq-fill-hover);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
.dsq_segOption {
|
|
1335
|
+
position: relative;
|
|
1336
|
+
display: inline-flex;
|
|
1337
|
+
align-items: center;
|
|
1338
|
+
justify-content: center;
|
|
1339
|
+
height: 28px;
|
|
1340
|
+
padding: 0 12px;
|
|
1341
|
+
border-radius: 6px;
|
|
1342
|
+
color: var(--dsq-label-2);
|
|
1343
|
+
font-size: 13px;
|
|
1344
|
+
cursor: pointer;
|
|
1345
|
+
transition: background-color 0.12s ease, color 0.12s ease;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
.dsq_segOption:hover {
|
|
1349
|
+
color: var(--dsq-label);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
.dsq_segOption[data-active="true"] {
|
|
1353
|
+
background: var(--dsq-bg-raised);
|
|
1354
|
+
box-shadow: 0 0 0 0.5px var(--dsq-line);
|
|
1355
|
+
color: var(--dsq-label);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
.dsq_segOption:has(:focus-visible) {
|
|
1359
|
+
outline: 2px solid var(--dsq-focus-ring-soft);
|
|
1360
|
+
outline-offset: 1px;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
.dsq_settingsDivider {
|
|
1364
|
+
height: 1px;
|
|
1365
|
+
background: var(--dsq-line);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/* The whole row is the target: label text, hint, and the switch itself. */
|
|
1369
|
+
.dsq_settingsSwitchRow {
|
|
1370
|
+
display: flex;
|
|
1371
|
+
align-items: center;
|
|
1372
|
+
justify-content: space-between;
|
|
1373
|
+
gap: 16px;
|
|
1374
|
+
cursor: pointer;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
.dsq_settingsSwitchRow .dsq_settingsCopy {
|
|
1378
|
+
flex: 1;
|
|
1379
|
+
min-width: 0;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/* Exactly the Host switch metrics: 36×20 track, 16px thumb, 2px inset, and
|
|
1383
|
+
the same on/off tokens. The thumb only animates its transform, like the
|
|
1384
|
+
product control; the track changes color in one step. */
|
|
1385
|
+
.dsq_switch {
|
|
1386
|
+
box-sizing: border-box;
|
|
1387
|
+
position: relative;
|
|
1388
|
+
flex: none;
|
|
1389
|
+
width: 36px;
|
|
1390
|
+
height: 20px;
|
|
1391
|
+
padding: 2px;
|
|
1392
|
+
border: 0;
|
|
1393
|
+
border-radius: 999px;
|
|
1394
|
+
corner-shape: round;
|
|
1395
|
+
background: var(--dsq-switch-off);
|
|
1396
|
+
cursor: pointer;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
.dsq_switch[data-on='true'] {
|
|
1400
|
+
background: var(--dsq-switch-on);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
.dsq_switchTrack {
|
|
1404
|
+
position: absolute;
|
|
1405
|
+
top: 2px;
|
|
1406
|
+
left: 2px;
|
|
1407
|
+
width: 16px;
|
|
1408
|
+
height: 16px;
|
|
1409
|
+
border-radius: 50%;
|
|
1410
|
+
corner-shape: round;
|
|
1411
|
+
background: var(--dsq-switch-thumb-off);
|
|
1412
|
+
transition: transform 0.12s ease;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
.dsq_switch[data-on='true'] .dsq_switchTrack {
|
|
1416
|
+
background: var(--dsq-switch-thumb-on);
|
|
1417
|
+
transform: translateX(16px);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
.dsq_switch:has(:focus-visible) {
|
|
1421
|
+
outline: 2px solid var(--dsq-focus-ring-soft);
|
|
1422
|
+
outline-offset: 2px;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/* Visually hidden but still focusable: the native radio/checkbox carries the
|
|
1426
|
+
semantics and keyboard behavior, the label carries the paint. */
|
|
1427
|
+
.dsq_srOnly {
|
|
1428
|
+
position: absolute;
|
|
1429
|
+
width: 1px;
|
|
1430
|
+
height: 1px;
|
|
1431
|
+
margin: -1px;
|
|
1432
|
+
padding: 0;
|
|
1433
|
+
overflow: hidden;
|
|
1434
|
+
clip: rect(0 0 0 0);
|
|
1435
|
+
white-space: nowrap;
|
|
1436
|
+
border: 0;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
790
1439
|
/* Layer 4 — host surface: keyframes, theme flips, and the two rules that reach
|
|
791
1440
|
into product-owned DOM. */
|
|
792
1441
|
|
|
@@ -794,10 +1443,12 @@ ${atomRules}
|
|
|
794
1443
|
0% {
|
|
795
1444
|
background: var(--dsq-mark-color);
|
|
796
1445
|
color: #fff;
|
|
1446
|
+
box-shadow: 0 0 0 3px var(--dsq-mark-tint);
|
|
797
1447
|
}
|
|
798
1448
|
100% {
|
|
799
1449
|
background: var(--dsq-mark-tint);
|
|
800
1450
|
color: inherit;
|
|
1451
|
+
box-shadow: 0 0 0 0 transparent;
|
|
801
1452
|
}
|
|
802
1453
|
}
|
|
803
1454
|
|
|
@@ -805,9 +1456,10 @@ ${atomRules}
|
|
|
805
1456
|
--dsq-* alias follows it. Only values we own ourselves need restating. */
|
|
806
1457
|
body[data-ds-dark-theme] .dsq_annMark {
|
|
807
1458
|
--dsq-mark-tint: var(--dsq-accent-tint-dark);
|
|
1459
|
+
--dsq-mark-hover: var(--dsw-static-blue-800, #1e40af);
|
|
808
1460
|
}
|
|
809
1461
|
|
|
810
|
-
/* Reserve room for the floating
|
|
1462
|
+
/* Reserve room for the floating annotation summary inside the composer card, so the
|
|
811
1463
|
attachment rail and the editor flow below them instead of being covered. */
|
|
812
1464
|
.dsq_cardPad {
|
|
813
1465
|
padding-top: var(--dsq-quote-pad, 88px) !important;
|
|
@@ -830,6 +1482,8 @@ const styles = {
|
|
|
830
1482
|
buttonAddComment: "dsq_buttonAddComment",
|
|
831
1483
|
divider: "dsq_divider",
|
|
832
1484
|
status: "dsq_status",
|
|
1485
|
+
ctxMenu: "dsq_ctxMenu",
|
|
1486
|
+
ctxItem: "dsq_ctxItem",
|
|
833
1487
|
cardStack: "dsq_cardStack",
|
|
834
1488
|
cardClose: "dsq_cardClose",
|
|
835
1489
|
cardPad: "dsq_cardPad",
|
|
@@ -838,7 +1492,10 @@ const styles = {
|
|
|
838
1492
|
summaryBody: "dsq_summaryBody",
|
|
839
1493
|
summaryTitle: "dsq_summaryTitle",
|
|
840
1494
|
summaryHint: "dsq_summaryHint",
|
|
1495
|
+
summaryHintPointer: "dsq_summaryHintPointer",
|
|
1496
|
+
summaryHintTouch: "dsq_summaryHintTouch",
|
|
841
1497
|
summaryHover: "dsq_summaryHover",
|
|
1498
|
+
summaryHoverLeaving: "dsq_summaryHoverLeaving",
|
|
842
1499
|
summaryItem: "dsq_summaryItem",
|
|
843
1500
|
summaryItemLabel: "dsq_summaryItemLabel",
|
|
844
1501
|
summaryItemText: "dsq_summaryItemText",
|
|
@@ -846,7 +1503,7 @@ const styles = {
|
|
|
846
1503
|
summaryItemMain: "dsq_summaryItemMain",
|
|
847
1504
|
summaryItemHead: "dsq_summaryItemHead",
|
|
848
1505
|
summaryItemTextEmpty: "dsq_summaryItemTextEmpty",
|
|
849
|
-
|
|
1506
|
+
summaryItemTextEdit: "dsq_summaryItemTextEdit",
|
|
850
1507
|
annEditInput: "dsq_annEditInput",
|
|
851
1508
|
annEditActions: "dsq_annEditActions",
|
|
852
1509
|
annEditCancel: "dsq_annEditCancel",
|
|
@@ -861,23 +1518,110 @@ const styles = {
|
|
|
861
1518
|
annAnchor: "dsq_annAnchor",
|
|
862
1519
|
annMark: "dsq_annMark",
|
|
863
1520
|
annMarkFlash: "dsq_annMarkFlash",
|
|
1521
|
+
annMessageFlash: "dsq_annMessageFlash",
|
|
864
1522
|
commentPop: "dsq_commentPop",
|
|
865
1523
|
commentInput: "dsq_commentInput",
|
|
866
1524
|
commentActions: "dsq_commentActions",
|
|
1525
|
+
chipPreview: "dsq_chipPreview",
|
|
1526
|
+
chipPreviewBody: "dsq_chipPreviewBody",
|
|
1527
|
+
chipPreviewLeaving: "dsq_chipPreviewLeaving",
|
|
1528
|
+
chipPreviewHead: "dsq_chipPreviewHead",
|
|
1529
|
+
chipPreviewTitle: "dsq_chipPreviewTitle",
|
|
1530
|
+
chipPreviewLabel: "dsq_chipPreviewLabel",
|
|
1531
|
+
chipPreviewQuote: "dsq_chipPreviewQuote",
|
|
1532
|
+
chipPreviewComment: "dsq_chipPreviewComment",
|
|
1533
|
+
chipPreviewCommentEmpty: "dsq_chipPreviewCommentEmpty",
|
|
1534
|
+
chipPreviewJump: "dsq_chipPreviewJump",
|
|
1535
|
+
touchBar: "dsq_touchBar",
|
|
1536
|
+
touchAction: "dsq_touchAction",
|
|
1537
|
+
touchScrim: "dsq_touchScrim",
|
|
1538
|
+
touchSheet: "dsq_touchSheet",
|
|
1539
|
+
touchSheetHead: "dsq_touchSheetHead",
|
|
1540
|
+
touchSheetTitle: "dsq_touchSheetTitle",
|
|
1541
|
+
touchSheetClose: "dsq_touchSheetClose",
|
|
1542
|
+
touchQuote: "dsq_touchQuote",
|
|
1543
|
+
touchComment: "dsq_touchComment",
|
|
1544
|
+
touchActions: "dsq_touchActions",
|
|
1545
|
+
touchSecondary: "dsq_touchSecondary",
|
|
1546
|
+
touchStatus: "dsq_touchStatus",
|
|
867
1547
|
tSummary: "dsq_tSummary",
|
|
868
1548
|
userRow: "dsq_userRow",
|
|
869
1549
|
userStack: "dsq_userStack",
|
|
870
1550
|
userImages: "dsq_userImages",
|
|
871
|
-
userBubble: "dsq_userBubble"
|
|
1551
|
+
userBubble: "dsq_userBubble",
|
|
1552
|
+
settings: "dsq_settings",
|
|
1553
|
+
settingsSection: "dsq_settingsSection",
|
|
1554
|
+
settingsCopy: "dsq_settingsCopy",
|
|
1555
|
+
settingsLabel: "dsq_settingsLabel",
|
|
1556
|
+
settingsHint: "dsq_settingsHint",
|
|
1557
|
+
settingsShortcuts: "dsq_settingsShortcuts",
|
|
1558
|
+
settingsShortcutRow: "dsq_settingsShortcutRow",
|
|
1559
|
+
settingsShortcutName: "dsq_settingsShortcutName",
|
|
1560
|
+
settingsShortcutKeys: "dsq_settingsShortcutKeys",
|
|
1561
|
+
keycap: "dsq_keycap",
|
|
1562
|
+
settingsDivider: "dsq_settingsDivider",
|
|
1563
|
+
settingsSwitchRow: "dsq_settingsSwitchRow",
|
|
1564
|
+
switch: "dsq_switch",
|
|
1565
|
+
switchTrack: "dsq_switchTrack",
|
|
1566
|
+
seg: "dsq_seg",
|
|
1567
|
+
segOption: "dsq_segOption",
|
|
1568
|
+
srOnly: "dsq_srOnly"
|
|
872
1569
|
};
|
|
873
1570
|
//#endregion
|
|
1571
|
+
//#region src/client/dom/badge-layout.ts
|
|
1572
|
+
const STEP_X = 28;
|
|
1573
|
+
const STEP_Y = 18;
|
|
1574
|
+
/** Rest, then two steps right; after that the stack grows upward. */
|
|
1575
|
+
const MAX_COLUMNS = 3;
|
|
1576
|
+
const MAX_ROWS = 24;
|
|
1577
|
+
function overlaps(a, b) {
|
|
1578
|
+
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
1579
|
+
}
|
|
1580
|
+
function fits(anchor, dx, dy, bounds) {
|
|
1581
|
+
if (dx > 0 && bounds.right !== void 0 && anchor.x + dx + anchor.width > bounds.right) return false;
|
|
1582
|
+
if (dy < 0 && bounds.top !== void 0 && anchor.y + dy < bounds.top) return false;
|
|
1583
|
+
return true;
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Greedy placement in reading order. Each badge first tries its resting spot,
|
|
1587
|
+
* then shifts right a couple of badge widths, and finally stacks upward. The
|
|
1588
|
+
* resting spot is always returned when no candidate fits, so a badge never
|
|
1589
|
+
* disappears because of an edge.
|
|
1590
|
+
*/
|
|
1591
|
+
function placeBadges(anchors, bounds = {}) {
|
|
1592
|
+
const placed = [];
|
|
1593
|
+
return anchors.map((anchor) => {
|
|
1594
|
+
for (let row = 0; row < MAX_ROWS; row += 1) for (let column = 0; column < MAX_COLUMNS; column += 1) {
|
|
1595
|
+
const dx = column * STEP_X;
|
|
1596
|
+
const dy = -row * STEP_Y;
|
|
1597
|
+
if (!fits(anchor, dx, dy, bounds)) continue;
|
|
1598
|
+
const candidate = {
|
|
1599
|
+
...anchor,
|
|
1600
|
+
x: anchor.x + dx,
|
|
1601
|
+
y: anchor.y + dy
|
|
1602
|
+
};
|
|
1603
|
+
if (placed.some((box) => overlaps(candidate, box))) continue;
|
|
1604
|
+
placed.push(candidate);
|
|
1605
|
+
return {
|
|
1606
|
+
dx,
|
|
1607
|
+
dy
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
placed.push(anchor);
|
|
1611
|
+
return {
|
|
1612
|
+
dx: 0,
|
|
1613
|
+
dy: 0
|
|
1614
|
+
};
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
//#endregion
|
|
874
1618
|
//#region src/client/dom/message-text.ts
|
|
875
1619
|
/**
|
|
876
1620
|
* Message-text geometry: locate a selection inside the transcript and express
|
|
877
1621
|
* it as stable character offsets over the message's own text.
|
|
878
1622
|
*
|
|
879
1623
|
* Offsets are computed over "clean text": the flow item's text nodes with the
|
|
880
|
-
* plugin's own chrome (summary
|
|
1624
|
+
* plugin's own chrome (summary blocks, directive chips, anchors) skipped. Marks
|
|
881
1625
|
* are transparent — their text still counts — so an offset captured before a
|
|
882
1626
|
* mark exists stays valid after decoration.
|
|
883
1627
|
*/
|
|
@@ -1009,6 +1753,9 @@ function cleanTextOf(item) {
|
|
|
1009
1753
|
const MARK_ATTR = "data-dsq-ann-mark";
|
|
1010
1754
|
const INDEX_ATTR = "data-dsq-ann-index";
|
|
1011
1755
|
const FLASH_MS = 1500;
|
|
1756
|
+
/** Distance from the mark's bottom edge to the badge's bottom edge (CSS). */
|
|
1757
|
+
const BADGE_BOTTOM = 8;
|
|
1758
|
+
const BADGE_MARGIN = 2;
|
|
1012
1759
|
const jobs = /* @__PURE__ */ new Map();
|
|
1013
1760
|
let pendingIds = /* @__PURE__ */ new Set();
|
|
1014
1761
|
function attrValue(value) {
|
|
@@ -1085,16 +1832,15 @@ function decorateBySearch(item, job) {
|
|
|
1085
1832
|
const full = cleanTextOf(item);
|
|
1086
1833
|
const needle = job.text.trim();
|
|
1087
1834
|
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);
|
|
1835
|
+
const patterns = [new RegExp(escapeRegExp(needle), "g"), new RegExp(needle.split(/\s+/).map(escapeRegExp).join("\\s*"), "g")];
|
|
1836
|
+
for (const pattern of patterns) for (const match of full.matchAll(pattern)) {
|
|
1837
|
+
const at = match.index ?? 0;
|
|
1838
|
+
const length = match[0].length;
|
|
1839
|
+
if (length === 0) continue;
|
|
1840
|
+
const located = cleanTextRange(item, at, at + length);
|
|
1841
|
+
if (located) {
|
|
1842
|
+
const pieces = overlappingEntries(located.entries, located.from, located.to);
|
|
1843
|
+
if (pieces.length > 0 && !isMarked(pieces) && wrapRange(item, job, located.from, located.to)) return true;
|
|
1098
1844
|
}
|
|
1099
1845
|
}
|
|
1100
1846
|
return false;
|
|
@@ -1106,6 +1852,60 @@ function decorateJob(item, job) {
|
|
|
1106
1852
|
if (decorateAtOffsets(item, job)) return true;
|
|
1107
1853
|
return decorateBySearch(item, job);
|
|
1108
1854
|
}
|
|
1855
|
+
/**
|
|
1856
|
+
* Existing marks are kept, but their ordinal is owned by the job: removing an
|
|
1857
|
+
* earlier annotation renumbers the rest without re-wrapping the text. Only the
|
|
1858
|
+
* final fragment carries the badge.
|
|
1859
|
+
*/
|
|
1860
|
+
function refreshMarkIndex(item, job) {
|
|
1861
|
+
const marks = Array.from(item.querySelectorAll(`[${MARK_ATTR}="${attrValue(job.markId)}"]`));
|
|
1862
|
+
if (marks.length === 0) return false;
|
|
1863
|
+
const last = marks[marks.length - 1];
|
|
1864
|
+
const next = String(job.index);
|
|
1865
|
+
if (last.getAttribute(INDEX_ATTR) !== next) last.setAttribute(INDEX_ATTR, next);
|
|
1866
|
+
return true;
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Resolve overlapping ordinal badges. Two annotations can end at the same
|
|
1870
|
+
* offset (nested ranges), which puts their pseudo-element badges on the same
|
|
1871
|
+
* pixel; nearby ends can also touch. Measure every last fragment, then write
|
|
1872
|
+
* the offset back as variables inherited by `::after`. The badge's transform
|
|
1873
|
+
* never participates in text layout.
|
|
1874
|
+
*/
|
|
1875
|
+
function layoutBadges(scope) {
|
|
1876
|
+
const marks = Array.from(scope.querySelectorAll(`[${INDEX_ATTR}]`));
|
|
1877
|
+
for (const mark of marks) {
|
|
1878
|
+
mark.style.removeProperty("--dsq-ann-badge-x");
|
|
1879
|
+
mark.style.removeProperty("--dsq-ann-badge-y");
|
|
1880
|
+
}
|
|
1881
|
+
if (marks.length < 2) return;
|
|
1882
|
+
const scroller = marks[0]?.closest("[data-conversation-scroll]");
|
|
1883
|
+
const scrollerRect = scroller instanceof Element ? scroller.getBoundingClientRect() : null;
|
|
1884
|
+
const rightEdge = scrollerRect && scrollerRect.width > 0 ? scrollerRect.right : document.documentElement.clientWidth;
|
|
1885
|
+
const topEdge = scrollerRect && scrollerRect.height > 0 ? scrollerRect.top : 0;
|
|
1886
|
+
const anchors = marks.map((mark) => {
|
|
1887
|
+
const rect = mark.getBoundingClientRect();
|
|
1888
|
+
return {
|
|
1889
|
+
mark,
|
|
1890
|
+
index: Number(mark.getAttribute(INDEX_ATTR) ?? 0),
|
|
1891
|
+
x: rect.right + BADGE_MARGIN,
|
|
1892
|
+
y: rect.bottom - BADGE_BOTTOM - 15,
|
|
1893
|
+
width: 24,
|
|
1894
|
+
height: 15,
|
|
1895
|
+
visible: rect.width > 0 && rect.height > 0
|
|
1896
|
+
};
|
|
1897
|
+
}).filter((anchor) => anchor.visible).sort((a, b) => a.index - b.index);
|
|
1898
|
+
if (anchors.length < 2) return;
|
|
1899
|
+
const offsets = placeBadges(anchors, {
|
|
1900
|
+
right: rightEdge - 4,
|
|
1901
|
+
top: topEdge + 4
|
|
1902
|
+
});
|
|
1903
|
+
anchors.forEach((anchor, i) => {
|
|
1904
|
+
const { dx, dy } = offsets[i];
|
|
1905
|
+
if (dx !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-x", `${dx}px`);
|
|
1906
|
+
if (dy !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-y", `${dy}px`);
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1109
1909
|
/** Apply every registered job whose message is currently rendered. */
|
|
1110
1910
|
function applyAnnotationMarks(root) {
|
|
1111
1911
|
if (jobs.size === 0) return;
|
|
@@ -1113,12 +1913,18 @@ function applyAnnotationMarks(root) {
|
|
|
1113
1913
|
if (!scope) return;
|
|
1114
1914
|
for (const job of [...jobs.values()]) {
|
|
1115
1915
|
const items = flowItems(job.messageId, scope);
|
|
1916
|
+
let decorated = false;
|
|
1116
1917
|
for (const item of items) {
|
|
1117
|
-
if (item
|
|
1918
|
+
if (refreshMarkIndex(item, job)) {
|
|
1919
|
+
decorated = true;
|
|
1920
|
+
continue;
|
|
1921
|
+
}
|
|
1922
|
+
if (decorated) continue;
|
|
1118
1923
|
if (job.messageId === "" && !item.textContent?.includes(job.text.trim().slice(0, 16))) continue;
|
|
1119
|
-
if (decorateJob(item, job))
|
|
1924
|
+
if (decorateJob(item, job)) decorated = true;
|
|
1120
1925
|
}
|
|
1121
1926
|
}
|
|
1927
|
+
layoutBadges(scope);
|
|
1122
1928
|
}
|
|
1123
1929
|
/** Insert or refresh jobs; identical jobs are no-ops. */
|
|
1124
1930
|
function upsertAnnotationJobs(next) {
|
|
@@ -1172,6 +1978,7 @@ function watchAnnotationMarks(root) {
|
|
|
1172
1978
|
applyAnnotationMarks(el);
|
|
1173
1979
|
let frame = 0;
|
|
1174
1980
|
const schedule = () => {
|
|
1981
|
+
if (jobs.size === 0) return;
|
|
1175
1982
|
if (frame !== 0) return;
|
|
1176
1983
|
frame = window.requestAnimationFrame(() => {
|
|
1177
1984
|
frame = 0;
|
|
@@ -1183,8 +1990,11 @@ function watchAnnotationMarks(root) {
|
|
|
1183
1990
|
childList: true,
|
|
1184
1991
|
subtree: true
|
|
1185
1992
|
});
|
|
1993
|
+
const onResize = () => schedule();
|
|
1994
|
+
window.addEventListener("resize", onResize);
|
|
1186
1995
|
return () => {
|
|
1187
1996
|
observer.disconnect();
|
|
1997
|
+
window.removeEventListener("resize", onResize);
|
|
1188
1998
|
if (frame !== 0) window.cancelAnimationFrame(frame);
|
|
1189
1999
|
};
|
|
1190
2000
|
}
|
|
@@ -1390,43 +2200,52 @@ function composeAnnotatedMessage(draft, annotations) {
|
|
|
1390
2200
|
const rest = stripDraftMarker(draft).replace(/^\s+/, "");
|
|
1391
2201
|
if (annotations.length === 0) return rest;
|
|
1392
2202
|
const payload = annotations.map(toWireItem);
|
|
2203
|
+
const json = JSON.stringify(payload).replace(/</g, "\\u003c");
|
|
1393
2204
|
const block = [
|
|
1394
2205
|
PROTOCOL_HEADER,
|
|
1395
2206
|
"",
|
|
1396
2207
|
ANNOTATIONS_BLOCK_OPEN,
|
|
1397
|
-
|
|
2208
|
+
json,
|
|
1398
2209
|
ANNOTATIONS_BLOCK_CLOSE
|
|
1399
2210
|
].join("\n");
|
|
1400
2211
|
return rest ? `${block}\n\n${rest}` : `${block}\n\n`;
|
|
1401
2212
|
}
|
|
1402
2213
|
/** Remove `# Response annotations:` instructional prose before the JSON block. */
|
|
1403
2214
|
function stripProtocolHeader(prefix) {
|
|
1404
|
-
|
|
2215
|
+
const marker = prefix.indexOf("# Response annotations:");
|
|
2216
|
+
if (marker >= 0) return prefix.slice(0, marker).replace(/\s+$/, "");
|
|
1405
2217
|
return prefix.replace(/\s+$/, "");
|
|
1406
2218
|
}
|
|
1407
2219
|
function blockParts(text) {
|
|
1408
2220
|
const open = text.indexOf(ANNOTATIONS_BLOCK_OPEN);
|
|
1409
2221
|
if (open < 0) return null;
|
|
1410
|
-
const
|
|
1411
|
-
|
|
2222
|
+
const payloadStart = open + 22;
|
|
2223
|
+
const firstClose = text.indexOf(ANNOTATIONS_BLOCK_CLOSE, payloadStart);
|
|
2224
|
+
let close = firstClose;
|
|
2225
|
+
while (close >= 0) {
|
|
2226
|
+
const json = text.slice(payloadStart, close).trim();
|
|
2227
|
+
try {
|
|
2228
|
+
return {
|
|
2229
|
+
before: stripProtocolHeader(text.slice(0, open)),
|
|
2230
|
+
after: text.slice(close + 23).replace(/^\s+/, ""),
|
|
2231
|
+
parsed: JSON.parse(json)
|
|
2232
|
+
};
|
|
2233
|
+
} catch {
|
|
2234
|
+
close = text.indexOf(ANNOTATIONS_BLOCK_CLOSE, close + 23);
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
if (firstClose < 0) return null;
|
|
1412
2238
|
return {
|
|
1413
2239
|
before: stripProtocolHeader(text.slice(0, open)),
|
|
1414
|
-
after: text.slice(
|
|
1415
|
-
json: text.slice(open + 22, close).trim()
|
|
2240
|
+
after: text.slice(firstClose + 23).replace(/^\s+/, "")
|
|
1416
2241
|
};
|
|
1417
2242
|
}
|
|
1418
2243
|
function joinedRest(parts) {
|
|
1419
2244
|
return [parts.before, parts.after].filter((part) => part.length > 0).join("\n\n").trim();
|
|
1420
2245
|
}
|
|
1421
|
-
/** Wire payload as an array; null when the block is not
|
|
2246
|
+
/** Wire payload as an array; null when the block is not an array. */
|
|
1422
2247
|
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;
|
|
2248
|
+
return Array.isArray(parts.parsed) ? parts.parsed : null;
|
|
1430
2249
|
}
|
|
1431
2250
|
/** Every source an item carries; falls back to the selected text itself. */
|
|
1432
2251
|
function sourcesOf(record, body) {
|
|
@@ -1453,6 +2272,9 @@ function wireBody(entry) {
|
|
|
1453
2272
|
/**
|
|
1454
2273
|
* Read plugin annotations out of a durable message. Tolerates the JSON block
|
|
1455
2274
|
* and strips it from `rest` so the bubble can show only the user's question.
|
|
2275
|
+
* A complete block with a malformed or non-array payload yields no
|
|
2276
|
+
* annotations but is still stripped — the transcript never shows protocol
|
|
2277
|
+
* text, valid or not.
|
|
1456
2278
|
*/
|
|
1457
2279
|
function scanAnnotatedMessage(text) {
|
|
1458
2280
|
const parts = blockParts(text);
|
|
@@ -1460,12 +2282,12 @@ function scanAnnotatedMessage(text) {
|
|
|
1460
2282
|
annotations: [],
|
|
1461
2283
|
rest: text
|
|
1462
2284
|
};
|
|
2285
|
+
const rest = joinedRest(parts);
|
|
1463
2286
|
const parsed = parseWire(parts);
|
|
1464
2287
|
if (parsed === null) return {
|
|
1465
2288
|
annotations: [],
|
|
1466
|
-
rest
|
|
2289
|
+
rest
|
|
1467
2290
|
};
|
|
1468
|
-
const rest = joinedRest(parts);
|
|
1469
2291
|
const annotations = [];
|
|
1470
2292
|
for (const entry of parsed) {
|
|
1471
2293
|
const item = wireBody(entry);
|
|
@@ -1517,13 +2339,6 @@ function readComposedDraft(text) {
|
|
|
1517
2339
|
}
|
|
1518
2340
|
//#endregion
|
|
1519
2341
|
//#region src/client/runtime.ts
|
|
1520
|
-
/**
|
|
1521
|
-
* Shared runtime handles across slot scopes.
|
|
1522
|
-
*
|
|
1523
|
-
* The selection toolbar mounts at root (`shell.overlay`) while the annotation
|
|
1524
|
-
* panel is session-scoped, so both sides read one partition keyed by the
|
|
1525
|
-
* session the host minted for the active composer.
|
|
1526
|
-
*/
|
|
1527
2342
|
const runtime = {};
|
|
1528
2343
|
function setRuntimeSession(sessionId) {
|
|
1529
2344
|
if (sessionId) runtime.sessionId = sessionId;
|
|
@@ -1535,15 +2350,15 @@ const EMPTY$1 = [];
|
|
|
1535
2350
|
/** Bucket for annotations added before a session id is known (never persisted). */
|
|
1536
2351
|
const MEMORY_KEY = "pending";
|
|
1537
2352
|
const bySession = /* @__PURE__ */ new Map();
|
|
1538
|
-
const listeners = /* @__PURE__ */ new Set();
|
|
2353
|
+
const listeners$1 = /* @__PURE__ */ new Set();
|
|
1539
2354
|
let persistence = null;
|
|
1540
2355
|
function notify() {
|
|
1541
|
-
for (const listener of [...listeners]) listener();
|
|
2356
|
+
for (const listener of [...listeners$1]) listener();
|
|
1542
2357
|
}
|
|
1543
2358
|
function subscribeAnnotations(listener) {
|
|
1544
|
-
listeners.add(listener);
|
|
2359
|
+
listeners$1.add(listener);
|
|
1545
2360
|
return () => {
|
|
1546
|
-
listeners.delete(listener);
|
|
2361
|
+
listeners$1.delete(listener);
|
|
1547
2362
|
};
|
|
1548
2363
|
}
|
|
1549
2364
|
/**
|
|
@@ -1686,18 +2501,63 @@ function adoptPersistedAnnotations(sessionId) {
|
|
|
1686
2501
|
if (orphaned.length > 0) bySession.delete(MEMORY_KEY);
|
|
1687
2502
|
notify();
|
|
1688
2503
|
}
|
|
2504
|
+
/** Same ids in the same order, with the only mutable field (comment) equal. */
|
|
2505
|
+
function sameAnnotations(a, b) {
|
|
2506
|
+
return a.length === b.length && a.every((item, i) => {
|
|
2507
|
+
const other = b[i];
|
|
2508
|
+
return other !== void 0 && item.id === other.id && (item.comment ?? "") === (other.comment ?? "");
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
/**
|
|
2512
|
+
* Re-read a session after another tab wrote its storage key.
|
|
2513
|
+
*
|
|
2514
|
+
* Storage is the shared source of truth: every local mutation persists
|
|
2515
|
+
* synchronously, so this tab's memory can only be stale relative to it. The
|
|
2516
|
+
* stored array replaces the local one (last writer wins) — merging would
|
|
2517
|
+
* resurrect annotations the other tab already sent and cleared.
|
|
2518
|
+
*
|
|
2519
|
+
* `null` is the `localStorage.clear()` event: reload every partition this tab
|
|
2520
|
+
* holds. Sessions that are neither cached nor active are ignored; binding the
|
|
2521
|
+
* session later reads storage anyway.
|
|
2522
|
+
*/
|
|
2523
|
+
function adoptStorageAnnotations(sessionId) {
|
|
2524
|
+
if (!persistence) return;
|
|
2525
|
+
if (sessionId === null) {
|
|
2526
|
+
let changed = false;
|
|
2527
|
+
for (const key of [...bySession.keys()]) {
|
|
2528
|
+
if (key === MEMORY_KEY) continue;
|
|
2529
|
+
const stored = persistence.load(key);
|
|
2530
|
+
const current = bySession.get(key);
|
|
2531
|
+
if (stored.length === 0) {
|
|
2532
|
+
bySession.delete(key);
|
|
2533
|
+
changed = true;
|
|
2534
|
+
} else if (current === void 0 || !sameAnnotations(stored, current)) {
|
|
2535
|
+
bySession.set(key, [...stored]);
|
|
2536
|
+
changed = true;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
if (changed) notify();
|
|
2540
|
+
return;
|
|
2541
|
+
}
|
|
2542
|
+
if (!bySession.has(sessionId) && sessionId !== runtime.sessionId) return;
|
|
2543
|
+
const stored = persistence.load(sessionId);
|
|
2544
|
+
if (sameAnnotations(stored, bySession.get(sessionId) ?? EMPTY$1)) return;
|
|
2545
|
+
if (stored.length === 0) bySession.delete(sessionId);
|
|
2546
|
+
else bySession.set(sessionId, [...stored]);
|
|
2547
|
+
notify();
|
|
2548
|
+
}
|
|
1689
2549
|
function annotationTitle(annotation) {
|
|
1690
2550
|
return previewTitle(annotation.text);
|
|
1691
2551
|
}
|
|
1692
2552
|
//#endregion
|
|
1693
2553
|
//#region src/client/dom/popoverPlacement.ts
|
|
1694
|
-
const GAP = 8;
|
|
2554
|
+
const GAP$2 = 8;
|
|
1695
2555
|
const MARGIN = 8;
|
|
1696
2556
|
/**
|
|
1697
2557
|
* Place `panel` near `anchor`, flipping vertical side when the preferred side
|
|
1698
2558
|
* lacks room. Keeps the panel inside `bounds` (the viewport by default).
|
|
1699
2559
|
*/
|
|
1700
|
-
function placePopover(anchor, panel, preferred = "bottom", gap = GAP, bounds) {
|
|
2560
|
+
function placePopover(anchor, panel, preferred = "bottom", gap = GAP$2, bounds) {
|
|
1701
2561
|
const box = bounds ?? {
|
|
1702
2562
|
top: 0,
|
|
1703
2563
|
left: 0,
|
|
@@ -1784,6 +2644,7 @@ const zh = {
|
|
|
1784
2644
|
"common.selectedText": "选中的文本",
|
|
1785
2645
|
"common.commentPlaceholder": "添加可选评论…",
|
|
1786
2646
|
"common.cancel": "取消",
|
|
2647
|
+
"common.close": "关闭",
|
|
1787
2648
|
"common.save": "保存",
|
|
1788
2649
|
"toolbar.aria": "划词操作",
|
|
1789
2650
|
"toolbar.commentHint": "添加批注…",
|
|
@@ -1794,21 +2655,42 @@ const zh = {
|
|
|
1794
2655
|
"toolbar.commentDialogAria": "添加可选评论",
|
|
1795
2656
|
"toolbar.skipComment": "暂不评论",
|
|
1796
2657
|
"toolbar.addComment": "添加评论",
|
|
2658
|
+
"menu.aria": "划词菜单",
|
|
2659
|
+
"menu.shortcutLabel": "打开划词菜单",
|
|
2660
|
+
"menu.addShortcutLabel": "将选中文字添加到任务",
|
|
2661
|
+
"menu.noSelection": "先在对话里选中一段文字",
|
|
2662
|
+
"menu.busy": "先完成当前批注",
|
|
1797
2663
|
"summary.hint": "悬停查看批注",
|
|
2664
|
+
"summary.hintTouch": "点按查看批注",
|
|
1798
2665
|
"summary.dialogAria": "{count} 条划词批注详情",
|
|
1799
2666
|
"summary.edit": "编辑批注 {index}",
|
|
1800
2667
|
"summary.remove": "移除批注 {index}",
|
|
1801
2668
|
"summary.userComment": "用户评论",
|
|
1802
2669
|
"summary.noComment": "未添加评论",
|
|
1803
|
-
"summary.aria.other": "{count}
|
|
2670
|
+
"summary.aria.other": "{count} 条划词批注。展开查看详情。",
|
|
1804
2671
|
"summary.title.other": "{count} 条批注",
|
|
1805
2672
|
"summary.removeAll": "移除全部划词批注",
|
|
1806
|
-
"chip.annotation": "批注 {index}"
|
|
2673
|
+
"chip.annotation": "批注 {index}",
|
|
2674
|
+
"chip.previewAria": "批注预览",
|
|
2675
|
+
"chip.jump": "原文",
|
|
2676
|
+
"chip.jumpAria": "跳转到原文",
|
|
2677
|
+
"settings.trigger": "划词入口",
|
|
2678
|
+
"settings.triggerHint": "选中文字后何时显示操作条。触屏始终使用底部操作条。",
|
|
2679
|
+
"settings.trigger.auto": "自动浮出",
|
|
2680
|
+
"settings.trigger.contextmenu": "右键菜单",
|
|
2681
|
+
"settings.trigger.both": "两者都开",
|
|
2682
|
+
"settings.shortcut": "快捷键",
|
|
2683
|
+
"settings.shortcutHint": "开启后,选中文字即可使用下面的快捷键;可在宿主的「快捷键」设置里改键。",
|
|
2684
|
+
"settings.shortcutListAria": "当前快捷键",
|
|
2685
|
+
"settings.shortcutUnavailable": "快捷键暂不可用(可能被其它插件占用)",
|
|
2686
|
+
"settings.shortcutUnbound": "未绑定",
|
|
2687
|
+
"settings.shortcutConflict": "与其它快捷键冲突"
|
|
1807
2688
|
};
|
|
1808
2689
|
const en = {
|
|
1809
2690
|
"common.selectedText": "Selected text",
|
|
1810
2691
|
"common.commentPlaceholder": "Add an optional comment…",
|
|
1811
2692
|
"common.cancel": "Cancel",
|
|
2693
|
+
"common.close": "Close",
|
|
1812
2694
|
"common.save": "Save",
|
|
1813
2695
|
"toolbar.aria": "Selection actions",
|
|
1814
2696
|
"toolbar.commentHint": "Add annotation…",
|
|
@@ -1819,18 +2701,38 @@ const en = {
|
|
|
1819
2701
|
"toolbar.commentDialogAria": "Add an optional comment",
|
|
1820
2702
|
"toolbar.skipComment": "Skip comment",
|
|
1821
2703
|
"toolbar.addComment": "Add comment",
|
|
2704
|
+
"menu.aria": "Selection menu",
|
|
2705
|
+
"menu.shortcutLabel": "Open selection menu",
|
|
2706
|
+
"menu.addShortcutLabel": "Add selected text to task",
|
|
2707
|
+
"menu.noSelection": "Select text in the conversation first",
|
|
2708
|
+
"menu.busy": "Finish the current annotation first",
|
|
1822
2709
|
"summary.hint": "Hover to view annotations",
|
|
2710
|
+
"summary.hintTouch": "Tap to view annotations",
|
|
1823
2711
|
"summary.dialogAria": "Annotation details: {count}",
|
|
1824
2712
|
"summary.edit": "Edit annotation {index}",
|
|
1825
2713
|
"summary.remove": "Remove annotation {index}",
|
|
1826
2714
|
"summary.userComment": "User comment",
|
|
1827
2715
|
"summary.noComment": "No comment added",
|
|
1828
|
-
"summary.aria.one": "{count} annotation.
|
|
1829
|
-
"summary.aria.other": "{count} annotations.
|
|
2716
|
+
"summary.aria.one": "{count} annotation. Open to view details.",
|
|
2717
|
+
"summary.aria.other": "{count} annotations. Open to view details.",
|
|
1830
2718
|
"summary.title.one": "{count} annotation",
|
|
1831
2719
|
"summary.title.other": "{count} annotations",
|
|
1832
2720
|
"summary.removeAll": "Remove all annotations",
|
|
1833
|
-
"chip.annotation": "Annotation {index}"
|
|
2721
|
+
"chip.annotation": "Annotation {index}",
|
|
2722
|
+
"chip.previewAria": "Annotation preview",
|
|
2723
|
+
"chip.jump": "Original",
|
|
2724
|
+
"chip.jumpAria": "Jump to original",
|
|
2725
|
+
"settings.trigger": "Trigger",
|
|
2726
|
+
"settings.triggerHint": "When the action bar appears after you select text. Touch screens always use the bottom bar.",
|
|
2727
|
+
"settings.trigger.auto": "Automatic",
|
|
2728
|
+
"settings.trigger.contextmenu": "Right-click",
|
|
2729
|
+
"settings.trigger.both": "Both",
|
|
2730
|
+
"settings.shortcut": "Shortcut",
|
|
2731
|
+
"settings.shortcutHint": "When on, the shortcuts below work on the selected text. Rebind them in the Host's Keyboard shortcuts settings.",
|
|
2732
|
+
"settings.shortcutListAria": "Active shortcuts",
|
|
2733
|
+
"settings.shortcutUnavailable": "Shortcuts are unavailable (another plugin may already use the keys).",
|
|
2734
|
+
"settings.shortcutUnbound": "Unbound",
|
|
2735
|
+
"settings.shortcutConflict": "Conflicts with another shortcut"
|
|
1834
2736
|
};
|
|
1835
2737
|
/**
|
|
1836
2738
|
* Russian is not a shipped host locale: it is added through
|
|
@@ -1841,6 +2743,7 @@ const ru = {
|
|
|
1841
2743
|
"common.selectedText": "Выделенный текст",
|
|
1842
2744
|
"common.commentPlaceholder": "Добавить необязательный комментарий…",
|
|
1843
2745
|
"common.cancel": "Отмена",
|
|
2746
|
+
"common.close": "Закрыть",
|
|
1844
2747
|
"common.save": "Сохранить",
|
|
1845
2748
|
"toolbar.aria": "Действия с выделенным текстом",
|
|
1846
2749
|
"toolbar.commentHint": "Добавить аннотацию…",
|
|
@@ -1851,22 +2754,42 @@ const ru = {
|
|
|
1851
2754
|
"toolbar.commentDialogAria": "Добавить необязательный комментарий",
|
|
1852
2755
|
"toolbar.skipComment": "Без комментария",
|
|
1853
2756
|
"toolbar.addComment": "Добавить комментарий",
|
|
2757
|
+
"menu.aria": "Меню выделения",
|
|
2758
|
+
"menu.shortcutLabel": "Открыть меню выделения",
|
|
2759
|
+
"menu.addShortcutLabel": "Добавить выделенный текст к задаче",
|
|
2760
|
+
"menu.noSelection": "Сначала выделите текст в диалоге",
|
|
2761
|
+
"menu.busy": "Сначала завершите текущую аннотацию",
|
|
1854
2762
|
"summary.hint": "Наведите, чтобы увидеть аннотации",
|
|
2763
|
+
"summary.hintTouch": "Нажмите, чтобы увидеть аннотации",
|
|
1855
2764
|
"summary.dialogAria": "Детали аннотаций: {count}",
|
|
1856
2765
|
"summary.edit": "Изменить аннотацию {index}",
|
|
1857
2766
|
"summary.remove": "Удалить аннотацию {index}",
|
|
1858
2767
|
"summary.userComment": "Комментарий пользователя",
|
|
1859
2768
|
"summary.noComment": "Комментарий не добавлен",
|
|
1860
|
-
"summary.aria.one": "{count} аннотация.
|
|
1861
|
-
"summary.aria.few": "{count} аннотации.
|
|
1862
|
-
"summary.aria.many": "{count} аннотаций.
|
|
1863
|
-
"summary.aria.other": "{count} аннотаций.
|
|
2769
|
+
"summary.aria.one": "{count} аннотация. Откройте, чтобы увидеть подробности.",
|
|
2770
|
+
"summary.aria.few": "{count} аннотации. Откройте, чтобы увидеть подробности.",
|
|
2771
|
+
"summary.aria.many": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
|
|
2772
|
+
"summary.aria.other": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
|
|
1864
2773
|
"summary.title.one": "{count} аннотация",
|
|
1865
2774
|
"summary.title.few": "{count} аннотации",
|
|
1866
2775
|
"summary.title.many": "{count} аннотаций",
|
|
1867
2776
|
"summary.title.other": "{count} аннотаций",
|
|
1868
2777
|
"summary.removeAll": "Удалить все аннотации",
|
|
1869
|
-
"chip.annotation": "Аннотация {index}"
|
|
2778
|
+
"chip.annotation": "Аннотация {index}",
|
|
2779
|
+
"chip.previewAria": "Просмотр аннотации",
|
|
2780
|
+
"chip.jump": "Оригинал",
|
|
2781
|
+
"chip.jumpAria": "Перейти к оригиналу",
|
|
2782
|
+
"settings.trigger": "Триггер",
|
|
2783
|
+
"settings.triggerHint": "Когда показывать панель действий после выделения текста. На сенсорных экранах всегда используется нижняя панель.",
|
|
2784
|
+
"settings.trigger.auto": "Автоматически",
|
|
2785
|
+
"settings.trigger.contextmenu": "По правому клику",
|
|
2786
|
+
"settings.trigger.both": "Оба способа",
|
|
2787
|
+
"settings.shortcut": "Горячая клавиша",
|
|
2788
|
+
"settings.shortcutHint": "Когда включено, сочетания ниже работают с выделенным текстом. Изменить их можно в настройках горячих клавиш.",
|
|
2789
|
+
"settings.shortcutListAria": "Текущие сочетания",
|
|
2790
|
+
"settings.shortcutUnavailable": "Сочетания недоступны (возможно, клавиши заняты другим плагином).",
|
|
2791
|
+
"settings.shortcutUnbound": "Не назначено",
|
|
2792
|
+
"settings.shortcutConflict": "Конфликтует с другим сочетанием"
|
|
1870
2793
|
};
|
|
1871
2794
|
/** Both shipped host locales, required together by the typed `register()`. */
|
|
1872
2795
|
const SHIPPED_DICTS = Object.freeze({
|
|
@@ -1948,10 +2871,18 @@ function useLocale() {
|
|
|
1948
2871
|
}
|
|
1949
2872
|
//#endregion
|
|
1950
2873
|
//#region src/client/ui/AnnotationCardView.tsx
|
|
1951
|
-
/**
|
|
1952
|
-
const CLOSE_DELAY =
|
|
2874
|
+
/** Short grace for crossing the 12px bridge; the exit starts right after it. */
|
|
2875
|
+
const CLOSE_DELAY$1 = 40;
|
|
1953
2876
|
/** After a viewport change, wait this long before position-checking the pointer. */
|
|
1954
2877
|
const SETTLE_DELAY = 600;
|
|
2878
|
+
/** Transparent strip on the facing side; the pointer crosses it in transit. */
|
|
2879
|
+
const BRIDGE = 12;
|
|
2880
|
+
/** Matches the list's leaving keyframe; the panel unmounts after it. */
|
|
2881
|
+
const EXIT_MS$1 = 120;
|
|
2882
|
+
/** Stable edit target: item id, with an index fallback for id-less callers. */
|
|
2883
|
+
function editKeyOf(item, index) {
|
|
2884
|
+
return item?.id ?? `index:${index}`;
|
|
2885
|
+
}
|
|
1955
2886
|
/**
|
|
1956
2887
|
* Qoder-style summary with a placement-aware hover list.
|
|
1957
2888
|
*
|
|
@@ -1971,21 +2902,31 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
1971
2902
|
const settleTimerRef = (0, react.useRef)(null);
|
|
1972
2903
|
const lastPointerRef = (0, react.useRef)(null);
|
|
1973
2904
|
const editInputRef = (0, react.useRef)(null);
|
|
2905
|
+
/** Set while keyboard focus is being restored after a deliberate close. */
|
|
2906
|
+
const focusGuardRef = (0, react.useRef)(false);
|
|
2907
|
+
/** Set by keyboard open; the effect focuses the panel once it is mounted. */
|
|
2908
|
+
const enterPanelRef = (0, react.useRef)(false);
|
|
2909
|
+
const panelId = (0, react.useId)();
|
|
1974
2910
|
const [open, setOpen] = (0, react.useState)(false);
|
|
2911
|
+
const [renderPanel, setRenderPanel] = (0, react.useState)(false);
|
|
2912
|
+
const [closingPanel, setClosingPanel] = (0, react.useState)(false);
|
|
2913
|
+
const exitTimerRef = (0, react.useRef)(null);
|
|
1975
2914
|
const [editing, setEditing] = (0, react.useState)(null);
|
|
1976
2915
|
const [editDraft, setEditDraft] = (0, react.useState)("");
|
|
1977
2916
|
const place = (0, react.useCallback)(() => {
|
|
1978
2917
|
const root = rootRef.current;
|
|
1979
2918
|
const pop = popRef.current;
|
|
1980
2919
|
if (!root || !pop) return;
|
|
1981
|
-
const
|
|
1982
|
-
|
|
1983
|
-
|
|
2920
|
+
const anchor = root.getBoundingClientRect();
|
|
2921
|
+
const inner = pop.firstElementChild instanceof HTMLElement ? pop.firstElementChild : null;
|
|
2922
|
+
const { top, left, side } = placePopover(anchor, {
|
|
2923
|
+
width: inner?.offsetWidth || pop.offsetWidth || 300,
|
|
2924
|
+
height: (inner?.offsetHeight || pop.offsetHeight || 220) + BRIDGE
|
|
1984
2925
|
}, "top", 0);
|
|
1985
2926
|
pop.style.top = `${top}px`;
|
|
1986
2927
|
pop.style.left = `${left}px`;
|
|
1987
|
-
pop.style.paddingTop = side === "bottom" ?
|
|
1988
|
-
pop.style.paddingBottom = side === "top" ?
|
|
2928
|
+
pop.style.paddingTop = side === "bottom" ? `${BRIDGE}px` : "0";
|
|
2929
|
+
pop.style.paddingBottom = side === "top" ? `${BRIDGE}px` : "0";
|
|
1989
2930
|
pop.dataset.side = side;
|
|
1990
2931
|
pop.style.opacity = "1";
|
|
1991
2932
|
}, []);
|
|
@@ -2003,11 +2944,81 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2003
2944
|
cancelClose();
|
|
2004
2945
|
setOpen(false);
|
|
2005
2946
|
}, [cancelClose]);
|
|
2947
|
+
/** Keyboard-focusable controls in the open panel, in DOM order. */
|
|
2948
|
+
const focusables = (0, react.useCallback)(() => {
|
|
2949
|
+
const pop = popRef.current;
|
|
2950
|
+
if (!pop) return [];
|
|
2951
|
+
return Array.from(pop.querySelectorAll("button, textarea, [href], [tabindex]:not([tabindex=\"-1\"])")).filter((el) => !el.hasAttribute("disabled") && el.getClientRects().length > 0);
|
|
2952
|
+
}, []);
|
|
2953
|
+
const focusPanel = (0, react.useCallback)((edge = "first") => {
|
|
2954
|
+
const list = focusables();
|
|
2955
|
+
(edge === "first" ? list[0] : list[list.length - 1])?.focus({ preventScroll: true });
|
|
2956
|
+
}, [focusables]);
|
|
2957
|
+
/** Close the panel and put focus back on the summary without re-opening it. */
|
|
2958
|
+
const closeAndRestoreFocus = (0, react.useCallback)(() => {
|
|
2959
|
+
focusGuardRef.current = true;
|
|
2960
|
+
closePop();
|
|
2961
|
+
rootRef.current?.focus({ preventScroll: true });
|
|
2962
|
+
window.setTimeout(() => {
|
|
2963
|
+
focusGuardRef.current = false;
|
|
2964
|
+
}, 0);
|
|
2965
|
+
}, [closePop]);
|
|
2966
|
+
const openAndEnterPanel = (0, react.useCallback)(() => {
|
|
2967
|
+
enterPanelRef.current = true;
|
|
2968
|
+
openPop();
|
|
2969
|
+
}, [openPop]);
|
|
2006
2970
|
const scheduleClose = (0, react.useCallback)(() => {
|
|
2007
2971
|
cancelClose();
|
|
2008
2972
|
if (editing !== null) return;
|
|
2009
|
-
closeTimerRef.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY);
|
|
2973
|
+
closeTimerRef.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY$1);
|
|
2010
2974
|
}, [cancelClose, editing]);
|
|
2975
|
+
/** Touch pointers emit pointerleave on lift; only hover pointers auto-close. */
|
|
2976
|
+
const onPointerLeave = (0, react.useCallback)((event) => {
|
|
2977
|
+
if (event?.pointerType === "touch") return;
|
|
2978
|
+
scheduleClose();
|
|
2979
|
+
}, [scheduleClose]);
|
|
2980
|
+
const onSummaryPointerUp = (0, react.useCallback)((event) => {
|
|
2981
|
+
if (event.pointerType !== "touch") return;
|
|
2982
|
+
const target = event.target;
|
|
2983
|
+
if (target instanceof Element && target.closest("button, textarea") !== null) return;
|
|
2984
|
+
openPop();
|
|
2985
|
+
}, [openPop]);
|
|
2986
|
+
(0, react.useEffect)(() => {
|
|
2987
|
+
if (!open) return;
|
|
2988
|
+
const onPointerDown = (event) => {
|
|
2989
|
+
const target = event.target;
|
|
2990
|
+
if (!(target instanceof Node)) return;
|
|
2991
|
+
if (rootRef.current?.contains(target) || popRef.current?.contains(target)) return;
|
|
2992
|
+
if (editing !== null) return;
|
|
2993
|
+
setOpen(false);
|
|
2994
|
+
};
|
|
2995
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
2996
|
+
return () => document.removeEventListener("pointerdown", onPointerDown, true);
|
|
2997
|
+
}, [open, editing]);
|
|
2998
|
+
(0, react.useLayoutEffect)(() => {
|
|
2999
|
+
if (open) {
|
|
3000
|
+
if (exitTimerRef.current !== null) {
|
|
3001
|
+
window.clearTimeout(exitTimerRef.current);
|
|
3002
|
+
exitTimerRef.current = null;
|
|
3003
|
+
}
|
|
3004
|
+
setClosingPanel(false);
|
|
3005
|
+
setRenderPanel(true);
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
if (!renderPanel) return;
|
|
3009
|
+
setClosingPanel(true);
|
|
3010
|
+
exitTimerRef.current = window.setTimeout(() => {
|
|
3011
|
+
exitTimerRef.current = null;
|
|
3012
|
+
setRenderPanel(false);
|
|
3013
|
+
setClosingPanel(false);
|
|
3014
|
+
}, EXIT_MS$1);
|
|
3015
|
+
return () => {
|
|
3016
|
+
if (exitTimerRef.current !== null) {
|
|
3017
|
+
window.clearTimeout(exitTimerRef.current);
|
|
3018
|
+
exitTimerRef.current = null;
|
|
3019
|
+
}
|
|
3020
|
+
};
|
|
3021
|
+
}, [open, renderPanel]);
|
|
2011
3022
|
/** True when the last seen pointer sits on the summary or the panel. */
|
|
2012
3023
|
const pointerInsideCluster = (0, react.useCallback)(() => {
|
|
2013
3024
|
const point = lastPointerRef.current;
|
|
@@ -2030,6 +3041,11 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2030
3041
|
items,
|
|
2031
3042
|
editing
|
|
2032
3043
|
]);
|
|
3044
|
+
(0, react.useLayoutEffect)(() => {
|
|
3045
|
+
if (!open || !enterPanelRef.current) return;
|
|
3046
|
+
enterPanelRef.current = false;
|
|
3047
|
+
focusPanel("first");
|
|
3048
|
+
}, [open, focusPanel]);
|
|
2033
3049
|
(0, react.useEffect)(() => {
|
|
2034
3050
|
if (!open) return;
|
|
2035
3051
|
const track = (event) => {
|
|
@@ -2084,31 +3100,98 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2084
3100
|
const onPointerEnter = (0, react.useCallback)(() => {
|
|
2085
3101
|
openPop();
|
|
2086
3102
|
}, [openPop]);
|
|
2087
|
-
const onPointerLeave = (0, react.useCallback)(() => {
|
|
2088
|
-
scheduleClose();
|
|
2089
|
-
}, [scheduleClose]);
|
|
2090
3103
|
const onBlur = (0, react.useCallback)((e) => {
|
|
3104
|
+
if (editing !== null) return;
|
|
2091
3105
|
const next = e.relatedTarget;
|
|
2092
3106
|
if (next instanceof Node) {
|
|
2093
3107
|
if (rootRef.current?.contains(next)) return;
|
|
2094
3108
|
if (popRef.current?.contains(next)) return;
|
|
2095
3109
|
}
|
|
2096
3110
|
closePop();
|
|
2097
|
-
}, [closePop]);
|
|
3111
|
+
}, [closePop, editing]);
|
|
3112
|
+
/** Keyboard focus opens the panel; mouse/touch focus does not. */
|
|
3113
|
+
const onSummaryFocus = (0, react.useCallback)(() => {
|
|
3114
|
+
if (focusGuardRef.current) return;
|
|
3115
|
+
if (rootRef.current?.matches(":focus-visible")) openPop();
|
|
3116
|
+
}, [openPop]);
|
|
2098
3117
|
const onKeyDown = (0, react.useCallback)((e) => {
|
|
2099
3118
|
if (e.key === "Escape") {
|
|
3119
|
+
e.preventDefault();
|
|
2100
3120
|
if (editing !== null) setEditing(null);
|
|
2101
|
-
else
|
|
3121
|
+
else closeAndRestoreFocus();
|
|
3122
|
+
return;
|
|
2102
3123
|
}
|
|
2103
|
-
|
|
3124
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
3125
|
+
if (e.target !== e.currentTarget) return;
|
|
3126
|
+
e.preventDefault();
|
|
3127
|
+
if (open) closePop();
|
|
3128
|
+
else openAndEnterPanel();
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
if (e.key === "ArrowDown" && e.target === e.currentTarget) {
|
|
3132
|
+
e.preventDefault();
|
|
3133
|
+
if (open) focusPanel("first");
|
|
3134
|
+
else openAndEnterPanel();
|
|
3135
|
+
return;
|
|
3136
|
+
}
|
|
3137
|
+
if (e.key === "Tab" && open && !e.shiftKey && e.target === e.currentTarget) {
|
|
3138
|
+
e.preventDefault();
|
|
3139
|
+
focusPanel("first");
|
|
3140
|
+
}
|
|
3141
|
+
}, [
|
|
3142
|
+
closeAndRestoreFocus,
|
|
3143
|
+
closePop,
|
|
3144
|
+
editing,
|
|
3145
|
+
focusPanel,
|
|
3146
|
+
open,
|
|
3147
|
+
openAndEnterPanel
|
|
3148
|
+
]);
|
|
3149
|
+
const onPanelKeyDown = (0, react.useCallback)((e) => {
|
|
3150
|
+
if (e.key === "Escape") {
|
|
3151
|
+
e.preventDefault();
|
|
3152
|
+
e.stopPropagation();
|
|
3153
|
+
if (editing !== null) setEditing(null);
|
|
3154
|
+
else closeAndRestoreFocus();
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
if (e.key === "Tab" || e.key === "ArrowDown" || e.key === "ArrowUp") e.stopPropagation();
|
|
3158
|
+
const list = focusables();
|
|
3159
|
+
if (list.length === 0) return;
|
|
3160
|
+
const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
3161
|
+
const index = active === null ? -1 : list.indexOf(active);
|
|
3162
|
+
if (e.key === "Tab") {
|
|
3163
|
+
if (list.length === 1) {
|
|
3164
|
+
e.preventDefault();
|
|
3165
|
+
list[0].focus();
|
|
3166
|
+
} else if (!e.shiftKey && index === list.length - 1) {
|
|
3167
|
+
e.preventDefault();
|
|
3168
|
+
list[0].focus();
|
|
3169
|
+
} else if (e.shiftKey && index <= 0) {
|
|
3170
|
+
e.preventDefault();
|
|
3171
|
+
list[list.length - 1].focus();
|
|
3172
|
+
}
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
3175
|
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
|
3176
|
+
if (e.target instanceof HTMLTextAreaElement) return;
|
|
3177
|
+
e.preventDefault();
|
|
3178
|
+
list[(index + (e.key === "ArrowDown" ? 1 : -1) + list.length) % list.length]?.focus({ preventScroll: true });
|
|
3179
|
+
}, [
|
|
3180
|
+
closeAndRestoreFocus,
|
|
3181
|
+
editing,
|
|
3182
|
+
focusables
|
|
3183
|
+
]);
|
|
2104
3184
|
const startEdit = (0, react.useCallback)((index) => {
|
|
2105
|
-
|
|
2106
|
-
|
|
3185
|
+
const item = items[index];
|
|
3186
|
+
if (!item) return;
|
|
3187
|
+
setEditing(editKeyOf(item, index));
|
|
3188
|
+
setEditDraft(item.comment ?? "");
|
|
2107
3189
|
}, [items]);
|
|
2108
3190
|
const commitEdit = (0, react.useCallback)(() => {
|
|
2109
3191
|
if (editing === null) return;
|
|
2110
|
-
const
|
|
2111
|
-
|
|
3192
|
+
const index = items.findIndex((item, i) => editKeyOf(item, i) === editing);
|
|
3193
|
+
const item = items[index];
|
|
3194
|
+
if (item) onItemEdit?.(index, item, editDraft);
|
|
2112
3195
|
setEditing(null);
|
|
2113
3196
|
}, [
|
|
2114
3197
|
editing,
|
|
@@ -2116,19 +3199,22 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2116
3199
|
editDraft,
|
|
2117
3200
|
onItemEdit
|
|
2118
3201
|
]);
|
|
2119
|
-
const panel = open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3202
|
+
const panel = open || renderPanel ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2120
3203
|
ref: popRef,
|
|
2121
|
-
|
|
3204
|
+
id: panelId,
|
|
3205
|
+
className: cx(styles.summaryHover, styles.summaryHoverOpen, closingPanel && !open && styles.summaryHoverLeaving),
|
|
2122
3206
|
role: "dialog",
|
|
2123
3207
|
"aria-label": t("summary.dialogAria", { count }),
|
|
2124
3208
|
onPointerEnter: openPop,
|
|
2125
3209
|
onPointerLeave,
|
|
3210
|
+
onKeyDown: onPanelKeyDown,
|
|
2126
3211
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2127
3212
|
className: cx(A.col, styles.summaryHoverInner),
|
|
2128
3213
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2129
3214
|
className: cx(A.col, styles.summaryList),
|
|
2130
3215
|
children: items.map((item, i) => {
|
|
2131
|
-
const
|
|
3216
|
+
const itemKey = editKeyOf(item, i);
|
|
3217
|
+
const isEditing = editing === itemKey;
|
|
2132
3218
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2133
3219
|
className: styles.summaryItem,
|
|
2134
3220
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -2164,7 +3250,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2164
3250
|
className: styles.summaryItemMain,
|
|
2165
3251
|
children: [
|
|
2166
3252
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2167
|
-
className: styles.
|
|
3253
|
+
className: cx(styles.summaryItemText, styles.summaryItemTextEdit),
|
|
2168
3254
|
children: item.text
|
|
2169
3255
|
}),
|
|
2170
3256
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
@@ -2180,6 +3266,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2180
3266
|
onKeyDown: (e) => {
|
|
2181
3267
|
if (e.key === "Escape") {
|
|
2182
3268
|
e.preventDefault();
|
|
3269
|
+
e.stopPropagation();
|
|
2183
3270
|
setEditing(null);
|
|
2184
3271
|
return;
|
|
2185
3272
|
}
|
|
@@ -2198,7 +3285,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2198
3285
|
children: t("common.cancel")
|
|
2199
3286
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2200
3287
|
type: "button",
|
|
2201
|
-
className: styles.annEditSave,
|
|
3288
|
+
className: cx(A.plain, styles.annEditSave),
|
|
2202
3289
|
onClick: commitEdit,
|
|
2203
3290
|
children: t("common.save")
|
|
2204
3291
|
})]
|
|
@@ -2241,9 +3328,12 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2241
3328
|
"data-selection-annotation-summary": "true",
|
|
2242
3329
|
"aria-label": t("summary.aria", { count }),
|
|
2243
3330
|
"aria-expanded": open,
|
|
3331
|
+
"aria-haspopup": "dialog",
|
|
3332
|
+
"aria-controls": open ? panelId : void 0,
|
|
2244
3333
|
onPointerEnter,
|
|
2245
3334
|
onPointerLeave,
|
|
2246
|
-
|
|
3335
|
+
onPointerUp: onSummaryPointerUp,
|
|
3336
|
+
onFocus: onSummaryFocus,
|
|
2247
3337
|
onBlur,
|
|
2248
3338
|
onKeyDown,
|
|
2249
3339
|
children: [
|
|
@@ -2254,13 +3344,20 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
|
|
|
2254
3344
|
}),
|
|
2255
3345
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2256
3346
|
className: cx(A.grow, styles.summaryBody),
|
|
2257
|
-
children: [
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
3347
|
+
children: [
|
|
3348
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3349
|
+
className: styles.summaryTitle,
|
|
3350
|
+
children: t("summary.title", { count })
|
|
3351
|
+
}),
|
|
3352
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3353
|
+
className: cx(styles.summaryHint, styles.summaryHintPointer),
|
|
3354
|
+
children: t("summary.hint")
|
|
3355
|
+
}),
|
|
3356
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3357
|
+
className: cx(styles.summaryHint, styles.summaryHintTouch),
|
|
3358
|
+
children: t("summary.hintTouch")
|
|
3359
|
+
})
|
|
3360
|
+
]
|
|
2264
3361
|
}),
|
|
2265
3362
|
onRemoveAll ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2266
3363
|
type: "button",
|
|
@@ -2321,8 +3418,7 @@ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded, foldingRef
|
|
|
2321
3418
|
onFolded();
|
|
2322
3419
|
};
|
|
2323
3420
|
const onKeyDown = (event) => {
|
|
2324
|
-
if (event
|
|
2325
|
-
if (event.isComposing) return;
|
|
3421
|
+
if (!isSubmitKey(event)) return;
|
|
2326
3422
|
const target = event.target;
|
|
2327
3423
|
if (!(target instanceof HTMLElement) || !target.isContentEditable) return;
|
|
2328
3424
|
const card = composerCardOf(target);
|
|
@@ -2524,6 +3620,67 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
|
|
|
2524
3620
|
/** Alias kept for overlay registration. */
|
|
2525
3621
|
const QuoteCard = AnnotationPanel;
|
|
2526
3622
|
//#endregion
|
|
3623
|
+
//#region src/client/config.ts
|
|
3624
|
+
const DEFAULT_CONFIG = Object.freeze({
|
|
3625
|
+
triggerMode: "auto",
|
|
3626
|
+
shortcut: false
|
|
3627
|
+
});
|
|
3628
|
+
const TRIGGER_MODES = /* @__PURE__ */ new Set([
|
|
3629
|
+
"auto",
|
|
3630
|
+
"contextmenu",
|
|
3631
|
+
"both"
|
|
3632
|
+
]);
|
|
3633
|
+
/** Collapse an untrusted snapshot value onto the known shape. */
|
|
3634
|
+
function normalizeConfig(value) {
|
|
3635
|
+
if (typeof value !== "object" || value === null) return { ...DEFAULT_CONFIG };
|
|
3636
|
+
const record = value;
|
|
3637
|
+
return {
|
|
3638
|
+
triggerMode: TRIGGER_MODES.has(record.triggerMode) ? record.triggerMode : DEFAULT_CONFIG.triggerMode,
|
|
3639
|
+
shortcut: typeof record.shortcut === "boolean" ? record.shortcut : DEFAULT_CONFIG.shortcut
|
|
3640
|
+
};
|
|
3641
|
+
}
|
|
3642
|
+
function resolveTriggers(config) {
|
|
3643
|
+
return {
|
|
3644
|
+
auto: config.triggerMode === "auto" || config.triggerMode === "both",
|
|
3645
|
+
contextMenu: config.triggerMode === "contextmenu" || config.triggerMode === "both",
|
|
3646
|
+
shortcut: config.shortcut
|
|
3647
|
+
};
|
|
3648
|
+
}
|
|
3649
|
+
let current = { ...DEFAULT_CONFIG };
|
|
3650
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
3651
|
+
function getConfig() {
|
|
3652
|
+
return current;
|
|
3653
|
+
}
|
|
3654
|
+
function subscribeConfig(listener) {
|
|
3655
|
+
listeners.add(listener);
|
|
3656
|
+
return () => {
|
|
3657
|
+
listeners.delete(listener);
|
|
3658
|
+
};
|
|
3659
|
+
}
|
|
3660
|
+
/** Publish a snapshot value; returns the normalized config actually stored. */
|
|
3661
|
+
function publishConfig(value) {
|
|
3662
|
+
const next = normalizeConfig(value);
|
|
3663
|
+
if (next.triggerMode === current.triggerMode && next.shortcut === current.shortcut) return current;
|
|
3664
|
+
current = next;
|
|
3665
|
+
for (const listener of [...listeners]) listener();
|
|
3666
|
+
return current;
|
|
3667
|
+
}
|
|
3668
|
+
/**
|
|
3669
|
+
* Follow one Host config form. Works with a live form and with the fake one in
|
|
3670
|
+
* `verify`; the caller owns disposal.
|
|
3671
|
+
*/
|
|
3672
|
+
function attachConfigForm(form) {
|
|
3673
|
+
const publish = () => {
|
|
3674
|
+
publishConfig(form.getSnapshot().value);
|
|
3675
|
+
};
|
|
3676
|
+
publish();
|
|
3677
|
+
const off = form.subscribe(publish);
|
|
3678
|
+
return () => {
|
|
3679
|
+
off();
|
|
3680
|
+
publishConfig(void 0);
|
|
3681
|
+
};
|
|
3682
|
+
}
|
|
3683
|
+
//#endregion
|
|
2527
3684
|
//#region src/client/dom/floating-layer.ts
|
|
2528
3685
|
/**
|
|
2529
3686
|
* Mount point for the selection UI: a zero-size host inside the transcript's
|
|
@@ -2545,10 +3702,35 @@ const QuoteCard = AnnotationPanel;
|
|
|
2545
3702
|
const HOST_ATTR = "data-dsq-layer";
|
|
2546
3703
|
/** Below the composer seat (sticky, z-index 7); above in-content stickies (6). */
|
|
2547
3704
|
const HOST_Z = 6;
|
|
3705
|
+
/**
|
|
3706
|
+
* Raised while the comment step is open. The composer summary card lives in
|
|
3707
|
+
* the seat (z 7) and can extend over the scroller area; the popover, clipped
|
|
3708
|
+
* to the scroller, would otherwise paint underneath it.
|
|
3709
|
+
*/
|
|
3710
|
+
const HOST_Z_ABOVE_COMPOSER = 8;
|
|
2548
3711
|
const layers = /* @__PURE__ */ new WeakMap();
|
|
2549
3712
|
/** flow item → layer, so scrolling does not re-walk the ancestor chain. */
|
|
2550
3713
|
const itemLayers = /* @__PURE__ */ new WeakMap();
|
|
2551
3714
|
let fallback = null;
|
|
3715
|
+
const SEAT_SELECTOR = "[data-composer-seat]";
|
|
3716
|
+
const CARD_SELECTOR = "[data-composer-card]";
|
|
3717
|
+
/**
|
|
3718
|
+
* The composer belonging to `scroller`, or the document's first one when the
|
|
3719
|
+
* scroller has no seat in its ancestor chain.
|
|
3720
|
+
*
|
|
3721
|
+
* The desktop layout puts the sticky seat outside the scroll container; a
|
|
3722
|
+
* document-wide lookup picks the first chat on the page, which is wrong when
|
|
3723
|
+
* more than one conversation is mounted. Walk out from the scroller and take
|
|
3724
|
+
* the first seat inside the nearest ancestor that has one.
|
|
3725
|
+
*/
|
|
3726
|
+
function composerSeatFor(scroller) {
|
|
3727
|
+
if (typeof document === "undefined") return null;
|
|
3728
|
+
if (scroller !== null) for (let el = scroller; el !== null && el !== document.body; el = el.parentElement) {
|
|
3729
|
+
const seat = el.querySelector(SEAT_SELECTOR) ?? el.querySelector(CARD_SELECTOR);
|
|
3730
|
+
if (seat !== null) return seat;
|
|
3731
|
+
}
|
|
3732
|
+
return document.querySelector(SEAT_SELECTOR) ?? document.querySelector(CARD_SELECTOR);
|
|
3733
|
+
}
|
|
2552
3734
|
/**
|
|
2553
3735
|
* Top edge of the composer seat, in viewport coordinates.
|
|
2554
3736
|
*
|
|
@@ -2558,11 +3740,8 @@ let fallback = null;
|
|
|
2558
3740
|
* parks popovers under the input box — the bottom edge must be the composer.
|
|
2559
3741
|
* In the regular layout the seat sits below the scroller, so it changes nothing.
|
|
2560
3742
|
*/
|
|
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;
|
|
3743
|
+
function composerTop(scroller) {
|
|
3744
|
+
return composerSeatFor(scroller)?.getBoundingClientRect().top ?? null;
|
|
2566
3745
|
}
|
|
2567
3746
|
function contentCoords(scroller, left, top) {
|
|
2568
3747
|
const rect = scroller.getBoundingClientRect();
|
|
@@ -2589,7 +3768,7 @@ function layerFor(scroller) {
|
|
|
2589
3768
|
const rect = scroller.getBoundingClientRect();
|
|
2590
3769
|
const top = rect.top + scroller.clientTop;
|
|
2591
3770
|
const rawBottom = top + scroller.clientHeight;
|
|
2592
|
-
const cover = composerTop();
|
|
3771
|
+
const cover = composerTop(scroller);
|
|
2593
3772
|
const bottom = cover !== null && cover > top && cover < rawBottom ? cover : rawBottom;
|
|
2594
3773
|
return new DOMRect(rect.left + scroller.clientLeft, top, scroller.clientWidth, Math.max(0, bottom - top));
|
|
2595
3774
|
},
|
|
@@ -2600,6 +3779,9 @@ function layerFor(scroller) {
|
|
|
2600
3779
|
left: left + rect.left + scroller.clientLeft - scroller.scrollLeft,
|
|
2601
3780
|
top: top + rect.top + scroller.clientTop - scroller.scrollTop
|
|
2602
3781
|
};
|
|
3782
|
+
},
|
|
3783
|
+
setAboveComposer: (above) => {
|
|
3784
|
+
host.style.zIndex = String(above ? HOST_Z_ABOVE_COMPOSER : HOST_Z);
|
|
2603
3785
|
}
|
|
2604
3786
|
};
|
|
2605
3787
|
layers.set(scroller, layer);
|
|
@@ -2616,7 +3798,7 @@ function fallbackLayer() {
|
|
|
2616
3798
|
host,
|
|
2617
3799
|
scroller: document.body,
|
|
2618
3800
|
bounds: () => {
|
|
2619
|
-
const bottom = composerTop() ?? window.innerHeight;
|
|
3801
|
+
const bottom = composerTop(null) ?? window.innerHeight;
|
|
2620
3802
|
return new DOMRect(0, 0, window.innerWidth, Math.max(0, bottom));
|
|
2621
3803
|
},
|
|
2622
3804
|
toContent: (left, top) => ({
|
|
@@ -2626,7 +3808,10 @@ function fallbackLayer() {
|
|
|
2626
3808
|
fromContent: (left, top) => ({
|
|
2627
3809
|
left,
|
|
2628
3810
|
top
|
|
2629
|
-
})
|
|
3811
|
+
}),
|
|
3812
|
+
setAboveComposer: (above) => {
|
|
3813
|
+
host.style.zIndex = String(above ? HOST_Z_ABOVE_COMPOSER : HOST_Z);
|
|
3814
|
+
}
|
|
2630
3815
|
};
|
|
2631
3816
|
return fallback;
|
|
2632
3817
|
}
|
|
@@ -2716,6 +3901,17 @@ function isFirstLineOnScreen(rect) {
|
|
|
2716
3901
|
return rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth;
|
|
2717
3902
|
}
|
|
2718
3903
|
/**
|
|
3904
|
+
* First-line box of the live selection in viewport coordinates.
|
|
3905
|
+
*
|
|
3906
|
+
* The keyboard entry has no pointer to anchor to, so it opens the same menu
|
|
3907
|
+
* beside the selected words; the first line is what the reader is looking at.
|
|
3908
|
+
*/
|
|
3909
|
+
function selectionFirstLineRect() {
|
|
3910
|
+
const selection = window.getSelection();
|
|
3911
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
|
|
3912
|
+
return getRangeFirstLineRect(selection.getRangeAt(0)) ?? null;
|
|
3913
|
+
}
|
|
3914
|
+
/**
|
|
2719
3915
|
* Read the live selection when it is a non-empty range inside a conversation
|
|
2720
3916
|
* message and outside the composer / our own chrome.
|
|
2721
3917
|
*/
|
|
@@ -2756,62 +3952,643 @@ function readSelectionSnapshot(toolbarRoot) {
|
|
|
2756
3952
|
function clearNativeSelection() {
|
|
2757
3953
|
window.getSelection()?.removeAllRanges();
|
|
2758
3954
|
}
|
|
2759
|
-
async function copyText(text) {
|
|
2760
|
-
if (navigator.clipboard?.writeText) {
|
|
2761
|
-
await navigator.clipboard.writeText(text);
|
|
2762
|
-
return;
|
|
2763
|
-
}
|
|
2764
|
-
const area = document.createElement("textarea");
|
|
2765
|
-
area.value = text;
|
|
2766
|
-
area.setAttribute("readonly", "");
|
|
2767
|
-
area.style.position = "fixed";
|
|
2768
|
-
area.style.left = "-9999px";
|
|
2769
|
-
document.body.appendChild(area);
|
|
2770
|
-
area.select();
|
|
2771
|
-
document.execCommand("copy");
|
|
2772
|
-
area.remove();
|
|
3955
|
+
async function copyText(text) {
|
|
3956
|
+
if (navigator.clipboard?.writeText) {
|
|
3957
|
+
await navigator.clipboard.writeText(text);
|
|
3958
|
+
return;
|
|
3959
|
+
}
|
|
3960
|
+
const area = document.createElement("textarea");
|
|
3961
|
+
area.value = text;
|
|
3962
|
+
area.setAttribute("readonly", "");
|
|
3963
|
+
area.style.position = "fixed";
|
|
3964
|
+
area.style.left = "-9999px";
|
|
3965
|
+
document.body.appendChild(area);
|
|
3966
|
+
area.select();
|
|
3967
|
+
document.execCommand("copy");
|
|
3968
|
+
area.remove();
|
|
3969
|
+
}
|
|
3970
|
+
//#endregion
|
|
3971
|
+
//#region src/client/dom/selection-url.ts
|
|
3972
|
+
/**
|
|
3973
|
+
* Link detection for the selection toolbar.
|
|
3974
|
+
*
|
|
3975
|
+
* A selection that is exactly one URL gets an "open in browser" action. Only
|
|
3976
|
+
* `http(s)` qualifies: the desktop shell hands those to `shell.openExternal`
|
|
3977
|
+
* (the real default browser), while every other scheme is refused by the app's
|
|
3978
|
+
* window-open handler — a button that silently does nothing is worse than no
|
|
3979
|
+
* button at all.
|
|
3980
|
+
*/
|
|
3981
|
+
/** Punctuation a drag-selection tends to swallow around a link. */
|
|
3982
|
+
const EDGE_PUNCTUATION = /^[\s"'“”‘’()\[\]{}<>( )【】《》]+|[\s"'“”‘’()\[\]{}<>( )【】《》.,;:!?。,;:!?]+$/g;
|
|
3983
|
+
/** Schemes the app can actually hand to the system browser. */
|
|
3984
|
+
const OPENABLE = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
3985
|
+
/** The selected text as a URL, or null when it is not one. */
|
|
3986
|
+
function selectionUrl(text) {
|
|
3987
|
+
const trimmed = text.trim();
|
|
3988
|
+
if (trimmed === "" || /\s/.test(trimmed.replace(EDGE_PUNCTUATION, ""))) return null;
|
|
3989
|
+
const candidate = trimmed.replace(EDGE_PUNCTUATION, "");
|
|
3990
|
+
if (!/^https?:\/\//i.test(candidate)) return null;
|
|
3991
|
+
try {
|
|
3992
|
+
const url = new URL(candidate);
|
|
3993
|
+
if (!OPENABLE.has(url.protocol)) return null;
|
|
3994
|
+
return url.href;
|
|
3995
|
+
} catch {
|
|
3996
|
+
return null;
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
/**
|
|
4000
|
+
* URL the toolbar's open action should use.
|
|
4001
|
+
*
|
|
4002
|
+
* The selected text wins when it is itself a URL; otherwise the anchor the
|
|
4003
|
+
* selection sits in decides — chat links are usually rendered as wording, so
|
|
4004
|
+
* selecting "点击这里" must still offer to open what it points at.
|
|
4005
|
+
*/
|
|
4006
|
+
function linkTarget(text, anchorHref) {
|
|
4007
|
+
return selectionUrl(text) ?? (anchorHref ? selectionUrl(anchorHref) : null);
|
|
4008
|
+
}
|
|
4009
|
+
//#endregion
|
|
4010
|
+
//#region src/client/selection/SelectionMenu.tsx
|
|
4011
|
+
/** Space between the anchor line's bottom and the menu. */
|
|
4012
|
+
const ANCHOR_GAP = 2;
|
|
4013
|
+
function SelectionMenu({ snapshot, x, y, onAction, onClose, initialFocus = "first" }) {
|
|
4014
|
+
const rootRef = (0, react.useRef)(null);
|
|
4015
|
+
const firstRef = (0, react.useRef)(null);
|
|
4016
|
+
const [placement, setPlacement] = (0, react.useState)(null);
|
|
4017
|
+
const items = [
|
|
4018
|
+
...linkTarget(snapshot.text, snapshot.anchorHref) ? [{
|
|
4019
|
+
key: "open",
|
|
4020
|
+
icon: "external-link",
|
|
4021
|
+
label: t("toolbar.openLink")
|
|
4022
|
+
}] : [],
|
|
4023
|
+
{
|
|
4024
|
+
key: "copy",
|
|
4025
|
+
icon: "copy",
|
|
4026
|
+
label: t("toolbar.copy")
|
|
4027
|
+
},
|
|
4028
|
+
{
|
|
4029
|
+
key: "add",
|
|
4030
|
+
icon: "plus",
|
|
4031
|
+
label: t("toolbar.addToTask")
|
|
4032
|
+
}
|
|
4033
|
+
];
|
|
4034
|
+
(0, react.useLayoutEffect)(() => {
|
|
4035
|
+
const el = rootRef.current;
|
|
4036
|
+
if (!el) return;
|
|
4037
|
+
const anchor = DOMRect.fromRect({
|
|
4038
|
+
x,
|
|
4039
|
+
y,
|
|
4040
|
+
width: 0,
|
|
4041
|
+
height: 0
|
|
4042
|
+
});
|
|
4043
|
+
setPlacement(placePopover(anchor, {
|
|
4044
|
+
width: el.offsetWidth,
|
|
4045
|
+
height: el.offsetHeight
|
|
4046
|
+
}, "bottom", ANCHOR_GAP));
|
|
4047
|
+
}, [
|
|
4048
|
+
x,
|
|
4049
|
+
y,
|
|
4050
|
+
items.length
|
|
4051
|
+
]);
|
|
4052
|
+
(0, react.useLayoutEffect)(() => {
|
|
4053
|
+
if (!placement) return;
|
|
4054
|
+
if (initialFocus === "menu") rootRef.current?.focus({ preventScroll: true });
|
|
4055
|
+
else firstRef.current?.focus({ preventScroll: true });
|
|
4056
|
+
}, [placement, initialFocus]);
|
|
4057
|
+
(0, react.useEffect)(() => {
|
|
4058
|
+
const inside = (node) => node instanceof Node && rootRef.current?.contains(node) === true;
|
|
4059
|
+
const onPointerDown = (event) => {
|
|
4060
|
+
if (inside(event.target)) return;
|
|
4061
|
+
onClose();
|
|
4062
|
+
};
|
|
4063
|
+
const onContextMenu = (event) => {
|
|
4064
|
+
if (inside(event.target)) {
|
|
4065
|
+
event.preventDefault();
|
|
4066
|
+
return;
|
|
4067
|
+
}
|
|
4068
|
+
onClose();
|
|
4069
|
+
};
|
|
4070
|
+
const onScroll = () => onClose();
|
|
4071
|
+
const onResize = () => onClose();
|
|
4072
|
+
const onBlur = () => onClose();
|
|
4073
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
4074
|
+
document.addEventListener("contextmenu", onContextMenu, true);
|
|
4075
|
+
document.addEventListener("scroll", onScroll, true);
|
|
4076
|
+
window.addEventListener("resize", onResize);
|
|
4077
|
+
window.addEventListener("blur", onBlur);
|
|
4078
|
+
return () => {
|
|
4079
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
4080
|
+
document.removeEventListener("contextmenu", onContextMenu, true);
|
|
4081
|
+
document.removeEventListener("scroll", onScroll, true);
|
|
4082
|
+
window.removeEventListener("resize", onResize);
|
|
4083
|
+
window.removeEventListener("blur", onBlur);
|
|
4084
|
+
};
|
|
4085
|
+
}, [onClose]);
|
|
4086
|
+
const handleKeyDown = (0, react.useCallback)((event) => {
|
|
4087
|
+
if (event.key === "Escape") {
|
|
4088
|
+
event.preventDefault();
|
|
4089
|
+
event.stopPropagation();
|
|
4090
|
+
onClose();
|
|
4091
|
+
return;
|
|
4092
|
+
}
|
|
4093
|
+
if (event.key === "Tab") {
|
|
4094
|
+
onClose();
|
|
4095
|
+
return;
|
|
4096
|
+
}
|
|
4097
|
+
const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
|
|
4098
|
+
if (step === 0 && event.key !== "Home" && event.key !== "End") return;
|
|
4099
|
+
const buttons = [...rootRef.current?.querySelectorAll("[role=\"menuitem\"]") ?? []];
|
|
4100
|
+
if (buttons.length === 0) return;
|
|
4101
|
+
event.preventDefault();
|
|
4102
|
+
const current = buttons.indexOf(document.activeElement);
|
|
4103
|
+
const fromMenu = current === -1;
|
|
4104
|
+
buttons[event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : fromMenu ? step > 0 ? 0 : buttons.length - 1 : (current + step + buttons.length) % buttons.length]?.focus({ preventScroll: true });
|
|
4105
|
+
}, [onClose]);
|
|
4106
|
+
const keepSelection = (0, react.useCallback)((event) => {
|
|
4107
|
+
event.preventDefault();
|
|
4108
|
+
event.currentTarget.focus({ preventScroll: true });
|
|
4109
|
+
}, []);
|
|
4110
|
+
return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4111
|
+
ref: rootRef,
|
|
4112
|
+
className: cx(A.col, styles.ctxMenu),
|
|
4113
|
+
role: "menu",
|
|
4114
|
+
"aria-label": t("menu.aria"),
|
|
4115
|
+
"data-dsq-menu": "true",
|
|
4116
|
+
"data-side": placement?.side ?? "bottom",
|
|
4117
|
+
tabIndex: -1,
|
|
4118
|
+
style: {
|
|
4119
|
+
top: placement?.top ?? 0,
|
|
4120
|
+
left: placement?.left ?? 0,
|
|
4121
|
+
visibility: placement ? "visible" : "hidden"
|
|
4122
|
+
},
|
|
4123
|
+
onKeyDown: handleKeyDown,
|
|
4124
|
+
children: items.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
4125
|
+
ref: index === 0 ? firstRef : void 0,
|
|
4126
|
+
type: "button",
|
|
4127
|
+
role: "menuitem",
|
|
4128
|
+
className: cx(A.plain, A.row, styles.ctxItem),
|
|
4129
|
+
tabIndex: -1,
|
|
4130
|
+
onPointerDown: keepSelection,
|
|
4131
|
+
onPointerEnter: (event) => event.currentTarget.focus({ preventScroll: true }),
|
|
4132
|
+
onClick: () => onAction(item.key),
|
|
4133
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
|
|
4134
|
+
name: item.icon,
|
|
4135
|
+
size: 15
|
|
4136
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: item.label })]
|
|
4137
|
+
}, item.key))
|
|
4138
|
+
}), document.body);
|
|
4139
|
+
}
|
|
4140
|
+
//#endregion
|
|
4141
|
+
//#region src/client/selection/ToolbarView.tsx
|
|
4142
|
+
/**
|
|
4143
|
+
* Paint-only halves of the selection UI: the anchored desktop toolbar/popover
|
|
4144
|
+
* and the touch bottom bar/sheet. All state, persistence and placement stay in
|
|
4145
|
+
* `SelectionToolbar`; this module owns markup, portals and ARIA only.
|
|
4146
|
+
*/
|
|
4147
|
+
function ActionRow({ actions, stacked }) {
|
|
4148
|
+
return actions.map((action, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [!stacked && index > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
4149
|
+
className: styles.divider,
|
|
4150
|
+
"aria-hidden": "true"
|
|
4151
|
+
}) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
4152
|
+
type: "button",
|
|
4153
|
+
className: cx(A.plain, stacked ? A.col : A.inlineRow, stacked ? styles.touchAction : styles.button),
|
|
4154
|
+
onClick: action.onClick,
|
|
4155
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
|
|
4156
|
+
name: action.icon,
|
|
4157
|
+
size: stacked ? 18 : 14
|
|
4158
|
+
}), stacked ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: action.label }) : action.label]
|
|
4159
|
+
})] }, action.key));
|
|
4160
|
+
}
|
|
4161
|
+
function CommentField({ comment, touch, commentRef, onChange, onKeyDown }) {
|
|
4162
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
|
|
4163
|
+
ref: commentRef,
|
|
4164
|
+
className: cx(A.box, styles.commentInput, touch && styles.touchComment),
|
|
4165
|
+
placeholder: t("common.commentPlaceholder"),
|
|
4166
|
+
value: comment,
|
|
4167
|
+
onChange: (event) => onChange(event.target.value),
|
|
4168
|
+
onKeyDown
|
|
4169
|
+
});
|
|
4170
|
+
}
|
|
4171
|
+
function CommentActions({ comment, touch, onFinishComment }) {
|
|
4172
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
4173
|
+
className: cx(A.row, styles.commentActions, touch && styles.touchActions),
|
|
4174
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
4175
|
+
type: "button",
|
|
4176
|
+
className: cx(A.plain, A.inlineRow, styles.button, touch && styles.touchSecondary),
|
|
4177
|
+
onClick: () => onFinishComment(false),
|
|
4178
|
+
children: t("toolbar.skipComment")
|
|
4179
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
4180
|
+
type: "button",
|
|
4181
|
+
className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
|
|
4182
|
+
disabled: !comment.trim(),
|
|
4183
|
+
onClick: () => onFinishComment(true),
|
|
4184
|
+
children: t("toolbar.addComment")
|
|
4185
|
+
})]
|
|
4186
|
+
});
|
|
4187
|
+
}
|
|
4188
|
+
function ToolbarView({ touch, phase, active, status, viewport, actions, comment, menuOpen, toolbarStyle, toolbarRef, commentWrapRef, commentRef, onToolbarPointerDown, onCommentChange, onCommentKeyDown, onFinishComment, onCloseSheet }) {
|
|
4189
|
+
const commentField = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CommentField, {
|
|
4190
|
+
comment,
|
|
4191
|
+
touch,
|
|
4192
|
+
commentRef,
|
|
4193
|
+
onChange: onCommentChange,
|
|
4194
|
+
onKeyDown: onCommentKeyDown
|
|
4195
|
+
});
|
|
4196
|
+
const commentActions = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CommentActions, {
|
|
4197
|
+
comment,
|
|
4198
|
+
touch,
|
|
4199
|
+
onFinishComment
|
|
4200
|
+
});
|
|
4201
|
+
if (touch) {
|
|
4202
|
+
const bottom = `calc(${viewport.inset}px + 8px + env(safe-area-inset-bottom, 0px))`;
|
|
4203
|
+
return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
4204
|
+
phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4205
|
+
ref: toolbarRef,
|
|
4206
|
+
className: cx(A.row, styles.touchBar),
|
|
4207
|
+
style: { bottom },
|
|
4208
|
+
role: "toolbar",
|
|
4209
|
+
"aria-label": t("toolbar.aria"),
|
|
4210
|
+
"data-dsq-toolbar": "true",
|
|
4211
|
+
"data-touch": "true",
|
|
4212
|
+
onPointerDown: onToolbarPointerDown,
|
|
4213
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionRow, {
|
|
4214
|
+
actions,
|
|
4215
|
+
stacked: true
|
|
4216
|
+
})
|
|
4217
|
+
}) : null,
|
|
4218
|
+
phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4219
|
+
className: styles.touchScrim,
|
|
4220
|
+
"aria-hidden": "true"
|
|
4221
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
4222
|
+
ref: commentWrapRef,
|
|
4223
|
+
className: cx(A.col, styles.touchSheet),
|
|
4224
|
+
style: {
|
|
4225
|
+
bottom,
|
|
4226
|
+
maxHeight: `${Math.max(240, Math.round(viewport.height * .72))}px`
|
|
4227
|
+
},
|
|
4228
|
+
role: "dialog",
|
|
4229
|
+
"aria-label": t("toolbar.commentDialogAria"),
|
|
4230
|
+
children: [
|
|
4231
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
4232
|
+
className: cx(A.row, styles.touchSheetHead),
|
|
4233
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
4234
|
+
className: styles.touchSheetTitle,
|
|
4235
|
+
children: t("toolbar.commentHint")
|
|
4236
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
4237
|
+
type: "button",
|
|
4238
|
+
className: cx(A.plain, A.gridMid, styles.touchSheetClose),
|
|
4239
|
+
"aria-label": t("common.close"),
|
|
4240
|
+
onClick: onCloseSheet,
|
|
4241
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
|
|
4242
|
+
name: "x",
|
|
4243
|
+
size: 18
|
|
4244
|
+
})
|
|
4245
|
+
})]
|
|
4246
|
+
}),
|
|
4247
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4248
|
+
className: styles.touchQuote,
|
|
4249
|
+
children: active.text
|
|
4250
|
+
}),
|
|
4251
|
+
commentField,
|
|
4252
|
+
commentActions
|
|
4253
|
+
]
|
|
4254
|
+
})] }) : null,
|
|
4255
|
+
status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4256
|
+
className: styles.touchStatus,
|
|
4257
|
+
style: { bottom: `calc(${viewport.inset}px + 72px + env(safe-area-inset-bottom, 0px))` },
|
|
4258
|
+
role: "status",
|
|
4259
|
+
children: status
|
|
4260
|
+
}) : null
|
|
4261
|
+
] }), document.body);
|
|
4262
|
+
}
|
|
4263
|
+
return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
4264
|
+
menuOpen ? null : phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4265
|
+
ref: toolbarRef,
|
|
4266
|
+
className: cx(A.row, styles.toolbar),
|
|
4267
|
+
style: toolbarStyle,
|
|
4268
|
+
role: "toolbar",
|
|
4269
|
+
"aria-label": t("toolbar.aria"),
|
|
4270
|
+
"data-dsq-toolbar": "true",
|
|
4271
|
+
"data-anchor": active.below ? "below" : "above",
|
|
4272
|
+
onPointerDown: onToolbarPointerDown,
|
|
4273
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4274
|
+
className: cx(A.inlineRow, styles.toolbarInner),
|
|
4275
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionRow, {
|
|
4276
|
+
actions,
|
|
4277
|
+
stacked: false
|
|
4278
|
+
})
|
|
4279
|
+
})
|
|
4280
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4281
|
+
ref: toolbarRef,
|
|
4282
|
+
className: cx(A.row, styles.toolbar),
|
|
4283
|
+
role: "toolbar",
|
|
4284
|
+
"aria-label": t("toolbar.aria"),
|
|
4285
|
+
"data-dsq-toolbar": "true",
|
|
4286
|
+
"data-anchor": "popover-top",
|
|
4287
|
+
onPointerDown: onToolbarPointerDown,
|
|
4288
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4289
|
+
className: cx(A.inlineRow, styles.toolbarInner),
|
|
4290
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
4291
|
+
style: {
|
|
4292
|
+
padding: "0 8px",
|
|
4293
|
+
fontSize: 13,
|
|
4294
|
+
opacity: .8,
|
|
4295
|
+
whiteSpace: "nowrap"
|
|
4296
|
+
},
|
|
4297
|
+
children: t("toolbar.commentHint")
|
|
4298
|
+
})
|
|
4299
|
+
})
|
|
4300
|
+
}),
|
|
4301
|
+
!menuOpen && phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
4302
|
+
ref: commentWrapRef,
|
|
4303
|
+
className: cx(A.col, styles.commentPop),
|
|
4304
|
+
role: "dialog",
|
|
4305
|
+
"aria-label": t("toolbar.commentDialogAria"),
|
|
4306
|
+
onPointerDown: onToolbarPointerDown,
|
|
4307
|
+
children: [commentField, commentActions]
|
|
4308
|
+
}) : null,
|
|
4309
|
+
status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4310
|
+
className: styles.status,
|
|
4311
|
+
style: toolbarStyle,
|
|
4312
|
+
role: "status",
|
|
4313
|
+
children: status
|
|
4314
|
+
}) : null
|
|
4315
|
+
] }), active.layer.host);
|
|
4316
|
+
}
|
|
4317
|
+
//#endregion
|
|
4318
|
+
//#region src/client/dom/comment-placement.ts
|
|
4319
|
+
const GAP$1 = 8;
|
|
4320
|
+
/** Horizontal slack around the popover, and the minimum margin to the box. */
|
|
4321
|
+
const EDGE = 8;
|
|
4322
|
+
/** Gap between the popover corner and the "add a comment" toolbar. */
|
|
4323
|
+
const TOOLBAR_GAP = 6;
|
|
4324
|
+
function placeCommentSurfaces({ layer, selection, toolbar, panel }) {
|
|
4325
|
+
const bounds = layer.bounds();
|
|
4326
|
+
const home = layer.fromContent(selection.left, selection.top);
|
|
4327
|
+
const anchor = {
|
|
4328
|
+
left: home.left - toolbar.width / 2,
|
|
4329
|
+
top: home.top - (selection.below ? 0 : toolbar.height),
|
|
4330
|
+
width: toolbar.width,
|
|
4331
|
+
height: toolbar.height
|
|
4332
|
+
};
|
|
4333
|
+
const belowTop = layer.fromContent(0, selection.selectionBottom).top + GAP$1;
|
|
4334
|
+
const aboveTop = anchor.top - panel.height - GAP$1;
|
|
4335
|
+
const top = belowTop <= bounds.bottom - EDGE - panel.height ? belowTop : aboveTop >= bounds.top + EDGE ? aboveTop : Math.max(bounds.top + EDGE, Math.min(belowTop, bounds.bottom - EDGE - panel.height));
|
|
4336
|
+
const half = panel.width / 2 + EDGE;
|
|
4337
|
+
const min = bounds.left + half;
|
|
4338
|
+
const center = Math.min(Math.max(anchor.left + anchor.width / 2, min), Math.max(min, bounds.right - half));
|
|
4339
|
+
const popover = layer.toContent(center, top);
|
|
4340
|
+
let leftView = layer.fromContent(popover.left, popover.top).left - panel.width / 2;
|
|
4341
|
+
let topView = top - TOOLBAR_GAP - toolbar.height;
|
|
4342
|
+
if (topView < bounds.top + EDGE) topView = top + panel.height + TOOLBAR_GAP;
|
|
4343
|
+
leftView = Math.max(bounds.left + EDGE, Math.min(leftView, bounds.right - EDGE - toolbar.width));
|
|
4344
|
+
topView = Math.max(bounds.top + EDGE, Math.min(topView, bounds.bottom - EDGE - toolbar.height));
|
|
4345
|
+
return {
|
|
4346
|
+
popover,
|
|
4347
|
+
toolbar: layer.toContent(leftView, topView)
|
|
4348
|
+
};
|
|
4349
|
+
}
|
|
4350
|
+
//#endregion
|
|
4351
|
+
//#region src/client/selection/useCommentPlacement.ts
|
|
4352
|
+
/**
|
|
4353
|
+
* Measures the comment popover and its hint toolbar, then writes the geometry
|
|
4354
|
+
* from `dom/comment-placement.ts` back as content coordinates. The React
|
|
4355
|
+
* component keeps the refs; this hook owns the measure/place lifecycle.
|
|
4356
|
+
*/
|
|
4357
|
+
function useCommentPlacement({ touch, phase, active, toolbarRef, commentWrapRef }) {
|
|
4358
|
+
const place = (0, react.useCallback)(() => {
|
|
4359
|
+
if (touch) return;
|
|
4360
|
+
const layer = active?.layer;
|
|
4361
|
+
const toolbar = toolbarRef.current;
|
|
4362
|
+
const pop = commentWrapRef.current;
|
|
4363
|
+
if (!active || !layer || !toolbar || !pop) return;
|
|
4364
|
+
pop.style.display = "flex";
|
|
4365
|
+
pop.style.visibility = "hidden";
|
|
4366
|
+
const panel = {
|
|
4367
|
+
width: pop.offsetWidth || 320,
|
|
4368
|
+
height: pop.offsetHeight || 180
|
|
4369
|
+
};
|
|
4370
|
+
pop.style.visibility = "";
|
|
4371
|
+
const at = placeCommentSurfaces({
|
|
4372
|
+
layer,
|
|
4373
|
+
selection: active,
|
|
4374
|
+
toolbar: {
|
|
4375
|
+
width: toolbar.offsetWidth,
|
|
4376
|
+
height: toolbar.offsetHeight
|
|
4377
|
+
},
|
|
4378
|
+
panel
|
|
4379
|
+
});
|
|
4380
|
+
pop.style.top = `${at.popover.top}px`;
|
|
4381
|
+
pop.style.left = `${at.popover.left}px`;
|
|
4382
|
+
toolbar.style.left = `${at.toolbar.left}px`;
|
|
4383
|
+
toolbar.style.top = `${at.toolbar.top}px`;
|
|
4384
|
+
}, [
|
|
4385
|
+
active,
|
|
4386
|
+
commentWrapRef,
|
|
4387
|
+
toolbarRef,
|
|
4388
|
+
touch
|
|
4389
|
+
]);
|
|
4390
|
+
(0, react.useLayoutEffect)(() => {
|
|
4391
|
+
if (phase === "comment") place();
|
|
4392
|
+
}, [phase, place]);
|
|
4393
|
+
(0, react.useEffect)(() => {
|
|
4394
|
+
if (touch) return;
|
|
4395
|
+
window.addEventListener("resize", place);
|
|
4396
|
+
return () => window.removeEventListener("resize", place);
|
|
4397
|
+
}, [place, touch]);
|
|
4398
|
+
return place;
|
|
2773
4399
|
}
|
|
2774
4400
|
//#endregion
|
|
2775
|
-
//#region src/client/dom/
|
|
4401
|
+
//#region src/client/dom/menu-trigger.ts
|
|
4402
|
+
function shouldOpenSelectionMenu(signal) {
|
|
4403
|
+
return signal.contextMenuEnabled && !signal.touch && !signal.defaultPrevented && signal.targetInTranscript && signal.pointInSelection && signal.hasSelection;
|
|
4404
|
+
}
|
|
2776
4405
|
/**
|
|
2777
|
-
*
|
|
4406
|
+
* True when `(x, y)` falls inside any of the selection's line boxes.
|
|
2778
4407
|
*
|
|
2779
|
-
*
|
|
2780
|
-
*
|
|
2781
|
-
*
|
|
2782
|
-
* window-open handler — a button that silently does nothing is worse than no
|
|
2783
|
-
* button at all.
|
|
4408
|
+
* The union box is wrong here: a short first line leaves empty space to its
|
|
4409
|
+
* right that belongs to no word, and a right-click there is not "on the
|
|
4410
|
+
* selection". Each line rect plus a small tolerance matches what the user sees.
|
|
2784
4411
|
*/
|
|
2785
|
-
|
|
2786
|
-
const
|
|
2787
|
-
|
|
2788
|
-
const OPENABLE = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
2789
|
-
/** The selected text as a URL, or null when it is not one. */
|
|
2790
|
-
function selectionUrl(text) {
|
|
2791
|
-
const trimmed = text.trim();
|
|
2792
|
-
if (trimmed === "" || /\s/.test(trimmed.replace(EDGE_PUNCTUATION, ""))) return null;
|
|
2793
|
-
const candidate = trimmed.replace(EDGE_PUNCTUATION, "");
|
|
2794
|
-
if (!/^https?:\/\//i.test(candidate)) return null;
|
|
2795
|
-
try {
|
|
2796
|
-
const url = new URL(candidate);
|
|
2797
|
-
if (!OPENABLE.has(url.protocol)) return null;
|
|
2798
|
-
return url.href;
|
|
2799
|
-
} catch {
|
|
2800
|
-
return null;
|
|
2801
|
-
}
|
|
4412
|
+
function pointInRects(x, y, rects, tolerance = 2) {
|
|
4413
|
+
for (const rect of rects) if (x >= rect.left - tolerance && x <= rect.right + tolerance && y >= rect.top - tolerance && y <= rect.bottom + tolerance) return true;
|
|
4414
|
+
return false;
|
|
2802
4415
|
}
|
|
4416
|
+
//#endregion
|
|
4417
|
+
//#region src/client/selection/useSelectionWatcher.ts
|
|
2803
4418
|
/**
|
|
2804
|
-
*
|
|
4419
|
+
* Transcript-wide pointer/selection observation for the toolbar.
|
|
2805
4420
|
*
|
|
2806
|
-
* The
|
|
2807
|
-
*
|
|
2808
|
-
*
|
|
4421
|
+
* The controller owns the state; this hook owns *when* to re-read it. Keeping
|
|
4422
|
+
* the listener choreography here means the toolbar component does not have to
|
|
4423
|
+
* know about the drag/context-menu/scroll ordering rules.
|
|
2809
4424
|
*/
|
|
2810
|
-
|
|
2811
|
-
|
|
4425
|
+
/** The menu's own chrome; a press inside it must not dismiss the selection. */
|
|
4426
|
+
const MENU_SELECTOR = "[data-dsq-menu]";
|
|
4427
|
+
function useSelectionWatcher({ toolbarRef, commentWrapRef, phaseRef, touch, dismissMenu, refresh, flushComment, hide, openMenu }) {
|
|
4428
|
+
(0, react.useEffect)(() => {
|
|
4429
|
+
let scheduled = false;
|
|
4430
|
+
const schedule = () => {
|
|
4431
|
+
if (scheduled) return;
|
|
4432
|
+
scheduled = true;
|
|
4433
|
+
window.setTimeout(() => {
|
|
4434
|
+
scheduled = false;
|
|
4435
|
+
refresh();
|
|
4436
|
+
}, 0);
|
|
4437
|
+
};
|
|
4438
|
+
let dragging = false;
|
|
4439
|
+
const onPointerDown = (event) => {
|
|
4440
|
+
const target = event.target;
|
|
4441
|
+
if (!(target instanceof Node)) return;
|
|
4442
|
+
const inToolbar = toolbarRef.current?.contains(target) === true;
|
|
4443
|
+
const inComment = commentWrapRef.current?.contains(target) === true;
|
|
4444
|
+
const inMenu = target instanceof Element && target.closest(MENU_SELECTOR) !== null;
|
|
4445
|
+
if (inToolbar || inComment || inMenu) return;
|
|
4446
|
+
dragging = true;
|
|
4447
|
+
if (phaseRef.current === "comment") {
|
|
4448
|
+
flushComment();
|
|
4449
|
+
hide();
|
|
4450
|
+
return;
|
|
4451
|
+
}
|
|
4452
|
+
const selection = window.getSelection();
|
|
4453
|
+
if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
|
|
4454
|
+
schedule();
|
|
4455
|
+
return;
|
|
4456
|
+
}
|
|
4457
|
+
hide();
|
|
4458
|
+
};
|
|
4459
|
+
const onPointerUp = () => {
|
|
4460
|
+
dragging = false;
|
|
4461
|
+
schedule();
|
|
4462
|
+
};
|
|
4463
|
+
const onSelectionChange = () => {
|
|
4464
|
+
if (dragging) return;
|
|
4465
|
+
schedule();
|
|
4466
|
+
};
|
|
4467
|
+
const onContextMenu = (event) => {
|
|
4468
|
+
if (dismissMenu()) refresh();
|
|
4469
|
+
if (event.defaultPrevented) return;
|
|
4470
|
+
const target = event.target;
|
|
4471
|
+
if (!(target instanceof Node)) return;
|
|
4472
|
+
if (toolbarRef.current?.contains(target)) return;
|
|
4473
|
+
if (target instanceof Element && target.closest(MENU_SELECTOR)) return;
|
|
4474
|
+
if (touch || phaseRef.current === "comment") return;
|
|
4475
|
+
const contextMenuEnabled = resolveTriggers(getConfig()).contextMenu;
|
|
4476
|
+
if (!contextMenuEnabled) return;
|
|
4477
|
+
const selection = window.getSelection();
|
|
4478
|
+
const hasSelection = selection !== null && !selection.isCollapsed && selection.rangeCount > 0;
|
|
4479
|
+
const pointInSelection = selection !== null && hasSelection && pointInRects(event.clientX, event.clientY, selection.getRangeAt(0).getClientRects());
|
|
4480
|
+
if (!shouldOpenSelectionMenu({
|
|
4481
|
+
defaultPrevented: event.defaultPrevented,
|
|
4482
|
+
contextMenuEnabled,
|
|
4483
|
+
touch,
|
|
4484
|
+
targetInTranscript: isInsideTranscript(target),
|
|
4485
|
+
pointInSelection,
|
|
4486
|
+
hasSelection
|
|
4487
|
+
})) return;
|
|
4488
|
+
const snapshot = readSelectionSnapshot(toolbarRef.current);
|
|
4489
|
+
if (!snapshot) return;
|
|
4490
|
+
event.preventDefault();
|
|
4491
|
+
event.stopPropagation();
|
|
4492
|
+
openMenu(snapshot, event.clientX, event.clientY, "menu");
|
|
4493
|
+
};
|
|
4494
|
+
const onScroll = (event) => {
|
|
4495
|
+
const target = event.target;
|
|
4496
|
+
if (target instanceof Node && (toolbarRef.current?.contains(target) === true || commentWrapRef.current?.contains(target) === true)) return;
|
|
4497
|
+
schedule();
|
|
4498
|
+
};
|
|
4499
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
4500
|
+
document.addEventListener("pointerup", onPointerUp, true);
|
|
4501
|
+
document.addEventListener("keyup", schedule, true);
|
|
4502
|
+
document.addEventListener("selectionchange", onSelectionChange);
|
|
4503
|
+
document.addEventListener("contextmenu", onContextMenu);
|
|
4504
|
+
window.addEventListener("resize", schedule);
|
|
4505
|
+
document.addEventListener("scroll", onScroll, true);
|
|
4506
|
+
return () => {
|
|
4507
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
4508
|
+
document.removeEventListener("pointerup", onPointerUp, true);
|
|
4509
|
+
document.removeEventListener("keyup", schedule, true);
|
|
4510
|
+
document.removeEventListener("selectionchange", onSelectionChange);
|
|
4511
|
+
document.removeEventListener("contextmenu", onContextMenu);
|
|
4512
|
+
window.removeEventListener("resize", schedule);
|
|
4513
|
+
document.removeEventListener("scroll", onScroll, true);
|
|
4514
|
+
};
|
|
4515
|
+
}, [
|
|
4516
|
+
commentWrapRef,
|
|
4517
|
+
dismissMenu,
|
|
4518
|
+
flushComment,
|
|
4519
|
+
hide,
|
|
4520
|
+
openMenu,
|
|
4521
|
+
phaseRef,
|
|
4522
|
+
refresh,
|
|
4523
|
+
toolbarRef,
|
|
4524
|
+
touch
|
|
4525
|
+
]);
|
|
4526
|
+
}
|
|
4527
|
+
//#endregion
|
|
4528
|
+
//#region src/client/ui/touch.ts
|
|
4529
|
+
/**
|
|
4530
|
+
* The same query the coarse-pointer CSS uses. Keeping JS and CSS on one
|
|
4531
|
+
* criterion means a device never gets the bottom bar with desktop positioning
|
|
4532
|
+
* (or the reverse).
|
|
4533
|
+
*/
|
|
4534
|
+
const COARSE_POINTER = "(hover: none), (pointer: coarse)";
|
|
4535
|
+
function coarsePointer() {
|
|
4536
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
|
4537
|
+
return window.matchMedia(COARSE_POINTER).matches;
|
|
4538
|
+
}
|
|
4539
|
+
function useCoarsePointer() {
|
|
4540
|
+
const [coarse, setCoarse] = (0, react.useState)(coarsePointer);
|
|
4541
|
+
(0, react.useEffect)(() => {
|
|
4542
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
4543
|
+
const query = window.matchMedia(COARSE_POINTER);
|
|
4544
|
+
const onChange = (event) => setCoarse(event.matches);
|
|
4545
|
+
setCoarse(query.matches);
|
|
4546
|
+
query.addEventListener("change", onChange);
|
|
4547
|
+
return () => query.removeEventListener("change", onChange);
|
|
4548
|
+
}, []);
|
|
4549
|
+
return coarse;
|
|
4550
|
+
}
|
|
4551
|
+
function measure() {
|
|
4552
|
+
if (typeof window === "undefined") return {
|
|
4553
|
+
inset: 0,
|
|
4554
|
+
height: 0
|
|
4555
|
+
};
|
|
4556
|
+
const viewport = window.visualViewport;
|
|
4557
|
+
const layout = document.documentElement.clientHeight || window.innerHeight;
|
|
4558
|
+
if (!viewport) return {
|
|
4559
|
+
inset: 0,
|
|
4560
|
+
height: window.innerHeight
|
|
4561
|
+
};
|
|
4562
|
+
return {
|
|
4563
|
+
inset: Math.max(0, Math.round(layout - viewport.height - viewport.offsetTop)),
|
|
4564
|
+
height: Math.round(viewport.height)
|
|
4565
|
+
};
|
|
4566
|
+
}
|
|
4567
|
+
/**
|
|
4568
|
+
* Ride above the soft keyboard. `innerHeight` alone is not enough: iOS keeps the
|
|
4569
|
+
* layout viewport at full height and only shrinks the visual viewport, while
|
|
4570
|
+
* Android resizes the layout viewport. The inset is zero in the Android case
|
|
4571
|
+
* because the fixed element then already sits above the keyboard.
|
|
4572
|
+
*/
|
|
4573
|
+
function useVisualViewport(enabled) {
|
|
4574
|
+
const [metrics, setMetrics] = (0, react.useState)(measure);
|
|
4575
|
+
(0, react.useEffect)(() => {
|
|
4576
|
+
if (!enabled || typeof window === "undefined") return;
|
|
4577
|
+
const update = () => setMetrics(measure());
|
|
4578
|
+
update();
|
|
4579
|
+
window.visualViewport?.addEventListener("resize", update);
|
|
4580
|
+
window.visualViewport?.addEventListener("scroll", update);
|
|
4581
|
+
window.addEventListener("resize", update);
|
|
4582
|
+
return () => {
|
|
4583
|
+
window.visualViewport?.removeEventListener("resize", update);
|
|
4584
|
+
window.visualViewport?.removeEventListener("scroll", update);
|
|
4585
|
+
window.removeEventListener("resize", update);
|
|
4586
|
+
};
|
|
4587
|
+
}, [enabled]);
|
|
4588
|
+
return metrics;
|
|
2812
4589
|
}
|
|
2813
4590
|
//#endregion
|
|
2814
|
-
//#region src/client/SelectionToolbar.tsx
|
|
4591
|
+
//#region src/client/selection/SelectionToolbar.tsx
|
|
2815
4592
|
/** Cap for the auto-growing comment box; beyond this it scrolls internally. */
|
|
2816
4593
|
const COMMENT_MAX_HEIGHT = 220;
|
|
2817
4594
|
function SelectionToolbar({ sessionId }) {
|
|
@@ -2823,21 +4600,40 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2823
4600
|
const [phase, setPhase] = (0, react.useState)("idle");
|
|
2824
4601
|
const [comment, setComment] = (0, react.useState)("");
|
|
2825
4602
|
const [status, setStatus] = (0, react.useState)(null);
|
|
4603
|
+
const [menu, setMenu] = (0, react.useState)(null);
|
|
2826
4604
|
const activeRef = (0, react.useRef)(null);
|
|
2827
4605
|
activeRef.current = active;
|
|
4606
|
+
const menuRef = (0, react.useRef)(null);
|
|
4607
|
+
menuRef.current = menu;
|
|
4608
|
+
const phaseRef = (0, react.useRef)(phase);
|
|
4609
|
+
phaseRef.current = phase;
|
|
2828
4610
|
/** Annotation saved when the comment step opened; the box only edits it. */
|
|
2829
4611
|
const pendingIdRef = (0, react.useRef)(null);
|
|
4612
|
+
const touch = useCoarsePointer();
|
|
4613
|
+
const viewport = useVisualViewport(touch);
|
|
2830
4614
|
(0, react.useEffect)(() => {
|
|
2831
4615
|
ensureToolbarStyles();
|
|
2832
4616
|
if (sessionId) bindAnnotationSession(sessionId);
|
|
2833
4617
|
}, [sessionId]);
|
|
4618
|
+
/**
|
|
4619
|
+
* Drop the menu state **and** its ref in one tick. A refresh that runs right
|
|
4620
|
+
* after a dismissal must see the menu as closed; reading the stale ref made
|
|
4621
|
+
* it keep `active`, which raised the auto toolbar in contextmenu mode.
|
|
4622
|
+
*/
|
|
4623
|
+
const dismissMenu = (0, react.useCallback)(() => {
|
|
4624
|
+
if (!menuRef.current) return false;
|
|
4625
|
+
menuRef.current = null;
|
|
4626
|
+
setMenu(null);
|
|
4627
|
+
return true;
|
|
4628
|
+
}, []);
|
|
2834
4629
|
const hide = (0, react.useCallback)(() => {
|
|
2835
4630
|
pendingIdRef.current = null;
|
|
4631
|
+
dismissMenu();
|
|
2836
4632
|
setActive(null);
|
|
2837
4633
|
setPhase("idle");
|
|
2838
4634
|
setComment("");
|
|
2839
4635
|
setStatus(null);
|
|
2840
|
-
}, []);
|
|
4636
|
+
}, [dismissMenu]);
|
|
2841
4637
|
/**
|
|
2842
4638
|
* Keep what was typed when the comment popover goes away without an explicit
|
|
2843
4639
|
* cancel. The annotation itself was saved when the step opened, so dropping
|
|
@@ -2851,9 +4647,7 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2851
4647
|
if (typed.trim()) updateAnnotationComment(void 0, id, typed);
|
|
2852
4648
|
}, []);
|
|
2853
4649
|
/** Save the current selection without a comment; returns the store id. */
|
|
2854
|
-
const saveSelection = (0, react.useCallback)(() => {
|
|
2855
|
-
const snapshot = activeRef.current;
|
|
2856
|
-
if (!snapshot) return null;
|
|
4650
|
+
const saveSelection = (0, react.useCallback)((snapshot) => {
|
|
2857
4651
|
return saveAnnotation(void 0, {
|
|
2858
4652
|
text: snapshot.text,
|
|
2859
4653
|
sources: snapshot.source ? [{
|
|
@@ -2865,12 +4659,31 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2865
4659
|
}] : void 0
|
|
2866
4660
|
}).id;
|
|
2867
4661
|
}, []);
|
|
4662
|
+
/**
|
|
4663
|
+
* "Add to task" is the commit: the quote is saved immediately, with or
|
|
4664
|
+
* without a comment. The popover only edits that saved entry, so dismissing
|
|
4665
|
+
* it (outside click, new selection) can never undo the annotation.
|
|
4666
|
+
*/
|
|
4667
|
+
const beginComment = (0, react.useCallback)((snapshot) => {
|
|
4668
|
+
pendingIdRef.current = saveSelection(snapshot);
|
|
4669
|
+
setActive(snapshot);
|
|
4670
|
+
dismissMenu();
|
|
4671
|
+
setPhase("comment");
|
|
4672
|
+
setComment("");
|
|
4673
|
+
setStatus(null);
|
|
4674
|
+
}, [dismissMenu, saveSelection]);
|
|
2868
4675
|
const refresh = (0, react.useCallback)(() => {
|
|
2869
4676
|
const next = readSelectionSnapshot(toolbarRef.current);
|
|
2870
4677
|
if (!next) {
|
|
4678
|
+
dismissMenu();
|
|
2871
4679
|
if (phaseRef.current === "idle") setActive(null);
|
|
2872
4680
|
return;
|
|
2873
4681
|
}
|
|
4682
|
+
if (menuRef.current && menuRef.current.snapshot.text !== next.text) dismissMenu();
|
|
4683
|
+
if (!touch && !resolveTriggers(getConfig()).auto) {
|
|
4684
|
+
if (phaseRef.current === "idle" && !menuRef.current) setActive(null);
|
|
4685
|
+
return;
|
|
4686
|
+
}
|
|
2874
4687
|
const current = activeRef.current;
|
|
2875
4688
|
if (phaseRef.current === "comment" && current && current.text !== next.text) {
|
|
2876
4689
|
flushComment();
|
|
@@ -2878,151 +4691,107 @@ function SelectionToolbar({ sessionId }) {
|
|
|
2878
4691
|
setComment("");
|
|
2879
4692
|
}
|
|
2880
4693
|
setActive((prev) => prev && prev.text === next.text && prev.left === next.left && prev.top === next.top && prev.below === next.below && prev.layer === next.layer ? prev : next);
|
|
2881
|
-
}, [
|
|
2882
|
-
|
|
2883
|
-
|
|
4694
|
+
}, [
|
|
4695
|
+
dismissMenu,
|
|
4696
|
+
flushComment,
|
|
4697
|
+
touch
|
|
4698
|
+
]);
|
|
4699
|
+
/** Latch the selection and anchor; the menu outlives later refresh passes. */
|
|
4700
|
+
const openMenu = (0, react.useCallback)((snapshot, x, y, initialFocus) => {
|
|
4701
|
+
setStatus(null);
|
|
4702
|
+
setActive(snapshot);
|
|
4703
|
+
setMenu({
|
|
4704
|
+
snapshot,
|
|
4705
|
+
x,
|
|
4706
|
+
y,
|
|
4707
|
+
initialFocus
|
|
4708
|
+
});
|
|
4709
|
+
}, []);
|
|
4710
|
+
(0, react.useEffect)(() => subscribeConfig(() => {
|
|
4711
|
+
dismissMenu();
|
|
4712
|
+
refresh();
|
|
4713
|
+
}), [dismissMenu, refresh]);
|
|
4714
|
+
useSelectionWatcher({
|
|
4715
|
+
toolbarRef,
|
|
4716
|
+
commentWrapRef,
|
|
4717
|
+
phaseRef,
|
|
4718
|
+
touch,
|
|
4719
|
+
dismissMenu,
|
|
4720
|
+
refresh,
|
|
4721
|
+
flushComment,
|
|
4722
|
+
hide,
|
|
4723
|
+
openMenu
|
|
4724
|
+
});
|
|
2884
4725
|
(0, react.useEffect)(() => {
|
|
2885
|
-
|
|
2886
|
-
const
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
hide();
|
|
2905
|
-
return;
|
|
2906
|
-
}
|
|
2907
|
-
const selection = window.getSelection();
|
|
2908
|
-
if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
|
|
2909
|
-
schedule();
|
|
2910
|
-
return;
|
|
4726
|
+
if (touch) return;
|
|
4727
|
+
const bridge = {
|
|
4728
|
+
state: () => {
|
|
4729
|
+
if (phaseRef.current !== "idle") return "busy";
|
|
4730
|
+
return readSelectionSnapshot(toolbarRef.current) ? "ready" : "no-selection";
|
|
4731
|
+
},
|
|
4732
|
+
open: () => {
|
|
4733
|
+
if (phaseRef.current !== "idle") return;
|
|
4734
|
+
const snapshot = readSelectionSnapshot(toolbarRef.current);
|
|
4735
|
+
if (!snapshot) return;
|
|
4736
|
+
const rect = selectionFirstLineRect();
|
|
4737
|
+
if (!rect) return;
|
|
4738
|
+
openMenu(snapshot, rect.left, rect.bottom, "first");
|
|
4739
|
+
},
|
|
4740
|
+
add: () => {
|
|
4741
|
+
if (phaseRef.current !== "idle") return;
|
|
4742
|
+
const snapshot = readSelectionSnapshot(toolbarRef.current);
|
|
4743
|
+
if (!snapshot) return;
|
|
4744
|
+
beginComment(snapshot);
|
|
2911
4745
|
}
|
|
2912
|
-
hide();
|
|
2913
|
-
};
|
|
2914
|
-
const onPointerUp = () => {
|
|
2915
|
-
dragging = false;
|
|
2916
|
-
schedule();
|
|
2917
4746
|
};
|
|
2918
|
-
|
|
2919
|
-
if (dragging) return;
|
|
2920
|
-
schedule();
|
|
2921
|
-
};
|
|
2922
|
-
const onScroll = (event) => {
|
|
2923
|
-
const target = event.target;
|
|
2924
|
-
if (target instanceof Node && (toolbarRef.current?.contains(target) === true || commentWrapRef.current?.contains(target) === true)) return;
|
|
2925
|
-
schedule();
|
|
2926
|
-
};
|
|
2927
|
-
document.addEventListener("pointerdown", onPointerDown, true);
|
|
2928
|
-
document.addEventListener("pointerup", onPointerUp, true);
|
|
2929
|
-
document.addEventListener("keyup", schedule, true);
|
|
2930
|
-
document.addEventListener("selectionchange", onSelectionChange);
|
|
2931
|
-
window.addEventListener("resize", schedule);
|
|
2932
|
-
document.addEventListener("scroll", onScroll, true);
|
|
4747
|
+
runtime.selection = bridge;
|
|
2933
4748
|
return () => {
|
|
2934
|
-
|
|
2935
|
-
document.removeEventListener("pointerup", onPointerUp, true);
|
|
2936
|
-
document.removeEventListener("keyup", schedule, true);
|
|
2937
|
-
document.removeEventListener("selectionchange", onSelectionChange);
|
|
2938
|
-
window.removeEventListener("resize", schedule);
|
|
2939
|
-
document.removeEventListener("scroll", onScroll, true);
|
|
4749
|
+
if (runtime.selection === bridge) delete runtime.selection;
|
|
2940
4750
|
};
|
|
2941
4751
|
}, [
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
4752
|
+
beginComment,
|
|
4753
|
+
openMenu,
|
|
4754
|
+
touch
|
|
2945
4755
|
]);
|
|
2946
|
-
const placeCommentPop = (
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
const toolbarH = toolbar.offsetHeight;
|
|
2955
|
-
const home = layer.fromContent(snapshot.left, snapshot.top);
|
|
2956
|
-
const anchor = {
|
|
2957
|
-
left: home.left - toolbarW / 2,
|
|
2958
|
-
top: home.top - (snapshot.below ? 0 : toolbarH),
|
|
2959
|
-
width: toolbarW,
|
|
2960
|
-
height: toolbarH
|
|
2961
|
-
};
|
|
2962
|
-
pop.style.display = "flex";
|
|
2963
|
-
pop.style.visibility = "hidden";
|
|
2964
|
-
const panel = {
|
|
2965
|
-
width: pop.offsetWidth || 320,
|
|
2966
|
-
height: pop.offsetHeight || 180
|
|
2967
|
-
};
|
|
2968
|
-
pop.style.visibility = "";
|
|
2969
|
-
const gap = 8;
|
|
2970
|
-
const margin = 8;
|
|
2971
|
-
const belowTop = layer.fromContent(0, snapshot.selectionBottom).top + gap;
|
|
2972
|
-
const aboveTop = anchor.top - panel.height - gap;
|
|
2973
|
-
const top = belowTop <= bounds.bottom - margin - panel.height ? belowTop : aboveTop >= bounds.top + margin ? aboveTop : Math.max(bounds.top + margin, Math.min(belowTop, bounds.bottom - margin - panel.height));
|
|
2974
|
-
const half = panel.width / 2 + 8;
|
|
2975
|
-
const min = bounds.left + half;
|
|
2976
|
-
const center = Math.min(Math.max(anchor.left + anchor.width / 2, min), Math.max(min, bounds.right - half));
|
|
2977
|
-
const at = layer.toContent(center, top);
|
|
2978
|
-
pop.style.top = `${at.top}px`;
|
|
2979
|
-
pop.style.left = `${at.left}px`;
|
|
2980
|
-
const sideGap = 6;
|
|
2981
|
-
const popLeftView = layer.fromContent(at.left, at.top).left - panel.width / 2;
|
|
2982
|
-
const popTopView = top;
|
|
2983
|
-
let leftView = popLeftView;
|
|
2984
|
-
let topView = popTopView - sideGap - toolbarH;
|
|
2985
|
-
if (topView < bounds.top + margin) topView = popTopView + panel.height + sideGap;
|
|
2986
|
-
leftView = Math.max(bounds.left + margin, Math.min(leftView, bounds.right - margin - toolbarW));
|
|
2987
|
-
topView = Math.max(bounds.top + margin, Math.min(topView, bounds.bottom - margin - toolbarH));
|
|
2988
|
-
const toolbarAt = layer.toContent(leftView, topView);
|
|
2989
|
-
toolbar.style.left = `${toolbarAt.left}px`;
|
|
2990
|
-
toolbar.style.top = `${toolbarAt.top}px`;
|
|
2991
|
-
}, []);
|
|
4756
|
+
const placeCommentPop = useCommentPlacement({
|
|
4757
|
+
touch,
|
|
4758
|
+
phase,
|
|
4759
|
+
active,
|
|
4760
|
+
toolbarRef,
|
|
4761
|
+
commentWrapRef
|
|
4762
|
+
});
|
|
4763
|
+
const layer = active?.layer;
|
|
2992
4764
|
(0, react.useLayoutEffect)(() => {
|
|
2993
|
-
if (
|
|
4765
|
+
if (!layer || touch) return;
|
|
4766
|
+
layer.setAboveComposer(phase === "comment");
|
|
4767
|
+
return () => layer.setAboveComposer(false);
|
|
2994
4768
|
}, [
|
|
2995
|
-
|
|
4769
|
+
layer,
|
|
2996
4770
|
phase,
|
|
2997
|
-
|
|
4771
|
+
touch
|
|
2998
4772
|
]);
|
|
2999
4773
|
(0, react.useEffect)(() => {
|
|
3000
|
-
if (phase === "comment") commentRef.current?.focus();
|
|
3001
|
-
}, [phase]);
|
|
3002
|
-
(0, react.useEffect)(() => {
|
|
3003
|
-
const onResize = () => placeCommentPop();
|
|
3004
|
-
window.addEventListener("resize", onResize);
|
|
3005
|
-
return () => window.removeEventListener("resize", onResize);
|
|
3006
|
-
}, [placeCommentPop]);
|
|
4774
|
+
if (phase === "comment" && !touch) commentRef.current?.focus();
|
|
4775
|
+
}, [phase, touch]);
|
|
3007
4776
|
(0, react.useLayoutEffect)(() => {
|
|
3008
4777
|
if (phase !== "comment") return;
|
|
3009
4778
|
const area = commentRef.current;
|
|
3010
4779
|
if (!area) return;
|
|
3011
4780
|
area.style.height = "auto";
|
|
3012
4781
|
area.style.height = `${Math.min(area.scrollHeight, COMMENT_MAX_HEIGHT)}px`;
|
|
3013
|
-
placeCommentPop();
|
|
4782
|
+
if (!touch) placeCommentPop();
|
|
3014
4783
|
}, [
|
|
3015
4784
|
phase,
|
|
3016
4785
|
comment,
|
|
3017
|
-
placeCommentPop
|
|
4786
|
+
placeCommentPop,
|
|
4787
|
+
touch
|
|
3018
4788
|
]);
|
|
3019
4789
|
const handleToolbarPointerDown = (0, react.useCallback)((event) => {
|
|
3020
4790
|
event.preventDefault();
|
|
3021
4791
|
event.stopPropagation();
|
|
3022
4792
|
}, []);
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
if (!snapshot) return;
|
|
4793
|
+
/** Copy a latched quote; a failure leaves the caller's surface in place. */
|
|
4794
|
+
const copySnapshot = (0, react.useCallback)(async (snapshot) => {
|
|
3026
4795
|
try {
|
|
3027
4796
|
await copyText(snapshot.text);
|
|
3028
4797
|
clearNativeSelection();
|
|
@@ -3031,18 +4800,14 @@ function SelectionToolbar({ sessionId }) {
|
|
|
3031
4800
|
setStatus(t("toolbar.copyFailed"));
|
|
3032
4801
|
}
|
|
3033
4802
|
}, [hide]);
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
*/
|
|
4803
|
+
const handleCopy = (0, react.useCallback)(() => {
|
|
4804
|
+
const snapshot = activeRef.current;
|
|
4805
|
+
if (snapshot) copySnapshot(snapshot);
|
|
4806
|
+
}, [copySnapshot]);
|
|
3039
4807
|
const openComment = (0, react.useCallback)(() => {
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
setComment("");
|
|
3044
|
-
setStatus(null);
|
|
3045
|
-
}, [saveSelection]);
|
|
4808
|
+
const snapshot = activeRef.current;
|
|
4809
|
+
if (snapshot) beginComment(snapshot);
|
|
4810
|
+
}, [beginComment]);
|
|
3046
4811
|
/**
|
|
3047
4812
|
* Hand the selected link to the system browser.
|
|
3048
4813
|
*
|
|
@@ -3052,137 +4817,840 @@ function SelectionToolbar({ sessionId }) {
|
|
|
3052
4817
|
* arbitrary URLs there is what wedges it). Only http(s) selections get this
|
|
3053
4818
|
* button — every other scheme is refused by that handler.
|
|
3054
4819
|
*/
|
|
3055
|
-
const
|
|
3056
|
-
const
|
|
3057
|
-
const url = snapshot ? linkTarget(snapshot.text, snapshot.anchorHref) : null;
|
|
4820
|
+
const openLinkSnapshot = (0, react.useCallback)((snapshot) => {
|
|
4821
|
+
const url = linkTarget(snapshot.text, snapshot.anchorHref);
|
|
3058
4822
|
if (!url) return;
|
|
3059
4823
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
3060
4824
|
clearNativeSelection();
|
|
3061
4825
|
hide();
|
|
3062
4826
|
}, [hide]);
|
|
4827
|
+
const handleOpenLink = (0, react.useCallback)(() => {
|
|
4828
|
+
const snapshot = activeRef.current;
|
|
4829
|
+
if (snapshot) openLinkSnapshot(snapshot);
|
|
4830
|
+
}, [openLinkSnapshot]);
|
|
4831
|
+
/** Dismissal without an action hands control back to the normal toolbar rules. */
|
|
4832
|
+
const closeMenu = (0, react.useCallback)(() => {
|
|
4833
|
+
if (!dismissMenu()) return;
|
|
4834
|
+
refresh();
|
|
4835
|
+
}, [dismissMenu, refresh]);
|
|
4836
|
+
const handleMenuAction = (0, react.useCallback)((action) => {
|
|
4837
|
+
const current = menuRef.current;
|
|
4838
|
+
if (!current) return;
|
|
4839
|
+
if (action === "add") beginComment(current.snapshot);
|
|
4840
|
+
else if (action === "copy") copySnapshot(current.snapshot);
|
|
4841
|
+
else openLinkSnapshot(current.snapshot);
|
|
4842
|
+
}, [
|
|
4843
|
+
beginComment,
|
|
4844
|
+
copySnapshot,
|
|
4845
|
+
openLinkSnapshot
|
|
4846
|
+
]);
|
|
3063
4847
|
/** Close the optional comment step; the annotation stays saved either way. */
|
|
3064
4848
|
const finishComment = (0, react.useCallback)((keepComment) => {
|
|
3065
4849
|
if (keepComment) flushComment();
|
|
3066
4850
|
else pendingIdRef.current = null;
|
|
3067
4851
|
clearNativeSelection();
|
|
3068
4852
|
hide();
|
|
3069
|
-
window.setTimeout(focusComposer, 16);
|
|
4853
|
+
if (!touch) window.setTimeout(focusComposer, 16);
|
|
4854
|
+
}, [
|
|
4855
|
+
flushComment,
|
|
4856
|
+
hide,
|
|
4857
|
+
touch
|
|
4858
|
+
]);
|
|
4859
|
+
/** Dismiss the sheet without discarding what was typed (desktop outside-click). */
|
|
4860
|
+
const closeCommentSheet = (0, react.useCallback)(() => {
|
|
4861
|
+
flushComment();
|
|
4862
|
+
hide();
|
|
3070
4863
|
}, [flushComment, hide]);
|
|
4864
|
+
const handleCommentKeyDown = (0, react.useCallback)((event) => {
|
|
4865
|
+
if (event.key === "Escape") {
|
|
4866
|
+
event.preventDefault();
|
|
4867
|
+
pendingIdRef.current = null;
|
|
4868
|
+
hide();
|
|
4869
|
+
return;
|
|
4870
|
+
}
|
|
4871
|
+
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
4872
|
+
event.preventDefault();
|
|
4873
|
+
finishComment(true);
|
|
4874
|
+
}
|
|
4875
|
+
}, [finishComment, hide]);
|
|
3071
4876
|
if (!active) return null;
|
|
3072
4877
|
const toolbarStyle = {
|
|
3073
4878
|
left: active.left,
|
|
3074
4879
|
top: active.top
|
|
3075
4880
|
};
|
|
3076
|
-
const
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
4881
|
+
const actions = [
|
|
4882
|
+
...linkTarget(active.text, active.anchorHref) ? [{
|
|
4883
|
+
key: "open",
|
|
4884
|
+
icon: "external-link",
|
|
4885
|
+
label: t("toolbar.openLink"),
|
|
4886
|
+
onClick: handleOpenLink
|
|
4887
|
+
}] : [],
|
|
4888
|
+
{
|
|
4889
|
+
key: "copy",
|
|
4890
|
+
icon: "copy",
|
|
4891
|
+
label: t("toolbar.copy"),
|
|
4892
|
+
onClick: handleCopy
|
|
4893
|
+
},
|
|
4894
|
+
{
|
|
4895
|
+
key: "add",
|
|
4896
|
+
icon: "plus",
|
|
4897
|
+
label: t("toolbar.addToTask"),
|
|
4898
|
+
onClick: openComment
|
|
4899
|
+
}
|
|
4900
|
+
];
|
|
4901
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolbarView, {
|
|
4902
|
+
touch,
|
|
4903
|
+
phase,
|
|
4904
|
+
active,
|
|
4905
|
+
status,
|
|
4906
|
+
viewport,
|
|
4907
|
+
actions,
|
|
4908
|
+
comment,
|
|
4909
|
+
toolbarStyle,
|
|
4910
|
+
toolbarRef,
|
|
4911
|
+
commentWrapRef,
|
|
4912
|
+
commentRef,
|
|
4913
|
+
menuOpen: menu !== null,
|
|
4914
|
+
onToolbarPointerDown: handleToolbarPointerDown,
|
|
4915
|
+
onCommentChange: setComment,
|
|
4916
|
+
onCommentKeyDown: handleCommentKeyDown,
|
|
4917
|
+
onFinishComment: finishComment,
|
|
4918
|
+
onCloseSheet: closeCommentSheet
|
|
4919
|
+
}), menu ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SelectionMenu, {
|
|
4920
|
+
snapshot: menu.snapshot,
|
|
4921
|
+
x: menu.x,
|
|
4922
|
+
y: menu.y,
|
|
4923
|
+
initialFocus: menu.initialFocus,
|
|
4924
|
+
onAction: handleMenuAction,
|
|
4925
|
+
onClose: closeMenu
|
|
4926
|
+
}) : null] });
|
|
4927
|
+
}
|
|
4928
|
+
//#endregion
|
|
4929
|
+
//#region src/client/shortcut-menu.ts
|
|
4930
|
+
const SELECTION_MENU_COMMAND_ID = "select-quote.openMenu";
|
|
4931
|
+
const ADD_TO_TASK_COMMAND_ID = "select-quote.addToTask";
|
|
4932
|
+
/**
|
|
4933
|
+
* Defaults per device profile.
|
|
4934
|
+
*
|
|
4935
|
+
* The two commands never share a binding. Web Linux admits only
|
|
4936
|
+
* `primary+Slash`, `primary+shift+Comma` and `primary+shift+Period`, and the
|
|
4937
|
+
* Host owns `primary+Slash` for its shortcut panel, so the menu takes comma and
|
|
4938
|
+
* add-to-task takes period. Web macOS/Windows accept two-modifier combinations;
|
|
4939
|
+
* theirs avoid the browser's own menu keys. Desktop Windows/Linux also take
|
|
4940
|
+
* period for add-to-task: `Ctrl+Alt+L` is AltGr+L on several layouts, and on
|
|
4941
|
+
* Windows the shell dispatches configured shortcuts before input regions.
|
|
4942
|
+
*/
|
|
4943
|
+
const SELECTION_MENU_DEFAULTS = Object.freeze({
|
|
4944
|
+
"desktop:macos": Object.freeze({
|
|
4945
|
+
code: "KeyL",
|
|
4946
|
+
modifiers: Object.freeze(["primary"])
|
|
4947
|
+
}),
|
|
4948
|
+
"desktop:windows": Object.freeze({
|
|
4949
|
+
code: "KeyL",
|
|
4950
|
+
modifiers: Object.freeze(["primary", "shift"])
|
|
4951
|
+
}),
|
|
4952
|
+
"desktop:linux": Object.freeze({
|
|
4953
|
+
code: "KeyL",
|
|
4954
|
+
modifiers: Object.freeze(["primary", "shift"])
|
|
4955
|
+
}),
|
|
4956
|
+
"web:macos": Object.freeze({
|
|
4957
|
+
code: "KeyL",
|
|
4958
|
+
modifiers: Object.freeze(["primary", "shift"])
|
|
4959
|
+
}),
|
|
4960
|
+
"web:windows": Object.freeze({
|
|
4961
|
+
code: "KeyL",
|
|
4962
|
+
modifiers: Object.freeze(["primary", "shift"])
|
|
4963
|
+
}),
|
|
4964
|
+
"web:linux": Object.freeze({
|
|
4965
|
+
code: "Comma",
|
|
4966
|
+
modifiers: Object.freeze(["primary", "shift"])
|
|
4967
|
+
})
|
|
4968
|
+
});
|
|
4969
|
+
const ADD_TO_TASK_DEFAULTS = Object.freeze({
|
|
4970
|
+
"desktop:macos": Object.freeze({
|
|
4971
|
+
code: "KeyL",
|
|
4972
|
+
modifiers: Object.freeze(["primary", "shift"])
|
|
4973
|
+
}),
|
|
4974
|
+
"desktop:windows": Object.freeze({
|
|
4975
|
+
code: "Period",
|
|
4976
|
+
modifiers: Object.freeze(["primary", "shift"])
|
|
4977
|
+
}),
|
|
4978
|
+
"desktop:linux": Object.freeze({
|
|
4979
|
+
code: "Period",
|
|
4980
|
+
modifiers: Object.freeze(["primary", "shift"])
|
|
4981
|
+
}),
|
|
4982
|
+
"web:macos": Object.freeze({
|
|
4983
|
+
code: "KeyL",
|
|
4984
|
+
modifiers: Object.freeze(["primary", "alt"])
|
|
4985
|
+
}),
|
|
4986
|
+
"web:windows": Object.freeze({
|
|
4987
|
+
code: "Period",
|
|
4988
|
+
modifiers: Object.freeze(["primary", "shift"])
|
|
4989
|
+
}),
|
|
4990
|
+
"web:linux": Object.freeze({
|
|
4991
|
+
code: "Period",
|
|
4992
|
+
modifiers: Object.freeze(["primary", "shift"])
|
|
4993
|
+
})
|
|
4994
|
+
});
|
|
4995
|
+
function resolveSelectionAction(action) {
|
|
4996
|
+
const bridge = runtime.selection;
|
|
4997
|
+
if (!bridge) return {
|
|
4998
|
+
status: "blocked",
|
|
4999
|
+
reason: t("menu.noSelection")
|
|
5000
|
+
};
|
|
5001
|
+
const state = bridge.state();
|
|
5002
|
+
if (state === "busy") return {
|
|
5003
|
+
status: "blocked",
|
|
5004
|
+
reason: t("menu.busy")
|
|
5005
|
+
};
|
|
5006
|
+
if (state === "no-selection") return {
|
|
5007
|
+
status: "blocked",
|
|
5008
|
+
reason: t("menu.noSelection")
|
|
5009
|
+
};
|
|
5010
|
+
return {
|
|
5011
|
+
status: "handled",
|
|
5012
|
+
run: () => bridge[action]()
|
|
5013
|
+
};
|
|
5014
|
+
}
|
|
5015
|
+
function selectionMenuCommand() {
|
|
5016
|
+
return {
|
|
5017
|
+
id: SELECTION_MENU_COMMAND_ID,
|
|
5018
|
+
label: () => t("menu.shortcutLabel"),
|
|
5019
|
+
aliases: ["selection menu", "quote menu"],
|
|
5020
|
+
defaults: SELECTION_MENU_DEFAULTS,
|
|
5021
|
+
regions: ["page"],
|
|
5022
|
+
modals: [],
|
|
5023
|
+
resolve: () => resolveSelectionAction("open")
|
|
5024
|
+
};
|
|
5025
|
+
}
|
|
5026
|
+
function addToTaskCommand() {
|
|
5027
|
+
return {
|
|
5028
|
+
id: ADD_TO_TASK_COMMAND_ID,
|
|
5029
|
+
label: () => t("menu.addShortcutLabel"),
|
|
5030
|
+
aliases: ["add to task", "annotate selection"],
|
|
5031
|
+
defaults: ADD_TO_TASK_DEFAULTS,
|
|
5032
|
+
regions: ["page"],
|
|
5033
|
+
modals: [],
|
|
5034
|
+
resolve: () => resolveSelectionAction("add")
|
|
5035
|
+
};
|
|
5036
|
+
}
|
|
5037
|
+
let shortcutRows = [];
|
|
5038
|
+
const shortcutListeners = /* @__PURE__ */ new Set();
|
|
5039
|
+
function getShortcutRows() {
|
|
5040
|
+
return shortcutRows;
|
|
5041
|
+
}
|
|
5042
|
+
function subscribeShortcutRows(listener) {
|
|
5043
|
+
shortcutListeners.add(listener);
|
|
5044
|
+
return () => {
|
|
5045
|
+
shortcutListeners.delete(listener);
|
|
5046
|
+
};
|
|
5047
|
+
}
|
|
5048
|
+
function publishShortcutRows(next) {
|
|
5049
|
+
if (next.length === shortcutRows.length && next.every((row, index) => {
|
|
5050
|
+
const previous = shortcutRows[index];
|
|
5051
|
+
return previous.id === row.id && previous.label === row.label && previous.enabled === row.enabled && previous.keys.length === row.keys.length && previous.keys.every((key, keyIndex) => key === row.keys[keyIndex]);
|
|
5052
|
+
})) return;
|
|
5053
|
+
shortcutRows = next;
|
|
5054
|
+
for (const listener of [...shortcutListeners]) listener();
|
|
5055
|
+
}
|
|
5056
|
+
/**
|
|
5057
|
+
* Mirror the two live catalog rows. The Host drops a command from the catalog
|
|
5058
|
+
* while it is unregistered, and marks a rebound conflict; the settings page
|
|
5059
|
+
* reads this instead of restating the defaults.
|
|
5060
|
+
*/
|
|
5061
|
+
function publishShortcutCatalog(ctx) {
|
|
5062
|
+
const catalog = ctx.shortcuts.catalog.getSnapshot();
|
|
5063
|
+
const next = [];
|
|
5064
|
+
for (const id of [SELECTION_MENU_COMMAND_ID, ADD_TO_TASK_COMMAND_ID]) {
|
|
5065
|
+
const row = catalog.find((candidate) => candidate.id === id);
|
|
5066
|
+
if (!row) continue;
|
|
5067
|
+
next.push({
|
|
5068
|
+
id,
|
|
5069
|
+
label: row.label,
|
|
5070
|
+
keys: row.keys,
|
|
5071
|
+
enabled: row.issue === null && row.conflicts.length === 0
|
|
5072
|
+
});
|
|
5073
|
+
}
|
|
5074
|
+
publishShortcutRows(next);
|
|
5075
|
+
}
|
|
5076
|
+
/** Keep both commands registered exactly while the setting is on. */
|
|
5077
|
+
function installSelectionShortcut(ctx) {
|
|
5078
|
+
const disposers = [];
|
|
5079
|
+
const unregisterAll = () => {
|
|
5080
|
+
while (disposers.length > 0) disposers.pop()?.();
|
|
5081
|
+
};
|
|
5082
|
+
const sync = () => {
|
|
5083
|
+
unregisterAll();
|
|
5084
|
+
if (!getConfig().shortcut) return;
|
|
5085
|
+
for (const command of [selectionMenuCommand(), addToTaskCommand()]) try {
|
|
5086
|
+
disposers.push(ctx.shortcuts.register(command));
|
|
5087
|
+
} catch (error) {
|
|
5088
|
+
console.warn(`[dsh-select-quote] shortcut registration failed for ${command.id}:`, error);
|
|
5089
|
+
}
|
|
5090
|
+
};
|
|
5091
|
+
ctx.effect(() => {
|
|
5092
|
+
const syncCatalog = () => publishShortcutCatalog(ctx);
|
|
5093
|
+
syncCatalog();
|
|
5094
|
+
const offCatalog = ctx.shortcuts.catalog.subscribe(syncCatalog);
|
|
5095
|
+
sync();
|
|
5096
|
+
const off = subscribeConfig(sync);
|
|
5097
|
+
return () => {
|
|
5098
|
+
off();
|
|
5099
|
+
offCatalog();
|
|
5100
|
+
unregisterAll();
|
|
5101
|
+
publishShortcutRows([]);
|
|
5102
|
+
};
|
|
5103
|
+
}, "dsh-select-quote: shortcut commands");
|
|
5104
|
+
}
|
|
5105
|
+
//#endregion
|
|
5106
|
+
//#region src/client/settings/ConfigRow.tsx
|
|
5107
|
+
const CHOICES = [
|
|
5108
|
+
"auto",
|
|
5109
|
+
"contextmenu",
|
|
5110
|
+
"both"
|
|
5111
|
+
];
|
|
5112
|
+
function ConfigRow({ view, form, t: slotTranslate }) {
|
|
5113
|
+
useLocale();
|
|
5114
|
+
if (view !== "page" || form === void 0) return null;
|
|
5115
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConfigSection, {
|
|
5116
|
+
form,
|
|
5117
|
+
slotTranslate
|
|
5118
|
+
});
|
|
5119
|
+
}
|
|
5120
|
+
/**
|
|
5121
|
+
* The bundle config entry gets no `form` prop from the Host (unlike a row
|
|
5122
|
+
* page), so it subscribes to the row's form controller itself. The controller
|
|
5123
|
+
* owns write ordering and revisions; `set` is the one-field convenience.
|
|
5124
|
+
*/
|
|
5125
|
+
function ConfigSection({ form, slotTranslate }) {
|
|
5126
|
+
const subscribe = (0, react.useCallback)((listener) => form.subscribe(listener), [form]);
|
|
5127
|
+
const getSnapshot = (0, react.useCallback)(() => form.getSnapshot(), [form]);
|
|
5128
|
+
const current = normalizeConfig((0, react.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot).value);
|
|
5129
|
+
const shortcuts = (0, react.useSyncExternalStore)(subscribeShortcutRows, getShortcutRows, getShortcutRows);
|
|
5130
|
+
const t$1 = slotTranslate ?? t;
|
|
5131
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
5132
|
+
className: styles.settings,
|
|
5133
|
+
children: [
|
|
5134
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
5135
|
+
className: styles.settingsSection,
|
|
5136
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
5137
|
+
className: styles.settingsCopy,
|
|
5138
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
5139
|
+
className: styles.settingsLabel,
|
|
5140
|
+
children: t$1("settings.trigger")
|
|
5141
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
5142
|
+
className: styles.settingsHint,
|
|
5143
|
+
children: t$1("settings.triggerHint")
|
|
5144
|
+
})]
|
|
5145
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
5146
|
+
className: styles.seg,
|
|
5147
|
+
role: "radiogroup",
|
|
5148
|
+
"aria-label": t$1("settings.trigger"),
|
|
5149
|
+
children: CHOICES.map((mode) => {
|
|
5150
|
+
const active = mode === current.triggerMode;
|
|
5151
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
5152
|
+
className: styles.segOption,
|
|
5153
|
+
"data-active": active,
|
|
5154
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
5155
|
+
type: "radio",
|
|
5156
|
+
className: styles.srOnly,
|
|
5157
|
+
name: "dsq-trigger-mode",
|
|
5158
|
+
value: mode,
|
|
5159
|
+
checked: active,
|
|
5160
|
+
onChange: () => {
|
|
5161
|
+
if (active) return;
|
|
5162
|
+
form.set("triggerMode", mode);
|
|
5163
|
+
}
|
|
5164
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t$1(`settings.trigger.${mode}`) })]
|
|
5165
|
+
}, mode);
|
|
3135
5166
|
})
|
|
3136
|
-
]
|
|
3137
|
-
})
|
|
3138
|
-
}),
|
|
3139
|
-
phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3140
|
-
ref: commentWrapRef,
|
|
3141
|
-
className: cx(A.col, styles.commentPop),
|
|
3142
|
-
role: "dialog",
|
|
3143
|
-
"aria-label": t("toolbar.commentDialogAria"),
|
|
3144
|
-
onPointerDown: handleToolbarPointerDown,
|
|
3145
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
|
|
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
5167
|
})]
|
|
3177
|
-
})
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
5168
|
+
}),
|
|
5169
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
5170
|
+
className: styles.settingsDivider,
|
|
5171
|
+
"aria-hidden": "true"
|
|
5172
|
+
}),
|
|
5173
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
5174
|
+
className: styles.settingsSwitchRow,
|
|
5175
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
5176
|
+
className: styles.settingsCopy,
|
|
5177
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
5178
|
+
className: styles.settingsLabel,
|
|
5179
|
+
children: t$1("settings.shortcut")
|
|
5180
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
5181
|
+
className: styles.settingsHint,
|
|
5182
|
+
children: t$1("settings.shortcutHint")
|
|
5183
|
+
})]
|
|
5184
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
5185
|
+
className: styles.switch,
|
|
5186
|
+
"data-on": current.shortcut,
|
|
5187
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
5188
|
+
type: "checkbox",
|
|
5189
|
+
className: styles.srOnly,
|
|
5190
|
+
"aria-label": t$1("settings.shortcut"),
|
|
5191
|
+
checked: current.shortcut,
|
|
5192
|
+
onChange: (event) => {
|
|
5193
|
+
const next = event.target.checked;
|
|
5194
|
+
if (next === current.shortcut) return;
|
|
5195
|
+
form.set("shortcut", next);
|
|
5196
|
+
}
|
|
5197
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
5198
|
+
className: styles.switchTrack,
|
|
5199
|
+
"aria-hidden": "true"
|
|
5200
|
+
})]
|
|
5201
|
+
})]
|
|
5202
|
+
}),
|
|
5203
|
+
current.shortcut ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
5204
|
+
className: styles.settingsShortcuts,
|
|
5205
|
+
"aria-label": t$1("settings.shortcutListAria"),
|
|
5206
|
+
children: shortcuts.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
|
|
5207
|
+
className: styles.settingsHint,
|
|
5208
|
+
children: t$1("settings.shortcutUnavailable")
|
|
5209
|
+
}) : shortcuts.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
5210
|
+
className: styles.settingsShortcutRow,
|
|
5211
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
5212
|
+
className: styles.settingsShortcutName,
|
|
5213
|
+
children: row.label
|
|
5214
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
5215
|
+
className: styles.settingsShortcutKeys,
|
|
5216
|
+
children: row.enabled ? row.keys.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("kbd", {
|
|
5217
|
+
className: styles.keycap,
|
|
5218
|
+
children: row.keys.join("")
|
|
5219
|
+
}) : t$1("settings.shortcutUnbound") : t$1("settings.shortcutConflict")
|
|
5220
|
+
})]
|
|
5221
|
+
}, row.id))
|
|
5222
|
+
}) : null
|
|
5223
|
+
]
|
|
5224
|
+
});
|
|
5225
|
+
}
|
|
5226
|
+
//#endregion
|
|
5227
|
+
//#region src/shared/identity.ts
|
|
5228
|
+
/**
|
|
5229
|
+
* Names the Loader and the settings layer agree on.
|
|
5230
|
+
*
|
|
5231
|
+
* The row id lives in `cordis.patch.yml` (and the local `dev.patch.yml`); the
|
|
5232
|
+
* Host config form is keyed by it, so renaming either without this constant
|
|
5233
|
+
* would silently orphan the form. The bundle config section is keyed by the
|
|
5234
|
+
* package name instead.
|
|
5235
|
+
*/
|
|
5236
|
+
const PACKAGE_NAME = "dsh-select-quote";
|
|
5237
|
+
const ROW_ID = "select-quote";
|
|
5238
|
+
//#endregion
|
|
5239
|
+
//#region src/client/transcript/quote-registry.ts
|
|
5240
|
+
/**
|
|
5241
|
+
* Quotes rendered for a user message, keyed by that message's flow key.
|
|
5242
|
+
*
|
|
5243
|
+
* The durable annotation data lives in the user turn, but the chip that refers
|
|
5244
|
+
* to it lives in the assistant reply. The chip decorator walks the DOM; this
|
|
5245
|
+
* registry is how it resolves an ordinal back to the original text and comment
|
|
5246
|
+
* without re-parsing the protocol or exposing JSON in the DOM.
|
|
5247
|
+
*/
|
|
5248
|
+
const quotesByOwner = /* @__PURE__ */ new Map();
|
|
5249
|
+
function registerMessageQuotes(ownerKey, quotes) {
|
|
5250
|
+
quotesByOwner.set(ownerKey, quotes);
|
|
5251
|
+
return () => {
|
|
5252
|
+
if (quotesByOwner.get(ownerKey) === quotes) quotesByOwner.delete(ownerKey);
|
|
5253
|
+
};
|
|
5254
|
+
}
|
|
5255
|
+
function messageQuotes(ownerKey) {
|
|
5256
|
+
return quotesByOwner.get(ownerKey);
|
|
5257
|
+
}
|
|
5258
|
+
//#endregion
|
|
5259
|
+
//#region src/client/dom/chipPreview.ts
|
|
5260
|
+
/**
|
|
5261
|
+
* Hover/focus/click preview for `:dsh-annotation{index="N"}` chips.
|
|
5262
|
+
*
|
|
5263
|
+
* The chip decorator is plain DOM, so the preview is too. One body-level
|
|
5264
|
+
* preview is reused for every chip: `contain: layout` surfaces in the
|
|
5265
|
+
* transcript would otherwise become the containing block of a fixed popover.
|
|
5266
|
+
*
|
|
5267
|
+
* Clicking the chip no longer jumps: it pins the preview, and the preview's
|
|
5268
|
+
* button owns the jump (same contract on touch, where there is no hover).
|
|
5269
|
+
*/
|
|
5270
|
+
const HOVER_DELAY = 250;
|
|
5271
|
+
/** Just enough for the pointer to cross the 8px gap into the preview. */
|
|
5272
|
+
const CLOSE_DELAY = 40;
|
|
5273
|
+
const GAP = 8;
|
|
5274
|
+
/** Matches the leaving keyframe; the node is detached after it finishes. */
|
|
5275
|
+
const EXIT_MS = 120;
|
|
5276
|
+
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>";
|
|
5277
|
+
let preview = null;
|
|
5278
|
+
let jumpButton = null;
|
|
5279
|
+
let activeChip = null;
|
|
5280
|
+
let activeMark = null;
|
|
5281
|
+
let activeOwnerKey = null;
|
|
5282
|
+
let activeMessageId = null;
|
|
5283
|
+
let activeText = "";
|
|
5284
|
+
let activeIndex = 0;
|
|
5285
|
+
let pinned = false;
|
|
5286
|
+
let showTimer = 0;
|
|
5287
|
+
let hideTimer = 0;
|
|
5288
|
+
let frame = 0;
|
|
5289
|
+
let suppressFocusOpen = false;
|
|
5290
|
+
let observer = null;
|
|
5291
|
+
let onDocPointerDown = null;
|
|
5292
|
+
let onScroll = null;
|
|
5293
|
+
let onResize = null;
|
|
5294
|
+
let leavingBox = null;
|
|
5295
|
+
let leavingTimer = 0;
|
|
5296
|
+
function cancelTimers() {
|
|
5297
|
+
if (showTimer !== 0) window.clearTimeout(showTimer);
|
|
5298
|
+
if (hideTimer !== 0) window.clearTimeout(hideTimer);
|
|
5299
|
+
showTimer = 0;
|
|
5300
|
+
hideTimer = 0;
|
|
5301
|
+
}
|
|
5302
|
+
/** A new preview replaces any panel still animating out. */
|
|
5303
|
+
function flushLeavingPreview() {
|
|
5304
|
+
if (leavingTimer !== 0) {
|
|
5305
|
+
window.clearTimeout(leavingTimer);
|
|
5306
|
+
leavingTimer = 0;
|
|
5307
|
+
}
|
|
5308
|
+
leavingBox?.remove();
|
|
5309
|
+
leavingBox = null;
|
|
5310
|
+
}
|
|
5311
|
+
function animatePreviewOut(box) {
|
|
5312
|
+
flushLeavingPreview();
|
|
5313
|
+
const active = document.activeElement;
|
|
5314
|
+
if (active instanceof HTMLElement && box.contains(active)) active.blur();
|
|
5315
|
+
box.classList.add(styles.chipPreviewLeaving);
|
|
5316
|
+
box.setAttribute("aria-hidden", "true");
|
|
5317
|
+
leavingBox = box;
|
|
5318
|
+
leavingTimer = window.setTimeout(() => {
|
|
5319
|
+
leavingTimer = 0;
|
|
5320
|
+
if (leavingBox === box) leavingBox = null;
|
|
5321
|
+
box.remove();
|
|
5322
|
+
}, EXIT_MS);
|
|
5323
|
+
}
|
|
5324
|
+
/**
|
|
5325
|
+
* The annotated text can sit in an earlier assistant turn while the quotes were
|
|
5326
|
+
* serialized into the user turn that sent them. The mark id is built by
|
|
5327
|
+
* `TranscriptQuoteCard` as `dqsm_<ownerFlowKey>_<zeroBasedIndex>`, so it — not
|
|
5328
|
+
* the mark's physical ancestor — names the message that owns the quotes.
|
|
5329
|
+
*/
|
|
5330
|
+
function quotesOwnerOf(mark) {
|
|
5331
|
+
if (!mark) return null;
|
|
5332
|
+
const id = mark.getAttribute("data-dsq-ann-mark") ?? "";
|
|
5333
|
+
const match = /^dqsm_(.+)_\d+$/.exec(id);
|
|
5334
|
+
return match ? match[1] : null;
|
|
5335
|
+
}
|
|
5336
|
+
/** The quote card for the user turn that sent the annotation, before the chip. */
|
|
5337
|
+
function quotesOwnerBefore(chip, index) {
|
|
5338
|
+
const cards = [...document.querySelectorAll(".dsq_tSummary")];
|
|
5339
|
+
for (let i = cards.length - 1; i >= 0; i -= 1) {
|
|
5340
|
+
const card = cards[i];
|
|
5341
|
+
if ((card.compareDocumentPosition(chip) & Node.DOCUMENT_POSITION_FOLLOWING) === 0) continue;
|
|
5342
|
+
const ownerKey = card.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
|
|
5343
|
+
if (ownerKey && messageQuotes(ownerKey)?.[index - 1]) return ownerKey;
|
|
5344
|
+
}
|
|
5345
|
+
return null;
|
|
5346
|
+
}
|
|
5347
|
+
function markByOwner(ownerKey, index) {
|
|
5348
|
+
const id = `dqsm_${ownerKey}_${index - 1}`;
|
|
5349
|
+
const marks = [...document.querySelectorAll("[data-dsq-ann-mark]")].filter((mark) => mark.getAttribute("data-dsq-ann-mark") === id);
|
|
5350
|
+
return marks.find((mark) => mark.getAttribute("data-dsq-ann-index") === String(index)) ?? marks[0] ?? null;
|
|
5351
|
+
}
|
|
5352
|
+
function flowItem(messageId) {
|
|
5353
|
+
for (const item of document.querySelectorAll("[data-chat-flow-key]")) if (item.getAttribute("data-chat-flow-key") === messageId) return item;
|
|
5354
|
+
return null;
|
|
5355
|
+
}
|
|
5356
|
+
function quoteCardFor(ownerKey) {
|
|
5357
|
+
for (const card of document.querySelectorAll(".dsq_tSummary")) if (card.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key") === ownerKey) return card;
|
|
5358
|
+
return null;
|
|
5359
|
+
}
|
|
5360
|
+
function isVisible(el) {
|
|
5361
|
+
if (!el?.isConnected) return false;
|
|
5362
|
+
const rect = el.getBoundingClientRect();
|
|
5363
|
+
return rect.width > 0 && rect.height > 0;
|
|
5364
|
+
}
|
|
5365
|
+
function flashMessage(el) {
|
|
5366
|
+
el.classList.add(styles.annMessageFlash);
|
|
5367
|
+
window.setTimeout(() => el.classList.remove(styles.annMessageFlash), 1400);
|
|
5368
|
+
}
|
|
5369
|
+
function cancelClose() {
|
|
5370
|
+
if (hideTimer !== 0) window.clearTimeout(hideTimer);
|
|
5371
|
+
hideTimer = 0;
|
|
5372
|
+
}
|
|
5373
|
+
function scheduleClose() {
|
|
5374
|
+
cancelClose();
|
|
5375
|
+
if (pinned) return;
|
|
5376
|
+
hideTimer = window.setTimeout(() => closeChipPreview(false), CLOSE_DELAY);
|
|
5377
|
+
}
|
|
5378
|
+
function scheduleShow(chip, index) {
|
|
5379
|
+
cancelTimers();
|
|
5380
|
+
showTimer = window.setTimeout(() => showChipPreview(chip, index, false), HOVER_DELAY);
|
|
5381
|
+
}
|
|
5382
|
+
function scheduleReposition() {
|
|
5383
|
+
if (frame !== 0) return;
|
|
5384
|
+
frame = window.requestAnimationFrame(() => {
|
|
5385
|
+
frame = 0;
|
|
5386
|
+
reposition();
|
|
5387
|
+
});
|
|
5388
|
+
}
|
|
5389
|
+
function reposition() {
|
|
5390
|
+
if (!preview || !activeChip) return;
|
|
5391
|
+
if (!activeChip.isConnected) {
|
|
5392
|
+
closeChipPreview(false);
|
|
5393
|
+
return;
|
|
5394
|
+
}
|
|
5395
|
+
const anchor = activeChip.getBoundingClientRect();
|
|
5396
|
+
if (anchor.bottom < 0 || anchor.top > window.innerHeight) {
|
|
5397
|
+
closeChipPreview(false);
|
|
5398
|
+
return;
|
|
5399
|
+
}
|
|
5400
|
+
const { top, left } = placePopover(anchor, {
|
|
5401
|
+
width: preview.offsetWidth || 320,
|
|
5402
|
+
height: preview.offsetHeight || 180
|
|
5403
|
+
}, "top", GAP);
|
|
5404
|
+
preview.style.top = `${top}px`;
|
|
5405
|
+
preview.style.left = `${left}px`;
|
|
5406
|
+
}
|
|
5407
|
+
function appendLabel(parent, text) {
|
|
5408
|
+
const label = document.createElement("div");
|
|
5409
|
+
label.className = styles.chipPreviewLabel;
|
|
5410
|
+
label.textContent = text;
|
|
5411
|
+
parent.append(label);
|
|
5412
|
+
}
|
|
5413
|
+
function appendText(parent, className, text) {
|
|
5414
|
+
const node = document.createElement("div");
|
|
5415
|
+
node.className = className;
|
|
5416
|
+
node.textContent = text;
|
|
5417
|
+
parent.append(node);
|
|
5418
|
+
}
|
|
5419
|
+
function jumpToMark(event) {
|
|
5420
|
+
const ownerKey = activeOwnerKey;
|
|
5421
|
+
const messageId = activeMessageId;
|
|
5422
|
+
const text = activeText;
|
|
5423
|
+
const index = activeIndex;
|
|
5424
|
+
const keyboard = event.detail === 0;
|
|
5425
|
+
let mark = activeMark?.isConnected ? activeMark : null;
|
|
5426
|
+
if (!mark && ownerKey) mark = markByOwner(ownerKey, index);
|
|
5427
|
+
const card = ownerKey ? quoteCardFor(ownerKey) : null;
|
|
5428
|
+
const ownerItem = ownerKey ? flowItem(ownerKey) : null;
|
|
5429
|
+
const sourceItem = messageId ? flowItem(messageId) : null;
|
|
5430
|
+
closeChipPreview(false);
|
|
5431
|
+
if (isVisible(mark)) {
|
|
5432
|
+
mark.tabIndex = -1;
|
|
5433
|
+
flashAnnotationMark(mark);
|
|
5434
|
+
if (keyboard) window.setTimeout(() => {
|
|
5435
|
+
if (mark.isConnected) mark.focus({ preventScroll: true });
|
|
5436
|
+
}, 450);
|
|
5437
|
+
return;
|
|
5438
|
+
}
|
|
5439
|
+
const fallback = isVisible(card) ? card : ownerItem ?? sourceItem;
|
|
5440
|
+
if (fallback) {
|
|
5441
|
+
fallback.scrollIntoView({
|
|
5442
|
+
block: "center",
|
|
5443
|
+
behavior: "smooth"
|
|
5444
|
+
});
|
|
5445
|
+
flashMessage(fallback);
|
|
5446
|
+
if (keyboard) window.setTimeout(() => {
|
|
5447
|
+
if (!fallback.isConnected) return;
|
|
5448
|
+
const next = fallback.querySelector("button, [tabindex]") ?? fallback;
|
|
5449
|
+
if (next === fallback) fallback.tabIndex = -1;
|
|
5450
|
+
next.focus({ preventScroll: true });
|
|
5451
|
+
}, 450);
|
|
5452
|
+
return;
|
|
5453
|
+
}
|
|
5454
|
+
if (text) scrollToAnnotation(index, text);
|
|
5455
|
+
}
|
|
5456
|
+
function buildPreview(index, quoteText, comment) {
|
|
5457
|
+
const box = document.createElement("div");
|
|
5458
|
+
box.className = cx(A.col, styles.chipPreview);
|
|
5459
|
+
box.setAttribute("data-dsq-chip-preview", "true");
|
|
5460
|
+
box.setAttribute("role", "dialog");
|
|
5461
|
+
box.setAttribute("aria-label", t("chip.previewAria"));
|
|
5462
|
+
const body = document.createElement("div");
|
|
5463
|
+
body.className = styles.chipPreviewBody;
|
|
5464
|
+
body.style.animation = "none";
|
|
5465
|
+
const head = document.createElement("div");
|
|
5466
|
+
head.className = cx(A.row, styles.chipPreviewHead);
|
|
5467
|
+
const title = document.createElement("span");
|
|
5468
|
+
title.className = styles.chipPreviewTitle;
|
|
5469
|
+
title.textContent = t("chip.annotation", { index });
|
|
5470
|
+
jumpButton = document.createElement("button");
|
|
5471
|
+
jumpButton.type = "button";
|
|
5472
|
+
jumpButton.className = cx(A.plain, A.inlineRow, styles.chipPreviewJump);
|
|
5473
|
+
jumpButton.setAttribute("data-dsq-chip-jump", "true");
|
|
5474
|
+
jumpButton.setAttribute("aria-label", t("chip.jumpAria"));
|
|
5475
|
+
jumpButton.title = t("chip.jumpAria");
|
|
5476
|
+
jumpButton.insertAdjacentHTML("afterbegin", JUMP_ICON);
|
|
5477
|
+
const jumpLabel = document.createElement("span");
|
|
5478
|
+
jumpLabel.textContent = t("chip.jump");
|
|
5479
|
+
jumpButton.append(jumpLabel);
|
|
5480
|
+
jumpButton.addEventListener("click", jumpToMark);
|
|
5481
|
+
head.append(title, jumpButton);
|
|
5482
|
+
body.append(head);
|
|
5483
|
+
appendLabel(body, t("common.selectedText"));
|
|
5484
|
+
appendText(body, styles.chipPreviewQuote, quoteText);
|
|
5485
|
+
if (comment !== null) {
|
|
5486
|
+
appendLabel(body, t("summary.userComment"));
|
|
5487
|
+
appendText(body, cx(styles.chipPreviewComment, comment === "" && styles.chipPreviewCommentEmpty), comment === "" ? t("summary.noComment") : comment);
|
|
5488
|
+
}
|
|
5489
|
+
box.append(body);
|
|
5490
|
+
box.addEventListener("pointerenter", cancelClose);
|
|
5491
|
+
box.addEventListener("pointerleave", (event) => {
|
|
5492
|
+
if (event.pointerType === "touch") return;
|
|
5493
|
+
scheduleClose();
|
|
5494
|
+
});
|
|
5495
|
+
box.addEventListener("focusout", (event) => {
|
|
5496
|
+
const next = event.relatedTarget;
|
|
5497
|
+
if (next instanceof Node && (box.contains(next) || activeChip?.contains(next))) return;
|
|
5498
|
+
closeChipPreview(false);
|
|
5499
|
+
});
|
|
5500
|
+
box.addEventListener("keydown", (event) => {
|
|
5501
|
+
if (event.key !== "Escape") return;
|
|
5502
|
+
event.preventDefault();
|
|
5503
|
+
event.stopPropagation();
|
|
5504
|
+
closeChipPreview(true);
|
|
5505
|
+
});
|
|
5506
|
+
return box;
|
|
5507
|
+
}
|
|
5508
|
+
function showChipPreview(chip, index, nextPinned) {
|
|
5509
|
+
cancelTimers();
|
|
5510
|
+
if (!chip.isConnected) return;
|
|
5511
|
+
let mark = findAnnotationMarkBefore(index, chip);
|
|
5512
|
+
let ownerKey = quotesOwnerOf(mark) ?? mark?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key") ?? null;
|
|
5513
|
+
let quote = ownerKey ? messageQuotes(ownerKey)?.[index - 1] : void 0;
|
|
5514
|
+
if (!quote) {
|
|
5515
|
+
const fallbackKey = quotesOwnerBefore(chip, index);
|
|
5516
|
+
if (fallbackKey) {
|
|
5517
|
+
ownerKey = fallbackKey;
|
|
5518
|
+
quote = messageQuotes(fallbackKey)?.[index - 1];
|
|
5519
|
+
}
|
|
5520
|
+
}
|
|
5521
|
+
if (ownerKey) {
|
|
5522
|
+
const exact = markByOwner(ownerKey, index);
|
|
5523
|
+
if (exact) mark = exact;
|
|
5524
|
+
else if (quotesOwnerOf(mark) !== ownerKey) mark = null;
|
|
5525
|
+
}
|
|
5526
|
+
const text = (quote?.text ?? mark?.textContent ?? "").trim();
|
|
5527
|
+
if (text === "") return;
|
|
5528
|
+
if (activeChip === chip && preview) {
|
|
5529
|
+
pinned = pinned || nextPinned;
|
|
5530
|
+
return;
|
|
5531
|
+
}
|
|
5532
|
+
closeChipPreview(false);
|
|
5533
|
+
flushLeavingPreview();
|
|
5534
|
+
activeChip = chip;
|
|
5535
|
+
activeMark = mark;
|
|
5536
|
+
activeOwnerKey = ownerKey;
|
|
5537
|
+
activeMessageId = quote?.messageId ?? null;
|
|
5538
|
+
activeText = text;
|
|
5539
|
+
activeIndex = index;
|
|
5540
|
+
pinned = nextPinned;
|
|
5541
|
+
const box = buildPreview(index, text, quote ? (quote.comment ?? "").trim() : null);
|
|
5542
|
+
preview = box;
|
|
5543
|
+
box.style.visibility = "hidden";
|
|
5544
|
+
document.body.append(box);
|
|
5545
|
+
const panel = {
|
|
5546
|
+
width: box.offsetWidth || 320,
|
|
5547
|
+
height: box.offsetHeight || 180
|
|
5548
|
+
};
|
|
5549
|
+
const { top, left } = placePopover(chip.getBoundingClientRect(), panel, "top", GAP);
|
|
5550
|
+
box.style.top = `${top}px`;
|
|
5551
|
+
box.style.left = `${left}px`;
|
|
5552
|
+
box.style.visibility = "";
|
|
5553
|
+
const animated = box.querySelector(`.${styles.chipPreviewBody}`);
|
|
5554
|
+
if (animated) animated.style.animation = "";
|
|
5555
|
+
onDocPointerDown = (event) => {
|
|
5556
|
+
const target = event.target;
|
|
5557
|
+
if (!(target instanceof Node)) return;
|
|
5558
|
+
if (box.contains(target) || activeChip?.contains(target)) return;
|
|
5559
|
+
closeChipPreview(false);
|
|
5560
|
+
};
|
|
5561
|
+
document.addEventListener("pointerdown", onDocPointerDown, true);
|
|
5562
|
+
onScroll = scheduleReposition;
|
|
5563
|
+
document.addEventListener("scroll", onScroll, true);
|
|
5564
|
+
onResize = scheduleReposition;
|
|
5565
|
+
window.addEventListener("resize", onResize);
|
|
5566
|
+
observer = new MutationObserver(() => {
|
|
5567
|
+
if (activeChip && !activeChip.isConnected) closeChipPreview(false);
|
|
5568
|
+
});
|
|
5569
|
+
observer.observe(document.body, {
|
|
5570
|
+
childList: true,
|
|
5571
|
+
subtree: true
|
|
5572
|
+
});
|
|
5573
|
+
}
|
|
5574
|
+
/** Close the preview. `restoreFocus` is for a deliberate Escape. */
|
|
5575
|
+
function closeChipPreview(restoreFocus) {
|
|
5576
|
+
const box = preview;
|
|
5577
|
+
const chip = activeChip;
|
|
5578
|
+
cancelTimers();
|
|
5579
|
+
if (frame !== 0) {
|
|
5580
|
+
window.cancelAnimationFrame(frame);
|
|
5581
|
+
frame = 0;
|
|
5582
|
+
}
|
|
5583
|
+
if (observer) {
|
|
5584
|
+
observer.disconnect();
|
|
5585
|
+
observer = null;
|
|
5586
|
+
}
|
|
5587
|
+
if (onDocPointerDown) {
|
|
5588
|
+
document.removeEventListener("pointerdown", onDocPointerDown, true);
|
|
5589
|
+
onDocPointerDown = null;
|
|
5590
|
+
}
|
|
5591
|
+
if (onScroll) {
|
|
5592
|
+
document.removeEventListener("scroll", onScroll, true);
|
|
5593
|
+
onScroll = null;
|
|
5594
|
+
}
|
|
5595
|
+
if (onResize) {
|
|
5596
|
+
window.removeEventListener("resize", onResize);
|
|
5597
|
+
onResize = null;
|
|
5598
|
+
}
|
|
5599
|
+
preview = null;
|
|
5600
|
+
jumpButton = null;
|
|
5601
|
+
activeChip = null;
|
|
5602
|
+
activeMark = null;
|
|
5603
|
+
activeOwnerKey = null;
|
|
5604
|
+
activeMessageId = null;
|
|
5605
|
+
activeText = "";
|
|
5606
|
+
activeIndex = 0;
|
|
5607
|
+
pinned = false;
|
|
5608
|
+
if (restoreFocus && chip?.isConnected) {
|
|
5609
|
+
suppressFocusOpen = true;
|
|
5610
|
+
chip.focus({ preventScroll: true });
|
|
5611
|
+
}
|
|
5612
|
+
if (box) animatePreviewOut(box);
|
|
5613
|
+
suppressFocusOpen = false;
|
|
5614
|
+
}
|
|
5615
|
+
/** Bind the preview to a freshly decorated chip. Safe to call twice. */
|
|
5616
|
+
function bindChipPreview(chip, index) {
|
|
5617
|
+
if (chip.dataset.dsqChipPreviewBound === "true") return;
|
|
5618
|
+
chip.dataset.dsqChipPreviewBound = "true";
|
|
5619
|
+
chip.addEventListener("pointerenter", (event) => {
|
|
5620
|
+
if (event.pointerType === "touch") return;
|
|
5621
|
+
if (activeChip === chip && preview) {
|
|
5622
|
+
cancelClose();
|
|
5623
|
+
return;
|
|
5624
|
+
}
|
|
5625
|
+
scheduleShow(chip, index);
|
|
5626
|
+
});
|
|
5627
|
+
chip.addEventListener("pointerleave", (event) => {
|
|
5628
|
+
if (event.pointerType === "touch") return;
|
|
5629
|
+
scheduleClose();
|
|
5630
|
+
});
|
|
5631
|
+
chip.addEventListener("focus", () => {
|
|
5632
|
+
if (suppressFocusOpen) return;
|
|
5633
|
+
showChipPreview(chip, index, true);
|
|
5634
|
+
});
|
|
5635
|
+
chip.addEventListener("blur", (event) => {
|
|
5636
|
+
const next = event.relatedTarget;
|
|
5637
|
+
if (next instanceof Node && (preview?.contains(next) || chip.contains(next))) return;
|
|
5638
|
+
closeChipPreview(false);
|
|
5639
|
+
});
|
|
5640
|
+
chip.addEventListener("click", () => showChipPreview(chip, index, true));
|
|
5641
|
+
chip.addEventListener("keydown", (event) => {
|
|
5642
|
+
if (event.key === "Escape") {
|
|
5643
|
+
if (activeChip !== chip) return;
|
|
5644
|
+
event.preventDefault();
|
|
5645
|
+
closeChipPreview(true);
|
|
5646
|
+
return;
|
|
5647
|
+
}
|
|
5648
|
+
if (event.key === "ArrowDown") {
|
|
5649
|
+
event.preventDefault();
|
|
5650
|
+
showChipPreview(chip, index, true);
|
|
5651
|
+
jumpButton?.focus();
|
|
5652
|
+
}
|
|
5653
|
+
});
|
|
3186
5654
|
}
|
|
3187
5655
|
//#endregion
|
|
3188
5656
|
//#region src/client/dom/decorateAnnotationDirectives.ts
|
|
@@ -3197,7 +5665,7 @@ function decorateTextNode(node) {
|
|
|
3197
5665
|
if (!value || !value.includes(":dsh-annotation{")) return;
|
|
3198
5666
|
const parent = node.parentElement;
|
|
3199
5667
|
if (!parent || parent.closest(`[data-${DECO}]`)) return;
|
|
3200
|
-
if (parent.closest("pre, code, [data-dsq-toolbar]")) return;
|
|
5668
|
+
if (parent.closest("pre, code, [data-dsq-toolbar], [data-dsq-chip-preview]")) return;
|
|
3201
5669
|
if (parent.closest("textarea, input, [data-lexical-editor=\"true\"]")) return;
|
|
3202
5670
|
const frag = document.createDocumentFragment();
|
|
3203
5671
|
let last = 0;
|
|
@@ -3214,10 +5682,7 @@ function decorateTextNode(node) {
|
|
|
3214
5682
|
chip.setAttribute("data-dsq-deco", "true");
|
|
3215
5683
|
chip.dataset[DECO_KEY] = String(index);
|
|
3216
5684
|
chip.textContent = t("chip.annotation", { index });
|
|
3217
|
-
chip
|
|
3218
|
-
const mark = findAnnotationMarkBefore(index, chip);
|
|
3219
|
-
if (mark) flashAnnotationMark(mark);
|
|
3220
|
-
});
|
|
5685
|
+
bindChipPreview(chip, index);
|
|
3221
5686
|
frag.append(chip);
|
|
3222
5687
|
last = m.index + m[0].length;
|
|
3223
5688
|
}
|
|
@@ -3247,6 +5712,7 @@ function decorateAnnotationDirectives(root) {
|
|
|
3247
5712
|
* literal, so `decorateTextNode()` returns before it can touch the DOM.
|
|
3248
5713
|
*/
|
|
3249
5714
|
function refreshDirectiveChips(root) {
|
|
5715
|
+
closeChipPreview(false);
|
|
3250
5716
|
const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
|
|
3251
5717
|
if (!el) return;
|
|
3252
5718
|
for (const chip of el.querySelectorAll(`[data-${DECO}]`)) {
|
|
@@ -3279,66 +5745,142 @@ function watchAnnotationDirectives(root) {
|
|
|
3279
5745
|
//#endregion
|
|
3280
5746
|
//#region src/client/dom/stripSubmissionEcho.ts
|
|
3281
5747
|
/**
|
|
3282
|
-
* Strip the annotation protocol from
|
|
5748
|
+
* Strip the annotation protocol from host surfaces that paint the raw draft.
|
|
3283
5749
|
*
|
|
3284
5750
|
* On submit the host paints an optimistic bubble (`[data-submission-echo]`)
|
|
3285
5751
|
* from the composer draft — which, at that instant, is the protocol block the
|
|
3286
|
-
* send gesture just folded in.
|
|
3287
|
-
*
|
|
3288
|
-
*
|
|
3289
|
-
*
|
|
5752
|
+
* send gesture just folded in. A message submitted while a turn is running is
|
|
5753
|
+
* also previewed in the queue dock (`[data-queue-dock]`) from the same wire
|
|
5754
|
+
* content. Both are rendered by the chat flow itself, not by a
|
|
5755
|
+
* `conversation.chat.node` registration, so `UserMessageDisplay` never sees
|
|
5756
|
+
* them; without this, the raw JSON sits on screen until (or unless) a durable
|
|
5757
|
+
* message replaces it.
|
|
3290
5758
|
*
|
|
3291
5759
|
* The durable message keeps the block (the model needs it). This is display
|
|
3292
|
-
* only: the echo's
|
|
3293
|
-
*
|
|
3294
|
-
*
|
|
5760
|
+
* only: the echo's protocol run is rewritten in place, and the queue preview's
|
|
5761
|
+
* word-split text container is rebuilt to the user's own text. The echo row
|
|
5762
|
+
* itself is never rebuilt (it may carry session chips); a truncated block is
|
|
5763
|
+
* left untouched, so nothing but our own protocol is edited.
|
|
3295
5764
|
*/
|
|
3296
5765
|
const ECHO_SELECTOR = "[data-submission-echo]";
|
|
3297
|
-
const
|
|
3298
|
-
|
|
3299
|
-
function
|
|
3300
|
-
|
|
5766
|
+
const QUEUE_DOCK_SELECTOR = "[data-queue-dock]";
|
|
5767
|
+
const SURFACE_SELECTOR = `${ECHO_SELECTOR}, ${QUEUE_DOCK_SELECTOR}`;
|
|
5768
|
+
function carriesProtocol(text) {
|
|
5769
|
+
return text.includes("<response-annotations>") && text.includes("</response-annotations>");
|
|
5770
|
+
}
|
|
5771
|
+
/** Deepest runs that carry both markers. A queue dock may carry one per row. */
|
|
5772
|
+
function protocolRuns(row) {
|
|
5773
|
+
if (row.children.length === 0) return carriesProtocol(row.textContent ?? "") ? [row] : [];
|
|
5774
|
+
const runs = [];
|
|
5775
|
+
for (const el of Array.from(row.querySelectorAll("*"))) {
|
|
3301
5776
|
if (el.children.length > 0) continue;
|
|
3302
|
-
|
|
3303
|
-
if (text.includes("<response-annotations>") && text.includes("</response-annotations>")) return el;
|
|
5777
|
+
if (carriesProtocol(el.textContent ?? "")) runs.push(el);
|
|
3304
5778
|
}
|
|
3305
|
-
return
|
|
5779
|
+
return runs;
|
|
5780
|
+
}
|
|
5781
|
+
/**
|
|
5782
|
+
* Rewrite one surface's protocol runs in place.
|
|
5783
|
+
*
|
|
5784
|
+
* Idempotent: once stripped a run no longer carries both markers, so a later
|
|
5785
|
+
* sweep (after the host re-renders the surface) is a no-op rather than a
|
|
5786
|
+
* reason to remember the node forever.
|
|
5787
|
+
*/
|
|
5788
|
+
function stripEchoRow(row) {
|
|
5789
|
+
let stripped = false;
|
|
5790
|
+
for (const run of protocolRuns(row)) {
|
|
5791
|
+
const original = run.textContent ?? "";
|
|
5792
|
+
const scan = scanAnnotatedMessage(original);
|
|
5793
|
+
if (scan.rest === original) continue;
|
|
5794
|
+
run.textContent = scan.rest;
|
|
5795
|
+
stripped = true;
|
|
5796
|
+
}
|
|
5797
|
+
return stripped;
|
|
5798
|
+
}
|
|
5799
|
+
/**
|
|
5800
|
+
* Innermost text containers whose text carries a complete block.
|
|
5801
|
+
*
|
|
5802
|
+
* Queue previews go through the host's `conversation.user.text` factory, which
|
|
5803
|
+
* splits the draft into per-word spans; the open and close markers therefore
|
|
5804
|
+
* never share a leaf. The innermost ancestor holding both is the preview's
|
|
5805
|
+
* text container — rewriting that one leaves the row's icon and actions alone.
|
|
5806
|
+
*/
|
|
5807
|
+
function textContainers(surface) {
|
|
5808
|
+
const all = Array.from(surface.querySelectorAll("*")).filter((el) => el.children.length > 0 && carriesProtocol(el.textContent ?? ""));
|
|
5809
|
+
return all.filter((el) => !all.some((other) => other !== el && el.contains?.(other) === true));
|
|
3306
5810
|
}
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
5811
|
+
/**
|
|
5812
|
+
* Rewrite each queued row's text container in place. Unlike the echo, the
|
|
5813
|
+
* preview is a word-split run with no chips or attachments inside it, so
|
|
5814
|
+
* rebuilding that container is safe; the row itself is never touched.
|
|
5815
|
+
*/
|
|
5816
|
+
function stripQueueDock(dock) {
|
|
5817
|
+
let stripped = false;
|
|
5818
|
+
for (const container of textContainers(dock)) {
|
|
5819
|
+
const original = container.textContent ?? "";
|
|
5820
|
+
const scan = scanAnnotatedMessage(original);
|
|
5821
|
+
if (scan.rest === original) continue;
|
|
5822
|
+
container.textContent = scan.rest;
|
|
5823
|
+
stripped = true;
|
|
5824
|
+
}
|
|
5825
|
+
return stripped;
|
|
3315
5826
|
}
|
|
3316
5827
|
/**
|
|
3317
|
-
* Watch for
|
|
3318
|
-
* them. Runs once immediately, then on
|
|
5828
|
+
* Watch for host surfaces that paint the composed draft and rewrite the
|
|
5829
|
+
* protocol block out of them. Runs once immediately, then only on mutations
|
|
5830
|
+
* that can affect one of those surfaces.
|
|
3319
5831
|
*/
|
|
3320
5832
|
function watchSubmissionEcho(root) {
|
|
3321
5833
|
const scope = root ?? (typeof document === "undefined" ? void 0 : document.body);
|
|
3322
5834
|
if (!scope) return () => {};
|
|
5835
|
+
const stripSurface = (surface) => {
|
|
5836
|
+
const node = surface;
|
|
5837
|
+
if (surface.matches(QUEUE_DOCK_SELECTOR)) stripQueueDock(node);
|
|
5838
|
+
else stripEchoRow(node);
|
|
5839
|
+
};
|
|
3323
5840
|
const sweep = () => {
|
|
3324
|
-
for (const
|
|
5841
|
+
for (const surface of Array.from(scope.querySelectorAll(SURFACE_SELECTOR))) stripSurface(surface);
|
|
3325
5842
|
};
|
|
3326
5843
|
sweep();
|
|
3327
5844
|
if (typeof MutationObserver === "undefined") return () => {};
|
|
5845
|
+
const pending = /* @__PURE__ */ new Set();
|
|
3328
5846
|
let frame = 0;
|
|
3329
|
-
const
|
|
5847
|
+
const collect = (records) => {
|
|
5848
|
+
for (const record of records) {
|
|
5849
|
+
if (record.type === "characterData") {
|
|
5850
|
+
const row = record.target.parentElement?.closest(SURFACE_SELECTOR);
|
|
5851
|
+
if (row) pending.add(row);
|
|
5852
|
+
continue;
|
|
5853
|
+
}
|
|
5854
|
+
for (const node of Array.from(record.addedNodes)) {
|
|
5855
|
+
const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
5856
|
+
if (el === null) continue;
|
|
5857
|
+
const row = el.closest(SURFACE_SELECTOR);
|
|
5858
|
+
if (row) {
|
|
5859
|
+
pending.add(row);
|
|
5860
|
+
continue;
|
|
5861
|
+
}
|
|
5862
|
+
for (const inner of Array.from(el.querySelectorAll(SURFACE_SELECTOR))) pending.add(inner);
|
|
5863
|
+
}
|
|
5864
|
+
}
|
|
5865
|
+
};
|
|
5866
|
+
const observer = new MutationObserver((records) => {
|
|
5867
|
+
collect(records);
|
|
5868
|
+
if (pending.size === 0) return;
|
|
3330
5869
|
if (frame !== 0) return;
|
|
3331
5870
|
frame = window.requestAnimationFrame(() => {
|
|
3332
5871
|
frame = 0;
|
|
3333
|
-
|
|
5872
|
+
for (const surface of pending) stripSurface(surface);
|
|
5873
|
+
pending.clear();
|
|
3334
5874
|
});
|
|
3335
5875
|
});
|
|
3336
5876
|
observer.observe(scope, {
|
|
3337
5877
|
childList: true,
|
|
3338
|
-
subtree: true
|
|
5878
|
+
subtree: true,
|
|
5879
|
+
characterData: true
|
|
3339
5880
|
});
|
|
3340
5881
|
return () => {
|
|
3341
5882
|
observer.disconnect();
|
|
5883
|
+
pending.clear();
|
|
3342
5884
|
if (frame !== 0) window.cancelAnimationFrame(frame);
|
|
3343
5885
|
};
|
|
3344
5886
|
}
|
|
@@ -3370,6 +5912,12 @@ function TranscriptQuoteCard({ node }) {
|
|
|
3370
5912
|
endOffset: quote.endOffset
|
|
3371
5913
|
})));
|
|
3372
5914
|
}, [quotes]);
|
|
5915
|
+
(0, react.useEffect)(() => {
|
|
5916
|
+
if (quotes.length === 0) return;
|
|
5917
|
+
const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
|
|
5918
|
+
if (!ownerKey) return;
|
|
5919
|
+
return registerMessageQuotes(ownerKey, quotes);
|
|
5920
|
+
}, [quotes]);
|
|
3373
5921
|
const markIdOf = (0, react.useCallback)((index) => {
|
|
3374
5922
|
const ownerKey = stackRef.current?.closest("[data-chat-flow-key]")?.getAttribute("data-chat-flow-key");
|
|
3375
5923
|
return ownerKey ? `dqsm_${ownerKey}_${index}` : void 0;
|
|
@@ -3438,9 +5986,9 @@ function imageAttachments(raw) {
|
|
|
3438
5986
|
* block is removed from the text blocks, everything else (attachments, file
|
|
3439
5987
|
* cards, reference labels, time, copy, and any plugin that portals into the
|
|
3440
5988
|
* actions row, e.g. dsh-rewind's ↶) stays host-owned and follows host changes.
|
|
3441
|
-
* The annotation
|
|
5989
|
+
* The annotation summary is the only extra DOM, prepended above the host row.
|
|
3442
5990
|
*
|
|
3443
|
-
*
|
|
5991
|
+
* The summary must live inside the `user` node view rather than on a
|
|
3444
5992
|
* `select-quote` Chat node: `conversation.chat.node` is gated by the shipped
|
|
3445
5993
|
* `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
|
|
3446
5994
|
* turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail), and any
|
|
@@ -3448,10 +5996,10 @@ function imageAttachments(raw) {
|
|
|
3448
5996
|
*/
|
|
3449
5997
|
function UserMessageDisplay(props) {
|
|
3450
5998
|
ensureToolbarStyles();
|
|
3451
|
-
const { node,
|
|
5999
|
+
const { node, hostNodeOf, ...rest } = props;
|
|
3452
6000
|
const raw = contentBlocksToText(node.data?.content);
|
|
3453
6001
|
const scanned = scanAnnotatedMessage(raw);
|
|
3454
|
-
const text = scanned.
|
|
6002
|
+
const text = displayTextWithoutQuote(scanned.rest).trim();
|
|
3455
6003
|
const quotes = scanned.annotations;
|
|
3456
6004
|
const rewritten = quotes.length > 0 || text !== raw.trim();
|
|
3457
6005
|
const displayNode = (0, react.useMemo)(() => rewritten ? {
|
|
@@ -3466,7 +6014,7 @@ function UserMessageDisplay(props) {
|
|
|
3466
6014
|
text
|
|
3467
6015
|
]);
|
|
3468
6016
|
const cards = quotes.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }) : null;
|
|
3469
|
-
const Host =
|
|
6017
|
+
const Host = hostNodeOf?.();
|
|
3470
6018
|
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
6019
|
...rest,
|
|
3472
6020
|
node: displayNode
|
|
@@ -3496,6 +6044,27 @@ function UserMessageDisplay(props) {
|
|
|
3496
6044
|
})] });
|
|
3497
6045
|
}
|
|
3498
6046
|
//#endregion
|
|
6047
|
+
//#region src/client/transcript/host-user.ts
|
|
6048
|
+
const HOST_NODE_NAME = "UserMessageNodeView";
|
|
6049
|
+
/** Component name across memo/forwardRef/plain function shapes. */
|
|
6050
|
+
function componentName(component) {
|
|
6051
|
+
const candidate = component;
|
|
6052
|
+
return candidate?.type?.name ?? candidate?.render?.name ?? candidate?.name ?? candidate?.displayName;
|
|
6053
|
+
}
|
|
6054
|
+
/**
|
|
6055
|
+
* The host view registered for `key`, or undefined when it cannot be
|
|
6056
|
+
* identified safely.
|
|
6057
|
+
*
|
|
6058
|
+
* A renamed host leaves one non-self entry for that key, which is still
|
|
6059
|
+
* almost certainly the host; more than one is ambiguous, so callers should
|
|
6060
|
+
* show the hostless fallback instead of delegating to an arbitrary plugin's
|
|
6061
|
+
* wrapper.
|
|
6062
|
+
*/
|
|
6063
|
+
function pickHostNode(entries, self, key) {
|
|
6064
|
+
const candidates = entries.filter((entry) => entry.options?.key === key && entry.component !== void 0 && entry.component !== self);
|
|
6065
|
+
return (candidates.find((entry) => componentName(entry.component) === HOST_NODE_NAME) ?? (candidates.length === 1 ? candidates[0] : void 0))?.component;
|
|
6066
|
+
}
|
|
6067
|
+
//#endregion
|
|
3499
6068
|
//#region src/client/state/annotation-persistence.ts
|
|
3500
6069
|
/** localStorage prefix; a session id is appended as `.<sessionId>`. */
|
|
3501
6070
|
const ANNOTATION_KEY_PREFIX = "dsh.select-quote.annotations";
|
|
@@ -3652,7 +6221,7 @@ function createAnnotationPersistence() {
|
|
|
3652
6221
|
* Selection toolbar is root-scoped (`shell.overlay`) so transcript selections
|
|
3653
6222
|
* are always observed. Annotation summary stays on the session composer.
|
|
3654
6223
|
*
|
|
3655
|
-
* History
|
|
6224
|
+
* History summary renders **inside** the `user` Chat node view, not on a
|
|
3656
6225
|
* `select-quote` Chat node. `conversation.chat.node` is gated by the shipped
|
|
3657
6226
|
* `TURN_PROCESS_INDEPENDENT_KINDS` allowlist (system-prompt, user, steering,
|
|
3658
6227
|
* turn-trigger, turn-process, turn-error, turn-max-tokens, turn-tail): any other
|
|
@@ -3685,6 +6254,19 @@ function apply(ctx) {
|
|
|
3685
6254
|
subscribe: (listener) => ctx.locale.subscribe(listener)
|
|
3686
6255
|
});
|
|
3687
6256
|
ctx.effect(() => subscribeLocale(() => refreshDirectiveChips()), "dsh-select-quote: chip language");
|
|
6257
|
+
ctx.inject(["configForms"], (scope) => {
|
|
6258
|
+
scope.effect(() => attachConfigForm(scope.configForms.get(ROW_ID)), "dsh-select-quote: config");
|
|
6259
|
+
const form = scope.configForms.get(ROW_ID);
|
|
6260
|
+
scope.effect(() => scope.configForms.whileServed([ROW_ID], () => scope.slots.inject("plugins.bundle.config", () => scope.slots.register({
|
|
6261
|
+
name: "plugins.bundle.config",
|
|
6262
|
+
key: PACKAGE_NAME,
|
|
6263
|
+
locale: LOCALE_NS,
|
|
6264
|
+
inject: () => ({ form })
|
|
6265
|
+
}, ConfigRow))), "dsh-select-quote: settings section");
|
|
6266
|
+
});
|
|
6267
|
+
ctx.inject(["shortcuts"], (scope) => {
|
|
6268
|
+
installSelectionShortcut(scope);
|
|
6269
|
+
});
|
|
3688
6270
|
ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
|
|
3689
6271
|
ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
|
|
3690
6272
|
ctx.effect(() => watchSubmissionEcho(), "dsh-select-quote: submission echo");
|
|
@@ -3696,7 +6278,20 @@ function apply(ctx) {
|
|
|
3696
6278
|
const port = createAnnotationPersistence();
|
|
3697
6279
|
setAnnotationPersistence(port);
|
|
3698
6280
|
port.prune();
|
|
3699
|
-
|
|
6281
|
+
const onStorage = (event) => {
|
|
6282
|
+
if (event.storageArea !== null && event.storageArea !== window.localStorage) return;
|
|
6283
|
+
if (event.key === null) {
|
|
6284
|
+
adoptStorageAnnotations(null);
|
|
6285
|
+
return;
|
|
6286
|
+
}
|
|
6287
|
+
const sessionId = sessionIdOfKey(event.key);
|
|
6288
|
+
if (sessionId !== null) adoptStorageAnnotations(sessionId);
|
|
6289
|
+
};
|
|
6290
|
+
window.addEventListener("storage", onStorage);
|
|
6291
|
+
return () => {
|
|
6292
|
+
window.removeEventListener("storage", onStorage);
|
|
6293
|
+
setAnnotationPersistence(null);
|
|
6294
|
+
};
|
|
3700
6295
|
}, "dsh-select-quote: annotation persistence");
|
|
3701
6296
|
ctx.slots.inject("shell.overlay", () => {
|
|
3702
6297
|
ctx.slots.register({
|
|
@@ -3719,28 +6314,30 @@ function apply(ctx) {
|
|
|
3719
6314
|
order: 1
|
|
3720
6315
|
}, SessionBridge);
|
|
3721
6316
|
});
|
|
3722
|
-
const
|
|
3723
|
-
|
|
3724
|
-
return (candidates.find((entry) => componentName(entry.component) === "UserMessageNodeView") ?? candidates[0])?.component;
|
|
6317
|
+
const hostNodeOf = (key, self) => {
|
|
6318
|
+
return pickHostNode(typeof ctx.slots.entries === "function" ? ctx.slots.entries("conversation.chat.node") : [], self, key);
|
|
3725
6319
|
};
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
6320
|
+
function UserNode(props) {
|
|
6321
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMessageDisplay, {
|
|
6322
|
+
...props,
|
|
6323
|
+
hostNodeOf: () => hostNodeOf("user", UserNode)
|
|
6324
|
+
});
|
|
6325
|
+
}
|
|
6326
|
+
function SteeringNode(props) {
|
|
6327
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserMessageDisplay, {
|
|
6328
|
+
...props,
|
|
6329
|
+
hostNodeOf: () => hostNodeOf("steering", SteeringNode)
|
|
6330
|
+
});
|
|
6331
|
+
}
|
|
6332
|
+
for (const [key, View] of [["user", UserNode], ["steering", SteeringNode]]) ctx.slots.inject("conversation.chat.node", () => {
|
|
3731
6333
|
ctx.slots.register({
|
|
3732
6334
|
name: "conversation.chat.node",
|
|
3733
|
-
key
|
|
6335
|
+
key,
|
|
3734
6336
|
priority: -10,
|
|
3735
6337
|
locale: "chat"
|
|
3736
|
-
},
|
|
6338
|
+
}, View);
|
|
3737
6339
|
});
|
|
3738
6340
|
}
|
|
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
6341
|
/** Invisible session-scoped bridge that publishes sessionId for the root toolbar. */
|
|
3745
6342
|
function SessionBridge({ sessionId }) {
|
|
3746
6343
|
bindAnnotationSession(sessionId);
|