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.
- package/README.md +40 -8
- package/lib/client.js +2000 -627
- package/lib/index.js +911 -2
- 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
|
-
两个坑,改之前先读:①
|
|
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
|
|
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
|
-
/*
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
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)
|
|
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$
|
|
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$
|
|
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/
|
|
2889
|
+
//#region src/client/useLocale.ts
|
|
2234
2890
|
/**
|
|
2235
|
-
*
|
|
2236
|
-
*
|
|
2237
|
-
*
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
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
|
-
*
|
|
2243
|
-
*
|
|
2244
|
-
*
|
|
2245
|
-
*
|
|
2246
|
-
*
|
|
2247
|
-
*
|
|
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/
|
|
3498
|
-
/**
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
const
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
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
|
-
|
|
3512
|
-
const
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
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
|
|
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
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
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
|
-
|
|
3692
|
-
refresh
|
|
4723
|
+
touch
|
|
3693
4724
|
]);
|
|
3694
|
-
|
|
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
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
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
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
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
|
-
|
|
3745
|
-
|
|
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
|
-
|
|
3786
|
-
|
|
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
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
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
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
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
|
|
3818
|
-
const
|
|
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:
|
|
3859
|
-
},
|
|
3860
|
-
{
|
|
3861
|
-
key: "add",
|
|
3862
|
-
icon: "plus",
|
|
3863
|
-
label: t("toolbar.addToTask"),
|
|
3864
|
-
onClick: openComment
|
|
3865
|
-
}
|
|
3866
|
-
];
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
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
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
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
|
-
}),
|
|
3931
|
-
|
|
3932
|
-
className:
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
}
|
|
3955
|
-
}),
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
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
|
-
]
|
|
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
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
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
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
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
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
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
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
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(() =>
|
|
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());
|