dsh-select-quote 0.3.9 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +40 -8
  2. package/lib/client.js +2000 -627
  3. package/lib/index.js +911 -2
  4. package/package.json +12 -4
package/lib/client.js CHANGED
@@ -64,6 +64,246 @@ function lastEditable() {
64
64
  return null;
65
65
  }
66
66
  //#endregion
67
+ //#region src/client/i18n.ts
68
+ /**
69
+ * Copy for dsh-select-quote, served through the host locale service.
70
+ *
71
+ * The active language is owned by `@deepseek-ai/dsh-client-locale` (Settings →
72
+ * General → Language, persisted as `locale.preference`). This module owns only
73
+ * the dictionaries and the plural form selection that the host's plain-template
74
+ * lookup cannot express; it never resolves a language on its own.
75
+ *
76
+ * NOT localized on purpose:
77
+ * - wire markers (`> [选中文本]`, `<response-annotations>`,
78
+ * `:dsh-annotation{index="N"}`) — they are the message format the model
79
+ * and the transcript parser agree on;
80
+ * - the `# Response annotations:` header — model-facing instruction prose,
81
+ * not UI text.
82
+ */
83
+ const LOCALE_NS = "select-quote";
84
+ const zh = {
85
+ "common.selectedText": "选中的文本",
86
+ "common.commentPlaceholder": "添加可选评论…",
87
+ "common.cancel": "取消",
88
+ "common.close": "关闭",
89
+ "common.save": "保存",
90
+ "toolbar.aria": "划词操作",
91
+ "toolbar.commentHint": "添加批注…",
92
+ "toolbar.copy": "复制",
93
+ "toolbar.openLink": "打开链接",
94
+ "toolbar.copyFailed": "复制失败",
95
+ "toolbar.addToTask": "添加到任务",
96
+ "toolbar.commentDialogAria": "添加可选评论",
97
+ "toolbar.skipComment": "暂不评论",
98
+ "toolbar.addComment": "添加评论",
99
+ "menu.aria": "划词菜单",
100
+ "menu.shortcutLabel": "打开划词菜单",
101
+ "menu.addShortcutLabel": "将选中文字添加到任务",
102
+ "menu.noSelection": "先在对话里选中一段文字",
103
+ "menu.busy": "先完成当前批注",
104
+ "summary.hint": "悬停查看批注",
105
+ "summary.hintTouch": "点按查看批注",
106
+ "summary.dialogAria": "{count} 条划词批注详情",
107
+ "summary.edit": "编辑批注 {index}",
108
+ "summary.remove": "移除批注 {index}",
109
+ "summary.userComment": "用户评论",
110
+ "summary.noComment": "未添加评论",
111
+ "summary.aria.other": "{count} 条划词批注。展开查看详情。",
112
+ "summary.title.other": "{count} 条批注",
113
+ "summary.removeAll": "移除全部划词批注",
114
+ "chip.annotation": "批注 {index}",
115
+ "chip.previewAria": "批注预览",
116
+ "mark.previewAria": "查看第 {index} 条批注",
117
+ "chip.jump": "原文",
118
+ "chip.jumpAria": "跳转到原文",
119
+ "settings.trigger": "划词入口",
120
+ "settings.triggerHint": "选中文字后何时显示操作条。触屏始终使用底部操作条。",
121
+ "settings.trigger.auto": "自动浮出",
122
+ "settings.trigger.contextmenu": "右键菜单",
123
+ "settings.trigger.both": "两者都开",
124
+ "settings.shortcut": "快捷键",
125
+ "settings.shortcutHint": "开启后,选中文字即可使用下面的快捷键;可在宿主的「快捷键」设置里改键。",
126
+ "settings.shortcutListAria": "当前快捷键",
127
+ "settings.shortcutUnavailable": "快捷键暂不可用(可能被其它插件占用)",
128
+ "settings.shortcutUnbound": "未绑定",
129
+ "settings.shortcutConflict": "与其它快捷键冲突"
130
+ };
131
+ const en = {
132
+ "common.selectedText": "Selected text",
133
+ "common.commentPlaceholder": "Add an optional comment…",
134
+ "common.cancel": "Cancel",
135
+ "common.close": "Close",
136
+ "common.save": "Save",
137
+ "toolbar.aria": "Selection actions",
138
+ "toolbar.commentHint": "Add annotation…",
139
+ "toolbar.copy": "Copy",
140
+ "toolbar.openLink": "Open link",
141
+ "toolbar.copyFailed": "Copy failed",
142
+ "toolbar.addToTask": "Add to task",
143
+ "toolbar.commentDialogAria": "Add an optional comment",
144
+ "toolbar.skipComment": "Skip comment",
145
+ "toolbar.addComment": "Add comment",
146
+ "menu.aria": "Selection menu",
147
+ "menu.shortcutLabel": "Open selection menu",
148
+ "menu.addShortcutLabel": "Add selected text to task",
149
+ "menu.noSelection": "Select text in the conversation first",
150
+ "menu.busy": "Finish the current annotation first",
151
+ "summary.hint": "Hover to view annotations",
152
+ "summary.hintTouch": "Tap to view annotations",
153
+ "summary.dialogAria": "Annotation details: {count}",
154
+ "summary.edit": "Edit annotation {index}",
155
+ "summary.remove": "Remove annotation {index}",
156
+ "summary.userComment": "User comment",
157
+ "summary.noComment": "No comment added",
158
+ "summary.aria.one": "{count} annotation. Open to view details.",
159
+ "summary.aria.other": "{count} annotations. Open to view details.",
160
+ "summary.title.one": "{count} annotation",
161
+ "summary.title.other": "{count} annotations",
162
+ "summary.removeAll": "Remove all annotations",
163
+ "chip.annotation": "Annotation {index}",
164
+ "chip.previewAria": "Annotation preview",
165
+ "mark.previewAria": "View annotation {index}",
166
+ "chip.jump": "Original",
167
+ "chip.jumpAria": "Jump to original",
168
+ "settings.trigger": "Trigger",
169
+ "settings.triggerHint": "When the action bar appears after you select text. Touch screens always use the bottom bar.",
170
+ "settings.trigger.auto": "Automatic",
171
+ "settings.trigger.contextmenu": "Right-click",
172
+ "settings.trigger.both": "Both",
173
+ "settings.shortcut": "Shortcut",
174
+ "settings.shortcutHint": "When on, the shortcuts below work on the selected text. Rebind them in the Host's Keyboard shortcuts settings.",
175
+ "settings.shortcutListAria": "Active shortcuts",
176
+ "settings.shortcutUnavailable": "Shortcuts are unavailable (another plugin may already use the keys).",
177
+ "settings.shortcutUnbound": "Unbound",
178
+ "settings.shortcutConflict": "Conflicts with another shortcut"
179
+ };
180
+ /**
181
+ * Russian is not a shipped host locale: it is added through
182
+ * `ctx.locale.addLanguage()` and its dictionary through the three-argument
183
+ * `register()`. Its fallback chain ends at `en`, as the host requires.
184
+ */
185
+ const ru = {
186
+ "common.selectedText": "Выделенный текст",
187
+ "common.commentPlaceholder": "Добавить необязательный комментарий…",
188
+ "common.cancel": "Отмена",
189
+ "common.close": "Закрыть",
190
+ "common.save": "Сохранить",
191
+ "toolbar.aria": "Действия с выделенным текстом",
192
+ "toolbar.commentHint": "Добавить аннотацию…",
193
+ "toolbar.copy": "Копировать",
194
+ "toolbar.openLink": "Открыть ссылку",
195
+ "toolbar.copyFailed": "Не удалось скопировать",
196
+ "toolbar.addToTask": "Добавить к задаче",
197
+ "toolbar.commentDialogAria": "Добавить необязательный комментарий",
198
+ "toolbar.skipComment": "Без комментария",
199
+ "toolbar.addComment": "Добавить комментарий",
200
+ "menu.aria": "Меню выделения",
201
+ "menu.shortcutLabel": "Открыть меню выделения",
202
+ "menu.addShortcutLabel": "Добавить выделенный текст к задаче",
203
+ "menu.noSelection": "Сначала выделите текст в диалоге",
204
+ "menu.busy": "Сначала завершите текущую аннотацию",
205
+ "summary.hint": "Наведите, чтобы увидеть аннотации",
206
+ "summary.hintTouch": "Нажмите, чтобы увидеть аннотации",
207
+ "summary.dialogAria": "Детали аннотаций: {count}",
208
+ "summary.edit": "Изменить аннотацию {index}",
209
+ "summary.remove": "Удалить аннотацию {index}",
210
+ "summary.userComment": "Комментарий пользователя",
211
+ "summary.noComment": "Комментарий не добавлен",
212
+ "summary.aria.one": "{count} аннотация. Откройте, чтобы увидеть подробности.",
213
+ "summary.aria.few": "{count} аннотации. Откройте, чтобы увидеть подробности.",
214
+ "summary.aria.many": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
215
+ "summary.aria.other": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
216
+ "summary.title.one": "{count} аннотация",
217
+ "summary.title.few": "{count} аннотации",
218
+ "summary.title.many": "{count} аннотаций",
219
+ "summary.title.other": "{count} аннотаций",
220
+ "summary.removeAll": "Удалить все аннотации",
221
+ "chip.annotation": "Аннотация {index}",
222
+ "chip.previewAria": "Просмотр аннотации",
223
+ "mark.previewAria": "Открыть аннотацию {index}",
224
+ "chip.jump": "Оригинал",
225
+ "chip.jumpAria": "Перейти к оригиналу",
226
+ "settings.trigger": "Триггер",
227
+ "settings.triggerHint": "Когда показывать панель действий после выделения текста. На сенсорных экранах всегда используется нижняя панель.",
228
+ "settings.trigger.auto": "Автоматически",
229
+ "settings.trigger.contextmenu": "По правому клику",
230
+ "settings.trigger.both": "Оба способа",
231
+ "settings.shortcut": "Горячая клавиша",
232
+ "settings.shortcutHint": "Когда включено, сочетания ниже работают с выделенным текстом. Изменить их можно в настройках горячих клавиш.",
233
+ "settings.shortcutListAria": "Текущие сочетания",
234
+ "settings.shortcutUnavailable": "Сочетания недоступны (возможно, клавиши заняты другим плагином).",
235
+ "settings.shortcutUnbound": "Не назначено",
236
+ "settings.shortcutConflict": "Конфликтует с другим сочетанием"
237
+ };
238
+ /** Both shipped host locales, required together by the typed `register()`. */
239
+ const SHIPPED_DICTS = Object.freeze({
240
+ zh,
241
+ en
242
+ });
243
+ /** Dictionary for the language registered through {@link LANGUAGE_RU}. */
244
+ const DICTIONARIES = Object.freeze({
245
+ zh,
246
+ en,
247
+ ru
248
+ });
249
+ const LANGUAGE_RU = Object.freeze({
250
+ id: "ru",
251
+ label: "Русский",
252
+ fallback: "en"
253
+ });
254
+ let api;
255
+ /**
256
+ * Install the host-bound translation entry point. Called once from `apply()`
257
+ * with `ctx.locale.bind(LOCALE_NS)` and the live snapshot accessors.
258
+ */
259
+ function bindLocale(next) {
260
+ api = next;
261
+ }
262
+ /** Active language id, or `en` before the host service is bound. */
263
+ function activeLocale() {
264
+ return api?.getActive() ?? "en";
265
+ }
266
+ function pluralForm(count) {
267
+ const locale = activeLocale();
268
+ if (locale === "ru") {
269
+ const mod10 = count % 10;
270
+ const mod100 = count % 100;
271
+ if (mod10 === 1 && mod100 !== 11) return "one";
272
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "few";
273
+ return "many";
274
+ }
275
+ if (locale === "en") return count === 1 ? "one" : "other";
276
+ return "other";
277
+ }
278
+ /**
279
+ * Translate a `select-quote` key. A numeric `count` selects a plural form and
280
+ * the matching `key.form` entry is used, falling back to `key.other`.
281
+ */
282
+ function t(key, params = {}) {
283
+ if (!api) return key;
284
+ if (typeof params.count === "number") {
285
+ const form = pluralForm(params.count);
286
+ const exact = api.translate(`${key}.${form}`, params);
287
+ if (exact !== `${key}.${form}`) return exact;
288
+ const other = api.translate(`${key}.other`, params);
289
+ if (other !== `${key}.other`) return other;
290
+ }
291
+ return api.translate(key, params);
292
+ }
293
+ /**
294
+ * Subscribe to language or dictionary changes. Components go through
295
+ * `useLocale()` instead; this is the non-React entry point (DOM decorations).
296
+ * Subscribing before the host service is bound is a no-op, so callers subscribe
297
+ * from `apply()`, after `bindLocale()`.
298
+ */
299
+ function subscribeLocale(listener) {
300
+ return api ? api.subscribe(listener) : () => {};
301
+ }
302
+ /** Monotonic host revision counter, bumped on locale and dictionary changes. */
303
+ function localeRevision() {
304
+ return api?.getRevision() ?? 0;
305
+ }
306
+ //#endregion
67
307
  //#region src/client/styles.ts
