dsh-select-quote 0.3.10 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +9 -6
  2. package/lib/client.js +752 -469
  3. package/package.json +5 -3
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.
@@ -1168,7 +1408,9 @@ ${atomRules}
1168
1408
  font-size: 10px;
1169
1409
  font-weight: 600;
1170
1410
  line-height: 1;
1171
- pointer-events: none;
1411
+ /* The only click target on a mark; the prose itself stays selectable. */
1412
+ pointer-events: auto;
1413
+ cursor: pointer;
1172
1414
  }
1173
1415
 
1174
1416
  .dsq_annMark:hover {
@@ -1567,13 +1809,41 @@ const styles = {
1567
1809
  segOption: "dsq_segOption",
1568
1810
  srOnly: "dsq_srOnly"
1569
1811
  };
1570
- //#endregion
1571
- //#region src/client/dom/badge-layout.ts
1812
+ const BADGE_MIN_WIDTH = 15;
1813
+ /** 10px/600 digits plus the 3px side padding. */
1814
+ const BADGE_DIGIT_WIDTH = 6;
1815
+ const BADGE_PADDING = 6;
1816
+ const HIT_SLOP = 3;
1572
1817
  const STEP_X = 28;
1573
1818
  const STEP_Y = 18;
1574
1819
  /** Rest, then two steps right; after that the stack grows upward. */
1575
1820
  const MAX_COLUMNS = 3;
1576
1821
  const MAX_ROWS = 24;
1822
+ /** Rendered badge width, matching `min-width` + digit advance + padding. */
1823
+ function badgeWidth(index) {
1824
+ return Math.max(BADGE_MIN_WIDTH, String(index).length * BADGE_DIGIT_WIDTH + BADGE_PADDING);
1825
+ }
1826
+ /**
1827
+ * Hit box of a badge as painted: anchored to the final fragment's bottom-right
1828
+ * corner, then shifted by the collision offset. Used to tell a badge click from
1829
+ * a click on the marked prose.
1830
+ */
1831
+ function badgeHitBox(anchor, index, offset = {
1832
+ dx: 0,
1833
+ dy: 0
1834
+ }) {
1835
+ const left = anchor.right + 2 + offset.dx;
1836
+ const top = anchor.bottom - 8 - 15 + offset.dy;
1837
+ return {
1838
+ left: left - HIT_SLOP,
1839
+ top: top - HIT_SLOP,
1840
+ right: left + badgeWidth(index) + HIT_SLOP,
1841
+ bottom: top + 15 + HIT_SLOP
1842
+ };
1843
+ }
1844
+ function badgeContainsPoint(box, x, y) {
1845
+ return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
1846
+ }
1577
1847
  function overlaps(a, b) {
1578
1848
  return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
1579
1849
  }
@@ -1593,7 +1863,7 @@ function placeBadges(anchors, bounds = {}) {
1593
1863
  return anchors.map((anchor) => {
1594
1864
  for (let row = 0; row < MAX_ROWS; row += 1) for (let column = 0; column < MAX_COLUMNS; column += 1) {
1595
1865
  const dx = column * STEP_X;
1596
- const dy = -row * STEP_Y;
1866
+ const dy = row === 0 ? 0 : -row * STEP_Y;
1597
1867
  if (!fits(anchor, dx, dy, bounds)) continue;
1598
1868
  const candidate = {
1599
1869
  ...anchor,
@@ -1753,25 +2023,88 @@ function cleanTextOf(item) {
1753
2023
  const MARK_ATTR = "data-dsq-ann-mark";
1754
2024
  const INDEX_ATTR = "data-dsq-ann-index";
1755
2025
  const FLASH_MS = 1500;
1756
- /** Distance from the mark's bottom edge to the badge's bottom edge (CSS). */
1757
- const BADGE_BOTTOM = 8;
1758
- const BADGE_MARGIN = 2;
1759
2026
  const jobs = /* @__PURE__ */ new Map();
1760
2027
  let pendingIds = /* @__PURE__ */ new Set();
1761
2028
  function attrValue(value) {
1762
2029
  return value.replace(/["\\]/g, "\\$&");
1763
2030
  }
1764
- function flowItems(messageId, scope) {
1765
- if (messageId !== "") {
1766
- const item = scope.querySelector(`[data-chat-flow-key="${attrValue(messageId)}"]`);
1767
- return item instanceof HTMLElement ? [item] : [];
2031
+ const FLOW_SELECTOR = "[data-chat-flow-key]";
2032
+ /**
2033
+ * One pass over the rendered messages instead of one query per annotation.
2034
+ * `querySelectorAll` preserves document order, so the first copy of a key wins,
2035
+ * matching the old per-job `querySelector`.
2036
+ */
2037
+ function flowLookup(scope) {
2038
+ const all = [...scope.querySelectorAll(FLOW_SELECTOR)];
2039
+ const byKey = /* @__PURE__ */ new Map();
2040
+ for (const item of all) {
2041
+ const key = item.getAttribute("data-chat-flow-key");
2042
+ if (key && !byKey.has(key)) byKey.set(key, item);
1768
2043
  }
1769
- return [...scope.querySelectorAll("[data-chat-flow-key]")].filter((node) => node instanceof HTMLElement);
2044
+ return {
2045
+ all,
2046
+ byKey
2047
+ };
2048
+ }
2049
+ function flowKeyOf(item) {
2050
+ return item.getAttribute("data-chat-flow-key") ?? "";
1770
2051
  }
1771
2052
  /**
1772
- * Whitespace-insensitive comparison: `selection.toString()` inserts newlines
1773
- * at block boundaries, while the clean-text concatenation has none, so both
1774
- * sides are compared with all whitespace removed.
2053
+ * An annotation's source may sit in a block nested inside its message (for
2054
+ * example a reasoning panel), so a touched flow item answers to its own key
2055
+ * and every enclosing message key.
2056
+ */
2057
+ function anchorKeysOf(item) {
2058
+ const keys = [];
2059
+ for (let el = item; el; el = el.parentElement?.closest(FLOW_SELECTOR) ?? null) {
2060
+ const key = el.getAttribute("data-chat-flow-key");
2061
+ if (key) keys.push(key);
2062
+ }
2063
+ return keys;
2064
+ }
2065
+ /** Items in a partial pass, grouped by flow key; other messages are not touched. */
2066
+ function groupByFlowKey(items) {
2067
+ const grouped = /* @__PURE__ */ new Map();
2068
+ for (const item of items) for (const key of anchorKeysOf(item)) {
2069
+ const list = grouped.get(key) ?? [];
2070
+ list.push(item);
2071
+ grouped.set(key, list);
2072
+ }
2073
+ return grouped;
2074
+ }
2075
+ /** The flow item that owns a mutated node, if any. */
2076
+ function ownerFlowItem(node) {
2077
+ if (!node) return null;
2078
+ if (node instanceof Element) return node.closest(FLOW_SELECTOR);
2079
+ return node.parentElement?.closest(FLOW_SELECTOR) ?? null;
2080
+ }
2081
+ function affectedFlowItems(records) {
2082
+ const items = /* @__PURE__ */ new Set();
2083
+ const addOwner = (node) => {
2084
+ const owner = ownerFlowItem(node);
2085
+ if (owner?.isConnected) items.add(owner);
2086
+ };
2087
+ /** An added wrapper can hold several newly rendered messages. */
2088
+ const addSubtree = (node) => {
2089
+ const owner = ownerFlowItem(node);
2090
+ if (owner) {
2091
+ if (owner.isConnected) items.add(owner);
2092
+ return;
2093
+ }
2094
+ if (!(node instanceof Element)) return;
2095
+ for (const nested of node.querySelectorAll(FLOW_SELECTOR)) if (nested.isConnected) items.add(nested);
2096
+ };
2097
+ for (const record of records) {
2098
+ addOwner(record.target);
2099
+ if (record.type !== "childList") continue;
2100
+ for (const node of record.addedNodes) addSubtree(node);
2101
+ }
2102
+ return items;
2103
+ }
2104
+ /**
2105
+ * Whitespace-insensitive comparison: `selection.toString()` inserts newlines
2106
+ * at block boundaries, while the clean-text concatenation has none, so both
2107
+ * sides are compared with all whitespace removed.
1775
2108
  */
1776
2109
  function sameText(a, b) {
1777
2110
  const norm = (value) => value.replace(/\s+/g, "");
@@ -1782,11 +2115,25 @@ function isMarked(pieces) {
1782
2115
  if (!first) return false;
1783
2116
  return first.entry.node.parentElement?.closest(`[${MARK_ATTR}]`) != null;
1784
2117
  }
2118
+ function markLabel(index) {
2119
+ return t("mark.previewAria", { index });
2120
+ }
2121
+ /** The badge fragment is the keyboard entry into that annotation's preview. */
2122
+ function makeBadgeInteractive(mark, index) {
2123
+ mark.tabIndex = 0;
2124
+ mark.setAttribute("role", "button");
2125
+ mark.setAttribute("aria-haspopup", "dialog");
2126
+ const label = markLabel(index);
2127
+ if (mark.getAttribute("aria-label") !== label) mark.setAttribute("aria-label", label);
2128
+ }
1785
2129
  function createMark(job, withIndex) {
1786
2130
  const mark = document.createElement("span");
1787
2131
  mark.className = styles.annMark;
1788
2132
  mark.setAttribute(MARK_ATTR, job.markId);
1789
- if (withIndex) mark.setAttribute(INDEX_ATTR, String(job.index));
2133
+ if (withIndex) {
2134
+ mark.setAttribute(INDEX_ATTR, String(job.index));
2135
+ makeBadgeInteractive(mark, job.index);
2136
+ }
1790
2137
  return mark;
1791
2138
  }
1792
2139
  /**
@@ -1863,14 +2210,24 @@ function refreshMarkIndex(item, job) {
1863
2210
  const last = marks[marks.length - 1];
1864
2211
  const next = String(job.index);
1865
2212
  if (last.getAttribute(INDEX_ATTR) !== next) last.setAttribute(INDEX_ATTR, next);
2213
+ makeBadgeInteractive(last, job.index);
1866
2214
  return true;
1867
2215
  }
2216
+ /** Refresh the localized labels after a language switch (marks survive renders). */
2217
+ function refreshAnnotationMarkLabels() {
2218
+ if (typeof document === "undefined") return;
2219
+ for (const mark of document.querySelectorAll(`[${INDEX_ATTR}]`)) {
2220
+ const index = Number(mark.getAttribute(INDEX_ATTR) ?? 0);
2221
+ if (index > 0) makeBadgeInteractive(mark, index);
2222
+ }
2223
+ }
1868
2224
  /**
1869
2225
  * Resolve overlapping ordinal badges. Two annotations can end at the same
1870
2226
  * offset (nested ranges), which puts their pseudo-element badges on the same
1871
2227
  * pixel; nearby ends can also touch. Measure every last fragment, then write
1872
2228
  * the offset back as variables inherited by `::after`. The badge's transform
1873
- * never participates in text layout.
2229
+ * never participates in text layout. Collisions are resolved inside each
2230
+ * message block, and a stack may not climb into the message above.
1874
2231
  */
1875
2232
  function layoutBadges(scope) {
1876
2233
  const marks = Array.from(scope.querySelectorAll(`[${INDEX_ATTR}]`));
@@ -1882,37 +2239,57 @@ function layoutBadges(scope) {
1882
2239
  const scroller = marks[0]?.closest("[data-conversation-scroll]");
1883
2240
  const scrollerRect = scroller instanceof Element ? scroller.getBoundingClientRect() : null;
1884
2241
  const rightEdge = scrollerRect && scrollerRect.width > 0 ? scrollerRect.right : document.documentElement.clientWidth;
1885
- const topEdge = scrollerRect && scrollerRect.height > 0 ? scrollerRect.top : 0;
1886
- const anchors = marks.map((mark) => {
1887
- const rect = mark.getBoundingClientRect();
1888
- return {
1889
- mark,
1890
- index: Number(mark.getAttribute(INDEX_ATTR) ?? 0),
1891
- x: rect.right + BADGE_MARGIN,
1892
- y: rect.bottom - BADGE_BOTTOM - 15,
1893
- width: 24,
1894
- height: 15,
1895
- visible: rect.width > 0 && rect.height > 0
1896
- };
1897
- }).filter((anchor) => anchor.visible).sort((a, b) => a.index - b.index);
1898
- if (anchors.length < 2) return;
1899
- const offsets = placeBadges(anchors, {
1900
- right: rightEdge - 4,
1901
- top: topEdge + 4
1902
- });
1903
- anchors.forEach((anchor, i) => {
1904
- const { dx, dy } = offsets[i];
1905
- if (dx !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-x", `${dx}px`);
1906
- if (dy !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-y", `${dy}px`);
1907
- });
2242
+ const scrollerTop = scrollerRect && scrollerRect.height > 0 ? scrollerRect.top : 0;
2243
+ const groups = /* @__PURE__ */ new Map();
2244
+ for (const mark of marks) {
2245
+ const owner = mark.closest(FLOW_SELECTOR);
2246
+ const group = groups.get(owner);
2247
+ if (group) group.push(mark);
2248
+ else groups.set(owner, [mark]);
2249
+ }
2250
+ for (const [owner, group] of groups) {
2251
+ if (group.length < 2) continue;
2252
+ const ownerRect = owner?.getBoundingClientRect();
2253
+ const topEdge = Math.max(scrollerTop, ownerRect && ownerRect.height > 0 ? ownerRect.top : scrollerTop) + 4;
2254
+ const anchors = group.map((mark) => {
2255
+ const rect = mark.getBoundingClientRect();
2256
+ return {
2257
+ mark,
2258
+ index: Number(mark.getAttribute(INDEX_ATTR) ?? 0),
2259
+ x: rect.right + 2,
2260
+ y: rect.bottom - 8 - 15,
2261
+ width: 24,
2262
+ height: 15,
2263
+ visible: rect.width > 0 && rect.height > 0
2264
+ };
2265
+ }).filter((anchor) => anchor.visible).sort((a, b) => a.index - b.index);
2266
+ if (anchors.length < 2) continue;
2267
+ const offsets = placeBadges(anchors, {
2268
+ right: rightEdge - 4,
2269
+ top: topEdge
2270
+ });
2271
+ anchors.forEach((anchor, i) => {
2272
+ const { dx, dy } = offsets[i];
2273
+ if (dx !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-x", `${dx}px`);
2274
+ if (dy !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-y", `${dy}px`);
2275
+ });
2276
+ }
1908
2277
  }
1909
- /** Apply every registered job whose message is currently rendered. */
1910
- function applyAnnotationMarks(root) {
2278
+ /**
2279
+ * Apply registered jobs. With `only`, touch just those rendered messages — the
2280
+ * mutation-driven path — instead of resolving and scanning every message for
2281
+ * every job. Badge collisions live inside one message's text flow, so both
2282
+ * paths resolve them per message.
2283
+ */
2284
+ function applyAnnotationMarks(root, only) {
1911
2285
  if (jobs.size === 0) return;
1912
2286
  const scope = root ?? (typeof document === "undefined" ? void 0 : document.body);
1913
2287
  if (!scope) return;
2288
+ const lookup = only ? null : flowLookup(scope);
2289
+ const onlyByKey = only ? groupByFlowKey(only) : null;
2290
+ const onlyItems = only ? [...only].filter((item) => flowKeyOf(item) !== "") : [];
1914
2291
  for (const job of [...jobs.values()]) {
1915
- const items = flowItems(job.messageId, scope);
2292
+ const items = only ? job.messageId === "" ? onlyItems : onlyByKey?.get(job.messageId) ?? [] : job.messageId === "" ? lookup?.all ?? [] : [lookup?.byKey.get(job.messageId)].filter((item) => item !== void 0);
1916
2293
  let decorated = false;
1917
2294
  for (const item of items) {
1918
2295
  if (refreshMarkIndex(item, job)) {
@@ -1924,7 +2301,11 @@ function applyAnnotationMarks(root) {
1924
2301
  if (decorateJob(item, job)) decorated = true;
1925
2302
  }
1926
2303
  }
1927
- layoutBadges(scope);
2304
+ if (!only) {
2305
+ layoutBadges(scope);
2306
+ return;
2307
+ }
2308
+ for (const item of only) if (item.isConnected) layoutBadges(item);
1928
2309
  }
1929
2310
  /** Insert or refresh jobs; identical jobs are no-ops. */
1930
2311
  function upsertAnnotationJobs(next) {
@@ -1971,26 +2352,46 @@ function syncPendingAnnotationJobs(annotations) {
1971
2352
  endOffset: item.sources[0]?.endOffset
1972
2353
  })));
1973
2354
  }
1974
- /** Observe the transcript and re-apply marks after re-renders. Returns disposer. */
2355
+ /**
2356
+ * Observe the transcript and re-apply marks after re-renders. Mutations are
2357
+ * narrowed to the flow items that actually changed; streaming a reply no
2358
+ * longer walks every annotation in the conversation on each frame. Resizes
2359
+ * still force a full pass because line wrapping moves every badge anchor.
2360
+ */
1975
2361
  function watchAnnotationMarks(root) {
1976
2362
  const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
1977
2363
  if (!el || typeof MutationObserver === "undefined") return () => {};
1978
2364
  applyAnnotationMarks(el);
1979
2365
  let frame = 0;
1980
- const schedule = () => {
1981
- if (jobs.size === 0) return;
1982
- if (frame !== 0) return;
1983
- frame = window.requestAnimationFrame(() => {
1984
- frame = 0;
1985
- applyAnnotationMarks(el);
1986
- });
2366
+ let pending = /* @__PURE__ */ new Set();
2367
+ let needsFull = false;
2368
+ const run = () => {
2369
+ frame = 0;
2370
+ const only = pending;
2371
+ const full = needsFull;
2372
+ pending = /* @__PURE__ */ new Set();
2373
+ needsFull = false;
2374
+ if (full) applyAnnotationMarks(el);
2375
+ else if (only.size > 0) applyAnnotationMarks(el, only);
2376
+ };
2377
+ const schedule = (records) => {
2378
+ if (jobs.size === 0) {
2379
+ pending.clear();
2380
+ needsFull = false;
2381
+ return;
2382
+ }
2383
+ if (records) for (const item of affectedFlowItems(records)) pending.add(item);
2384
+ if (frame === 0) frame = window.requestAnimationFrame(run);
1987
2385
  };
1988
- const observer = new MutationObserver(schedule);
2386
+ const observer = new MutationObserver((records) => schedule(records));
1989
2387
  observer.observe(el, {
1990
2388
  childList: true,
1991
2389
  subtree: true
1992
2390
  });
1993
- const onResize = () => schedule();
2391
+ const onResize = () => {
2392
+ needsFull = true;
2393
+ schedule();
2394
+ };
1994
2395
  window.addEventListener("resize", onResize);
1995
2396
  return () => {
1996
2397
  observer.disconnect();
@@ -2477,387 +2878,150 @@ function clearAnnotations(sessionId) {
2477
2878
  */
2478
2879
  function bindAnnotationSession(sessionId) {
2479
2880
  if (!sessionId || sessionId === runtime.sessionId) return;
2480
- setRuntimeSession(sessionId);
2481
- queueMicrotask(() => adoptPersistedAnnotations(sessionId));
2482
- }
2483
- /**
2484
- * Adopt persisted annotations for a session.
2485
- *
2486
- * In-memory entries win on conflict — they can only be newer, because anything
2487
- * added after the bind is persisted immediately. Annotations parked in the
2488
- * memory-only bucket (added before the bind) move over as well.
2489
- */
2490
- function adoptPersistedAnnotations(sessionId) {
2491
- if (!persistence) return;
2492
- const stored = persistence.load(sessionId);
2493
- const current = bySession.get(sessionId) ?? EMPTY$1;
2494
- const orphaned = bySession.get(MEMORY_KEY) ?? EMPTY$1;
2495
- const merged = /* @__PURE__ */ new Map();
2496
- for (const item of stored) merged.set(item.id, item);
2497
- for (const item of [...current, ...orphaned]) merged.set(item.id, item);
2498
- const next = [...merged.values()];
2499
- if (next.length === current.length && next.every((item, i) => item.id === current[i]?.id) && orphaned.length === 0) return;
2500
- bySession.set(sessionId, next);
2501
- if (orphaned.length > 0) bySession.delete(MEMORY_KEY);
2502
- notify();
2503
- }
2504
- /** Same ids in the same order, with the only mutable field (comment) equal. */
2505
- function sameAnnotations(a, b) {
2506
- return a.length === b.length && a.every((item, i) => {
2507
- const other = b[i];
2508
- return other !== void 0 && item.id === other.id && (item.comment ?? "") === (other.comment ?? "");
2509
- });
2510
- }
2511
- /**
2512
- * Re-read a session after another tab wrote its storage key.
2513
- *
2514
- * Storage is the shared source of truth: every local mutation persists
2515
- * synchronously, so this tab's memory can only be stale relative to it. The
2516
- * stored array replaces the local one (last writer wins) — merging would
2517
- * resurrect annotations the other tab already sent and cleared.
2518
- *
2519
- * `null` is the `localStorage.clear()` event: reload every partition this tab
2520
- * holds. Sessions that are neither cached nor active are ignored; binding the
2521
- * session later reads storage anyway.
2522
- */
2523
- function adoptStorageAnnotations(sessionId) {
2524
- if (!persistence) return;
2525
- if (sessionId === null) {
2526
- let changed = false;
2527
- for (const key of [...bySession.keys()]) {
2528
- if (key === MEMORY_KEY) continue;
2529
- const stored = persistence.load(key);
2530
- const current = bySession.get(key);
2531
- if (stored.length === 0) {
2532
- bySession.delete(key);
2533
- changed = true;
2534
- } else if (current === void 0 || !sameAnnotations(stored, current)) {
2535
- bySession.set(key, [...stored]);
2536
- changed = true;
2537
- }
2538
- }
2539
- if (changed) notify();
2540
- return;
2541
- }
2542
- if (!bySession.has(sessionId) && sessionId !== runtime.sessionId) return;
2543
- const stored = persistence.load(sessionId);
2544
- if (sameAnnotations(stored, bySession.get(sessionId) ?? EMPTY$1)) return;
2545
- if (stored.length === 0) bySession.delete(sessionId);
2546
- else bySession.set(sessionId, [...stored]);
2547
- notify();
2548
- }
2549
- function annotationTitle(annotation) {
2550
- return previewTitle(annotation.text);
2551
- }
2552
- //#endregion
2553
- //#region src/client/dom/popoverPlacement.ts
2554
- const GAP$2 = 8;
2555
- const MARGIN = 8;
2556
- /**
2557
- * Place `panel` near `anchor`, flipping vertical side when the preferred side
2558
- * lacks room. Keeps the panel inside `bounds` (the viewport by default).
2559
- */
2560
- function placePopover(anchor, panel, preferred = "bottom", gap = GAP$2, bounds) {
2561
- const box = bounds ?? {
2562
- top: 0,
2563
- left: 0,
2564
- right: window.innerWidth,
2565
- bottom: window.innerHeight
2566
- };
2567
- const spaceBelow = box.bottom - anchor.bottom;
2568
- const spaceAbove = anchor.top - box.top;
2569
- let side = preferred;
2570
- const need = panel.height + gap;
2571
- if (preferred === "bottom" && spaceBelow < need && spaceAbove > spaceBelow) side = "top";
2572
- else if (preferred === "top" && spaceAbove < need && spaceBelow > spaceAbove) side = "bottom";
2573
- return {
2574
- top: side === "bottom" ? Math.min(anchor.bottom + gap, box.bottom - panel.height - MARGIN) : Math.max(anchor.top - panel.height - gap, box.top + MARGIN),
2575
- left: Math.min(Math.max(anchor.left, box.left + MARGIN), Math.max(box.left + MARGIN, box.right - panel.width - MARGIN)),
2576
- side
2577
- };
2578
- }
2579
- //#endregion
2580
- //#region src/client/ui/icons.tsx
2581
- const SVG_ATTRS = {
2582
- xmlns: "http://www.w3.org/2000/svg",
2583
- viewBox: "0 0 24 24",
2584
- fill: "none",
2585
- stroke: "currentColor",
2586
- strokeWidth: 2,
2587
- strokeLinecap: "round",
2588
- strokeLinejoin: "round",
2589
- "aria-hidden": true,
2590
- focusable: false
2591
- };
2592
- const SHAPES = {
2593
- copy: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
2594
- width: 14,
2595
- height: 14,
2596
- x: 8,
2597
- y: 8,
2598
- rx: 2,
2599
- ry: 2
2600
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" })] }),
2601
- pencil: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m15 5 4 4" })] }),
2602
- "trash-2": /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [
2603
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 11v6" }),
2604
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M14 11v6" }),
2605
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" }),
2606
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h18" }),
2607
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" })
2608
- ] }),
2609
- x: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 6 12 12" })] }),
2610
- plus: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12h14" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 5v14" })] }),
2611
- "external-link": /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [
2612
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M15 3h6v6" }),
2613
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 14 21 3" }),
2614
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" })
2615
- ] })
2616
- };
2617
- function LucideIcon({ name, size = 15 }) {
2618
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2619
- ...SVG_ATTRS,
2620
- width: size,
2621
- height: size,
2622
- children: SHAPES[name]
2623
- });
2624
- }
2625
- //#endregion
2626
- //#region src/client/i18n.ts
2627
- /**
2628
- * Copy for dsh-select-quote, served through the host locale service.
2629
- *
2630
- * The active language is owned by `@deepseek-ai/dsh-client-locale` (Settings →
2631
- * General → Language, persisted as `locale.preference`). This module owns only
2632
- * the dictionaries and the plural form selection that the host's plain-template
2633
- * lookup cannot express; it never resolves a language on its own.
2634
- *
2635
- * NOT localized on purpose:
2636
- * - wire markers (`> [选中文本]`, `<response-annotations>`,
2637
- * `:dsh-annotation{index="N"}`) — they are the message format the model
2638
- * and the transcript parser agree on;
2639
- * - the `# Response annotations:` header — model-facing instruction prose,
2640
- * not UI text.
2641
- */
2642
- const LOCALE_NS = "select-quote";
2643
- const zh = {
2644
- "common.selectedText": "选中的文本",
2645
- "common.commentPlaceholder": "添加可选评论…",
2646
- "common.cancel": "取消",
2647
- "common.close": "关闭",
2648
- "common.save": "保存",
2649
- "toolbar.aria": "划词操作",
2650
- "toolbar.commentHint": "添加批注…",
2651
- "toolbar.copy": "复制",
2652
- "toolbar.openLink": "打开链接",
2653
- "toolbar.copyFailed": "复制失败",
2654
- "toolbar.addToTask": "添加到任务",
2655
- "toolbar.commentDialogAria": "添加可选评论",
2656
- "toolbar.skipComment": "暂不评论",
2657
- "toolbar.addComment": "添加评论",
2658
- "menu.aria": "划词菜单",
2659
- "menu.shortcutLabel": "打开划词菜单",
2660
- "menu.addShortcutLabel": "将选中文字添加到任务",
2661
- "menu.noSelection": "先在对话里选中一段文字",
2662
- "menu.busy": "先完成当前批注",
2663
- "summary.hint": "悬停查看批注",
2664
- "summary.hintTouch": "点按查看批注",
2665
- "summary.dialogAria": "{count} 条划词批注详情",
2666
- "summary.edit": "编辑批注 {index}",
2667
- "summary.remove": "移除批注 {index}",
2668
- "summary.userComment": "用户评论",
2669
- "summary.noComment": "未添加评论",
2670
- "summary.aria.other": "{count} 条划词批注。展开查看详情。",
2671
- "summary.title.other": "{count} 条批注",
2672
- "summary.removeAll": "移除全部划词批注",
2673
- "chip.annotation": "批注 {index}",
2674
- "chip.previewAria": "批注预览",
2675
- "chip.jump": "原文",
2676
- "chip.jumpAria": "跳转到原文",
2677
- "settings.trigger": "划词入口",
2678
- "settings.triggerHint": "选中文字后何时显示操作条。触屏始终使用底部操作条。",
2679
- "settings.trigger.auto": "自动浮出",
2680
- "settings.trigger.contextmenu": "右键菜单",
2681
- "settings.trigger.both": "两者都开",
2682
- "settings.shortcut": "快捷键",
2683
- "settings.shortcutHint": "开启后,选中文字即可使用下面的快捷键;可在宿主的「快捷键」设置里改键。",
2684
- "settings.shortcutListAria": "当前快捷键",
2685
- "settings.shortcutUnavailable": "快捷键暂不可用(可能被其它插件占用)",
2686
- "settings.shortcutUnbound": "未绑定",
2687
- "settings.shortcutConflict": "与其它快捷键冲突"
2688
- };
2689
- const en = {
2690
- "common.selectedText": "Selected text",
2691
- "common.commentPlaceholder": "Add an optional comment…",
2692
- "common.cancel": "Cancel",
2693
- "common.close": "Close",
2694
- "common.save": "Save",
2695
- "toolbar.aria": "Selection actions",
2696
- "toolbar.commentHint": "Add annotation…",
2697
- "toolbar.copy": "Copy",
2698
- "toolbar.openLink": "Open link",
2699
- "toolbar.copyFailed": "Copy failed",
2700
- "toolbar.addToTask": "Add to task",
2701
- "toolbar.commentDialogAria": "Add an optional comment",
2702
- "toolbar.skipComment": "Skip comment",
2703
- "toolbar.addComment": "Add comment",
2704
- "menu.aria": "Selection menu",
2705
- "menu.shortcutLabel": "Open selection menu",
2706
- "menu.addShortcutLabel": "Add selected text to task",
2707
- "menu.noSelection": "Select text in the conversation first",
2708
- "menu.busy": "Finish the current annotation first",
2709
- "summary.hint": "Hover to view annotations",
2710
- "summary.hintTouch": "Tap to view annotations",
2711
- "summary.dialogAria": "Annotation details: {count}",
2712
- "summary.edit": "Edit annotation {index}",
2713
- "summary.remove": "Remove annotation {index}",
2714
- "summary.userComment": "User comment",
2715
- "summary.noComment": "No comment added",
2716
- "summary.aria.one": "{count} annotation. Open to view details.",
2717
- "summary.aria.other": "{count} annotations. Open to view details.",
2718
- "summary.title.one": "{count} annotation",
2719
- "summary.title.other": "{count} annotations",
2720
- "summary.removeAll": "Remove all annotations",
2721
- "chip.annotation": "Annotation {index}",
2722
- "chip.previewAria": "Annotation preview",
2723
- "chip.jump": "Original",
2724
- "chip.jumpAria": "Jump to original",
2725
- "settings.trigger": "Trigger",
2726
- "settings.triggerHint": "When the action bar appears after you select text. Touch screens always use the bottom bar.",
2727
- "settings.trigger.auto": "Automatic",
2728
- "settings.trigger.contextmenu": "Right-click",
2729
- "settings.trigger.both": "Both",
2730
- "settings.shortcut": "Shortcut",
2731
- "settings.shortcutHint": "When on, the shortcuts below work on the selected text. Rebind them in the Host's Keyboard shortcuts settings.",
2732
- "settings.shortcutListAria": "Active shortcuts",
2733
- "settings.shortcutUnavailable": "Shortcuts are unavailable (another plugin may already use the keys).",
2734
- "settings.shortcutUnbound": "Unbound",
2735
- "settings.shortcutConflict": "Conflicts with another shortcut"
2736
- };
2737
- /**
2738
- * Russian is not a shipped host locale: it is added through
2739
- * `ctx.locale.addLanguage()` and its dictionary through the three-argument
2740
- * `register()`. Its fallback chain ends at `en`, as the host requires.
2741
- */
2742
- const ru = {
2743
- "common.selectedText": "Выделенный текст",
2744
- "common.commentPlaceholder": "Добавить необязательный комментарий…",
2745
- "common.cancel": "Отмена",
2746
- "common.close": "Закрыть",
2747
- "common.save": "Сохранить",
2748
- "toolbar.aria": "Действия с выделенным текстом",
2749
- "toolbar.commentHint": "Добавить аннотацию…",
2750
- "toolbar.copy": "Копировать",
2751
- "toolbar.openLink": "Открыть ссылку",
2752
- "toolbar.copyFailed": "Не удалось скопировать",
2753
- "toolbar.addToTask": "Добавить к задаче",
2754
- "toolbar.commentDialogAria": "Добавить необязательный комментарий",
2755
- "toolbar.skipComment": "Без комментария",
2756
- "toolbar.addComment": "Добавить комментарий",
2757
- "menu.aria": "Меню выделения",
2758
- "menu.shortcutLabel": "Открыть меню выделения",
2759
- "menu.addShortcutLabel": "Добавить выделенный текст к задаче",
2760
- "menu.noSelection": "Сначала выделите текст в диалоге",
2761
- "menu.busy": "Сначала завершите текущую аннотацию",
2762
- "summary.hint": "Наведите, чтобы увидеть аннотации",
2763
- "summary.hintTouch": "Нажмите, чтобы увидеть аннотации",
2764
- "summary.dialogAria": "Детали аннотаций: {count}",
2765
- "summary.edit": "Изменить аннотацию {index}",
2766
- "summary.remove": "Удалить аннотацию {index}",
2767
- "summary.userComment": "Комментарий пользователя",
2768
- "summary.noComment": "Комментарий не добавлен",
2769
- "summary.aria.one": "{count} аннотация. Откройте, чтобы увидеть подробности.",
2770
- "summary.aria.few": "{count} аннотации. Откройте, чтобы увидеть подробности.",
2771
- "summary.aria.many": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
2772
- "summary.aria.other": "{count} аннотаций. Откройте, чтобы увидеть подробности.",
2773
- "summary.title.one": "{count} аннотация",
2774
- "summary.title.few": "{count} аннотации",
2775
- "summary.title.many": "{count} аннотаций",
2776
- "summary.title.other": "{count} аннотаций",
2777
- "summary.removeAll": "Удалить все аннотации",
2778
- "chip.annotation": "Аннотация {index}",
2779
- "chip.previewAria": "Просмотр аннотации",
2780
- "chip.jump": "Оригинал",
2781
- "chip.jumpAria": "Перейти к оригиналу",
2782
- "settings.trigger": "Триггер",
2783
- "settings.triggerHint": "Когда показывать панель действий после выделения текста. На сенсорных экранах всегда используется нижняя панель.",
2784
- "settings.trigger.auto": "Автоматически",
2785
- "settings.trigger.contextmenu": "По правому клику",
2786
- "settings.trigger.both": "Оба способа",
2787
- "settings.shortcut": "Горячая клавиша",
2788
- "settings.shortcutHint": "Когда включено, сочетания ниже работают с выделенным текстом. Изменить их можно в настройках горячих клавиш.",
2789
- "settings.shortcutListAria": "Текущие сочетания",
2790
- "settings.shortcutUnavailable": "Сочетания недоступны (возможно, клавиши заняты другим плагином).",
2791
- "settings.shortcutUnbound": "Не назначено",
2792
- "settings.shortcutConflict": "Конфликтует с другим сочетанием"
2793
- };
2794
- /** Both shipped host locales, required together by the typed `register()`. */
2795
- const SHIPPED_DICTS = Object.freeze({
2796
- zh,
2797
- en
2798
- });
2799
- /** Dictionary for the language registered through {@link LANGUAGE_RU}. */
2800
- const DICTIONARIES = Object.freeze({
2801
- zh,
2802
- en,
2803
- ru
2804
- });
2805
- const LANGUAGE_RU = Object.freeze({
2806
- id: "ru",
2807
- label: "Русский",
2808
- fallback: "en"
2809
- });
2810
- let api;
2811
- /**
2812
- * Install the host-bound translation entry point. Called once from `apply()`
2813
- * with `ctx.locale.bind(LOCALE_NS)` and the live snapshot accessors.
2814
- */
2815
- function bindLocale(next) {
2816
- api = next;
2881
+ setRuntimeSession(sessionId);
2882
+ queueMicrotask(() => adoptPersistedAnnotations(sessionId));
2817
2883
  }
2818
- /** Active language id, or `en` before the host service is bound. */
2819
- function activeLocale() {
2820
- return api?.getActive() ?? "en";
2884
+ /**
2885
+ * Adopt persisted annotations for a session.
2886
+ *
2887
+ * In-memory entries win on conflict — they can only be newer, because anything
2888
+ * added after the bind is persisted immediately. Annotations parked in the
2889
+ * memory-only bucket (added before the bind) move over as well.
2890
+ */
2891
+ function adoptPersistedAnnotations(sessionId) {
2892
+ if (!persistence) return;
2893
+ const stored = persistence.load(sessionId);
2894
+ const current = bySession.get(sessionId) ?? EMPTY$1;
2895
+ const orphaned = bySession.get(MEMORY_KEY) ?? EMPTY$1;
2896
+ const merged = /* @__PURE__ */ new Map();
2897
+ for (const item of stored) merged.set(item.id, item);
2898
+ for (const item of [...current, ...orphaned]) merged.set(item.id, item);
2899
+ const next = [...merged.values()];
2900
+ if (next.length === current.length && next.every((item, i) => item.id === current[i]?.id) && orphaned.length === 0) return;
2901
+ bySession.set(sessionId, next);
2902
+ if (orphaned.length > 0) bySession.delete(MEMORY_KEY);
2903
+ notify();
2821
2904
  }
2822
- function pluralForm(count) {
2823
- const locale = activeLocale();
2824
- if (locale === "ru") {
2825
- const mod10 = count % 10;
2826
- const mod100 = count % 100;
2827
- if (mod10 === 1 && mod100 !== 11) return "one";
2828
- if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "few";
2829
- return "many";
2830
- }
2831
- if (locale === "en") return count === 1 ? "one" : "other";
2832
- return "other";
2905
+ /** Same ids in the same order, with the only mutable field (comment) equal. */
2906
+ function sameAnnotations(a, b) {
2907
+ return a.length === b.length && a.every((item, i) => {
2908
+ const other = b[i];
2909
+ return other !== void 0 && item.id === other.id && (item.comment ?? "") === (other.comment ?? "");
2910
+ });
2833
2911
  }
2834
2912
  /**
2835
- * Translate a `select-quote` key. A numeric `count` selects a plural form and
2836
- * the matching `key.form` entry is used, falling back to `key.other`.
2913
+ * Re-read a session after another tab wrote its storage key.
2914
+ *
2915
+ * Storage is the shared source of truth: every local mutation persists
2916
+ * synchronously, so this tab's memory can only be stale relative to it. The
2917
+ * stored array replaces the local one (last writer wins) — merging would
2918
+ * resurrect annotations the other tab already sent and cleared.
2919
+ *
2920
+ * `null` is the `localStorage.clear()` event: reload every partition this tab
2921
+ * holds. Sessions that are neither cached nor active are ignored; binding the
2922
+ * session later reads storage anyway.
2837
2923
  */
2838
- function t(key, params = {}) {
2839
- if (!api) return key;
2840
- if (typeof params.count === "number") {
2841
- const form = pluralForm(params.count);
2842
- const exact = api.translate(`${key}.${form}`, params);
2843
- if (exact !== `${key}.${form}`) return exact;
2844
- const other = api.translate(`${key}.other`, params);
2845
- if (other !== `${key}.other`) return other;
2924
+ function adoptStorageAnnotations(sessionId) {
2925
+ if (!persistence) return;
2926
+ if (sessionId === null) {
2927
+ let changed = false;
2928
+ for (const key of [...bySession.keys()]) {
2929
+ if (key === MEMORY_KEY) continue;
2930
+ const stored = persistence.load(key);
2931
+ const current = bySession.get(key);
2932
+ if (stored.length === 0) {
2933
+ bySession.delete(key);
2934
+ changed = true;
2935
+ } else if (current === void 0 || !sameAnnotations(stored, current)) {
2936
+ bySession.set(key, [...stored]);
2937
+ changed = true;
2938
+ }
2939
+ }
2940
+ if (changed) notify();
2941
+ return;
2846
2942
  }
2847
- return api.translate(key, params);
2943
+ if (!bySession.has(sessionId) && sessionId !== runtime.sessionId) return;
2944
+ const stored = persistence.load(sessionId);
2945
+ if (sameAnnotations(stored, bySession.get(sessionId) ?? EMPTY$1)) return;
2946
+ if (stored.length === 0) bySession.delete(sessionId);
2947
+ else bySession.set(sessionId, [...stored]);
2948
+ notify();
2949
+ }
2950
+ function annotationTitle(annotation) {
2951
+ return previewTitle(annotation.text);
2848
2952
  }
2953
+ //#endregion
2954
+ //#region src/client/dom/popoverPlacement.ts
2955
+ const GAP$2 = 8;
2956
+ const MARGIN = 8;
2849
2957
  /**
2850
- * Subscribe to language or dictionary changes. Components go through
2851
- * `useLocale()` instead; this is the non-React entry point (DOM decorations).
2852
- * Subscribing before the host service is bound is a no-op, so callers subscribe
2853
- * from `apply()`, after `bindLocale()`.
2958
+ * Place `panel` near `anchor`, flipping vertical side when the preferred side
2959
+ * lacks room. Keeps the panel inside `bounds` (the viewport by default).
2854
2960
  */
2855
- function subscribeLocale(listener) {
2856
- return api ? api.subscribe(listener) : () => {};
2961
+ function placePopover(anchor, panel, preferred = "bottom", gap = GAP$2, bounds) {
2962
+ const box = bounds ?? {
2963
+ top: 0,
2964
+ left: 0,
2965
+ right: window.innerWidth,
2966
+ bottom: window.innerHeight
2967
+ };
2968
+ const spaceBelow = box.bottom - anchor.bottom;
2969
+ const spaceAbove = anchor.top - box.top;
2970
+ let side = preferred;
2971
+ const need = panel.height + gap;
2972
+ if (preferred === "bottom" && spaceBelow < need && spaceAbove > spaceBelow) side = "top";
2973
+ else if (preferred === "top" && spaceAbove < need && spaceBelow > spaceAbove) side = "bottom";
2974
+ return {
2975
+ top: side === "bottom" ? Math.min(anchor.bottom + gap, box.bottom - panel.height - MARGIN) : Math.max(anchor.top - panel.height - gap, box.top + MARGIN),
2976
+ left: Math.min(Math.max(anchor.left, box.left + MARGIN), Math.max(box.left + MARGIN, box.right - panel.width - MARGIN)),
2977
+ side
2978
+ };
2857
2979
  }
2858
- /** Monotonic host revision counter, bumped on locale and dictionary changes. */
2859
- function localeRevision() {
2860
- return api?.getRevision() ?? 0;
2980
+ //#endregion
2981
+ //#region src/client/ui/icons.tsx
2982
+ const SVG_ATTRS = {
2983
+ xmlns: "http://www.w3.org/2000/svg",
2984
+ viewBox: "0 0 24 24",
2985
+ fill: "none",
2986
+ stroke: "currentColor",
2987
+ strokeWidth: 2,
2988
+ strokeLinecap: "round",
2989
+ strokeLinejoin: "round",
2990
+ "aria-hidden": true,
2991
+ focusable: false
2992
+ };
2993
+ const SHAPES = {
2994
+ copy: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
2995
+ width: 14,
2996
+ height: 14,
2997
+ x: 8,
2998
+ y: 8,
2999
+ rx: 2,
3000
+ ry: 2
3001
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" })] }),
3002
+ pencil: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m15 5 4 4" })] }),
3003
+ "trash-2": /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [
3004
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 11v6" }),
3005
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M14 11v6" }),
3006
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" }),
3007
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h18" }),
3008
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" })
3009
+ ] }),
3010
+ x: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 6 12 12" })] }),
3011
+ plus: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12h14" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 5v14" })] }),
3012
+ "external-link": /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [
3013
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M15 3h6v6" }),
3014
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 14 21 3" }),
3015
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" })
3016
+ ] })
3017
+ };
3018
+ function LucideIcon({ name, size = 15 }) {
3019
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
3020
+ ...SVG_ATTRS,
3021
+ width: size,
3022
+ height: size,
3023
+ children: SHAPES[name]
3024
+ });
2861
3025
  }
2862
3026
  //#endregion
2863
3027
  //#region src/client/useLocale.ts
@@ -5293,6 +5457,7 @@ let onScroll = null;
5293
5457
  let onResize = null;
5294
5458
  let leavingBox = null;
5295
5459
  let leavingTimer = 0;
5460
+ let markBindingCleanup = null;
5296
5461
  function cancelTimers() {
5297
5462
  if (showTimer !== 0) window.clearTimeout(showTimer);
5298
5463
  if (hideTimer !== 0) window.clearTimeout(hideTimer);
@@ -5453,12 +5618,14 @@ function jumpToMark(event) {
5453
5618
  }
5454
5619
  if (text) scrollToAnnotation(index, text);
5455
5620
  }
5456
- function buildPreview(index, quoteText, comment) {
5621
+ function buildPreview(index, quoteText, comment, withJump = true) {
5457
5622
  const box = document.createElement("div");
5458
5623
  box.className = cx(A.col, styles.chipPreview);
5459
5624
  box.setAttribute("data-dsq-chip-preview", "true");
5460
5625
  box.setAttribute("role", "dialog");
5461
5626
  box.setAttribute("aria-label", t("chip.previewAria"));
5627
+ box.tabIndex = -1;
5628
+ jumpButton = null;
5462
5629
  const body = document.createElement("div");
5463
5630
  body.className = styles.chipPreviewBody;
5464
5631
  body.style.animation = "none";
@@ -5467,18 +5634,21 @@ function buildPreview(index, quoteText, comment) {
5467
5634
  const title = document.createElement("span");
5468
5635
  title.className = styles.chipPreviewTitle;
5469
5636
  title.textContent = t("chip.annotation", { index });
5470
- jumpButton = document.createElement("button");
5471
- jumpButton.type = "button";
5472
- jumpButton.className = cx(A.plain, A.inlineRow, styles.chipPreviewJump);
5473
- jumpButton.setAttribute("data-dsq-chip-jump", "true");
5474
- jumpButton.setAttribute("aria-label", t("chip.jumpAria"));
5475
- jumpButton.title = t("chip.jumpAria");
5476
- jumpButton.insertAdjacentHTML("afterbegin", JUMP_ICON);
5477
- const jumpLabel = document.createElement("span");
5478
- jumpLabel.textContent = t("chip.jump");
5479
- jumpButton.append(jumpLabel);
5480
- jumpButton.addEventListener("click", jumpToMark);
5481
- head.append(title, jumpButton);
5637
+ head.append(title);
5638
+ if (withJump) {
5639
+ jumpButton = document.createElement("button");
5640
+ jumpButton.type = "button";
5641
+ jumpButton.className = cx(A.plain, A.inlineRow, styles.chipPreviewJump);
5642
+ jumpButton.setAttribute("data-dsq-chip-jump", "true");
5643
+ jumpButton.setAttribute("aria-label", t("chip.jumpAria"));
5644
+ jumpButton.title = t("chip.jumpAria");
5645
+ jumpButton.insertAdjacentHTML("afterbegin", JUMP_ICON);
5646
+ const jumpLabel = document.createElement("span");
5647
+ jumpLabel.textContent = t("chip.jump");
5648
+ jumpButton.append(jumpLabel);
5649
+ jumpButton.addEventListener("click", jumpToMark);
5650
+ head.append(jumpButton);
5651
+ }
5482
5652
  body.append(head);
5483
5653
  appendLabel(body, t("common.selectedText"));
5484
5654
  appendText(body, styles.chipPreviewQuote, quoteText);
@@ -5505,6 +5675,39 @@ function buildPreview(index, quoteText, comment) {
5505
5675
  });
5506
5676
  return box;
5507
5677
  }
5678
+ /** Show and watch one preview; `activeChip` is the element it belongs to. */
5679
+ function mountPreview(box, anchor) {
5680
+ preview = box;
5681
+ box.style.visibility = "hidden";
5682
+ document.body.append(box);
5683
+ const { top, left } = placePopover(anchor, {
5684
+ width: box.offsetWidth || 320,
5685
+ height: box.offsetHeight || 180
5686
+ }, "top", GAP);
5687
+ box.style.top = `${top}px`;
5688
+ box.style.left = `${left}px`;
5689
+ box.style.visibility = "";
5690
+ const animated = box.querySelector(`.${styles.chipPreviewBody}`);
5691
+ if (animated) animated.style.animation = "";
5692
+ onDocPointerDown = (event) => {
5693
+ const target = event.target;
5694
+ if (!(target instanceof Node)) return;
5695
+ if (box.contains(target) || activeChip?.contains(target)) return;
5696
+ closeChipPreview(false);
5697
+ };
5698
+ document.addEventListener("pointerdown", onDocPointerDown, true);
5699
+ onScroll = scheduleReposition;
5700
+ document.addEventListener("scroll", onScroll, true);
5701
+ onResize = scheduleReposition;
5702
+ window.addEventListener("resize", onResize);
5703
+ observer = new MutationObserver(() => {
5704
+ if (activeChip && !activeChip.isConnected) closeChipPreview(false);
5705
+ });
5706
+ observer.observe(document.body, {
5707
+ childList: true,
5708
+ subtree: true
5709
+ });
5710
+ }
5508
5711
  function showChipPreview(chip, index, nextPinned) {
5509
5712
  cancelTimers();
5510
5713
  if (!chip.isConnected) return;
@@ -5538,38 +5741,114 @@ function showChipPreview(chip, index, nextPinned) {
5538
5741
  activeText = text;
5539
5742
  activeIndex = index;
5540
5743
  pinned = nextPinned;
5541
- const box = buildPreview(index, text, quote ? (quote.comment ?? "").trim() : null);
5542
- preview = box;
5543
- box.style.visibility = "hidden";
5544
- document.body.append(box);
5545
- const panel = {
5546
- width: box.offsetWidth || 320,
5547
- height: box.offsetHeight || 180
5744
+ mountPreview(buildPreview(index, text, quote ? (quote.comment ?? "").trim() : null), chip.getBoundingClientRect());
5745
+ }
5746
+ /**
5747
+ * Resolve the annotation behind a transcript mark. Durable marks carry a
5748
+ * `dqsm_` id and read their quote from the user turn's registry; pending marks
5749
+ * carry the store id and read the live list, so a comment edited after
5750
+ * decoration still shows up.
5751
+ */
5752
+ function annotationOfMark(mark) {
5753
+ const id = mark.getAttribute("data-dsq-ann-mark") ?? "";
5754
+ const index = Number(mark.getAttribute("data-dsq-ann-index") ?? 0);
5755
+ if (index <= 0) return null;
5756
+ if (id.startsWith("dqsm_")) {
5757
+ const ownerKey = quotesOwnerOf(mark);
5758
+ const quote = ownerKey ? messageQuotes(ownerKey)?.[index - 1] : void 0;
5759
+ if (!ownerKey || !quote) return null;
5760
+ const text = quote.text.trim();
5761
+ return text ? {
5762
+ text,
5763
+ comment: (quote.comment ?? "").trim(),
5764
+ ownerKey,
5765
+ messageId: quote.messageId ?? null
5766
+ } : null;
5767
+ }
5768
+ const item = getAnnotations().find((annotation) => annotation.id === id);
5769
+ if (!item) return null;
5770
+ const text = item.text.trim();
5771
+ return text ? {
5772
+ text,
5773
+ comment: (item.comment ?? "").trim(),
5774
+ ownerKey: null,
5775
+ messageId: item.sources[0]?.messageId ?? null
5776
+ } : null;
5777
+ }
5778
+ function showMarkPreview(mark, focus) {
5779
+ if (!mark.isConnected) return;
5780
+ const index = Number(mark.getAttribute("data-dsq-ann-index") ?? 0);
5781
+ if (index <= 0) return;
5782
+ if (activeChip === mark && preview) {
5783
+ pinned = true;
5784
+ return;
5785
+ }
5786
+ const data = annotationOfMark(mark);
5787
+ if (!data) return;
5788
+ cancelTimers();
5789
+ closeChipPreview(false);
5790
+ flushLeavingPreview();
5791
+ activeChip = mark;
5792
+ activeMark = mark;
5793
+ activeOwnerKey = data.ownerKey;
5794
+ activeMessageId = data.messageId;
5795
+ activeText = data.text;
5796
+ activeIndex = index;
5797
+ pinned = true;
5798
+ const box = buildPreview(index, data.text, data.comment, false);
5799
+ mountPreview(box, mark.getBoundingClientRect());
5800
+ if (focus) box.focus({ preventScroll: true });
5801
+ }
5802
+ /**
5803
+ * Click/keyboard previews for the ordinal badge fragments in transcript
5804
+ * marks. Delegated on the document: marks are replaced by host re-renders and
5805
+ * binding every fragment would leak listeners across those passes.
5806
+ */
5807
+ function bindAnnotationMarkPreview() {
5808
+ if (typeof document === "undefined") return () => {};
5809
+ if (markBindingCleanup) return markBindingCleanup;
5810
+ const markOf = (target) => {
5811
+ if (!(target instanceof Element)) return null;
5812
+ return target.closest("[data-dsq-ann-mark]");
5548
5813
  };
5549
- const { top, left } = placePopover(chip.getBoundingClientRect(), panel, "top", GAP);
5550
- box.style.top = `${top}px`;
5551
- box.style.left = `${left}px`;
5552
- box.style.visibility = "";
5553
- const animated = box.querySelector(`.${styles.chipPreviewBody}`);
5554
- if (animated) animated.style.animation = "";
5555
- onDocPointerDown = (event) => {
5556
- const target = event.target;
5557
- if (!(target instanceof Node)) return;
5558
- if (box.contains(target) || activeChip?.contains(target)) return;
5559
- closeChipPreview(false);
5814
+ const onClick = (event) => {
5815
+ const mark = markOf(event.target);
5816
+ if (!mark) return;
5817
+ const index = Number(mark.getAttribute("data-dsq-ann-index"));
5818
+ if (!Number.isFinite(index) || index <= 0) return;
5819
+ if (event.detail !== 0) {
5820
+ const offset = {
5821
+ dx: Number.parseFloat(mark.style.getPropertyValue("--dsq-ann-badge-x")) || 0,
5822
+ dy: Number.parseFloat(mark.style.getPropertyValue("--dsq-ann-badge-y")) || 0
5823
+ };
5824
+ if (!badgeContainsPoint(badgeHitBox(mark.getBoundingClientRect(), index, offset), event.clientX, event.clientY)) return;
5825
+ }
5826
+ const selection = window.getSelection();
5827
+ if (selection && !selection.isCollapsed) return;
5828
+ showMarkPreview(mark, false);
5560
5829
  };
5561
- document.addEventListener("pointerdown", onDocPointerDown, true);
5562
- onScroll = scheduleReposition;
5563
- document.addEventListener("scroll", onScroll, true);
5564
- onResize = scheduleReposition;
5565
- window.addEventListener("resize", onResize);
5566
- observer = new MutationObserver(() => {
5567
- if (activeChip && !activeChip.isConnected) closeChipPreview(false);
5568
- });
5569
- observer.observe(document.body, {
5570
- childList: true,
5571
- subtree: true
5572
- });
5830
+ const onKeyDown = (event) => {
5831
+ const mark = markOf(event.target);
5832
+ if (!mark) return;
5833
+ if (event.key === "Escape" && preview !== null) {
5834
+ event.preventDefault();
5835
+ closeChipPreview(true);
5836
+ return;
5837
+ }
5838
+ if ((event.key === "Enter" || event.key === " ") && mark.hasAttribute("data-dsq-ann-index")) {
5839
+ event.preventDefault();
5840
+ showMarkPreview(mark, true);
5841
+ }
5842
+ };
5843
+ document.addEventListener("click", onClick);
5844
+ document.addEventListener("keydown", onKeyDown);
5845
+ const cleanup = () => {
5846
+ document.removeEventListener("click", onClick);
5847
+ document.removeEventListener("keydown", onKeyDown);
5848
+ markBindingCleanup = null;
5849
+ };
5850
+ markBindingCleanup = cleanup;
5851
+ return cleanup;
5573
5852
  }
5574
5853
  /** Close the preview. `restoreFocus` is for a deliberate Escape. */
5575
5854
  function closeChipPreview(restoreFocus) {
@@ -6253,7 +6532,10 @@ function apply(ctx) {
6253
6532
  getRevision: () => ctx.locale.getSnapshot().revision,
6254
6533
  subscribe: (listener) => ctx.locale.subscribe(listener)
6255
6534
  });
6256
- ctx.effect(() => subscribeLocale(() => refreshDirectiveChips()), "dsh-select-quote: chip language");
6535
+ ctx.effect(() => subscribeLocale(() => {
6536
+ refreshDirectiveChips();
6537
+ refreshAnnotationMarkLabels();
6538
+ }), "dsh-select-quote: chip language");
6257
6539
  ctx.inject(["configForms"], (scope) => {
6258
6540
  scope.effect(() => attachConfigForm(scope.configForms.get(ROW_ID)), "dsh-select-quote: config");
6259
6541
  const form = scope.configForms.get(ROW_ID);
@@ -6269,6 +6551,7 @@ function apply(ctx) {
6269
6551
  });
6270
6552
  ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
6271
6553
  ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
6554
+ ctx.effect(() => bindAnnotationMarkPreview(), "dsh-select-quote: mark preview");
6272
6555
  ctx.effect(() => watchSubmissionEcho(), "dsh-select-quote: submission echo");
6273
6556
  ctx.effect(() => {
6274
6557
  syncPendingAnnotationJobs(getAnnotations());