68
308
  /**
69
309
  * Every style the plugin ships, in four layers.
@@ -110,6 +350,12 @@ body {
110
350
  --dsq-font-delta: var(--dsh-content-font-delta, 0px);
111
351
  --dsq-bg-raised: var(--dsw-alias-bg-layer-1, #fff);
112
352
  --dsq-bg-toast: var(--dsw-alias-bg-layer-3, rgba(0, 0, 0, 0.78));
353
+ /* The settings switch mirrors the Host's own switch: neutral track when off,
354
+ brand fill when on, and a thumb that inverts with the theme. */
355
+ --dsq-switch-off: var(--dsw-alias-border-l3, rgba(0, 0, 0, 0.12));
356
+ --dsq-switch-on: var(--dsw-alias-brand-primary, #0f1115);
357
+ --dsq-switch-thumb-off: var(--dsw-alias-switch-thumb, #fff);
358
+ --dsq-switch-thumb-on: var(--dsw-alias-label-primary-foreground, #fff);
113
359
  --dsq-line: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
114
360
  --dsq-line-divider: var(--dsw-alias-border-l4, rgba(0, 0, 0, 0.12));
115
361
  --dsq-focus-ring: var(--dsw-alias-button-primary-fill, #1677ff);
@@ -178,6 +424,26 @@ ${atomRules}
178
424
  }
179
425
  }
180
426
 
427
+ /* Right-click / shortcut menu: transform only, for the same reason as the
428
+ toolbar — an animated opacity would cut the material layer's backdrop. */
429
+ @keyframes dsq-menu-in {
430
+ from {
431
+ transform: translateY(4px) scale(0.97);
432
+ }
433
+ to {
434
+ transform: translateY(0) scale(1);
435
+ }
436
+ }
437
+
438
+ @keyframes dsq-menu-in-up {
439
+ from {
440
+ transform: translateY(-4px) scale(0.97);
441
+ }
442
+ to {
443
+ transform: translateY(0) scale(1);
444
+ }
445
+ }
446
+
181
447
  /* 内容层的 rise:悬停面板的列表在用;工具条改用容器 scale 后这条在这里待命。 */
182
448
  @keyframes dsq-rise-in {
183
449
  from {
@@ -202,14 +468,15 @@ ${atomRules}
202
468
  /* 宿主菜单材质(官方 material 图层同款):填充走 --dsw-menu-surface-fill,模糊走
203
469
  --dsw-menu-backdrop-filter(恒为 blur(40px) saturate(150%)),亮暗自动翻。
204
470
  画在 z-index:-1 的独立图层上,内容在它之上:模糊只取身后的页面,不会把
205
- 面板里的文字一起糊掉。工具条、评论气泡、悬停面板三个浮层共用这一条。
471
+ 面板里的文字一起糊掉。所有浮层共用这一条。
206
472
  注意:macOS 上宿主另有一个 --dsw-specific-menu(覆写成 94% 不透明)给它的
207
473
  菜单用;这里按需求用 surface-fill,所以我们的浮层会比宿主自己的菜单更透。 */
208
474
  .dsq_toolbar::before,
209
475
  .dsq_commentPop::before,
210
476
  .dsq_summaryHoverInner::before,
211
477
  .dsq_touchBar::before,
212
- .dsq_chipPreview::before {
478
+ .dsq_chipPreview::before,
479
+ .dsq_ctxMenu::before {
213
480
  content: "";
214
481
  position: absolute;
215
482
  inset: 0;
@@ -224,7 +491,7 @@ ${atomRules}
224
491
  /* 覆层不描边:elevation-prominent 自带的那圈 0 0 0 .5px(--dsw-elevation-stroke-color,
225
492
  默认 border-l4)对磨砂覆层是多余的——边缘自带材质分界,再勾一道反而抢视觉,覆写成
226
493
  更淡的 border-l1。
227
- 两个坑,改之前先读:① 这两行必须写在三个浮层自己的选择器上。写在 body 上会随继承
494
+ 两个坑,改之前先读:① 这两行必须写在浮层自己的选择器上。写在 body 上会随继承
228
495
  影响页面里其它依赖默认值的面板(宿主带 [data-menu-material] 的菜单有自己的覆写,
229
496
  不受影响)。② --dsq-shadow 也必须在这儿声明,不能留在 body:自定义属性是在**声明处**
230
497
  展开的,--dsq-shadow 留在 body 上时 var(--dsw-elevation-prominent) 会在 body 就被
@@ -235,11 +502,12 @@ ${atomRules}
235
502
  .dsq_summaryHoverInner,
236
503
  .dsq_touchBar,
237
504
  .dsq_touchSheet,
238
- .dsq_chipPreview {
505
+ .dsq_chipPreview,
506
+ .dsq_ctxMenu {
239
507
  --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
240
508
  --dsq-shadow: var(--dsw-elevation-prominent, 0 0 0 .5px rgba(0, 0, 0, 0.04), 0 3px 8px rgba(0, 0, 0, 0.04), 0 0 20px rgba(0, 0, 0, 0.05));
241
509
  /* Every surface above takes the same elevation; declaring it once here is
242
- what keeps the six component blocks free of a copied box-shadow. */
510
+ what keeps the component blocks free of a copied box-shadow. */
243
511
  box-shadow: var(--dsq-shadow);
244
512
  }
245
513
 
@@ -369,6 +637,60 @@ ${atomRules}
369
637
  transform: translate(-50%, 8px);
370
638
  }
371
639
 
640
+ /* Right-click / shortcut menu. Body-level fixed: it must escape the
641
+ transcript's clipping. It closes on scroll instead of following the text,
642
+ so it never points at a line that has moved away. */
643
+ .dsq_ctxMenu {
644
+ position: fixed;
645
+ z-index: 64;
646
+ width: max-content;
647
+ min-width: 172px;
648
+ max-width: min(280px, calc(100vw - 16px));
649
+ padding: 4px;
650
+ border: 0;
651
+ border-radius: 12px;
652
+ outline: none;
653
+ animation: dsq-menu-in 0.14s cubic-bezier(0.2, 0.8, 0.2, 1);
654
+ transform-origin: top left;
655
+ }
656
+
657
+ .dsq_ctxMenu[data-side='top'] {
658
+ animation-name: dsq-menu-in-up;
659
+ transform-origin: bottom left;
660
+ }
661
+
662
+ .dsq_ctxItem {
663
+ width: 100%;
664
+ gap: 8px;
665
+ padding: 7px 10px;
666
+ border-radius: 8px;
667
+ color: var(--dsq-label);
668
+ font: inherit;
669
+ font-size: 13px;
670
+ line-height: 18px;
671
+ text-align: left;
672
+ white-space: nowrap;
673
+ }
674
+
675
+ .dsq_ctxItem svg {
676
+ flex: none;
677
+ color: var(--dsq-label-2);
678
+ }
679
+
680
+ .dsq_ctxItem:hover,
681
+ .dsq_ctxItem:focus {
682
+ background: var(--dsq-fill-hover);
683
+ }
684
+
685
+ .dsq_ctxItem:focus {
686
+ outline: none;
687
+ }
688
+
689
+ .dsq_ctxItem:focus-visible {
690
+ outline: 2px solid var(--dsq-focus-ring);
691
+ outline-offset: -2px;
692
+ }
693
+
372
694
  /* Annotation stack — inside the composer card (input.overlay), ~1/5 width. */
373
695
  .dsq_cardStack {
374
696
  position: absolute;
@@ -537,7 +859,8 @@ ${atomRules}
537
859
  .dsq_commentPop::before,
538
860
  .dsq_summaryHoverInner::before,
539
861
  .dsq_touchBar::before,
540
- .dsq_chipPreview::before {
862
+ .dsq_chipPreview::before,
863
+ .dsq_ctxMenu::before {
541
864
  background: var(--dsq-bg-raised);
542
865
  backdrop-filter: none;
543
866
  -webkit-backdrop-filter: none;
@@ -1060,12 +1383,19 @@ ${atomRules}
1060
1383
  Absolute so the badge adds no inline advance width: annotating never reflows
1061
1384
  the paragraph or moves following words. Bottom (not top) anchors to the last
1062
1385
  fragment: for a mark spanning several lines the containing block is the union
1063
- of the first and last fragments, so its bottom edge is the last line. */
1386
+ of the first and last fragments, so its bottom edge is the last line.
1387
+ BADGE_BOTTOM / BADGE_HEIGHT / the 2px margin in annotate-text.ts mirror the
1388
+ numbers below for collision measurement. */
1064
1389
  .dsq_annMark[data-dsq-ann-index]::after {
1065
1390
  content: attr(data-dsq-ann-index);
1066
1391
  position: absolute;
1067
1392
  bottom: 8px;
1068
1393
  left: 100%;
1394
+ /* Above the native selection highlight and the background of a later mark:
1395
+ both otherwise paint over the badge when their ranges meet. */
1396
+ z-index: 2;
1397
+ /* JS shifts colliding badges right/up; the resting position is unchanged. */
1398
+ transform: translate(var(--dsq-ann-badge-x, 0px), var(--dsq-ann-badge-y, 0px));
1069
1399
  display: grid;
1070
1400
  place-items: center;
1071
1401
  min-width: 15px;
@@ -1148,25 +1478,221 @@ ${atomRules}
1148
1478
  .dsq_chipPreviewJump {
1149
1479
  min-height: 44px;
1150
1480
  }
1481
+ .dsq_segOption {
1482
+ min-height: 44px;
1483
+ }
1151
1484
  }
1152
1485
 
1153
- /* Layer 4 — host surface: keyframes, theme flips, and the two rules that reach
1154
- into product-owned DOM. */
1155
-
1156
- @keyframes dsq-ann-flash {
1157
- 0% {
1158
- background: var(--dsq-mark-color);
1159
- color: #fff;
1160
- box-shadow: 0 0 0 3px var(--dsq-mark-tint);
1161
- }
1162
- 100% {
1163
- background: var(--dsq-mark-tint);
1164
- color: inherit;
1165
- box-shadow: 0 0 0 0 transparent;
1166
- }
1486
+ /* Settings row (plugin page): an inline segmented control. The page owns the
1487
+ write, so there is no save button — each pick is committed immediately. */
1488
+ .dsq_settings {
1489
+ display: flex;
1490
+ flex-direction: column;
1491
+ gap: 10px;
1492
+ padding: 2px 0;
1167
1493
  }
1168
1494
 
1169
- /* The product flips its palette on body[data-ds-dark-theme], and every
1495
+ .dsq_settingsSection {
1496
+ display: flex;
1497
+ flex-direction: column;
1498
+ gap: 10px;
1499
+ }
1500
+
1501
+ .dsq_settingsCopy {
1502
+ display: flex;
1503
+ flex-direction: column;
1504
+ gap: 3px;
1505
+ }
1506
+
1507
+ .dsq_settingsLabel {
1508
+ color: var(--dsq-label);
1509
+ font-size: 13px;
1510
+ font-weight: 500;
1511
+ }
1512
+
1513
+ .dsq_settingsHint {
1514
+ color: var(--dsq-label-3);
1515
+ font-size: 12px;
1516
+ line-height: 1.5;
1517
+ }
1518
+
1519
+ /* Shortcut reference under the switch: the two commands and the keycaps the
1520
+ Host currently has in effect, so a rebind shows up here without a reload. */
1521
+ .dsq_settingsShortcuts {
1522
+ display: flex;
1523
+ flex-direction: column;
1524
+ gap: 6px;
1525
+ margin: 0;
1526
+ padding: 0;
1527
+ list-style: none;
1528
+ }
1529
+
1530
+ .dsq_settingsShortcutRow {
1531
+ display: flex;
1532
+ align-items: center;
1533
+ justify-content: space-between;
1534
+ gap: 12px;
1535
+ min-height: 22px;
1536
+ }
1537
+
1538
+ .dsq_settingsShortcutName {
1539
+ min-width: 0;
1540
+ color: var(--dsq-label-2);
1541
+ font-size: 12px;
1542
+ }
1543
+
1544
+ .dsq_settingsShortcutKeys {
1545
+ flex: none;
1546
+ color: var(--dsq-label-3);
1547
+ font-size: 12px;
1548
+ }
1549
+
1550
+ .dsq_keycap {
1551
+ display: inline-block;
1552
+ min-width: 18px;
1553
+ padding: 1px 6px;
1554
+ border: 1px solid var(--dsq-line);
1555
+ border-radius: 5px;
1556
+ background: var(--dsq-fill-hover);
1557
+ color: var(--dsq-label-2);
1558
+ font: inherit;
1559
+ font-size: 11px;
1560
+ line-height: 18px;
1561
+ text-align: center;
1562
+ white-space: nowrap;
1563
+ }
1564
+
1565
+ .dsq_seg {
1566
+ display: inline-flex;
1567
+ align-self: flex-start;
1568
+ gap: 2px;
1569
+ padding: 2px;
1570
+ border-radius: 8px;
1571
+ background: var(--dsq-fill-hover);
1572
+ }
1573
+
1574
+ .dsq_segOption {
1575
+ position: relative;
1576
+ display: inline-flex;
1577
+ align-items: center;
1578
+ justify-content: center;
1579
+ height: 28px;
1580
+ padding: 0 12px;
1581
+ border-radius: 6px;
1582
+ color: var(--dsq-label-2);
1583
+ font-size: 13px;
1584
+ cursor: pointer;
1585
+ transition: background-color 0.12s ease, color 0.12s ease;
1586
+ }
1587
+
1588
+ .dsq_segOption:hover {
1589
+ color: var(--dsq-label);
1590
+ }
1591
+
1592
+ .dsq_segOption[data-active="true"] {
1593
+ background: var(--dsq-bg-raised);
1594
+ box-shadow: 0 0 0 0.5px var(--dsq-line);
1595
+ color: var(--dsq-label);
1596
+ }
1597
+
1598
+ .dsq_segOption:has(:focus-visible) {
1599
+ outline: 2px solid var(--dsq-focus-ring-soft);
1600
+ outline-offset: 1px;
1601
+ }
1602
+
1603
+ .dsq_settingsDivider {
1604
+ height: 1px;
1605
+ background: var(--dsq-line);
1606
+ }
1607
+
1608
+ /* The whole row is the target: label text, hint, and the switch itself. */
1609
+ .dsq_settingsSwitchRow {
1610
+ display: flex;
1611
+ align-items: center;
1612
+ justify-content: space-between;
1613
+ gap: 16px;
1614
+ cursor: pointer;
1615
+ }
1616
+
1617
+ .dsq_settingsSwitchRow .dsq_settingsCopy {
1618
+ flex: 1;
1619
+ min-width: 0;
1620
+ }
1621
+
1622
+ /* Exactly the Host switch metrics: 36×20 track, 16px thumb, 2px inset, and
1623
+ the same on/off tokens. The thumb only animates its transform, like the
1624
+ product control; the track changes color in one step. */
1625
+ .dsq_switch {
1626
+ box-sizing: border-box;
1627
+ position: relative;
1628
+ flex: none;
1629
+ width: 36px;
1630
+ height: 20px;
1631
+ padding: 2px;
1632
+ border: 0;
1633
+ border-radius: 999px;
1634
+ corner-shape: round;
1635
+ background: var(--dsq-switch-off);
1636
+ cursor: pointer;
1637
+ }
1638
+
1639
+ .dsq_switch[data-on='true'] {
1640
+ background: var(--dsq-switch-on);
1641
+ }
1642
+
1643
+ .dsq_switchTrack {
1644
+ position: absolute;
1645
+ top: 2px;
1646
+ left: 2px;
1647
+ width: 16px;
1648
+ height: 16px;
1649
+ border-radius: 50%;
1650
+ corner-shape: round;
1651
+ background: var(--dsq-switch-thumb-off);
1652
+ transition: transform 0.12s ease;
1653
+ }
1654
+
1655
+ .dsq_switch[data-on='true'] .dsq_switchTrack {
1656
+ background: var(--dsq-switch-thumb-on);
1657
+ transform: translateX(16px);
1658
+ }
1659
+
1660
+ .dsq_switch:has(:focus-visible) {
1661
+ outline: 2px solid var(--dsq-focus-ring-soft);
1662
+ outline-offset: 2px;
1663
+ }
1664
+
1665
+ /* Visually hidden but still focusable: the native radio/checkbox carries the
1666
+ semantics and keyboard behavior, the label carries the paint. */
1667
+ .dsq_srOnly {
1668
+ position: absolute;
1669
+ width: 1px;
1670
+ height: 1px;
1671
+ margin: -1px;
1672
+ padding: 0;
1673
+ overflow: hidden;
1674
+ clip: rect(0 0 0 0);
1675
+ white-space: nowrap;
1676
+ border: 0;
1677
+ }
1678
+
1679
+ /* Layer 4 — host surface: keyframes, theme flips, and the two rules that reach
1680
+ into product-owned DOM. */
1681
+
1682
+ @keyframes dsq-ann-flash {
1683
+ 0% {
1684
+ background: var(--dsq-mark-color);
1685
+ color: #fff;
1686
+ box-shadow: 0 0 0 3px var(--dsq-mark-tint);
1687
+ }
1688
+ 100% {
1689
+ background: var(--dsq-mark-tint);
1690
+ color: inherit;
1691
+ box-shadow: 0 0 0 0 transparent;
1692
+ }
1693
+ }
1694
+
1695
+ /* The product flips its palette on body[data-ds-dark-theme], and every
1170
1696
  --dsq-* alias follows it. Only values we own ourselves need restating. */
1171
1697
  body[data-ds-dark-theme] .dsq_annMark {
1172
1698
  --dsq-mark-tint: var(--dsq-accent-tint-dark);
@@ -1196,6 +1722,8 @@ const styles = {
1196
1722
  buttonAddComment: "dsq_buttonAddComment",
1197
1723
  divider: "dsq_divider",
1198
1724
  status: "dsq_status",
1725
+ ctxMenu: "dsq_ctxMenu",
1726
+ ctxItem: "dsq_ctxItem",
1199
1727
  cardStack: "dsq_cardStack",
1200
1728
  cardClose: "dsq_cardClose",
1201
1729
  cardPad: "dsq_cardPad",
@@ -1260,9 +1788,73 @@ const styles = {
1260
1788
  userRow: "dsq_userRow",
1261
1789
  userStack: "dsq_userStack",
1262
1790
  userImages: "dsq_userImages",
1263
- userBubble: "dsq_userBubble"
1791
+ userBubble: "dsq_userBubble",
1792
+ settings: "dsq_settings",
1793
+ settingsSection: "dsq_settingsSection",
1794
+ settingsCopy: "dsq_settingsCopy",
1795
+ settingsLabel: "dsq_settingsLabel",
1796
+ settingsHint: "dsq_settingsHint",
1797
+ settingsShortcuts: "dsq_settingsShortcuts",
1798
+ settingsShortcutRow: "dsq_settingsShortcutRow",
1799
+ settingsShortcutName: "dsq_settingsShortcutName",
1800
+ settingsShortcutKeys: "dsq_settingsShortcutKeys",
1801
+ keycap: "dsq_keycap",
1802
+ settingsDivider: "dsq_settingsDivider",
1803
+ settingsSwitchRow: "dsq_settingsSwitchRow",
1804
+ switch: "dsq_switch",
1805
+ switchTrack: "dsq_switchTrack",
1806
+ seg: "dsq_seg",
1807
+ segOption: "dsq_segOption",
1808
+ srOnly: "dsq_srOnly"
1264
1809
  };
1265
1810
  //#endregion
1811
+ //#region src/client/dom/badge-layout.ts
1812
+ const STEP_X = 28;
1813
+ const STEP_Y = 18;
1814
+ /** Rest, then two steps right; after that the stack grows upward. */
1815
+ const MAX_COLUMNS = 3;
1816
+ const MAX_ROWS = 24;
1817
+ function overlaps(a, b) {
1818
+ 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;
1819
+ }
1820
+ function fits(anchor, dx, dy, bounds) {
1821
+ if (dx > 0 && bounds.right !== void 0 && anchor.x + dx + anchor.width > bounds.right) return false;
1822
+ if (dy < 0 && bounds.top !== void 0 && anchor.y + dy < bounds.top) return false;
1823
+ return true;
1824
+ }
1825
+ /**
1826
+ * Greedy placement in reading order. Each badge first tries its resting spot,
1827
+ * then shifts right a couple of badge widths, and finally stacks upward. The
1828
+ * resting spot is always returned when no candidate fits, so a badge never
1829
+ * disappears because of an edge.
1830
+ */
1831
+ function placeBadges(anchors, bounds = {}) {
1832
+ const placed = [];
1833
+ return anchors.map((anchor) => {
1834
+ for (let row = 0; row < MAX_ROWS; row += 1) for (let column = 0; column < MAX_COLUMNS; column += 1) {
1835
+ const dx = column * STEP_X;
1836
+ const dy = row === 0 ? 0 : -row * STEP_Y;
1837
+ if (!fits(anchor, dx, dy, bounds)) continue;
1838
+ const candidate = {
1839
+ ...anchor,
1840
+ x: anchor.x + dx,
1841
+ y: anchor.y + dy
1842
+ };
1843
+ if (placed.some((box) => overlaps(candidate, box))) continue;
1844
+ placed.push(candidate);
1845
+ return {
1846
+ dx,
1847
+ dy
1848
+ };
1849
+ }
1850
+ placed.push(anchor);
1851
+ return {
1852
+ dx: 0,
1853
+ dy: 0
1854
+ };
1855
+ });
1856
+ }
1857
+ //#endregion
1266
1858
  //#region src/client/dom/message-text.ts
1267
1859
  /**
1268
1860
  * Message-text geometry: locate a selection inside the transcript and express
@@ -1401,6 +1993,9 @@ function cleanTextOf(item) {
1401
1993
  const MARK_ATTR = "data-dsq-ann-mark";
1402
1994
  const INDEX_ATTR = "data-dsq-ann-index";
1403
1995
  const FLASH_MS = 1500;
1996
+ /** Distance from the mark's bottom edge to the badge's bottom edge (CSS). */
1997
+ const BADGE_BOTTOM = 8;
1998
+ const BADGE_MARGIN = 2;
1404
1999
  const jobs = /* @__PURE__ */ new Map();
1405
2000
  let pendingIds = /* @__PURE__ */ new Set();
1406
2001
  function attrValue(value) {
@@ -1427,11 +2022,25 @@ function isMarked(pieces) {
1427
2022
  if (!first) return false;
1428
2023
  return first.entry.node.parentElement?.closest(`[${MARK_ATTR}]`) != null;
1429
2024
  }
2025
+ function markLabel(index) {
2026
+ return t("mark.previewAria", { index });
2027
+ }
2028
+ /** The badge fragment is the keyboard entry into that annotation's preview. */
2029
+ function makeBadgeInteractive(mark, index) {
2030
+ mark.tabIndex = 0;
2031
+ mark.setAttribute("role", "button");
2032
+ mark.setAttribute("aria-haspopup", "dialog");
2033
+ const label = markLabel(index);
2034
+ if (mark.getAttribute("aria-label") !== label) mark.setAttribute("aria-label", label);
2035
+ }
1430
2036
  function createMark(job, withIndex) {
1431
2037
  const mark = document.createElement("span");
1432
2038
  mark.className = styles.annMark;
1433
2039
  mark.setAttribute(MARK_ATTR, job.markId);
1434
- if (withIndex) mark.setAttribute(INDEX_ATTR, String(job.index));
2040
+ if (withIndex) {
2041
+ mark.setAttribute(INDEX_ATTR, String(job.index));
2042
+ makeBadgeInteractive(mark, job.index);
2043
+ }
1435
2044
  return mark;
1436
2045
  }
1437
2046
  /**
@@ -1508,8 +2117,58 @@ function refreshMarkIndex(item, job) {
1508
2117
  const last = marks[marks.length - 1];
1509
2118
  const next = String(job.index);
1510
2119
  if (last.getAttribute(INDEX_ATTR) !== next) last.setAttribute(INDEX_ATTR, next);
2120
+ makeBadgeInteractive(last, job.index);
1511
2121
  return true;
1512
2122
  }
2123
+ /** Refresh the localized labels after a language switch (marks survive renders). */
2124
+ function refreshAnnotationMarkLabels() {
2125
+ if (typeof document === "undefined") return;
2126
+ for (const mark of document.querySelectorAll(`[${INDEX_ATTR}]`)) {
2127
+ const index = Number(mark.getAttribute(INDEX_ATTR) ?? 0);
2128
+ if (index > 0) makeBadgeInteractive(mark, index);
2129
+ }
2130
+ }
2131
+ /**
2132
+ * Resolve overlapping ordinal badges. Two annotations can end at the same
2133
+ * offset (nested ranges), which puts their pseudo-element badges on the same
2134
+ * pixel; nearby ends can also touch. Measure every last fragment, then write
2135
+ * the offset back as variables inherited by `::after`. The badge's transform
2136
+ * never participates in text layout.
2137
+ */
2138
+ function layoutBadges(scope) {
2139
+ const marks = Array.from(scope.querySelectorAll(`[${INDEX_ATTR}]`));
2140
+ for (const mark of marks) {
2141
+ mark.style.removeProperty("--dsq-ann-badge-x");
2142
+ mark.style.removeProperty("--dsq-ann-badge-y");
2143
+ }
2144
+ if (marks.length < 2) return;
2145
+ const scroller = marks[0]?.closest("[data-conversation-scroll]");
2146
+ const scrollerRect = scroller instanceof Element ? scroller.getBoundingClientRect() : null;
2147
+ const rightEdge = scrollerRect && scrollerRect.width > 0 ? scrollerRect.right : document.documentElement.clientWidth;
2148
+ const topEdge = scrollerRect && scrollerRect.height > 0 ? scrollerRect.top : 0;
2149
+ const anchors = marks.map((mark) => {
2150
+ const rect = mark.getBoundingClientRect();
2151
+ return {
2152
+ mark,
2153
+ index: Number(mark.getAttribute(INDEX_ATTR) ?? 0),
2154
+ x: rect.right + BADGE_MARGIN,
2155
+ y: rect.bottom - BADGE_BOTTOM - 15,
2156
+ width: 24,
2157
+ height: 15,
2158
+ visible: rect.width > 0 && rect.height > 0
2159
+ };
2160
+ }).filter((anchor) => anchor.visible).sort((a, b) => a.index - b.index);
2161
+ if (anchors.length < 2) return;
2162
+ const offsets = placeBadges(anchors, {
2163
+ right: rightEdge - 4,
2164
+ top: topEdge + 4
2165
+ });
2166
+ anchors.forEach((anchor, i) => {
2167
+ const { dx, dy } = offsets[i];
2168
+ if (dx !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-x", `${dx}px`);
2169
+ if (dy !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-y", `${dy}px`);
2170
+ });
2171
+ }
1513
2172
  /** Apply every registered job whose message is currently rendered. */
1514
2173
  function applyAnnotationMarks(root) {
1515
2174
  if (jobs.size === 0) return;
@@ -1528,6 +2187,7 @@ function applyAnnotationMarks(root) {
1528
2187
  if (decorateJob(item, job)) decorated = true;
1529
2188
  }
1530
2189
  }
2190
+ layoutBadges(scope);
1531
2191
  }
1532
2192
  /** Insert or refresh jobs; identical jobs are no-ops. */
1533
2193
  function upsertAnnotationJobs(next) {
@@ -1593,8 +2253,11 @@ function watchAnnotationMarks(root) {
1593
2253
  childList: true,
1594
2254
  subtree: true
1595
2255
  });
2256
+ const onResize = () => schedule();
2257
+ window.addEventListener("resize", onResize);
1596
2258
  return () => {
1597
2259
  observer.disconnect();
2260
+ window.removeEventListener("resize", onResize);
1598
2261
  if (frame !== 0) window.cancelAnimationFrame(frame);
1599
2262
  };
1600
2263
  }
@@ -1939,13 +2602,6 @@ function readComposedDraft(text) {
1939
2602
  }
1940
2603
  //#endregion
1941
2604
  //#region src/client/runtime.ts
1942
- /**
1943
- * Shared runtime handles across slot scopes.
1944
- *
1945
- * The selection toolbar mounts at root (`shell.overlay`) while the annotation
1946
- * panel is session-scoped, so both sides read one partition keyed by the
1947
- * session the host minted for the active composer.
1948
- */
1949
2605
  const runtime = {};
1950
2606
  function setRuntimeSession(sessionId) {
1951
2607
  if (sessionId) runtime.sessionId = sessionId;
@@ -1957,15 +2613,15 @@ const EMPTY$1 = [];
1957
2613
  /** Bucket for annotations added before a session id is known (never persisted). */
1958
2614
  const MEMORY_KEY = "pending";
1959
2615
  const bySession = /* @__PURE__ */ new Map();
1960
- const listeners = /* @__PURE__ */ new Set();
2616
+ const listeners$1 = /* @__PURE__ */ new Set();
1961
2617
  let persistence = null;
1962
2618
  function notify() {
1963
- for (const listener of [...listeners]) listener();
2619
+ for (const listener of [...listeners$1]) listener();
1964
2620
  }
1965
2621
  function subscribeAnnotations(listener) {
1966
- listeners.add(listener);
2622
+ listeners$1.add(listener);
1967
2623
  return () => {
1968
- listeners.delete(listener);
2624
+ listeners$1.delete(listener);
1969
2625
  };
1970
2626
  }
1971
2627
  /**
@@ -2158,13 +2814,13 @@ function annotationTitle(annotation) {
2158
2814
  }
2159
2815
  //#endregion
2160
2816
  //#region src/client/dom/popoverPlacement.ts
2161
- const GAP$1 = 8;
2817
+ const GAP$2 = 8;
2162
2818
  const MARGIN = 8;
2163
2819
  /**
2164
2820
  * Place `panel` near `anchor`, flipping vertical side when the preferred side
2165
2821
  * lacks room. Keeps the panel inside `bounds` (the viewport by default).
2166
2822
  */
2167
- function placePopover(anchor, panel, preferred = "bottom", gap = GAP$1, bounds) {
2823
+ function placePopover(anchor, panel, preferred = "bottom", gap = GAP$2, bounds) {
2168
2824
  const box = bounds ?? {
2169
2825
  top: 0,
2170
2826
  left: 0,
@@ -2230,228 +2886,39 @@ function LucideIcon({ name, size = 15 }) {
2230
2886
  });
2231
2887
  }
2232
2888
  //#endregion
2233
- //#region src/client/i18n.ts
2889
+ //#region src/client/useLocale.ts
2234
2890
  /**
2235
- * Copy for dsh-select-quote, served through the host locale service.
2236
- *
2237
- * The active language is owned by `@deepseek-ai/dsh-client-locale` (Settings →
2238
- * General → Language, persisted as `locale.preference`). This module owns only
2239
- * the dictionaries and the plural form selection that the host's plain-template
2240
- * lookup cannot express; it never resolves a language on its own.
2891
+ * Re-render the calling component when the host language changes, so slot copy
2892
+ * switches without a page reload. Subscriptions happen after mount, by which
2893
+ * time `apply()` has bound the locale service.
2894
+ */
2895
+ function useLocale() {
2896
+ (0, react.useSyncExternalStore)(subscribeLocale, localeRevision, localeRevision);
2897
+ }
2898
+ //#endregion
2899
+ //#region src/client/ui/AnnotationCardView.tsx
2900
+ /** Short grace for crossing the 12px bridge; the exit starts right after it. */
2901
+ const CLOSE_DELAY$1 = 40;
2902
+ /** After a viewport change, wait this long before position-checking the pointer. */
2903
+ const SETTLE_DELAY = 600;
2904
+ /** Transparent strip on the facing side; the pointer crosses it in transit. */
2905
+ const BRIDGE = 12;
2906
+ /** Matches the list's leaving keyframe; the panel unmounts after it. */
2907
+ const EXIT_MS$1 = 120;
2908
+ /** Stable edit target: item id, with an index fallback for id-less callers. */
2909
+ function editKeyOf(item, index) {
2910
+ return item?.id ?? `index:${index}`;
2911
+ }
2912
+ /**
2913
+ * Qoder-style summary with a placement-aware hover list.
2241
2914
  *
2242
- * NOT localized on purpose:
2243
- * - wire markers (`> [选中文本]`, `<response-annotations>`,
2244
- * `:dsh-annotation{index="N"}`) — they are the message format the model
2245
- * and the transcript parser agree on;
2246
- * - the `# Response annotations:` header — model-facing instruction prose,
2247
- * not UI text.
2248
- */
2249
- const LOCALE_NS = "select-quote";
2250
- const zh = {
2251
- "common.selectedText": "选中的文本",
2252
- "common.commentPlaceholder": "添加可选评论…",
2253
- "common.cancel": "取消",
2254
- "common.close": "关闭",
2255
- "common.save": "保存",
2256
- "toolbar.aria": "划词操作",
2257
- "toolbar.commentHint": "添加批注…",
2258
- "toolbar.copy": "复制",
2259
- "toolbar.openLink": "打开链接",
2260
- "toolbar.copyFailed": "复制失败",
2261
- "toolbar.addToTask": "添加到任务",
2262
- "toolbar.commentDialogAria": "添加可选评论",
2263
- "toolbar.skipComment": "暂不评论",
2264
- "toolbar.addComment": "添加评论",
2265
- "summary.hint": "悬停查看批注",
2266
- "summary.hintTouch": "点按查看批注",
2267
- "summary.dialogAria": "{count} 条划词批注详情",
2268
- "summary.edit": "编辑批注 {index}",
2269
- "summary.remove": "移除批注 {index}",
2270
- "summary.userComment": "用户评论",
2271
- "summary.noComment": "未添加评论",
2272
- "summary.aria.other": "{count} 条划词批注。展开查看详情。",
2273
- "summary.title.other": "{count} 条批注",
2274
- "summary.removeAll": "移除全部划词批注",
2275
- "chip.annotation": "批注 {index}",
2276
- "chip.previewAria": "批注预览",
2277
- "chip.jump": "原文",
2278
- "chip.jumpAria": "跳转到原文"
2279
- };
2280
- const en = {
2281
- "common.selectedText": "Selected text",
2282
- "common.commentPlaceholder": "Add an optional comment…",
2283
- "common.cancel": "Cancel",
2284
- "common.close": "Close",
2285
- "common.save": "Save",
2286
- "toolbar.aria": "Selection actions",
2287
- "toolbar.commentHint": "Add annotation…",
2288
- "toolbar.copy": "Copy",
2289
- "toolbar.openLink": "Open link",
2290
- "toolbar.copyFailed": "Copy failed",
2291
- "toolbar.addToTask": "Add to task",
2292
- "toolbar.commentDialogAria": "Add an optional comment",
2293
- "toolbar.skipComment": "Skip comment",
2294
- "toolbar.addComment": "Add comment",
2295
- "summary.hint": "Hover to view annotations",
2296
- "summary.hintTouch": "Tap to view annotations",
2297
- "summary.dialogAria": "Annotation details: {count}",
2298
- "summary.edit": "Edit annotation {index}",
2299
- "summary.remove": "Remove annotation {index}",
2300
- "summary.userComment": "User comment",
2301
- "summary.noComment": "No comment added",
2302
- "summary.aria.one": "{count} annotation. Open to view details.",
2303
- "summary.aria.other": "{count} annotations. Open to view details.",
2304
- "summary.title.one": "{count} annotation",
2305
- "summary.title.other": "{count} annotations",
2306
- "summary.removeAll": "Remove all annotations",
2307
- "chip.annotation": "Annotation {index}",
2308
- "chip.previewAria": "Annotation preview",
2309
- "chip.jump": "Original",
2310
- "chip.jumpAria": "Jump to original"
2311
- };
2312
- /**
2313
- * Russian is not a shipped host locale: it is added through
2314
- * `ctx.locale.addLanguage()` and its dictionary through the three-argument
2315
- * `register()`. Its fallback chain ends at `en`, as the host requires.
2316
- */
2317
- const ru = {
2318
- "common.selectedText": "Выделенный текст",
2319
- "common.commentPlaceholder": "Добавить необязательный комментарий…",
2320
- "common.cancel": "Отмена",
2321
- "common.close": "Закрыть",
2322
- "common.save": "Сохранить",
2323
- "toolbar.aria": "Действия с выделенным текстом",
2324
- "toolbar.commentHint": "Добавить аннотацию…",
2325
- "toolbar.copy": "Копировать",
2326
- "toolbar.openLink": "Открыть ссылку",
2327
- "toolbar.copyFailed": "Не удалось скопировать",
2328
- "toolbar.addToTask": "Добавить к задаче",
2329
- "toolbar.commentDialogAria": "Добавить необязательный комментарий",
2330
- "toolbar.skipComment": "Без комментария",
2331
- "toolbar.addComment": "Добавить комментарий",
2332
- "summary.hint": "Наведите, чтобы увидеть аннотации",
2333
- "summary.hintTouch": "Нажмите, чтобы увидеть аннотации",
2334
- "summary.dialogAria": "Детали аннотаций: {count}",
2335
- "summary.edit": "Изменить аннотацию {index}",
2336
- "summary.remove": "Удалить аннотацию {index}",
2337
- "summary.userComment": "Комментарий пользователя",
2338
- "summary.noComment": "Комментарий не добавлен",
2339
- "summary.aria.one": "{count} аннотация. Откройте, чтобы увидеть подробности.",
2340
- "summary.aria.few": "{count} аннотации. Откройте, чтобы увидеть подробности.",
2341
- "summary.aria.many": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
2342
- "summary.aria.other": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
2343
- "summary.title.one": "{count} аннотация",
2344
- "summary.title.few": "{count} аннотации",
2345
- "summary.title.many": "{count} аннотаций",
2346
- "summary.title.other": "{count} аннотаций",
2347
- "summary.removeAll": "Удалить все аннотации",
2348
- "chip.annotation": "Аннотация {index}",
2349
- "chip.previewAria": "Просмотр аннотации",
2350
- "chip.jump": "Оригинал",
2351
- "chip.jumpAria": "Перейти к оригиналу"
2352
- };
2353
- /** Both shipped host locales, required together by the typed `register()`. */
2354
- const SHIPPED_DICTS = Object.freeze({
2355
- zh,
2356
- en
2357
- });
2358
- /** Dictionary for the language registered through {@link LANGUAGE_RU}. */
2359
- const DICTIONARIES = Object.freeze({
2360
- zh,
2361
- en,
2362
- ru
2363
- });
2364
- const LANGUAGE_RU = Object.freeze({
2365
- id: "ru",
2366
- label: "Русский",
2367
- fallback: "en"
2368
- });
2369
- let api;
2370
- /**
2371
- * Install the host-bound translation entry point. Called once from `apply()`
2372
- * with `ctx.locale.bind(LOCALE_NS)` and the live snapshot accessors.
2373
- */
2374
- function bindLocale(next) {
2375
- api = next;
2376
- }
2377
- /** Active language id, or `en` before the host service is bound. */
2378
- function activeLocale() {
2379
- return api?.getActive() ?? "en";
2380
- }
2381
- function pluralForm(count) {
2382
- const locale = activeLocale();
2383
- if (locale === "ru") {
2384
- const mod10 = count % 10;
2385
- const mod100 = count % 100;
2386
- if (mod10 === 1 && mod100 !== 11) return "one";
2387
- if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "few";
2388
- return "many";
2389
- }
2390
- if (locale === "en") return count === 1 ? "one" : "other";
2391
- return "other";
2392
- }
2393
- /**
2394
- * Translate a `select-quote` key. A numeric `count` selects a plural form and
2395
- * the matching `key.form` entry is used, falling back to `key.other`.
2396
- */
2397
- function t(key, params = {}) {
2398
- if (!api) return key;
2399
- if (typeof params.count === "number") {
2400
- const form = pluralForm(params.count);
2401
- const exact = api.translate(`${key}.${form}`, params);
2402
- if (exact !== `${key}.${form}`) return exact;
2403
- const other = api.translate(`${key}.other`, params);
2404
- if (other !== `${key}.other`) return other;
2405
- }
2406
- return api.translate(key, params);
2407
- }
2408
- /**
2409
- * Subscribe to language or dictionary changes. Components go through
2410
- * `useLocale()` instead; this is the non-React entry point (DOM decorations).
2411
- * Subscribing before the host service is bound is a no-op, so callers subscribe
2412
- * from `apply()`, after `bindLocale()`.
2413
- */
2414
- function subscribeLocale(listener) {
2415
- return api ? api.subscribe(listener) : () => {};
2416
- }
2417
- /** Monotonic host revision counter, bumped on locale and dictionary changes. */
2418
- function localeRevision() {
2419
- return api?.getRevision() ?? 0;
2420
- }
2421
- //#endregion
2422
- //#region src/client/useLocale.ts
2423
- /**
2424
- * Re-render the calling component when the host language changes, so slot copy
2425
- * switches without a page reload. Subscriptions happen after mount, by which
2426
- * time `apply()` has bound the locale service.
2427
- */
2428
- function useLocale() {
2429
- (0, react.useSyncExternalStore)(subscribeLocale, localeRevision, localeRevision);
2430
- }
2431
- //#endregion
2432
- //#region src/client/ui/AnnotationCardView.tsx
2433
- /** Short grace for crossing the 12px bridge; the exit starts right after it. */
2434
- const CLOSE_DELAY$1 = 40;
2435
- /** After a viewport change, wait this long before position-checking the pointer. */
2436
- const SETTLE_DELAY = 600;
2437
- /** Transparent strip on the facing side; the pointer crosses it in transit. */
2438
- const BRIDGE = 12;
2439
- /** Matches the list's leaving keyframe; the panel unmounts after it. */
2440
- const EXIT_MS$1 = 120;
2441
- /** Stable edit target: item id, with an index fallback for id-less callers. */
2442
- function editKeyOf(item, index) {
2443
- return item?.id ?? `index:${index}`;
2444
- }
2445
- /**
2446
- * Qoder-style summary with a placement-aware hover list.
2447
- *
2448
- * The hover panel is portaled to <body>: the transcript wraps messages in
2449
- * `contain: layout` surfaces, which become the containing block for
2450
- * `position: fixed` descendants — a fixed panel inside them lands off-screen.
2451
- * Open/close is React state; the pointer may sit on either the summary or the
2452
- * portaled panel, so leaving one schedules a delayed close that entering the
2453
- * other cancels. Comment editing happens inline in the list — the panel stays
2454
- * put and auto-close is suppressed while an edit is open.
2915
+ * The hover panel is portaled to <body>: the transcript wraps messages in
2916
+ * `contain: layout` surfaces, which become the containing block for
2917
+ * `position: fixed` descendants — a fixed panel inside them lands off-screen.
2918
+ * Open/close is React state; the pointer may sit on either the summary or the
2919
+ * portaled panel, so leaving one schedules a delayed close that entering the
2920
+ * other cancels. Comment editing happens inline in the list — the panel stays
2921
+ * put and auto-close is suppressed while an edit is open.
2455
2922
  */
2456
2923
  function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemove, onItemEdit }) {
2457
2924
  useLocale();
@@ -3179,6 +3646,67 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
3179
3646
  /** Alias kept for overlay registration. */
3180
3647
  const QuoteCard = AnnotationPanel;
3181
3648
  //#endregion
3649
+ //#region src/client/config.ts
3650
+ const DEFAULT_CONFIG = Object.freeze({
3651
+ triggerMode: "auto",
3652
+ shortcut: false
3653
+ });
3654
+ const TRIGGER_MODES = /* @__PURE__ */ new Set([
3655
+ "auto",
3656
+ "contextmenu",
3657
+ "both"
3658
+ ]);
3659
+ /** Collapse an untrusted snapshot value onto the known shape. */
3660
+ function normalizeConfig(value) {
3661
+ if (typeof value !== "object" || value === null) return { ...DEFAULT_CONFIG };
3662
+ const record = value;
3663
+ return {
3664
+ triggerMode: TRIGGER_MODES.has(record.triggerMode) ? record.triggerMode : DEFAULT_CONFIG.triggerMode,
3665
+ shortcut: typeof record.shortcut === "boolean" ? record.shortcut : DEFAULT_CONFIG.shortcut
3666
+ };
3667
+ }
3668
+ function resolveTriggers(config) {
3669
+ return {
3670
+ auto: config.triggerMode === "auto" || config.triggerMode === "both",
3671
+ contextMenu: config.triggerMode === "contextmenu" || config.triggerMode === "both",
3672
+ shortcut: config.shortcut
3673
+ };
3674
+ }
3675
+ let current = { ...DEFAULT_CONFIG };
3676
+ const listeners = /* @__PURE__ */ new Set();
3677
+ function getConfig() {
3678
+ return current;
3679
+ }
3680
+ function subscribeConfig(listener) {
3681
+ listeners.add(listener);
3682
+ return () => {
3683
+ listeners.delete(listener);
3684
+ };
3685
+ }
3686
+ /** Publish a snapshot value; returns the normalized config actually stored. */
3687
+ function publishConfig(value) {
3688
+ const next = normalizeConfig(value);
3689
+ if (next.triggerMode === current.triggerMode && next.shortcut === current.shortcut) return current;
3690
+ current = next;
3691
+ for (const listener of [...listeners]) listener();
3692
+ return current;
3693
+ }
3694
+ /**
3695
+ * Follow one Host config form. Works with a live form and with the fake one in
3696
+ * `verify`; the caller owns disposal.
3697
+ */
3698
+ function attachConfigForm(form) {
3699
+ const publish = () => {
3700
+ publishConfig(form.getSnapshot().value);
3701
+ };
3702
+ publish();
3703
+ const off = form.subscribe(publish);
3704
+ return () => {
3705
+ off();
3706
+ publishConfig(void 0);
3707
+ };
3708
+ }
3709
+ //#endregion
3182
3710
  //#region src/client/dom/floating-layer.ts
3183
3711
  /**
3184
3712
  * Mount point for the selection UI: a zero-size host inside the transcript's
@@ -3399,6 +3927,17 @@ function isFirstLineOnScreen(rect) {
3399
3927
  return rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth;
3400
3928
  }
3401
3929
  /**
3930
+ * First-line box of the live selection in viewport coordinates.
3931
+ *
3932
+ * The keyboard entry has no pointer to anchor to, so it opens the same menu
3933
+ * beside the selected words; the first line is what the reader is looking at.
3934
+ */
3935
+ function selectionFirstLineRect() {
3936
+ const selection = window.getSelection();
3937
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
3938
+ return getRangeFirstLineRect(selection.getRangeAt(0)) ?? null;
3939
+ }
3940
+ /**
3402
3941
  * Read the live selection when it is a non-empty range inside a conversation
3403
3942
  * message and outside the composer / our own chrome.
3404
3943
  */
@@ -3494,34 +4033,552 @@ function linkTarget(text, anchorHref) {
3494
4033
  return selectionUrl(text) ?? (anchorHref ? selectionUrl(anchorHref) : null);
3495
4034
  }
3496
4035
  //#endregion
3497
- //#region src/client/ui/touch.ts
3498
- /**
3499
- * The same query the coarse-pointer CSS uses. Keeping JS and CSS on one
3500
- * criterion means a device never gets the bottom bar with desktop positioning
3501
- * (or the reverse).
3502
- */
3503
- const COARSE_POINTER = "(hover: none), (pointer: coarse)";
3504
- function coarsePointer() {
3505
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
3506
- return window.matchMedia(COARSE_POINTER).matches;
3507
- }
3508
- function useCoarsePointer() {
3509
- const [coarse, setCoarse] = (0, react.useState)(coarsePointer);
4036
+ //#region src/client/selection/SelectionMenu.tsx
4037
+ /** Space between the anchor line's bottom and the menu. */
4038
+ const ANCHOR_GAP = 2;
4039
+ function SelectionMenu({ snapshot, x, y, onAction, onClose, initialFocus = "first" }) {
4040
+ const rootRef = (0, react.useRef)(null);
4041
+ const firstRef = (0, react.useRef)(null);
4042
+ const [placement, setPlacement] = (0, react.useState)(null);
4043
+ const items = [
4044
+ ...linkTarget(snapshot.text, snapshot.anchorHref) ? [{
4045
+ key: "open",
4046
+ icon: "external-link",
4047
+ label: t("toolbar.openLink")
4048
+ }] : [],
4049
+ {
4050
+ key: "copy",
4051
+ icon: "copy",
4052
+ label: t("toolbar.copy")
4053
+ },
4054
+ {
4055
+ key: "add",
4056
+ icon: "plus",
4057
+ label: t("toolbar.addToTask")
4058
+ }
4059
+ ];
4060
+ (0, react.useLayoutEffect)(() => {
4061
+ const el = rootRef.current;
4062
+ if (!el) return;
4063
+ const anchor = DOMRect.fromRect({
4064
+ x,
4065
+ y,
4066
+ width: 0,
4067
+ height: 0
4068
+ });
4069
+ setPlacement(placePopover(anchor, {
4070
+ width: el.offsetWidth,
4071
+ height: el.offsetHeight
4072
+ }, "bottom", ANCHOR_GAP));
4073
+ }, [
4074
+ x,
4075
+ y,
4076
+ items.length
4077
+ ]);
4078
+ (0, react.useLayoutEffect)(() => {
4079
+ if (!placement) return;
4080
+ if (initialFocus === "menu") rootRef.current?.focus({ preventScroll: true });
4081
+ else firstRef.current?.focus({ preventScroll: true });
4082
+ }, [placement, initialFocus]);
3510
4083
  (0, react.useEffect)(() => {
3511
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
3512
- const query = window.matchMedia(COARSE_POINTER);
3513
- const onChange = (event) => setCoarse(event.matches);
3514
- setCoarse(query.matches);
3515
- query.addEventListener("change", onChange);
3516
- return () => query.removeEventListener("change", onChange);
4084
+ const inside = (node) => node instanceof Node && rootRef.current?.contains(node) === true;
4085
+ const onPointerDown = (event) => {
4086
+ if (inside(event.target)) return;
4087
+ onClose();
4088
+ };
4089
+ const onContextMenu = (event) => {
4090
+ if (inside(event.target)) {
4091
+ event.preventDefault();
4092
+ return;
4093
+ }
4094
+ onClose();
4095
+ };
4096
+ const onScroll = () => onClose();
4097
+ const onResize = () => onClose();
4098
+ const onBlur = () => onClose();
4099
+ document.addEventListener("pointerdown", onPointerDown, true);
4100
+ document.addEventListener("contextmenu", onContextMenu, true);
4101
+ document.addEventListener("scroll", onScroll, true);
4102
+ window.addEventListener("resize", onResize);
4103
+ window.addEventListener("blur", onBlur);
4104
+ return () => {
4105
+ document.removeEventListener("pointerdown", onPointerDown, true);
4106
+ document.removeEventListener("contextmenu", onContextMenu, true);
4107
+ document.removeEventListener("scroll", onScroll, true);
4108
+ window.removeEventListener("resize", onResize);
4109
+ window.removeEventListener("blur", onBlur);
4110
+ };
4111
+ }, [onClose]);
4112
+ const handleKeyDown = (0, react.useCallback)((event) => {
4113
+ if (event.key === "Escape") {
4114
+ event.preventDefault();
4115
+ event.stopPropagation();
4116
+ onClose();
4117
+ return;
4118
+ }
4119
+ if (event.key === "Tab") {
4120
+ onClose();
4121
+ return;
4122
+ }
4123
+ const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
4124
+ if (step === 0 && event.key !== "Home" && event.key !== "End") return;
4125
+ const buttons = [...rootRef.current?.querySelectorAll("[role=\"menuitem\"]") ?? []];
4126
+ if (buttons.length === 0) return;
4127
+ event.preventDefault();
4128
+ const current = buttons.indexOf(document.activeElement);
4129
+ const fromMenu = current === -1;
4130
+ 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 });
4131
+ }, [onClose]);
4132
+ const keepSelection = (0, react.useCallback)((event) => {
4133
+ event.preventDefault();
4134
+ event.currentTarget.focus({ preventScroll: true });
3517
4135
  }, []);
3518
- return coarse;
4136
+ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4137
+ ref: rootRef,
4138
+ className: cx(A.col, styles.ctxMenu),
4139
+ role: "menu",
4140
+ "aria-label": t("menu.aria"),
4141
+ "data-dsq-menu": "true",
4142
+ "data-side": placement?.side ?? "bottom",
4143
+ tabIndex: -1,
4144
+ style: {
4145
+ top: placement?.top ?? 0,
4146
+ left: placement?.left ?? 0,
4147
+ visibility: placement ? "visible" : "hidden"
4148
+ },
4149
+ onKeyDown: handleKeyDown,
4150
+ children: items.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4151
+ ref: index === 0 ? firstRef : void 0,
4152
+ type: "button",
4153
+ role: "menuitem",
4154
+ className: cx(A.plain, A.row, styles.ctxItem),
4155
+ tabIndex: -1,
4156
+ onPointerDown: keepSelection,
4157
+ onPointerEnter: (event) => event.currentTarget.focus({ preventScroll: true }),
4158
+ onClick: () => onAction(item.key),
4159
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
4160
+ name: item.icon,
4161
+ size: 15
4162
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: item.label })]
4163
+ }, item.key))
4164
+ }), document.body);
3519
4165
  }
3520
- function measure() {
3521
- if (typeof window === "undefined") return {
3522
- inset: 0,
3523
- height: 0
3524
- };
4166
+ //#endregion
4167
+ //#region src/client/selection/ToolbarView.tsx
4168
+ /**
4169
+ * Paint-only halves of the selection UI: the anchored desktop toolbar/popover
4170
+ * and the touch bottom bar/sheet. All state, persistence and placement stay in
4171
+ * `SelectionToolbar`; this module owns markup, portals and ARIA only.
4172
+ */
4173
+ function ActionRow({ actions, stacked }) {
4174
+ return actions.map((action, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [!stacked && index > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4175
+ className: styles.divider,
4176
+ "aria-hidden": "true"
4177
+ }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4178
+ type: "button",
4179
+ className: cx(A.plain, stacked ? A.col : A.inlineRow, stacked ? styles.touchAction : styles.button),
4180
+ onClick: action.onClick,
4181
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
4182
+ name: action.icon,
4183
+ size: stacked ? 18 : 14
4184
+ }), stacked ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: action.label }) : action.label]
4185
+ })] }, action.key));
4186
+ }
4187
+ function CommentField({ comment, touch, commentRef, onChange, onKeyDown }) {
4188
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
4189
+ ref: commentRef,
4190
+ className: cx(A.box, styles.commentInput, touch && styles.touchComment),
4191
+ placeholder: t("common.commentPlaceholder"),
4192
+ value: comment,
4193
+ onChange: (event) => onChange(event.target.value),
4194
+ onKeyDown
4195
+ });
4196
+ }
4197
+ function CommentActions({ comment, touch, onFinishComment }) {
4198
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4199
+ className: cx(A.row, styles.commentActions, touch && styles.touchActions),
4200
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4201
+ type: "button",
4202
+ className: cx(A.plain, A.inlineRow, styles.button, touch && styles.touchSecondary),
4203
+ onClick: () => onFinishComment(false),
4204
+ children: t("toolbar.skipComment")
4205
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4206
+ type: "button",
4207
+ className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
4208
+ disabled: !comment.trim(),
4209
+ onClick: () => onFinishComment(true),
4210
+ children: t("toolbar.addComment")
4211
+ })]
4212
+ });
4213
+ }
4214
+ function ToolbarView({ touch, phase, active, status, viewport, actions, comment, menuOpen, toolbarStyle, toolbarRef, commentWrapRef, commentRef, onToolbarPointerDown, onCommentChange, onCommentKeyDown, onFinishComment, onCloseSheet }) {
4215
+ const commentField = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CommentField, {
4216
+ comment,
4217
+ touch,
4218
+ commentRef,
4219
+ onChange: onCommentChange,
4220
+ onKeyDown: onCommentKeyDown
4221
+ });
4222
+ const commentActions = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CommentActions, {
4223
+ comment,
4224
+ touch,
4225
+ onFinishComment
4226
+ });
4227
+ if (touch) {
4228
+ const bottom = `calc(${viewport.inset}px + 8px + env(safe-area-inset-bottom, 0px))`;
4229
+ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
4230
+ phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4231
+ ref: toolbarRef,
4232
+ className: cx(A.row, styles.touchBar),
4233
+ style: { bottom },
4234
+ role: "toolbar",
4235
+ "aria-label": t("toolbar.aria"),
4236
+ "data-dsq-toolbar": "true",
4237
+ "data-touch": "true",
4238
+ onPointerDown: onToolbarPointerDown,
4239
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionRow, {
4240
+ actions,
4241
+ stacked: true
4242
+ })
4243
+ }) : null,
4244
+ phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4245
+ className: styles.touchScrim,
4246
+ "aria-hidden": "true"
4247
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4248
+ ref: commentWrapRef,
4249
+ className: cx(A.col, styles.touchSheet),
4250
+ style: {
4251
+ bottom,
4252
+ maxHeight: `${Math.max(240, Math.round(viewport.height * .72))}px`
4253
+ },
4254
+ role: "dialog",
4255
+ "aria-label": t("toolbar.commentDialogAria"),
4256
+ children: [
4257
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4258
+ className: cx(A.row, styles.touchSheetHead),
4259
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4260
+ className: styles.touchSheetTitle,
4261
+ children: t("toolbar.commentHint")
4262
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4263
+ type: "button",
4264
+ className: cx(A.plain, A.gridMid, styles.touchSheetClose),
4265
+ "aria-label": t("common.close"),
4266
+ onClick: onCloseSheet,
4267
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
4268
+ name: "x",
4269
+ size: 18
4270
+ })
4271
+ })]
4272
+ }),
4273
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4274
+ className: styles.touchQuote,
4275
+ children: active.text
4276
+ }),
4277
+ commentField,
4278
+ commentActions
4279
+ ]
4280
+ })] }) : null,
4281
+ status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4282
+ className: styles.touchStatus,
4283
+ style: { bottom: `calc(${viewport.inset}px + 72px + env(safe-area-inset-bottom, 0px))` },
4284
+ role: "status",
4285
+ children: status
4286
+ }) : null
4287
+ ] }), document.body);
4288
+ }
4289
+ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
4290
+ menuOpen ? null : phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4291
+ ref: toolbarRef,
4292
+ className: cx(A.row, styles.toolbar),
4293
+ style: toolbarStyle,
4294
+ role: "toolbar",
4295
+ "aria-label": t("toolbar.aria"),
4296
+ "data-dsq-toolbar": "true",
4297
+ "data-anchor": active.below ? "below" : "above",
4298
+ onPointerDown: onToolbarPointerDown,
4299
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4300
+ className: cx(A.inlineRow, styles.toolbarInner),
4301
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionRow, {
4302
+ actions,
4303
+ stacked: false
4304
+ })
4305
+ })
4306
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4307
+ ref: toolbarRef,
4308
+ className: cx(A.row, styles.toolbar),
4309
+ role: "toolbar",
4310
+ "aria-label": t("toolbar.aria"),
4311
+ "data-dsq-toolbar": "true",
4312
+ "data-anchor": "popover-top",
4313
+ onPointerDown: onToolbarPointerDown,
4314
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4315
+ className: cx(A.inlineRow, styles.toolbarInner),
4316
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4317
+ style: {
4318
+ padding: "0 8px",
4319
+ fontSize: 13,
4320
+ opacity: .8,
4321
+ whiteSpace: "nowrap"
4322
+ },
4323
+ children: t("toolbar.commentHint")
4324
+ })
4325
+ })
4326
+ }),
4327
+ !menuOpen && phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4328
+ ref: commentWrapRef,
4329
+ className: cx(A.col, styles.commentPop),
4330
+ role: "dialog",
4331
+ "aria-label": t("toolbar.commentDialogAria"),
4332
+ onPointerDown: onToolbarPointerDown,
4333
+ children: [commentField, commentActions]
4334
+ }) : null,
4335
+ status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4336
+ className: styles.status,
4337
+ style: toolbarStyle,
4338
+ role: "status",
4339
+ children: status
4340
+ }) : null
4341
+ ] }), active.layer.host);
4342
+ }
4343
+ //#endregion
4344
+ //#region src/client/dom/comment-placement.ts
4345
+ const GAP$1 = 8;
4346
+ /** Horizontal slack around the popover, and the minimum margin to the box. */
4347
+ const EDGE = 8;
4348
+ /** Gap between the popover corner and the "add a comment" toolbar. */
4349
+ const TOOLBAR_GAP = 6;
4350
+ function placeCommentSurfaces({ layer, selection, toolbar, panel }) {
4351
+ const bounds = layer.bounds();
4352
+ const home = layer.fromContent(selection.left, selection.top);
4353
+ const anchor = {
4354
+ left: home.left - toolbar.width / 2,
4355
+ top: home.top - (selection.below ? 0 : toolbar.height),
4356
+ width: toolbar.width,
4357
+ height: toolbar.height
4358
+ };
4359
+ const belowTop = layer.fromContent(0, selection.selectionBottom).top + GAP$1;
4360
+ const aboveTop = anchor.top - panel.height - GAP$1;
4361
+ 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));
4362
+ const half = panel.width / 2 + EDGE;
4363
+ const min = bounds.left + half;
4364
+ const center = Math.min(Math.max(anchor.left + anchor.width / 2, min), Math.max(min, bounds.right - half));
4365
+ const popover = layer.toContent(center, top);
4366
+ let leftView = layer.fromContent(popover.left, popover.top).left - panel.width / 2;
4367
+ let topView = top - TOOLBAR_GAP - toolbar.height;
4368
+ if (topView < bounds.top + EDGE) topView = top + panel.height + TOOLBAR_GAP;
4369
+ leftView = Math.max(bounds.left + EDGE, Math.min(leftView, bounds.right - EDGE - toolbar.width));
4370
+ topView = Math.max(bounds.top + EDGE, Math.min(topView, bounds.bottom - EDGE - toolbar.height));
4371
+ return {
4372
+ popover,
4373
+ toolbar: layer.toContent(leftView, topView)
4374
+ };
4375
+ }
4376
+ //#endregion
4377
+ //#region src/client/selection/useCommentPlacement.ts
4378
+ /**
4379
+ * Measures the comment popover and its hint toolbar, then writes the geometry
4380
+ * from `dom/comment-placement.ts` back as content coordinates. The React
4381
+ * component keeps the refs; this hook owns the measure/place lifecycle.
4382
+ */
4383
+ function useCommentPlacement({ touch, phase, active, toolbarRef, commentWrapRef }) {
4384
+ const place = (0, react.useCallback)(() => {
4385
+ if (touch) return;
4386
+ const layer = active?.layer;
4387
+ const toolbar = toolbarRef.current;
4388
+ const pop = commentWrapRef.current;
4389
+ if (!active || !layer || !toolbar || !pop) return;
4390
+ pop.style.display = "flex";
4391
+ pop.style.visibility = "hidden";
4392
+ const panel = {
4393
+ width: pop.offsetWidth || 320,
4394
+ height: pop.offsetHeight || 180
4395
+ };
4396
+ pop.style.visibility = "";
4397
+ const at = placeCommentSurfaces({
4398
+ layer,
4399
+ selection: active,
4400
+ toolbar: {
4401
+ width: toolbar.offsetWidth,
4402
+ height: toolbar.offsetHeight
4403
+ },
4404
+ panel
4405
+ });
4406
+ pop.style.top = `${at.popover.top}px`;
4407
+ pop.style.left = `${at.popover.left}px`;
4408
+ toolbar.style.left = `${at.toolbar.left}px`;
4409
+ toolbar.style.top = `${at.toolbar.top}px`;
4410
+ }, [
4411
+ active,
4412
+ commentWrapRef,
4413
+ toolbarRef,
4414
+ touch
4415
+ ]);
4416
+ (0, react.useLayoutEffect)(() => {
4417
+ if (phase === "comment") place();
4418
+ }, [phase, place]);
4419
+ (0, react.useEffect)(() => {
4420
+ if (touch) return;
4421
+ window.addEventListener("resize", place);
4422
+ return () => window.removeEventListener("resize", place);
4423
+ }, [place, touch]);
4424
+ return place;
4425
+ }
4426
+ //#endregion
4427
+ //#region src/client/dom/menu-trigger.ts
4428
+ function shouldOpenSelectionMenu(signal) {
4429
+ return signal.contextMenuEnabled && !signal.touch && !signal.defaultPrevented && signal.targetInTranscript && signal.pointInSelection && signal.hasSelection;
4430
+ }
4431
+ /**
4432
+ * True when `(x, y)` falls inside any of the selection's line boxes.
4433
+ *
4434
+ * The union box is wrong here: a short first line leaves empty space to its
4435
+ * right that belongs to no word, and a right-click there is not "on the
4436
+ * selection". Each line rect plus a small tolerance matches what the user sees.
4437
+ */
4438
+ function pointInRects(x, y, rects, tolerance = 2) {
4439
+ for (const rect of rects) if (x >= rect.left - tolerance && x <= rect.right + tolerance && y >= rect.top - tolerance && y <= rect.bottom + tolerance) return true;
4440
+ return false;
4441
+ }
4442
+ //#endregion
4443
+ //#region src/client/selection/useSelectionWatcher.ts
4444
+ /**
4445
+ * Transcript-wide pointer/selection observation for the toolbar.
4446
+ *
4447
+ * The controller owns the state; this hook owns *when* to re-read it. Keeping
4448
+ * the listener choreography here means the toolbar component does not have to
4449
+ * know about the drag/context-menu/scroll ordering rules.
4450
+ */
4451
+ /** The menu's own chrome; a press inside it must not dismiss the selection. */
4452
+ const MENU_SELECTOR = "[data-dsq-menu]";
4453
+ function useSelectionWatcher({ toolbarRef, commentWrapRef, phaseRef, touch, dismissMenu, refresh, flushComment, hide, openMenu }) {
4454
+ (0, react.useEffect)(() => {
4455
+ let scheduled = false;
4456
+ const schedule = () => {
4457
+ if (scheduled) return;
4458
+ scheduled = true;
4459
+ window.setTimeout(() => {
4460
+ scheduled = false;
4461
+ refresh();
4462
+ }, 0);
4463
+ };
4464
+ let dragging = false;
4465
+ const onPointerDown = (event) => {
4466
+ const target = event.target;
4467
+ if (!(target instanceof Node)) return;
4468
+ const inToolbar = toolbarRef.current?.contains(target) === true;
4469
+ const inComment = commentWrapRef.current?.contains(target) === true;
4470
+ const inMenu = target instanceof Element && target.closest(MENU_SELECTOR) !== null;
4471
+ if (inToolbar || inComment || inMenu) return;
4472
+ dragging = true;
4473
+ if (phaseRef.current === "comment") {
4474
+ flushComment();
4475
+ hide();
4476
+ return;
4477
+ }
4478
+ const selection = window.getSelection();
4479
+ if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
4480
+ schedule();
4481
+ return;
4482
+ }
4483
+ hide();
4484
+ };
4485
+ const onPointerUp = () => {
4486
+ dragging = false;
4487
+ schedule();
4488
+ };
4489
+ const onSelectionChange = () => {
4490
+ if (dragging) return;
4491
+ schedule();
4492
+ };
4493
+ const onContextMenu = (event) => {
4494
+ if (dismissMenu()) refresh();
4495
+ if (event.defaultPrevented) return;
4496
+ const target = event.target;
4497
+ if (!(target instanceof Node)) return;
4498
+ if (toolbarRef.current?.contains(target)) return;
4499
+ if (target instanceof Element && target.closest(MENU_SELECTOR)) return;
4500
+ if (touch || phaseRef.current === "comment") return;
4501
+ const contextMenuEnabled = resolveTriggers(getConfig()).contextMenu;
4502
+ if (!contextMenuEnabled) return;
4503
+ const selection = window.getSelection();
4504
+ const hasSelection = selection !== null && !selection.isCollapsed && selection.rangeCount > 0;
4505
+ const pointInSelection = selection !== null && hasSelection && pointInRects(event.clientX, event.clientY, selection.getRangeAt(0).getClientRects());
4506
+ if (!shouldOpenSelectionMenu({
4507
+ defaultPrevented: event.defaultPrevented,
4508
+ contextMenuEnabled,
4509
+ touch,
4510
+ targetInTranscript: isInsideTranscript(target),
4511
+ pointInSelection,
4512
+ hasSelection
4513
+ })) return;
4514
+ const snapshot = readSelectionSnapshot(toolbarRef.current);
4515
+ if (!snapshot) return;
4516
+ event.preventDefault();
4517
+ event.stopPropagation();
4518
+ openMenu(snapshot, event.clientX, event.clientY, "menu");
4519
+ };
4520
+ const onScroll = (event) => {
4521
+ const target = event.target;
4522
+ if (target instanceof Node && (toolbarRef.current?.contains(target) === true || commentWrapRef.current?.contains(target) === true)) return;
4523
+ schedule();
4524
+ };
4525
+ document.addEventListener("pointerdown", onPointerDown, true);
4526
+ document.addEventListener("pointerup", onPointerUp, true);
4527
+ document.addEventListener("keyup", schedule, true);
4528
+ document.addEventListener("selectionchange", onSelectionChange);
4529
+ document.addEventListener("contextmenu", onContextMenu);
4530
+ window.addEventListener("resize", schedule);
4531
+ document.addEventListener("scroll", onScroll, true);
4532
+ return () => {
4533
+ document.removeEventListener("pointerdown", onPointerDown, true);
4534
+ document.removeEventListener("pointerup", onPointerUp, true);
4535
+ document.removeEventListener("keyup", schedule, true);
4536
+ document.removeEventListener("selectionchange", onSelectionChange);
4537
+ document.removeEventListener("contextmenu", onContextMenu);
4538
+ window.removeEventListener("resize", schedule);
4539
+ document.removeEventListener("scroll", onScroll, true);
4540
+ };
4541
+ }, [
4542
+ commentWrapRef,
4543
+ dismissMenu,
4544
+ flushComment,
4545
+ hide,
4546
+ openMenu,
4547
+ phaseRef,
4548
+ refresh,
4549
+ toolbarRef,
4550
+ touch
4551
+ ]);
4552
+ }
4553
+ //#endregion
4554
+ //#region src/client/ui/touch.ts
4555
+ /**
4556
+ * The same query the coarse-pointer CSS uses. Keeping JS and CSS on one
4557
+ * criterion means a device never gets the bottom bar with desktop positioning
4558
+ * (or the reverse).
4559
+ */
4560
+ const COARSE_POINTER = "(hover: none), (pointer: coarse)";
4561
+ function coarsePointer() {
4562
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
4563
+ return window.matchMedia(COARSE_POINTER).matches;
4564
+ }
4565
+ function useCoarsePointer() {
4566
+ const [coarse, setCoarse] = (0, react.useState)(coarsePointer);
4567
+ (0, react.useEffect)(() => {
4568
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
4569
+ const query = window.matchMedia(COARSE_POINTER);
4570
+ const onChange = (event) => setCoarse(event.matches);
4571
+ setCoarse(query.matches);
4572
+ query.addEventListener("change", onChange);
4573
+ return () => query.removeEventListener("change", onChange);
4574
+ }, []);
4575
+ return coarse;
4576
+ }
4577
+ function measure() {
4578
+ if (typeof window === "undefined") return {
4579
+ inset: 0,
4580
+ height: 0
4581
+ };
3525
4582
  const viewport = window.visualViewport;
3526
4583
  const layout = document.documentElement.clientHeight || window.innerHeight;
3527
4584
  if (!viewport) return {
@@ -3557,7 +4614,7 @@ function useVisualViewport(enabled) {
3557
4614
  return metrics;
3558
4615
  }
3559
4616
  //#endregion
3560
- //#region src/client/SelectionToolbar.tsx
4617
+ //#region src/client/selection/SelectionToolbar.tsx
3561
4618
  /** Cap for the auto-growing comment box; beyond this it scrolls internally. */
3562
4619
  const COMMENT_MAX_HEIGHT = 220;
3563
4620
  function SelectionToolbar({ sessionId }) {
@@ -3569,8 +4626,13 @@ function SelectionToolbar({ sessionId }) {
3569
4626
  const [phase, setPhase] = (0, react.useState)("idle");
3570
4627
  const [comment, setComment] = (0, react.useState)("");
3571
4628
  const [status, setStatus] = (0, react.useState)(null);
4629
+ const [menu, setMenu] = (0, react.useState)(null);
3572
4630
  const activeRef = (0, react.useRef)(null);
3573
4631
  activeRef.current = active;
4632
+ const menuRef = (0, react.useRef)(null);
4633
+ menuRef.current = menu;
4634
+ const phaseRef = (0, react.useRef)(phase);
4635
+ phaseRef.current = phase;
3574
4636
  /** Annotation saved when the comment step opened; the box only edits it. */
3575
4637
  const pendingIdRef = (0, react.useRef)(null);
3576
4638
  const touch = useCoarsePointer();
@@ -3579,13 +4641,25 @@ function SelectionToolbar({ sessionId }) {
3579
4641
  ensureToolbarStyles();
3580
4642
  if (sessionId) bindAnnotationSession(sessionId);
3581
4643
  }, [sessionId]);
4644
+ /**
4645
+ * Drop the menu state **and** its ref in one tick. A refresh that runs right
4646
+ * after a dismissal must see the menu as closed; reading the stale ref made
4647
+ * it keep `active`, which raised the auto toolbar in contextmenu mode.
4648
+ */
4649
+ const dismissMenu = (0, react.useCallback)(() => {
4650
+ if (!menuRef.current) return false;
4651
+ menuRef.current = null;
4652
+ setMenu(null);
4653
+ return true;
4654
+ }, []);
3582
4655
  const hide = (0, react.useCallback)(() => {
3583
4656
  pendingIdRef.current = null;
4657
+ dismissMenu();
3584
4658
  setActive(null);
3585
4659
  setPhase("idle");
3586
4660
  setComment("");
3587
4661
  setStatus(null);
3588
- }, []);
4662
+ }, [dismissMenu]);
3589
4663
  /**
3590
4664
  * Keep what was typed when the comment popover goes away without an explicit
3591
4665
  * cancel. The annotation itself was saved when the step opened, so dropping
@@ -3599,9 +4673,7 @@ function SelectionToolbar({ sessionId }) {
3599
4673
  if (typed.trim()) updateAnnotationComment(void 0, id, typed);
3600
4674
  }, []);
3601
4675
  /** Save the current selection without a comment; returns the store id. */
3602
- const saveSelection = (0, react.useCallback)(() => {
3603
- const snapshot = activeRef.current;
3604
- if (!snapshot) return null;
4676
+ const saveSelection = (0, react.useCallback)((snapshot) => {
3605
4677
  return saveAnnotation(void 0, {
3606
4678
  text: snapshot.text,
3607
4679
  sources: snapshot.source ? [{
@@ -3613,12 +4685,31 @@ function SelectionToolbar({ sessionId }) {
3613
4685
  }] : void 0
3614
4686
  }).id;
3615
4687
  }, []);
4688
+ /**
4689
+ * "Add to task" is the commit: the quote is saved immediately, with or
4690
+ * without a comment. The popover only edits that saved entry, so dismissing
4691
+ * it (outside click, new selection) can never undo the annotation.
4692
+ */
4693
+ const beginComment = (0, react.useCallback)((snapshot) => {
4694
+ pendingIdRef.current = saveSelection(snapshot);
4695
+ setActive(snapshot);
4696
+ dismissMenu();
4697
+ setPhase("comment");
4698
+ setComment("");
4699
+ setStatus(null);
4700
+ }, [dismissMenu, saveSelection]);
3616
4701
  const refresh = (0, react.useCallback)(() => {
3617
4702
  const next = readSelectionSnapshot(toolbarRef.current);
3618
4703
  if (!next) {
4704
+ dismissMenu();
3619
4705
  if (phaseRef.current === "idle") setActive(null);
3620
4706
  return;
3621
4707
  }
4708
+ if (menuRef.current && menuRef.current.snapshot.text !== next.text) dismissMenu();
4709
+ if (!touch && !resolveTriggers(getConfig()).auto) {
4710
+ if (phaseRef.current === "idle" && !menuRef.current) setActive(null);
4711
+ return;
4712
+ }
3622
4713
  const current = activeRef.current;
3623
4714
  if (phaseRef.current === "comment" && current && current.text !== next.text) {
3624
4715
  flushComment();
@@ -3626,126 +4717,75 @@ function SelectionToolbar({ sessionId }) {
3626
4717
  setComment("");
3627
4718
  }
3628
4719
  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);
3629
- }, [flushComment]);
3630
- const phaseRef = (0, react.useRef)(phase);
3631
- phaseRef.current = phase;
3632
- (0, react.useEffect)(() => {
3633
- let scheduled = false;
3634
- const schedule = () => {
3635
- if (scheduled) return;
3636
- scheduled = true;
3637
- window.setTimeout(() => {
3638
- scheduled = false;
3639
- refresh();
3640
- }, 0);
3641
- };
3642
- let dragging = false;
3643
- const onPointerDown = (event) => {
3644
- const target = event.target;
3645
- if (!(target instanceof Node)) return;
3646
- const inToolbar = toolbarRef.current?.contains(target) === true;
3647
- const inComment = commentWrapRef.current?.contains(target) === true;
3648
- if (inToolbar || inComment) return;
3649
- dragging = true;
3650
- if (phaseRef.current === "comment") {
3651
- flushComment();
3652
- hide();
3653
- return;
3654
- }
3655
- const selection = window.getSelection();
3656
- if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
3657
- schedule();
3658
- return;
3659
- }
3660
- hide();
3661
- };
3662
- const onPointerUp = () => {
3663
- dragging = false;
3664
- schedule();
3665
- };
3666
- const onSelectionChange = () => {
3667
- if (dragging) return;
3668
- schedule();
3669
- };
3670
- const onScroll = (event) => {
3671
- const target = event.target;
3672
- if (target instanceof Node && (toolbarRef.current?.contains(target) === true || commentWrapRef.current?.contains(target) === true)) return;
3673
- schedule();
3674
- };
3675
- document.addEventListener("pointerdown", onPointerDown, true);
3676
- document.addEventListener("pointerup", onPointerUp, true);
3677
- document.addEventListener("keyup", schedule, true);
3678
- document.addEventListener("selectionchange", onSelectionChange);
3679
- window.addEventListener("resize", schedule);
3680
- document.addEventListener("scroll", onScroll, true);
3681
- return () => {
3682
- document.removeEventListener("pointerdown", onPointerDown, true);
3683
- document.removeEventListener("pointerup", onPointerUp, true);
3684
- document.removeEventListener("keyup", schedule, true);
3685
- document.removeEventListener("selectionchange", onSelectionChange);
3686
- window.removeEventListener("resize", schedule);
3687
- document.removeEventListener("scroll", onScroll, true);
3688
- };
3689
4720
  }, [
4721
+ dismissMenu,
3690
4722
  flushComment,
3691
- hide,
3692
- refresh
4723
+ touch
3693
4724
  ]);
3694
- const placeCommentPop = (0, react.useCallback)(() => {
4725
+ /** Latch the selection and anchor; the menu outlives later refresh passes. */
4726
+ const openMenu = (0, react.useCallback)((snapshot, x, y, initialFocus) => {
4727
+ setStatus(null);
4728
+ setActive(snapshot);
4729
+ setMenu({
4730
+ snapshot,
4731
+ x,
4732
+ y,
4733
+ initialFocus
4734
+ });
4735
+ }, []);
4736
+ (0, react.useEffect)(() => subscribeConfig(() => {
4737
+ dismissMenu();
4738
+ refresh();
4739
+ }), [dismissMenu, refresh]);
4740
+ useSelectionWatcher({
4741
+ toolbarRef,
4742
+ commentWrapRef,
4743
+ phaseRef,
4744
+ touch,
4745
+ dismissMenu,
4746
+ refresh,
4747
+ flushComment,
4748
+ hide,
4749
+ openMenu
4750
+ });
4751
+ (0, react.useEffect)(() => {
3695
4752
  if (touch) return;
3696
- const snapshot = activeRef.current;
3697
- const layer = snapshot?.layer;
3698
- const toolbar = toolbarRef.current;
3699
- const pop = commentWrapRef.current;
3700
- if (!snapshot || !layer || !toolbar || !pop) return;
3701
- const bounds = layer.bounds();
3702
- const toolbarW = toolbar.offsetWidth;
3703
- const toolbarH = toolbar.offsetHeight;
3704
- const home = layer.fromContent(snapshot.left, snapshot.top);
3705
- const anchor = {
3706
- left: home.left - toolbarW / 2,
3707
- top: home.top - (snapshot.below ? 0 : toolbarH),
3708
- width: toolbarW,
3709
- height: toolbarH
4753
+ const bridge = {
4754
+ state: () => {
4755
+ if (phaseRef.current !== "idle") return "busy";
4756
+ return readSelectionSnapshot(toolbarRef.current) ? "ready" : "no-selection";
4757
+ },
4758
+ open: () => {
4759
+ if (phaseRef.current !== "idle") return;
4760
+ const snapshot = readSelectionSnapshot(toolbarRef.current);
4761
+ if (!snapshot) return;
4762
+ const rect = selectionFirstLineRect();
4763
+ if (!rect) return;
4764
+ openMenu(snapshot, rect.left, rect.bottom, "first");
4765
+ },
4766
+ add: () => {
4767
+ if (phaseRef.current !== "idle") return;
4768
+ const snapshot = readSelectionSnapshot(toolbarRef.current);
4769
+ if (!snapshot) return;
4770
+ beginComment(snapshot);
4771
+ }
3710
4772
  };
3711
- pop.style.display = "flex";
3712
- pop.style.visibility = "hidden";
3713
- const panel = {
3714
- width: pop.offsetWidth || 320,
3715
- height: pop.offsetHeight || 180
4773
+ runtime.selection = bridge;
4774
+ return () => {
4775
+ if (runtime.selection === bridge) delete runtime.selection;
3716
4776
  };
3717
- pop.style.visibility = "";
3718
- const gap = 8;
3719
- const margin = 8;
3720
- const belowTop = layer.fromContent(0, snapshot.selectionBottom).top + gap;
3721
- const aboveTop = anchor.top - panel.height - gap;
3722
- 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));
3723
- const half = panel.width / 2 + 8;
3724
- const min = bounds.left + half;
3725
- const center = Math.min(Math.max(anchor.left + anchor.width / 2, min), Math.max(min, bounds.right - half));
3726
- const at = layer.toContent(center, top);
3727
- pop.style.top = `${at.top}px`;
3728
- pop.style.left = `${at.left}px`;
3729
- const sideGap = 6;
3730
- const popLeftView = layer.fromContent(at.left, at.top).left - panel.width / 2;
3731
- const popTopView = top;
3732
- let leftView = popLeftView;
3733
- let topView = popTopView - sideGap - toolbarH;
3734
- if (topView < bounds.top + margin) topView = popTopView + panel.height + sideGap;
3735
- leftView = Math.max(bounds.left + margin, Math.min(leftView, bounds.right - margin - toolbarW));
3736
- topView = Math.max(bounds.top + margin, Math.min(topView, bounds.bottom - margin - toolbarH));
3737
- const toolbarAt = layer.toContent(leftView, topView);
3738
- toolbar.style.left = `${toolbarAt.left}px`;
3739
- toolbar.style.top = `${toolbarAt.top}px`;
3740
- }, [touch]);
3741
- (0, react.useLayoutEffect)(() => {
3742
- if (phase === "comment" && !touch) placeCommentPop();
3743
4777
  }, [
3744
- active,
3745
- phase,
3746
- placeCommentPop,
4778
+ beginComment,
4779
+ openMenu,
3747
4780
  touch
3748
4781
  ]);
4782
+ const placeCommentPop = useCommentPlacement({
4783
+ touch,
4784
+ phase,
4785
+ active,
4786
+ toolbarRef,
4787
+ commentWrapRef
4788
+ });
3749
4789
  const layer = active?.layer;
3750
4790
  (0, react.useLayoutEffect)(() => {
3751
4791
  if (!layer || touch) return;
@@ -3759,12 +4799,6 @@ function SelectionToolbar({ sessionId }) {
3759
4799
  (0, react.useEffect)(() => {
3760
4800
  if (phase === "comment" && !touch) commentRef.current?.focus();
3761
4801
  }, [phase, touch]);
3762
- (0, react.useEffect)(() => {
3763
- if (touch) return;
3764
- const onResize = () => placeCommentPop();
3765
- window.addEventListener("resize", onResize);
3766
- return () => window.removeEventListener("resize", onResize);
3767
- }, [placeCommentPop, touch]);
3768
4802
  (0, react.useLayoutEffect)(() => {
3769
4803
  if (phase !== "comment") return;
3770
4804
  const area = commentRef.current;
@@ -3782,9 +4816,8 @@ function SelectionToolbar({ sessionId }) {
3782
4816
  event.preventDefault();
3783
4817
  event.stopPropagation();
3784
4818
  }, []);
3785
- const handleCopy = (0, react.useCallback)(async () => {
3786
- const snapshot = activeRef.current;
3787
- if (!snapshot) return;
4819
+ /** Copy a latched quote; a failure leaves the caller's surface in place. */
4820
+ const copySnapshot = (0, react.useCallback)(async (snapshot) => {
3788
4821
  try {
3789
4822
  await copyText(snapshot.text);
3790
4823
  clearNativeSelection();
@@ -3793,18 +4826,14 @@ function SelectionToolbar({ sessionId }) {
3793
4826
  setStatus(t("toolbar.copyFailed"));
3794
4827
  }
3795
4828
  }, [hide]);
3796
- /**
3797
- * "Add to task" is the commit: the quote is saved immediately, with or
3798
- * without a comment. The popover only edits that saved entry, so dismissing
3799
- * it (outside click, new selection) can never undo the annotation.
3800
- */
4829
+ const handleCopy = (0, react.useCallback)(() => {
4830
+ const snapshot = activeRef.current;
4831
+ if (snapshot) copySnapshot(snapshot);
4832
+ }, [copySnapshot]);
3801
4833
  const openComment = (0, react.useCallback)(() => {
3802
- if (!activeRef.current) return;
3803
- pendingIdRef.current = saveSelection();
3804
- setPhase("comment");
3805
- setComment("");
3806
- setStatus(null);
3807
- }, [saveSelection]);
4834
+ const snapshot = activeRef.current;
4835
+ if (snapshot) beginComment(snapshot);
4836
+ }, [beginComment]);
3808
4837
  /**
3809
4838
  * Hand the selected link to the system browser.
3810
4839
  *
@@ -3814,14 +4843,33 @@ function SelectionToolbar({ sessionId }) {
3814
4843
  * arbitrary URLs there is what wedges it). Only http(s) selections get this
3815
4844
  * button — every other scheme is refused by that handler.
3816
4845
  */
3817
- const handleOpenLink = (0, react.useCallback)(() => {
3818
- const snapshot = activeRef.current;
3819
- const url = snapshot ? linkTarget(snapshot.text, snapshot.anchorHref) : null;
4846
+ const openLinkSnapshot = (0, react.useCallback)((snapshot) => {
4847
+ const url = linkTarget(snapshot.text, snapshot.anchorHref);
3820
4848
  if (!url) return;
3821
4849
  window.open(url, "_blank", "noopener,noreferrer");
3822
4850
  clearNativeSelection();
3823
4851
  hide();
3824
4852
  }, [hide]);
4853
+ const handleOpenLink = (0, react.useCallback)(() => {
4854
+ const snapshot = activeRef.current;
4855
+ if (snapshot) openLinkSnapshot(snapshot);
4856
+ }, [openLinkSnapshot]);
4857
+ /** Dismissal without an action hands control back to the normal toolbar rules. */
4858
+ const closeMenu = (0, react.useCallback)(() => {
4859
+ if (!dismissMenu()) return;
4860
+ refresh();
4861
+ }, [dismissMenu, refresh]);
4862
+ const handleMenuAction = (0, react.useCallback)((action) => {
4863
+ const current = menuRef.current;
4864
+ if (!current) return;
4865
+ if (action === "add") beginComment(current.snapshot);
4866
+ else if (action === "copy") copySnapshot(current.snapshot);
4867
+ else openLinkSnapshot(current.snapshot);
4868
+ }, [
4869
+ beginComment,
4870
+ copySnapshot,
4871
+ openLinkSnapshot
4872
+ ]);
3825
4873
  /** Close the optional comment step; the annotation stays saved either way. */
3826
4874
  const finishComment = (0, react.useCallback)((keepComment) => {
3827
4875
  if (keepComment) flushComment();
@@ -3839,179 +4887,381 @@ function SelectionToolbar({ sessionId }) {
3839
4887
  flushComment();
3840
4888
  hide();
3841
4889
  }, [flushComment, hide]);
4890
+ const handleCommentKeyDown = (0, react.useCallback)((event) => {
4891
+ if (event.key === "Escape") {
4892
+ event.preventDefault();
4893
+ pendingIdRef.current = null;
4894
+ hide();
4895
+ return;
4896
+ }
4897
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
4898
+ event.preventDefault();
4899
+ finishComment(true);
4900
+ }
4901
+ }, [finishComment, hide]);
3842
4902
  if (!active) return null;
3843
4903
  const toolbarStyle = {
3844
4904
  left: active.left,
3845
4905
  top: active.top
3846
4906
  };
3847
- const actions = [
3848
- ...linkTarget(active.text, active.anchorHref) ? [{
3849
- key: "open",
3850
- icon: "external-link",
3851
- label: t("toolbar.openLink"),
3852
- onClick: handleOpenLink
3853
- }] : [],
3854
- {
3855
- key: "copy",
3856
- icon: "copy",
3857
- label: t("toolbar.copy"),
3858
- onClick: () => void handleCopy()
3859
- },
3860
- {
3861
- key: "add",
3862
- icon: "plus",
3863
- label: t("toolbar.addToTask"),
3864
- onClick: openComment
3865
- }
3866
- ];
3867
- const actionRow = (stacked) => actions.map((action, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [!stacked && index > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3868
- className: styles.divider,
3869
- "aria-hidden": "true"
3870
- }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3871
- type: "button",
3872
- className: cx(A.plain, stacked ? A.col : A.inlineRow, stacked ? styles.touchAction : styles.button),
3873
- onClick: action.onClick,
3874
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3875
- name: action.icon,
3876
- size: stacked ? 18 : 14
3877
- }), stacked ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: action.label }) : action.label]
3878
- })] }, action.key));
3879
- const commentField = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
3880
- ref: commentRef,
3881
- className: cx(A.box, styles.commentInput, touch && styles.touchComment),
3882
- placeholder: t("common.commentPlaceholder"),
3883
- value: comment,
3884
- onChange: (e) => setComment(e.target.value),
3885
- onKeyDown: (e) => {
3886
- if (e.key === "Escape") {
3887
- e.preventDefault();
3888
- pendingIdRef.current = null;
3889
- hide();
3890
- return;
3891
- }
3892
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
3893
- e.preventDefault();
3894
- finishComment(true);
3895
- }
4907
+ const actions = [
4908
+ ...linkTarget(active.text, active.anchorHref) ? [{
4909
+ key: "open",
4910
+ icon: "external-link",
4911
+ label: t("toolbar.openLink"),
4912
+ onClick: handleOpenLink
4913
+ }] : [],
4914
+ {
4915
+ key: "copy",
4916
+ icon: "copy",
4917
+ label: t("toolbar.copy"),
4918
+ onClick: handleCopy
4919
+ },
4920
+ {
4921
+ key: "add",
4922
+ icon: "plus",
4923
+ label: t("toolbar.addToTask"),
4924
+ onClick: openComment
4925
+ }
4926
+ ];
4927
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolbarView, {
4928
+ touch,
4929
+ phase,
4930
+ active,
4931
+ status,
4932
+ viewport,
4933
+ actions,
4934
+ comment,
4935
+ toolbarStyle,
4936
+ toolbarRef,
4937
+ commentWrapRef,
4938
+ commentRef,
4939
+ menuOpen: menu !== null,
4940
+ onToolbarPointerDown: handleToolbarPointerDown,
4941
+ onCommentChange: setComment,
4942
+ onCommentKeyDown: handleCommentKeyDown,
4943
+ onFinishComment: finishComment,
4944
+ onCloseSheet: closeCommentSheet
4945
+ }), menu ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SelectionMenu, {
4946
+ snapshot: menu.snapshot,
4947
+ x: menu.x,
4948
+ y: menu.y,
4949
+ initialFocus: menu.initialFocus,
4950
+ onAction: handleMenuAction,
4951
+ onClose: closeMenu
4952
+ }) : null] });
4953
+ }
4954
+ //#endregion
4955
+ //#region src/client/shortcut-menu.ts
4956
+ const SELECTION_MENU_COMMAND_ID = "select-quote.openMenu";
4957
+ const ADD_TO_TASK_COMMAND_ID = "select-quote.addToTask";
4958
+ /**
4959
+ * Defaults per device profile.
4960
+ *
4961
+ * The two commands never share a binding. Web Linux admits only
4962
+ * `primary+Slash`, `primary+shift+Comma` and `primary+shift+Period`, and the
4963
+ * Host owns `primary+Slash` for its shortcut panel, so the menu takes comma and
4964
+ * add-to-task takes period. Web macOS/Windows accept two-modifier combinations;
4965
+ * theirs avoid the browser's own menu keys. Desktop Windows/Linux also take
4966
+ * period for add-to-task: `Ctrl+Alt+L` is AltGr+L on several layouts, and on
4967
+ * Windows the shell dispatches configured shortcuts before input regions.
4968
+ */
4969
+ const SELECTION_MENU_DEFAULTS = Object.freeze({
4970
+ "desktop:macos": Object.freeze({
4971
+ code: "KeyL",
4972
+ modifiers: Object.freeze(["primary"])
4973
+ }),
4974
+ "desktop:windows": Object.freeze({
4975
+ code: "KeyL",
4976
+ modifiers: Object.freeze(["primary", "shift"])
4977
+ }),
4978
+ "desktop:linux": Object.freeze({
4979
+ code: "KeyL",
4980
+ modifiers: Object.freeze(["primary", "shift"])
4981
+ }),
4982
+ "web:macos": Object.freeze({
4983
+ code: "KeyL",
4984
+ modifiers: Object.freeze(["primary", "shift"])
4985
+ }),
4986
+ "web:windows": Object.freeze({
4987
+ code: "KeyL",
4988
+ modifiers: Object.freeze(["primary", "shift"])
4989
+ }),
4990
+ "web:linux": Object.freeze({
4991
+ code: "Comma",
4992
+ modifiers: Object.freeze(["primary", "shift"])
4993
+ })
4994
+ });
4995
+ const ADD_TO_TASK_DEFAULTS = Object.freeze({
4996
+ "desktop:macos": Object.freeze({
4997
+ code: "KeyL",
4998
+ modifiers: Object.freeze(["primary", "shift"])
4999
+ }),
5000
+ "desktop:windows": Object.freeze({
5001
+ code: "Period",
5002
+ modifiers: Object.freeze(["primary", "shift"])
5003
+ }),
5004
+ "desktop:linux": Object.freeze({
5005
+ code: "Period",
5006
+ modifiers: Object.freeze(["primary", "shift"])
5007
+ }),
5008
+ "web:macos": Object.freeze({
5009
+ code: "KeyL",
5010
+ modifiers: Object.freeze(["primary", "alt"])
5011
+ }),
5012
+ "web:windows": Object.freeze({
5013
+ code: "Period",
5014
+ modifiers: Object.freeze(["primary", "shift"])
5015
+ }),
5016
+ "web:linux": Object.freeze({
5017
+ code: "Period",
5018
+ modifiers: Object.freeze(["primary", "shift"])
5019
+ })
5020
+ });
5021
+ function resolveSelectionAction(action) {
5022
+ const bridge = runtime.selection;
5023
+ if (!bridge) return {
5024
+ status: "blocked",
5025
+ reason: t("menu.noSelection")
5026
+ };
5027
+ const state = bridge.state();
5028
+ if (state === "busy") return {
5029
+ status: "blocked",
5030
+ reason: t("menu.busy")
5031
+ };
5032
+ if (state === "no-selection") return {
5033
+ status: "blocked",
5034
+ reason: t("menu.noSelection")
5035
+ };
5036
+ return {
5037
+ status: "handled",
5038
+ run: () => bridge[action]()
5039
+ };
5040
+ }
5041
+ function selectionMenuCommand() {
5042
+ return {
5043
+ id: SELECTION_MENU_COMMAND_ID,
5044
+ label: () => t("menu.shortcutLabel"),
5045
+ aliases: ["selection menu", "quote menu"],
5046
+ defaults: SELECTION_MENU_DEFAULTS,
5047
+ regions: ["page"],
5048
+ modals: [],
5049
+ resolve: () => resolveSelectionAction("open")
5050
+ };
5051
+ }
5052
+ function addToTaskCommand() {
5053
+ return {
5054
+ id: ADD_TO_TASK_COMMAND_ID,
5055
+ label: () => t("menu.addShortcutLabel"),
5056
+ aliases: ["add to task", "annotate selection"],
5057
+ defaults: ADD_TO_TASK_DEFAULTS,
5058
+ regions: ["page"],
5059
+ modals: [],
5060
+ resolve: () => resolveSelectionAction("add")
5061
+ };
5062
+ }
5063
+ let shortcutRows = [];
5064
+ const shortcutListeners = /* @__PURE__ */ new Set();
5065
+ function getShortcutRows() {
5066
+ return shortcutRows;
5067
+ }
5068
+ function subscribeShortcutRows(listener) {
5069
+ shortcutListeners.add(listener);
5070
+ return () => {
5071
+ shortcutListeners.delete(listener);
5072
+ };
5073
+ }
5074
+ function publishShortcutRows(next) {
5075
+ if (next.length === shortcutRows.length && next.every((row, index) => {
5076
+ const previous = shortcutRows[index];
5077
+ 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]);
5078
+ })) return;
5079
+ shortcutRows = next;
5080
+ for (const listener of [...shortcutListeners]) listener();
5081
+ }
5082
+ /**
5083
+ * Mirror the two live catalog rows. The Host drops a command from the catalog
5084
+ * while it is unregistered, and marks a rebound conflict; the settings page
5085
+ * reads this instead of restating the defaults.
5086
+ */
5087
+ function publishShortcutCatalog(ctx) {
5088
+ const catalog = ctx.shortcuts.catalog.getSnapshot();
5089
+ const next = [];
5090
+ for (const id of [SELECTION_MENU_COMMAND_ID, ADD_TO_TASK_COMMAND_ID]) {
5091
+ const row = catalog.find((candidate) => candidate.id === id);
5092
+ if (!row) continue;
5093
+ next.push({
5094
+ id,
5095
+ label: row.label,
5096
+ keys: row.keys,
5097
+ enabled: row.issue === null && row.conflicts.length === 0
5098
+ });
5099
+ }
5100
+ publishShortcutRows(next);
5101
+ }
5102
+ /** Keep both commands registered exactly while the setting is on. */
5103
+ function installSelectionShortcut(ctx) {
5104
+ const disposers = [];
5105
+ const unregisterAll = () => {
5106
+ while (disposers.length > 0) disposers.pop()?.();
5107
+ };
5108
+ const sync = () => {
5109
+ unregisterAll();
5110
+ if (!getConfig().shortcut) return;
5111
+ for (const command of [selectionMenuCommand(), addToTaskCommand()]) try {
5112
+ disposers.push(ctx.shortcuts.register(command));
5113
+ } catch (error) {
5114
+ console.warn(`[dsh-select-quote] shortcut registration failed for ${command.id}:`, error);
3896
5115
  }
5116
+ };
5117
+ ctx.effect(() => {
5118
+ const syncCatalog = () => publishShortcutCatalog(ctx);
5119
+ syncCatalog();
5120
+ const offCatalog = ctx.shortcuts.catalog.subscribe(syncCatalog);
5121
+ sync();
5122
+ const off = subscribeConfig(sync);
5123
+ return () => {
5124
+ off();
5125
+ offCatalog();
5126
+ unregisterAll();
5127
+ publishShortcutRows([]);
5128
+ };
5129
+ }, "dsh-select-quote: shortcut commands");
5130
+ }
5131
+ //#endregion
5132
+ //#region src/client/settings/ConfigRow.tsx
5133
+ const CHOICES = [
5134
+ "auto",
5135
+ "contextmenu",
5136
+ "both"
5137
+ ];
5138
+ function ConfigRow({ view, form, t: slotTranslate }) {
5139
+ useLocale();
5140
+ if (view !== "page" || form === void 0) return null;
5141
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConfigSection, {
5142
+ form,
5143
+ slotTranslate
3897
5144
  });
3898
- const commentActions = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3899
- className: cx(A.row, styles.commentActions, touch && styles.touchActions),
3900
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3901
- type: "button",
3902
- className: cx(A.plain, A.inlineRow, styles.button, touch && styles.touchSecondary),
3903
- onClick: () => finishComment(false),
3904
- children: t("toolbar.skipComment")
3905
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3906
- type: "button",
3907
- className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
3908
- disabled: !comment.trim(),
3909
- onClick: () => finishComment(true),
3910
- children: t("toolbar.addComment")
3911
- })]
3912
- });
3913
- if (touch) {
3914
- const bottom = `calc(${viewport.inset}px + 8px + env(safe-area-inset-bottom, 0px))`;
3915
- return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3916
- phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3917
- ref: toolbarRef,
3918
- className: cx(A.row, styles.touchBar),
3919
- style: { bottom },
3920
- role: "toolbar",
3921
- "aria-label": t("toolbar.aria"),
3922
- "data-dsq-toolbar": "true",
3923
- "data-touch": "true",
3924
- onPointerDown: handleToolbarPointerDown,
3925
- children: actionRow(true)
3926
- }) : null,
3927
- phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3928
- className: styles.touchScrim,
5145
+ }
5146
+ /**
5147
+ * The bundle config entry gets no `form` prop from the Host (unlike a row
5148
+ * page), so it subscribes to the row's form controller itself. The controller
5149
+ * owns write ordering and revisions; `set` is the one-field convenience.
5150
+ */
5151
+ function ConfigSection({ form, slotTranslate }) {
5152
+ const subscribe = (0, react.useCallback)((listener) => form.subscribe(listener), [form]);
5153
+ const getSnapshot = (0, react.useCallback)(() => form.getSnapshot(), [form]);
5154
+ const current = normalizeConfig((0, react.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot).value);
5155
+ const shortcuts = (0, react.useSyncExternalStore)(subscribeShortcutRows, getShortcutRows, getShortcutRows);
5156
+ const t$1 = slotTranslate ?? t;
5157
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5158
+ className: styles.settings,
5159
+ children: [
5160
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5161
+ className: styles.settingsSection,
5162
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5163
+ className: styles.settingsCopy,
5164
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5165
+ className: styles.settingsLabel,
5166
+ children: t$1("settings.trigger")
5167
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5168
+ className: styles.settingsHint,
5169
+ children: t$1("settings.triggerHint")
5170
+ })]
5171
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5172
+ className: styles.seg,
5173
+ role: "radiogroup",
5174
+ "aria-label": t$1("settings.trigger"),
5175
+ children: CHOICES.map((mode) => {
5176
+ const active = mode === current.triggerMode;
5177
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5178
+ className: styles.segOption,
5179
+ "data-active": active,
5180
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5181
+ type: "radio",
5182
+ className: styles.srOnly,
5183
+ name: "dsq-trigger-mode",
5184
+ value: mode,
5185
+ checked: active,
5186
+ onChange: () => {
5187
+ if (active) return;
5188
+ form.set("triggerMode", mode);
5189
+ }
5190
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t$1(`settings.trigger.${mode}`) })]
5191
+ }, mode);
5192
+ })
5193
+ })]
5194
+ }),
5195
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5196
+ className: styles.settingsDivider,
3929
5197
  "aria-hidden": "true"
3930
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3931
- ref: commentWrapRef,
3932
- className: cx(A.col, styles.touchSheet),
3933
- style: {
3934
- bottom,
3935
- maxHeight: `${Math.max(240, Math.round(viewport.height * .72))}px`
3936
- },
3937
- role: "dialog",
3938
- "aria-label": t("toolbar.commentDialogAria"),
3939
- children: [
3940
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3941
- className: cx(A.row, styles.touchSheetHead),
3942
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3943
- className: styles.touchSheetTitle,
3944
- children: t("toolbar.commentHint")
3945
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3946
- type: "button",
3947
- className: cx(A.plain, A.gridMid, styles.touchSheetClose),
3948
- "aria-label": t("common.close"),
3949
- onClick: closeCommentSheet,
3950
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3951
- name: "x",
3952
- size: 18
3953
- })
3954
- })]
3955
- }),
3956
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3957
- className: styles.touchQuote,
3958
- children: active.text
3959
- }),
3960
- commentField,
3961
- commentActions
3962
- ]
3963
- })] }) : null,
3964
- status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3965
- className: styles.touchStatus,
3966
- style: { bottom: `calc(${viewport.inset}px + 72px + env(safe-area-inset-bottom, 0px))` },
3967
- role: "status",
3968
- children: status
5198
+ }),
5199
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5200
+ className: styles.settingsSwitchRow,
5201
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5202
+ className: styles.settingsCopy,
5203
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5204
+ className: styles.settingsLabel,
5205
+ children: t$1("settings.shortcut")
5206
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5207
+ className: styles.settingsHint,
5208
+ children: t$1("settings.shortcutHint")
5209
+ })]
5210
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5211
+ className: styles.switch,
5212
+ "data-on": current.shortcut,
5213
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5214
+ type: "checkbox",
5215
+ className: styles.srOnly,
5216
+ "aria-label": t$1("settings.shortcut"),
5217
+ checked: current.shortcut,
5218
+ onChange: (event) => {
5219
+ const next = event.target.checked;
5220
+ if (next === current.shortcut) return;
5221
+ form.set("shortcut", next);
5222
+ }
5223
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5224
+ className: styles.switchTrack,
5225
+ "aria-hidden": "true"
5226
+ })]
5227
+ })]
5228
+ }),
5229
+ current.shortcut ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
5230
+ className: styles.settingsShortcuts,
5231
+ "aria-label": t$1("settings.shortcutListAria"),
5232
+ children: shortcuts.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
5233
+ className: styles.settingsHint,
5234
+ children: t$1("settings.shortcutUnavailable")
5235
+ }) : shortcuts.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
5236
+ className: styles.settingsShortcutRow,
5237
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5238
+ className: styles.settingsShortcutName,
5239
+ children: row.label
5240
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5241
+ className: styles.settingsShortcutKeys,
5242
+ children: row.enabled ? row.keys.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("kbd", {
5243
+ className: styles.keycap,
5244
+ children: row.keys.join("")
5245
+ }) : t$1("settings.shortcutUnbound") : t$1("settings.shortcutConflict")
5246
+ })]
5247
+ }, row.id))
3969
5248
  }) : null
3970
- ] }), document.body);
3971
- }
3972
- return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3973
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3974
- ref: toolbarRef,
3975
- className: cx(A.row, styles.toolbar),
3976
- style: phase === "comment" ? void 0 : toolbarStyle,
3977
- role: "toolbar",
3978
- "aria-label": t("toolbar.aria"),
3979
- "data-dsq-toolbar": "true",
3980
- "data-anchor": phase === "comment" ? "popover-top" : active.below ? "below" : "above",
3981
- onPointerDown: handleToolbarPointerDown,
3982
- children: phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3983
- className: cx(A.inlineRow, styles.toolbarInner),
3984
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3985
- style: {
3986
- padding: "0 8px",
3987
- fontSize: 13,
3988
- opacity: .8,
3989
- whiteSpace: "nowrap"
3990
- },
3991
- children: t("toolbar.commentHint")
3992
- })
3993
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3994
- className: cx(A.inlineRow, styles.toolbarInner),
3995
- children: actionRow(false)
3996
- })
3997
- }),
3998
- phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3999
- ref: commentWrapRef,
4000
- className: cx(A.col, styles.commentPop),
4001
- role: "dialog",
4002
- "aria-label": t("toolbar.commentDialogAria"),
4003
- onPointerDown: handleToolbarPointerDown,
4004
- children: [commentField, commentActions]
4005
- }) : null,
4006
- status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4007
- className: styles.status,
4008
- style: toolbarStyle,
4009
- role: "status",
4010
- children: status
4011
- }) : null
4012
- ] }), active.layer.host);
5249
+ ]
5250
+ });
4013
5251
  }
4014
5252
  //#endregion
5253
+ //#region src/shared/identity.ts
5254
+ /**
5255
+ * Names the Loader and the settings layer agree on.
5256
+ *
5257
+ * The row id lives in `cordis.patch.yml` (and the local `dev.patch.yml`); the
5258
+ * Host config form is keyed by it, so renaming either without this constant
5259
+ * would silently orphan the form. The bundle config section is keyed by the
5260
+ * package name instead.
5261
+ */
5262
+ const PACKAGE_NAME = "dsh-select-quote";
5263
+ const ROW_ID = "select-quote";
5264
+ //#endregion
4015
5265
  //#region src/client/transcript/quote-registry.ts
4016
5266
  /**
4017
5267
  * Quotes rendered for a user message, keyed by that message's flow key.
@@ -4069,6 +5319,7 @@ let onScroll = null;
4069
5319
  let onResize = null;
4070
5320
  let leavingBox = null;
4071
5321
  let leavingTimer = 0;
5322
+ let markBindingCleanup = null;
4072
5323
  function cancelTimers() {
4073
5324
  if (showTimer !== 0) window.clearTimeout(showTimer);
4074
5325
  if (hideTimer !== 0) window.clearTimeout(hideTimer);
@@ -4229,12 +5480,14 @@ function jumpToMark(event) {
4229
5480
  }
4230
5481
  if (text) scrollToAnnotation(index, text);
4231
5482
  }
4232
- function buildPreview(index, quoteText, comment) {
5483
+ function buildPreview(index, quoteText, comment, withJump = true) {
4233
5484
  const box = document.createElement("div");
4234
5485
  box.className = cx(A.col, styles.chipPreview);
4235
5486
  box.setAttribute("data-dsq-chip-preview", "true");
4236
5487
  box.setAttribute("role", "dialog");
4237
5488
  box.setAttribute("aria-label", t("chip.previewAria"));
5489
+ box.tabIndex = -1;
5490
+ jumpButton = null;
4238
5491
  const body = document.createElement("div");
4239
5492
  body.className = styles.chipPreviewBody;
4240
5493
  body.style.animation = "none";
@@ -4243,18 +5496,21 @@ function buildPreview(index, quoteText, comment) {
4243
5496
  const title = document.createElement("span");
4244
5497
  title.className = styles.chipPreviewTitle;
4245
5498
  title.textContent = t("chip.annotation", { index });
4246
- jumpButton = document.createElement("button");
4247
- jumpButton.type = "button";
4248
- jumpButton.className = cx(A.plain, A.inlineRow, styles.chipPreviewJump);
4249
- jumpButton.setAttribute("data-dsq-chip-jump", "true");
4250
- jumpButton.setAttribute("aria-label", t("chip.jumpAria"));
4251
- jumpButton.title = t("chip.jumpAria");
4252
- jumpButton.insertAdjacentHTML("afterbegin", JUMP_ICON);
4253
- const jumpLabel = document.createElement("span");
4254
- jumpLabel.textContent = t("chip.jump");
4255
- jumpButton.append(jumpLabel);
4256
- jumpButton.addEventListener("click", jumpToMark);
4257
- head.append(title, jumpButton);
5499
+ head.append(title);
5500
+ if (withJump) {
5501
+ jumpButton = document.createElement("button");
5502
+ jumpButton.type = "button";
5503
+ jumpButton.className = cx(A.plain, A.inlineRow, styles.chipPreviewJump);
5504
+ jumpButton.setAttribute("data-dsq-chip-jump", "true");
5505
+ jumpButton.setAttribute("aria-label", t("chip.jumpAria"));
5506
+ jumpButton.title = t("chip.jumpAria");
5507
+ jumpButton.insertAdjacentHTML("afterbegin", JUMP_ICON);
5508
+ const jumpLabel = document.createElement("span");
5509
+ jumpLabel.textContent = t("chip.jump");
5510
+ jumpButton.append(jumpLabel);
5511
+ jumpButton.addEventListener("click", jumpToMark);
5512
+ head.append(jumpButton);
5513
+ }
4258
5514
  body.append(head);
4259
5515
  appendLabel(body, t("common.selectedText"));
4260
5516
  appendText(body, styles.chipPreviewQuote, quoteText);
@@ -4281,6 +5537,39 @@ function buildPreview(index, quoteText, comment) {
4281
5537
  });
4282
5538
  return box;
4283
5539
  }
5540
+ /** Show and watch one preview; `activeChip` is the element it belongs to. */
5541
+ function mountPreview(box, anchor) {
5542
+ preview = box;
5543
+ box.style.visibility = "hidden";
5544
+ document.body.append(box);
5545
+ const { top, left } = placePopover(anchor, {
5546
+ width: box.offsetWidth || 320,
5547
+ height: box.offsetHeight || 180
5548
+ }, "top", GAP);
5549
+ box.style.top = `${top}px`;
5550
+ box.style.left = `${left}px`;
5551
+ box.style.visibility = "";
5552
+ const animated = box.querySelector(`.${styles.chipPreviewBody}`);
5553
+ if (animated) animated.style.animation = "";
5554
+ onDocPointerDown = (event) => {
5555
+ const target = event.target;
5556
+ if (!(target instanceof Node)) return;
5557
+ if (box.contains(target) || activeChip?.contains(target)) return;
5558
+ closeChipPreview(false);
5559
+ };
5560
+ document.addEventListener("pointerdown", onDocPointerDown, true);
5561
+ onScroll = scheduleReposition;
5562
+ document.addEventListener("scroll", onScroll, true);
5563
+ onResize = scheduleReposition;
5564
+ window.addEventListener("resize", onResize);
5565
+ observer = new MutationObserver(() => {
5566
+ if (activeChip && !activeChip.isConnected) closeChipPreview(false);
5567
+ });
5568
+ observer.observe(document.body, {
5569
+ childList: true,
5570
+ subtree: true
5571
+ });
5572
+ }
4284
5573
  function showChipPreview(chip, index, nextPinned) {
4285
5574
  cancelTimers();
4286
5575
  if (!chip.isConnected) return;
@@ -4314,38 +5603,105 @@ function showChipPreview(chip, index, nextPinned) {
4314
5603
  activeText = text;
4315
5604
  activeIndex = index;
4316
5605
  pinned = nextPinned;
4317
- const box = buildPreview(index, text, quote ? (quote.comment ?? "").trim() : null);
4318
- preview = box;
4319
- box.style.visibility = "hidden";
4320
- document.body.append(box);
4321
- const panel = {
4322
- width: box.offsetWidth || 320,
4323
- height: box.offsetHeight || 180
5606
+ mountPreview(buildPreview(index, text, quote ? (quote.comment ?? "").trim() : null), chip.getBoundingClientRect());
5607
+ }
5608
+ /**
5609
+ * Resolve the annotation behind a transcript mark. Durable marks carry a
5610
+ * `dqsm_` id and read their quote from the user turn's registry; pending marks
5611
+ * carry the store id and read the live list, so a comment edited after
5612
+ * decoration still shows up.
5613
+ */
5614
+ function annotationOfMark(mark) {
5615
+ const id = mark.getAttribute("data-dsq-ann-mark") ?? "";
5616
+ const index = Number(mark.getAttribute("data-dsq-ann-index") ?? 0);
5617
+ if (index <= 0) return null;
5618
+ if (id.startsWith("dqsm_")) {
5619
+ const ownerKey = quotesOwnerOf(mark);
5620
+ const quote = ownerKey ? messageQuotes(ownerKey)?.[index - 1] : void 0;
5621
+ if (!ownerKey || !quote) return null;
5622
+ const text = quote.text.trim();
5623
+ return text ? {
5624
+ text,
5625
+ comment: (quote.comment ?? "").trim(),
5626
+ ownerKey,
5627
+ messageId: quote.messageId ?? null
5628
+ } : null;
5629
+ }
5630
+ const item = getAnnotations().find((annotation) => annotation.id === id);
5631
+ if (!item) return null;
5632
+ const text = item.text.trim();
5633
+ return text ? {
5634
+ text,
5635
+ comment: (item.comment ?? "").trim(),
5636
+ ownerKey: null,
5637
+ messageId: item.sources[0]?.messageId ?? null
5638
+ } : null;
5639
+ }
5640
+ function showMarkPreview(mark, focus) {
5641
+ if (!mark.isConnected) return;
5642
+ const index = Number(mark.getAttribute("data-dsq-ann-index") ?? 0);
5643
+ if (index <= 0) return;
5644
+ if (activeChip === mark && preview) {
5645
+ pinned = true;
5646
+ return;
5647
+ }
5648
+ const data = annotationOfMark(mark);
5649
+ if (!data) return;
5650
+ cancelTimers();
5651
+ closeChipPreview(false);
5652
+ flushLeavingPreview();
5653
+ activeChip = mark;
5654
+ activeMark = mark;
5655
+ activeOwnerKey = data.ownerKey;
5656
+ activeMessageId = data.messageId;
5657
+ activeText = data.text;
5658
+ activeIndex = index;
5659
+ pinned = true;
5660
+ const box = buildPreview(index, data.text, data.comment, false);
5661
+ mountPreview(box, mark.getBoundingClientRect());
5662
+ if (focus) box.focus({ preventScroll: true });
5663
+ }
5664
+ /**
5665
+ * Click/keyboard previews for the ordinal badge fragments in transcript
5666
+ * marks. Delegated on the document: marks are replaced by host re-renders and
5667
+ * binding every fragment would leak listeners across those passes.
5668
+ */
5669
+ function bindAnnotationMarkPreview() {
5670
+ if (typeof document === "undefined") return () => {};
5671
+ if (markBindingCleanup) return markBindingCleanup;
5672
+ const markOf = (target) => {
5673
+ if (!(target instanceof Element)) return null;
5674
+ return target.closest("[data-dsq-ann-mark]");
4324
5675
  };
4325
- const { top, left } = placePopover(chip.getBoundingClientRect(), panel, "top", GAP);
4326
- box.style.top = `${top}px`;
4327
- box.style.left = `${left}px`;
4328
- box.style.visibility = "";
4329
- const animated = box.querySelector(`.${styles.chipPreviewBody}`);
4330
- if (animated) animated.style.animation = "";
4331
- onDocPointerDown = (event) => {
4332
- const target = event.target;
4333
- if (!(target instanceof Node)) return;
4334
- if (box.contains(target) || activeChip?.contains(target)) return;
4335
- closeChipPreview(false);
5676
+ const onClick = (event) => {
5677
+ const mark = markOf(event.target);
5678
+ if (!mark) return;
5679
+ const selection = window.getSelection();
5680
+ if (selection && !selection.isCollapsed) return;
5681
+ showMarkPreview(mark, false);
4336
5682
  };
4337
- document.addEventListener("pointerdown", onDocPointerDown, true);
4338
- onScroll = scheduleReposition;
4339
- document.addEventListener("scroll", onScroll, true);
4340
- onResize = scheduleReposition;
4341
- window.addEventListener("resize", onResize);
4342
- observer = new MutationObserver(() => {
4343
- if (activeChip && !activeChip.isConnected) closeChipPreview(false);
4344
- });
4345
- observer.observe(document.body, {
4346
- childList: true,
4347
- subtree: true
4348
- });
5683
+ const onKeyDown = (event) => {
5684
+ const mark = markOf(event.target);
5685
+ if (!mark) return;
5686
+ if (event.key === "Escape" && preview !== null) {
5687
+ event.preventDefault();
5688
+ closeChipPreview(true);
5689
+ return;
5690
+ }
5691
+ if ((event.key === "Enter" || event.key === " ") && mark.hasAttribute("data-dsq-ann-index")) {
5692
+ event.preventDefault();
5693
+ showMarkPreview(mark, true);
5694
+ }
5695
+ };
5696
+ document.addEventListener("click", onClick);
5697
+ document.addEventListener("keydown", onKeyDown);
5698
+ const cleanup = () => {
5699
+ document.removeEventListener("click", onClick);
5700
+ document.removeEventListener("keydown", onKeyDown);
5701
+ markBindingCleanup = null;
5702
+ };
5703
+ markBindingCleanup = cleanup;
5704
+ return cleanup;
4349
5705
  }
4350
5706
  /** Close the preview. `restoreFocus` is for a deliberate Escape. */
4351
5707
  function closeChipPreview(restoreFocus) {
@@ -5029,9 +6385,26 @@ function apply(ctx) {
5029
6385
  getRevision: () => ctx.locale.getSnapshot().revision,
5030
6386
  subscribe: (listener) => ctx.locale.subscribe(listener)
5031
6387
  });
5032
- ctx.effect(() => subscribeLocale(() => refreshDirectiveChips()), "dsh-select-quote: chip language");
6388
+ ctx.effect(() => subscribeLocale(() => {
6389
+ refreshDirectiveChips();
6390
+ refreshAnnotationMarkLabels();
6391
+ }), "dsh-select-quote: chip language");
6392
+ ctx.inject(["configForms"], (scope) => {
6393
+ scope.effect(() => attachConfigForm(scope.configForms.get(ROW_ID)), "dsh-select-quote: config");
6394
+ const form = scope.configForms.get(ROW_ID);
6395
+ scope.effect(() => scope.configForms.whileServed([ROW_ID], () => scope.slots.inject("plugins.bundle.config", () => scope.slots.register({
6396
+ name: "plugins.bundle.config",
6397
+ key: PACKAGE_NAME,
6398
+ locale: LOCALE_NS,
6399
+ inject: () => ({ form })
6400
+ }, ConfigRow))), "dsh-select-quote: settings section");
6401
+ });
6402
+ ctx.inject(["shortcuts"], (scope) => {
6403
+ installSelectionShortcut(scope);
6404
+ });
5033
6405
  ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
5034
6406
  ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
6407
+ ctx.effect(() => bindAnnotationMarkPreview(), "dsh-select-quote: mark preview");
5035
6408
  ctx.effect(() => watchSubmissionEcho(), "dsh-select-quote: submission echo");
5036
6409
  ctx.effect(() => {
5037
6410
  syncPendingAnnotationJobs(getAnnotations());