dsh-select-quote 0.3.9 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -110,6 +110,12 @@ body {
110
110
  --dsq-font-delta: var(--dsh-content-font-delta, 0px);
111
111
  --dsq-bg-raised: var(--dsw-alias-bg-layer-1, #fff);
112
112
  --dsq-bg-toast: var(--dsw-alias-bg-layer-3, rgba(0, 0, 0, 0.78));
113
+ /* The settings switch mirrors the Host's own switch: neutral track when off,
114
+ brand fill when on, and a thumb that inverts with the theme. */
115
+ --dsq-switch-off: var(--dsw-alias-border-l3, rgba(0, 0, 0, 0.12));
116
+ --dsq-switch-on: var(--dsw-alias-brand-primary, #0f1115);
117
+ --dsq-switch-thumb-off: var(--dsw-alias-switch-thumb, #fff);
118
+ --dsq-switch-thumb-on: var(--dsw-alias-label-primary-foreground, #fff);
113
119
  --dsq-line: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
114
120
  --dsq-line-divider: var(--dsw-alias-border-l4, rgba(0, 0, 0, 0.12));
115
121
  --dsq-focus-ring: var(--dsw-alias-button-primary-fill, #1677ff);
@@ -178,6 +184,26 @@ ${atomRules}
178
184
  }
179
185
  }
180
186
 
187
+ /* Right-click / shortcut menu: transform only, for the same reason as the
188
+ toolbar — an animated opacity would cut the material layer's backdrop. */
189
+ @keyframes dsq-menu-in {
190
+ from {
191
+ transform: translateY(4px) scale(0.97);
192
+ }
193
+ to {
194
+ transform: translateY(0) scale(1);
195
+ }
196
+ }
197
+
198
+ @keyframes dsq-menu-in-up {
199
+ from {
200
+ transform: translateY(-4px) scale(0.97);
201
+ }
202
+ to {
203
+ transform: translateY(0) scale(1);
204
+ }
205
+ }
206
+
181
207
  /* 内容层的 rise:悬停面板的列表在用;工具条改用容器 scale 后这条在这里待命。 */
182
208
  @keyframes dsq-rise-in {
183
209
  from {
@@ -202,14 +228,15 @@ ${atomRules}
202
228
  /* 宿主菜单材质(官方 material 图层同款):填充走 --dsw-menu-surface-fill,模糊走
203
229
  --dsw-menu-backdrop-filter(恒为 blur(40px) saturate(150%)),亮暗自动翻。
204
230
  画在 z-index:-1 的独立图层上,内容在它之上:模糊只取身后的页面,不会把
205
- 面板里的文字一起糊掉。工具条、评论气泡、悬停面板三个浮层共用这一条。
231
+ 面板里的文字一起糊掉。所有浮层共用这一条。
206
232
  注意:macOS 上宿主另有一个 --dsw-specific-menu(覆写成 94% 不透明)给它的
207
233
  菜单用;这里按需求用 surface-fill,所以我们的浮层会比宿主自己的菜单更透。 */
208
234
  .dsq_toolbar::before,
209
235
  .dsq_commentPop::before,
210
236
  .dsq_summaryHoverInner::before,
211
237
  .dsq_touchBar::before,
212
- .dsq_chipPreview::before {
238
+ .dsq_chipPreview::before,
239
+ .dsq_ctxMenu::before {
213
240
  content: "";
214
241
  position: absolute;
215
242
  inset: 0;
@@ -224,7 +251,7 @@ ${atomRules}
224
251
  /* 覆层不描边:elevation-prominent 自带的那圈 0 0 0 .5px(--dsw-elevation-stroke-color,
225
252
  默认 border-l4)对磨砂覆层是多余的——边缘自带材质分界,再勾一道反而抢视觉,覆写成
226
253
  更淡的 border-l1。
227
- 两个坑,改之前先读:① 这两行必须写在三个浮层自己的选择器上。写在 body 上会随继承
254
+ 两个坑,改之前先读:① 这两行必须写在浮层自己的选择器上。写在 body 上会随继承
228
255
  影响页面里其它依赖默认值的面板(宿主带 [data-menu-material] 的菜单有自己的覆写,
229
256
  不受影响)。② --dsq-shadow 也必须在这儿声明,不能留在 body:自定义属性是在**声明处**
230
257
  展开的,--dsq-shadow 留在 body 上时 var(--dsw-elevation-prominent) 会在 body 就被
@@ -235,11 +262,12 @@ ${atomRules}
235
262
  .dsq_summaryHoverInner,
236
263
  .dsq_touchBar,
237
264
  .dsq_touchSheet,
238
- .dsq_chipPreview {
265
+ .dsq_chipPreview,
266
+ .dsq_ctxMenu {
239
267
  --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
240
268
  --dsq-shadow: var(--dsw-elevation-prominent, 0 0 0 .5px rgba(0, 0, 0, 0.04), 0 3px 8px rgba(0, 0, 0, 0.04), 0 0 20px rgba(0, 0, 0, 0.05));
241
269
  /* Every surface above takes the same elevation; declaring it once here is
242
- what keeps the six component blocks free of a copied box-shadow. */
270
+ what keeps the component blocks free of a copied box-shadow. */
243
271
  box-shadow: var(--dsq-shadow);
244
272
  }
245
273
 
@@ -369,6 +397,60 @@ ${atomRules}
369
397
  transform: translate(-50%, 8px);
370
398
  }
371
399
 
400
+ /* Right-click / shortcut menu. Body-level fixed: it must escape the
401
+ transcript's clipping. It closes on scroll instead of following the text,
402
+ so it never points at a line that has moved away. */
403
+ .dsq_ctxMenu {
404
+ position: fixed;
405
+ z-index: 64;
406
+ width: max-content;
407
+ min-width: 172px;
408
+ max-width: min(280px, calc(100vw - 16px));
409
+ padding: 4px;
410
+ border: 0;
411
+ border-radius: 12px;
412
+ outline: none;
413
+ animation: dsq-menu-in 0.14s cubic-bezier(0.2, 0.8, 0.2, 1);
414
+ transform-origin: top left;
415
+ }
416
+
417
+ .dsq_ctxMenu[data-side='top'] {
418
+ animation-name: dsq-menu-in-up;
419
+ transform-origin: bottom left;
420
+ }
421
+
422
+ .dsq_ctxItem {
423
+ width: 100%;
424
+ gap: 8px;
425
+ padding: 7px 10px;
426
+ border-radius: 8px;
427
+ color: var(--dsq-label);
428
+ font: inherit;
429
+ font-size: 13px;
430
+ line-height: 18px;
431
+ text-align: left;
432
+ white-space: nowrap;
433
+ }
434
+
435
+ .dsq_ctxItem svg {
436
+ flex: none;
437
+ color: var(--dsq-label-2);
438
+ }
439
+
440
+ .dsq_ctxItem:hover,
441
+ .dsq_ctxItem:focus {
442
+ background: var(--dsq-fill-hover);
443
+ }
444
+
445
+ .dsq_ctxItem:focus {
446
+ outline: none;
447
+ }
448
+
449
+ .dsq_ctxItem:focus-visible {
450
+ outline: 2px solid var(--dsq-focus-ring);
451
+ outline-offset: -2px;
452
+ }
453
+
372
454
  /* Annotation stack — inside the composer card (input.overlay), ~1/5 width. */
373
455
  .dsq_cardStack {
374
456
  position: absolute;
@@ -537,7 +619,8 @@ ${atomRules}
537
619
  .dsq_commentPop::before,
538
620
  .dsq_summaryHoverInner::before,
539
621
  .dsq_touchBar::before,
540
- .dsq_chipPreview::before {
622
+ .dsq_chipPreview::before,
623
+ .dsq_ctxMenu::before {
541
624
  background: var(--dsq-bg-raised);
542
625
  backdrop-filter: none;
543
626
  -webkit-backdrop-filter: none;
@@ -1060,12 +1143,19 @@ ${atomRules}
1060
1143
  Absolute so the badge adds no inline advance width: annotating never reflows
1061
1144
  the paragraph or moves following words. Bottom (not top) anchors to the last
1062
1145
  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. */
1146
+ of the first and last fragments, so its bottom edge is the last line.
1147
+ BADGE_BOTTOM / BADGE_HEIGHT / the 2px margin in annotate-text.ts mirror the
1148
+ numbers below for collision measurement. */
1064
1149
  .dsq_annMark[data-dsq-ann-index]::after {
1065
1150
  content: attr(data-dsq-ann-index);
1066
1151
  position: absolute;
1067
1152
  bottom: 8px;
1068
1153
  left: 100%;
1154
+ /* Above the native selection highlight and the background of a later mark:
1155
+ both otherwise paint over the badge when their ranges meet. */
1156
+ z-index: 2;
1157
+ /* JS shifts colliding badges right/up; the resting position is unchanged. */
1158
+ transform: translate(var(--dsq-ann-badge-x, 0px), var(--dsq-ann-badge-y, 0px));
1069
1159
  display: grid;
1070
1160
  place-items: center;
1071
1161
  min-width: 15px;
@@ -1148,6 +1238,202 @@ ${atomRules}
1148
1238
  .dsq_chipPreviewJump {
1149
1239
  min-height: 44px;
1150
1240
  }
1241
+ .dsq_segOption {
1242
+ min-height: 44px;
1243
+ }
1244
+ }
1245
+
1246
+ /* Settings row (plugin page): an inline segmented control. The page owns the
1247
+ write, so there is no save button — each pick is committed immediately. */
1248
+ .dsq_settings {
1249
+ display: flex;
1250
+ flex-direction: column;
1251
+ gap: 10px;
1252
+ padding: 2px 0;
1253
+ }
1254
+
1255
+ .dsq_settingsSection {
1256
+ display: flex;
1257
+ flex-direction: column;
1258
+ gap: 10px;
1259
+ }
1260
+
1261
+ .dsq_settingsCopy {
1262
+ display: flex;
1263
+ flex-direction: column;
1264
+ gap: 3px;
1265
+ }
1266
+
1267
+ .dsq_settingsLabel {
1268
+ color: var(--dsq-label);
1269
+ font-size: 13px;
1270
+ font-weight: 500;
1271
+ }
1272
+
1273
+ .dsq_settingsHint {
1274
+ color: var(--dsq-label-3);
1275
+ font-size: 12px;
1276
+ line-height: 1.5;
1277
+ }
1278
+
1279
+ /* Shortcut reference under the switch: the two commands and the keycaps the
1280
+ Host currently has in effect, so a rebind shows up here without a reload. */
1281
+ .dsq_settingsShortcuts {
1282
+ display: flex;
1283
+ flex-direction: column;
1284
+ gap: 6px;
1285
+ margin: 0;
1286
+ padding: 0;
1287
+ list-style: none;
1288
+ }
1289
+
1290
+ .dsq_settingsShortcutRow {
1291
+ display: flex;
1292
+ align-items: center;
1293
+ justify-content: space-between;
1294
+ gap: 12px;
1295
+ min-height: 22px;
1296
+ }
1297
+
1298
+ .dsq_settingsShortcutName {
1299
+ min-width: 0;
1300
+ color: var(--dsq-label-2);
1301
+ font-size: 12px;
1302
+ }
1303
+
1304
+ .dsq_settingsShortcutKeys {
1305
+ flex: none;
1306
+ color: var(--dsq-label-3);
1307
+ font-size: 12px;
1308
+ }
1309
+
1310
+ .dsq_keycap {
1311
+ display: inline-block;
1312
+ min-width: 18px;
1313
+ padding: 1px 6px;
1314
+ border: 1px solid var(--dsq-line);
1315
+ border-radius: 5px;
1316
+ background: var(--dsq-fill-hover);
1317
+ color: var(--dsq-label-2);
1318
+ font: inherit;
1319
+ font-size: 11px;
1320
+ line-height: 18px;
1321
+ text-align: center;
1322
+ white-space: nowrap;
1323
+ }
1324
+
1325
+ .dsq_seg {
1326
+ display: inline-flex;
1327
+ align-self: flex-start;
1328
+ gap: 2px;
1329
+ padding: 2px;
1330
+ border-radius: 8px;
1331
+ background: var(--dsq-fill-hover);
1332
+ }
1333
+
1334
+ .dsq_segOption {
1335
+ position: relative;
1336
+ display: inline-flex;
1337
+ align-items: center;
1338
+ justify-content: center;
1339
+ height: 28px;
1340
+ padding: 0 12px;
1341
+ border-radius: 6px;
1342
+ color: var(--dsq-label-2);
1343
+ font-size: 13px;
1344
+ cursor: pointer;
1345
+ transition: background-color 0.12s ease, color 0.12s ease;
1346
+ }
1347
+
1348
+ .dsq_segOption:hover {
1349
+ color: var(--dsq-label);
1350
+ }
1351
+
1352
+ .dsq_segOption[data-active="true"] {
1353
+ background: var(--dsq-bg-raised);
1354
+ box-shadow: 0 0 0 0.5px var(--dsq-line);
1355
+ color: var(--dsq-label);
1356
+ }
1357
+
1358
+ .dsq_segOption:has(:focus-visible) {
1359
+ outline: 2px solid var(--dsq-focus-ring-soft);
1360
+ outline-offset: 1px;
1361
+ }
1362
+
1363
+ .dsq_settingsDivider {
1364
+ height: 1px;
1365
+ background: var(--dsq-line);
1366
+ }
1367
+
1368
+ /* The whole row is the target: label text, hint, and the switch itself. */
1369
+ .dsq_settingsSwitchRow {
1370
+ display: flex;
1371
+ align-items: center;
1372
+ justify-content: space-between;
1373
+ gap: 16px;
1374
+ cursor: pointer;
1375
+ }
1376
+
1377
+ .dsq_settingsSwitchRow .dsq_settingsCopy {
1378
+ flex: 1;
1379
+ min-width: 0;
1380
+ }
1381
+
1382
+ /* Exactly the Host switch metrics: 36×20 track, 16px thumb, 2px inset, and
1383
+ the same on/off tokens. The thumb only animates its transform, like the
1384
+ product control; the track changes color in one step. */
1385
+ .dsq_switch {
1386
+ box-sizing: border-box;
1387
+ position: relative;
1388
+ flex: none;
1389
+ width: 36px;
1390
+ height: 20px;
1391
+ padding: 2px;
1392
+ border: 0;
1393
+ border-radius: 999px;
1394
+ corner-shape: round;
1395
+ background: var(--dsq-switch-off);
1396
+ cursor: pointer;
1397
+ }
1398
+
1399
+ .dsq_switch[data-on='true'] {
1400
+ background: var(--dsq-switch-on);
1401
+ }
1402
+
1403
+ .dsq_switchTrack {
1404
+ position: absolute;
1405
+ top: 2px;
1406
+ left: 2px;
1407
+ width: 16px;
1408
+ height: 16px;
1409
+ border-radius: 50%;
1410
+ corner-shape: round;
1411
+ background: var(--dsq-switch-thumb-off);
1412
+ transition: transform 0.12s ease;
1413
+ }
1414
+
1415
+ .dsq_switch[data-on='true'] .dsq_switchTrack {
1416
+ background: var(--dsq-switch-thumb-on);
1417
+ transform: translateX(16px);
1418
+ }
1419
+
1420
+ .dsq_switch:has(:focus-visible) {
1421
+ outline: 2px solid var(--dsq-focus-ring-soft);
1422
+ outline-offset: 2px;
1423
+ }
1424
+
1425
+ /* Visually hidden but still focusable: the native radio/checkbox carries the
1426
+ semantics and keyboard behavior, the label carries the paint. */
1427
+ .dsq_srOnly {
1428
+ position: absolute;
1429
+ width: 1px;
1430
+ height: 1px;
1431
+ margin: -1px;
1432
+ padding: 0;
1433
+ overflow: hidden;
1434
+ clip: rect(0 0 0 0);
1435
+ white-space: nowrap;
1436
+ border: 0;
1151
1437
  }
1152
1438
 
1153
1439
  /* Layer 4 — host surface: keyframes, theme flips, and the two rules that reach
@@ -1196,6 +1482,8 @@ const styles = {
1196
1482
  buttonAddComment: "dsq_buttonAddComment",
1197
1483
  divider: "dsq_divider",
1198
1484
  status: "dsq_status",
1485
+ ctxMenu: "dsq_ctxMenu",
1486
+ ctxItem: "dsq_ctxItem",
1199
1487
  cardStack: "dsq_cardStack",
1200
1488
  cardClose: "dsq_cardClose",
1201
1489
  cardPad: "dsq_cardPad",
@@ -1260,9 +1548,73 @@ const styles = {
1260
1548
  userRow: "dsq_userRow",
1261
1549
  userStack: "dsq_userStack",
1262
1550
  userImages: "dsq_userImages",
1263
- userBubble: "dsq_userBubble"
1551
+ userBubble: "dsq_userBubble",
1552
+ settings: "dsq_settings",
1553
+ settingsSection: "dsq_settingsSection",
1554
+ settingsCopy: "dsq_settingsCopy",
1555
+ settingsLabel: "dsq_settingsLabel",
1556
+ settingsHint: "dsq_settingsHint",
1557
+ settingsShortcuts: "dsq_settingsShortcuts",
1558
+ settingsShortcutRow: "dsq_settingsShortcutRow",
1559
+ settingsShortcutName: "dsq_settingsShortcutName",
1560
+ settingsShortcutKeys: "dsq_settingsShortcutKeys",
1561
+ keycap: "dsq_keycap",
1562
+ settingsDivider: "dsq_settingsDivider",
1563
+ settingsSwitchRow: "dsq_settingsSwitchRow",
1564
+ switch: "dsq_switch",
1565
+ switchTrack: "dsq_switchTrack",
1566
+ seg: "dsq_seg",
1567
+ segOption: "dsq_segOption",
1568
+ srOnly: "dsq_srOnly"
1264
1569
  };
1265
1570
  //#endregion
1571
+ //#region src/client/dom/badge-layout.ts
1572
+ const STEP_X = 28;
1573
+ const STEP_Y = 18;
1574
+ /** Rest, then two steps right; after that the stack grows upward. */
1575
+ const MAX_COLUMNS = 3;
1576
+ const MAX_ROWS = 24;
1577
+ function overlaps(a, b) {
1578
+ return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
1579
+ }
1580
+ function fits(anchor, dx, dy, bounds) {
1581
+ if (dx > 0 && bounds.right !== void 0 && anchor.x + dx + anchor.width > bounds.right) return false;
1582
+ if (dy < 0 && bounds.top !== void 0 && anchor.y + dy < bounds.top) return false;
1583
+ return true;
1584
+ }
1585
+ /**
1586
+ * Greedy placement in reading order. Each badge first tries its resting spot,
1587
+ * then shifts right a couple of badge widths, and finally stacks upward. The
1588
+ * resting spot is always returned when no candidate fits, so a badge never
1589
+ * disappears because of an edge.
1590
+ */
1591
+ function placeBadges(anchors, bounds = {}) {
1592
+ const placed = [];
1593
+ return anchors.map((anchor) => {
1594
+ for (let row = 0; row < MAX_ROWS; row += 1) for (let column = 0; column < MAX_COLUMNS; column += 1) {
1595
+ const dx = column * STEP_X;
1596
+ const dy = -row * STEP_Y;
1597
+ if (!fits(anchor, dx, dy, bounds)) continue;
1598
+ const candidate = {
1599
+ ...anchor,
1600
+ x: anchor.x + dx,
1601
+ y: anchor.y + dy
1602
+ };
1603
+ if (placed.some((box) => overlaps(candidate, box))) continue;
1604
+ placed.push(candidate);
1605
+ return {
1606
+ dx,
1607
+ dy
1608
+ };
1609
+ }
1610
+ placed.push(anchor);
1611
+ return {
1612
+ dx: 0,
1613
+ dy: 0
1614
+ };
1615
+ });
1616
+ }
1617
+ //#endregion
1266
1618
  //#region src/client/dom/message-text.ts
1267
1619
  /**
1268
1620
  * Message-text geometry: locate a selection inside the transcript and express
@@ -1401,6 +1753,9 @@ function cleanTextOf(item) {
1401
1753
  const MARK_ATTR = "data-dsq-ann-mark";
1402
1754
  const INDEX_ATTR = "data-dsq-ann-index";
1403
1755
  const FLASH_MS = 1500;
1756
+ /** Distance from the mark's bottom edge to the badge's bottom edge (CSS). */
1757
+ const BADGE_BOTTOM = 8;
1758
+ const BADGE_MARGIN = 2;
1404
1759
  const jobs = /* @__PURE__ */ new Map();
1405
1760
  let pendingIds = /* @__PURE__ */ new Set();
1406
1761
  function attrValue(value) {
@@ -1510,6 +1865,47 @@ function refreshMarkIndex(item, job) {
1510
1865
  if (last.getAttribute(INDEX_ATTR) !== next) last.setAttribute(INDEX_ATTR, next);
1511
1866
  return true;
1512
1867
  }
1868
+ /**
1869
+ * Resolve overlapping ordinal badges. Two annotations can end at the same
1870
+ * offset (nested ranges), which puts their pseudo-element badges on the same
1871
+ * pixel; nearby ends can also touch. Measure every last fragment, then write
1872
+ * the offset back as variables inherited by `::after`. The badge's transform
1873
+ * never participates in text layout.
1874
+ */
1875
+ function layoutBadges(scope) {
1876
+ const marks = Array.from(scope.querySelectorAll(`[${INDEX_ATTR}]`));
1877
+ for (const mark of marks) {
1878
+ mark.style.removeProperty("--dsq-ann-badge-x");
1879
+ mark.style.removeProperty("--dsq-ann-badge-y");
1880
+ }
1881
+ if (marks.length < 2) return;
1882
+ const scroller = marks[0]?.closest("[data-conversation-scroll]");
1883
+ const scrollerRect = scroller instanceof Element ? scroller.getBoundingClientRect() : null;
1884
+ const rightEdge = scrollerRect && scrollerRect.width > 0 ? scrollerRect.right : document.documentElement.clientWidth;
1885
+ const topEdge = scrollerRect && scrollerRect.height > 0 ? scrollerRect.top : 0;
1886
+ const anchors = marks.map((mark) => {
1887
+ const rect = mark.getBoundingClientRect();
1888
+ return {
1889
+ mark,
1890
+ index: Number(mark.getAttribute(INDEX_ATTR) ?? 0),
1891
+ x: rect.right + BADGE_MARGIN,
1892
+ y: rect.bottom - BADGE_BOTTOM - 15,
1893
+ width: 24,
1894
+ height: 15,
1895
+ visible: rect.width > 0 && rect.height > 0
1896
+ };
1897
+ }).filter((anchor) => anchor.visible).sort((a, b) => a.index - b.index);
1898
+ if (anchors.length < 2) return;
1899
+ const offsets = placeBadges(anchors, {
1900
+ right: rightEdge - 4,
1901
+ top: topEdge + 4
1902
+ });
1903
+ anchors.forEach((anchor, i) => {
1904
+ const { dx, dy } = offsets[i];
1905
+ if (dx !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-x", `${dx}px`);
1906
+ if (dy !== 0) anchor.mark.style.setProperty("--dsq-ann-badge-y", `${dy}px`);
1907
+ });
1908
+ }
1513
1909
  /** Apply every registered job whose message is currently rendered. */
1514
1910
  function applyAnnotationMarks(root) {
1515
1911
  if (jobs.size === 0) return;
@@ -1528,6 +1924,7 @@ function applyAnnotationMarks(root) {
1528
1924
  if (decorateJob(item, job)) decorated = true;
1529
1925
  }
1530
1926
  }
1927
+ layoutBadges(scope);
1531
1928
  }
1532
1929
  /** Insert or refresh jobs; identical jobs are no-ops. */
1533
1930
  function upsertAnnotationJobs(next) {
@@ -1593,8 +1990,11 @@ function watchAnnotationMarks(root) {
1593
1990
  childList: true,
1594
1991
  subtree: true
1595
1992
  });
1993
+ const onResize = () => schedule();
1994
+ window.addEventListener("resize", onResize);
1596
1995
  return () => {
1597
1996
  observer.disconnect();
1997
+ window.removeEventListener("resize", onResize);
1598
1998
  if (frame !== 0) window.cancelAnimationFrame(frame);
1599
1999
  };
1600
2000
  }
@@ -1939,13 +2339,6 @@ function readComposedDraft(text) {
1939
2339
  }
1940
2340
  //#endregion
1941
2341
  //#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
2342
  const runtime = {};
1950
2343
  function setRuntimeSession(sessionId) {
1951
2344
  if (sessionId) runtime.sessionId = sessionId;
@@ -1957,15 +2350,15 @@ const EMPTY$1 = [];
1957
2350
  /** Bucket for annotations added before a session id is known (never persisted). */
1958
2351
  const MEMORY_KEY = "pending";
1959
2352
  const bySession = /* @__PURE__ */ new Map();
1960
- const listeners = /* @__PURE__ */ new Set();
2353
+ const listeners$1 = /* @__PURE__ */ new Set();
1961
2354
  let persistence = null;
1962
2355
  function notify() {
1963
- for (const listener of [...listeners]) listener();
2356
+ for (const listener of [...listeners$1]) listener();
1964
2357
  }
1965
2358
  function subscribeAnnotations(listener) {
1966
- listeners.add(listener);
2359
+ listeners$1.add(listener);
1967
2360
  return () => {
1968
- listeners.delete(listener);
2361
+ listeners$1.delete(listener);
1969
2362
  };
1970
2363
  }
1971
2364
  /**
@@ -2158,13 +2551,13 @@ function annotationTitle(annotation) {
2158
2551
  }
2159
2552
  //#endregion
2160
2553
  //#region src/client/dom/popoverPlacement.ts
2161
- const GAP$1 = 8;
2554
+ const GAP$2 = 8;
2162
2555
  const MARGIN = 8;
2163
2556
  /**
2164
2557
  * Place `panel` near `anchor`, flipping vertical side when the preferred side
2165
2558
  * lacks room. Keeps the panel inside `bounds` (the viewport by default).
2166
2559
  */
2167
- function placePopover(anchor, panel, preferred = "bottom", gap = GAP$1, bounds) {
2560
+ function placePopover(anchor, panel, preferred = "bottom", gap = GAP$2, bounds) {
2168
2561
  const box = bounds ?? {
2169
2562
  top: 0,
2170
2563
  left: 0,
@@ -2262,6 +2655,11 @@ const zh = {
2262
2655
  "toolbar.commentDialogAria": "添加可选评论",
2263
2656
  "toolbar.skipComment": "暂不评论",
2264
2657
  "toolbar.addComment": "添加评论",
2658
+ "menu.aria": "划词菜单",
2659
+ "menu.shortcutLabel": "打开划词菜单",
2660
+ "menu.addShortcutLabel": "将选中文字添加到任务",
2661
+ "menu.noSelection": "先在对话里选中一段文字",
2662
+ "menu.busy": "先完成当前批注",
2265
2663
  "summary.hint": "悬停查看批注",
2266
2664
  "summary.hintTouch": "点按查看批注",
2267
2665
  "summary.dialogAria": "{count} 条划词批注详情",
@@ -2275,7 +2673,18 @@ const zh = {
2275
2673
  "chip.annotation": "批注 {index}",
2276
2674
  "chip.previewAria": "批注预览",
2277
2675
  "chip.jump": "原文",
2278
- "chip.jumpAria": "跳转到原文"
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": "与其它快捷键冲突"
2279
2688
  };
2280
2689
  const en = {
2281
2690
  "common.selectedText": "Selected text",
@@ -2292,6 +2701,11 @@ const en = {
2292
2701
  "toolbar.commentDialogAria": "Add an optional comment",
2293
2702
  "toolbar.skipComment": "Skip comment",
2294
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",
2295
2709
  "summary.hint": "Hover to view annotations",
2296
2710
  "summary.hintTouch": "Tap to view annotations",
2297
2711
  "summary.dialogAria": "Annotation details: {count}",
@@ -2307,7 +2721,18 @@ const en = {
2307
2721
  "chip.annotation": "Annotation {index}",
2308
2722
  "chip.previewAria": "Annotation preview",
2309
2723
  "chip.jump": "Original",
2310
- "chip.jumpAria": "Jump to 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"
2311
2736
  };
2312
2737
  /**
2313
2738
  * Russian is not a shipped host locale: it is added through
@@ -2329,6 +2754,11 @@ const ru = {
2329
2754
  "toolbar.commentDialogAria": "Добавить необязательный комментарий",
2330
2755
  "toolbar.skipComment": "Без комментария",
2331
2756
  "toolbar.addComment": "Добавить комментарий",
2757
+ "menu.aria": "Меню выделения",
2758
+ "menu.shortcutLabel": "Открыть меню выделения",
2759
+ "menu.addShortcutLabel": "Добавить выделенный текст к задаче",
2760
+ "menu.noSelection": "Сначала выделите текст в диалоге",
2761
+ "menu.busy": "Сначала завершите текущую аннотацию",
2332
2762
  "summary.hint": "Наведите, чтобы увидеть аннотации",
2333
2763
  "summary.hintTouch": "Нажмите, чтобы увидеть аннотации",
2334
2764
  "summary.dialogAria": "Детали аннотаций: {count}",
@@ -2348,7 +2778,18 @@ const ru = {
2348
2778
  "chip.annotation": "Аннотация {index}",
2349
2779
  "chip.previewAria": "Просмотр аннотации",
2350
2780
  "chip.jump": "Оригинал",
2351
- "chip.jumpAria": "Перейти к оригиналу"
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": "Конфликтует с другим сочетанием"
2352
2793
  };
2353
2794
  /** Both shipped host locales, required together by the typed `register()`. */
2354
2795
  const SHIPPED_DICTS = Object.freeze({
@@ -3179,6 +3620,67 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
3179
3620
  /** Alias kept for overlay registration. */
3180
3621
  const QuoteCard = AnnotationPanel;
3181
3622
  //#endregion
3623
+ //#region src/client/config.ts
3624
+ const DEFAULT_CONFIG = Object.freeze({
3625
+ triggerMode: "auto",
3626
+ shortcut: false
3627
+ });
3628
+ const TRIGGER_MODES = /* @__PURE__ */ new Set([
3629
+ "auto",
3630
+ "contextmenu",
3631
+ "both"
3632
+ ]);
3633
+ /** Collapse an untrusted snapshot value onto the known shape. */
3634
+ function normalizeConfig(value) {
3635
+ if (typeof value !== "object" || value === null) return { ...DEFAULT_CONFIG };
3636
+ const record = value;
3637
+ return {
3638
+ triggerMode: TRIGGER_MODES.has(record.triggerMode) ? record.triggerMode : DEFAULT_CONFIG.triggerMode,
3639
+ shortcut: typeof record.shortcut === "boolean" ? record.shortcut : DEFAULT_CONFIG.shortcut
3640
+ };
3641
+ }
3642
+ function resolveTriggers(config) {
3643
+ return {
3644
+ auto: config.triggerMode === "auto" || config.triggerMode === "both",
3645
+ contextMenu: config.triggerMode === "contextmenu" || config.triggerMode === "both",
3646
+ shortcut: config.shortcut
3647
+ };
3648
+ }
3649
+ let current = { ...DEFAULT_CONFIG };
3650
+ const listeners = /* @__PURE__ */ new Set();
3651
+ function getConfig() {
3652
+ return current;
3653
+ }
3654
+ function subscribeConfig(listener) {
3655
+ listeners.add(listener);
3656
+ return () => {
3657
+ listeners.delete(listener);
3658
+ };
3659
+ }
3660
+ /** Publish a snapshot value; returns the normalized config actually stored. */
3661
+ function publishConfig(value) {
3662
+ const next = normalizeConfig(value);
3663
+ if (next.triggerMode === current.triggerMode && next.shortcut === current.shortcut) return current;
3664
+ current = next;
3665
+ for (const listener of [...listeners]) listener();
3666
+ return current;
3667
+ }
3668
+ /**
3669
+ * Follow one Host config form. Works with a live form and with the fake one in
3670
+ * `verify`; the caller owns disposal.
3671
+ */
3672
+ function attachConfigForm(form) {
3673
+ const publish = () => {
3674
+ publishConfig(form.getSnapshot().value);
3675
+ };
3676
+ publish();
3677
+ const off = form.subscribe(publish);
3678
+ return () => {
3679
+ off();
3680
+ publishConfig(void 0);
3681
+ };
3682
+ }
3683
+ //#endregion
3182
3684
  //#region src/client/dom/floating-layer.ts
3183
3685
  /**
3184
3686
  * Mount point for the selection UI: a zero-size host inside the transcript's
@@ -3399,6 +3901,17 @@ function isFirstLineOnScreen(rect) {
3399
3901
  return rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth;
3400
3902
  }
3401
3903
  /**
3904
+ * First-line box of the live selection in viewport coordinates.
3905
+ *
3906
+ * The keyboard entry has no pointer to anchor to, so it opens the same menu
3907
+ * beside the selected words; the first line is what the reader is looking at.
3908
+ */
3909
+ function selectionFirstLineRect() {
3910
+ const selection = window.getSelection();
3911
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
3912
+ return getRangeFirstLineRect(selection.getRangeAt(0)) ?? null;
3913
+ }
3914
+ /**
3402
3915
  * Read the live selection when it is a non-empty range inside a conversation
3403
3916
  * message and outside the composer / our own chrome.
3404
3917
  */
@@ -3494,30 +4007,548 @@ function linkTarget(text, anchorHref) {
3494
4007
  return selectionUrl(text) ?? (anchorHref ? selectionUrl(anchorHref) : null);
3495
4008
  }
3496
4009
  //#endregion
3497
- //#region src/client/ui/touch.ts
3498
- /**
3499
- * The same query the coarse-pointer CSS uses. Keeping JS and CSS on one
3500
- * criterion means a device never gets the bottom bar with desktop positioning
3501
- * (or the reverse).
3502
- */
3503
- const COARSE_POINTER = "(hover: none), (pointer: coarse)";
3504
- function coarsePointer() {
3505
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
3506
- return window.matchMedia(COARSE_POINTER).matches;
3507
- }
3508
- function useCoarsePointer() {
3509
- const [coarse, setCoarse] = (0, react.useState)(coarsePointer);
3510
- (0, react.useEffect)(() => {
3511
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
3512
- const query = window.matchMedia(COARSE_POINTER);
3513
- const onChange = (event) => setCoarse(event.matches);
3514
- setCoarse(query.matches);
3515
- query.addEventListener("change", onChange);
3516
- return () => query.removeEventListener("change", onChange);
3517
- }, []);
3518
- return coarse;
3519
- }
3520
- function measure() {
4010
+ //#region src/client/selection/SelectionMenu.tsx
4011
+ /** Space between the anchor line's bottom and the menu. */
4012
+ const ANCHOR_GAP = 2;
4013
+ function SelectionMenu({ snapshot, x, y, onAction, onClose, initialFocus = "first" }) {
4014
+ const rootRef = (0, react.useRef)(null);
4015
+ const firstRef = (0, react.useRef)(null);
4016
+ const [placement, setPlacement] = (0, react.useState)(null);
4017
+ const items = [
4018
+ ...linkTarget(snapshot.text, snapshot.anchorHref) ? [{
4019
+ key: "open",
4020
+ icon: "external-link",
4021
+ label: t("toolbar.openLink")
4022
+ }] : [],
4023
+ {
4024
+ key: "copy",
4025
+ icon: "copy",
4026
+ label: t("toolbar.copy")
4027
+ },
4028
+ {
4029
+ key: "add",
4030
+ icon: "plus",
4031
+ label: t("toolbar.addToTask")
4032
+ }
4033
+ ];
4034
+ (0, react.useLayoutEffect)(() => {
4035
+ const el = rootRef.current;
4036
+ if (!el) return;
4037
+ const anchor = DOMRect.fromRect({
4038
+ x,
4039
+ y,
4040
+ width: 0,
4041
+ height: 0
4042
+ });
4043
+ setPlacement(placePopover(anchor, {
4044
+ width: el.offsetWidth,
4045
+ height: el.offsetHeight
4046
+ }, "bottom", ANCHOR_GAP));
4047
+ }, [
4048
+ x,
4049
+ y,
4050
+ items.length
4051
+ ]);
4052
+ (0, react.useLayoutEffect)(() => {
4053
+ if (!placement) return;
4054
+ if (initialFocus === "menu") rootRef.current?.focus({ preventScroll: true });
4055
+ else firstRef.current?.focus({ preventScroll: true });
4056
+ }, [placement, initialFocus]);
4057
+ (0, react.useEffect)(() => {
4058
+ const inside = (node) => node instanceof Node && rootRef.current?.contains(node) === true;
4059
+ const onPointerDown = (event) => {
4060
+ if (inside(event.target)) return;
4061
+ onClose();
4062
+ };
4063
+ const onContextMenu = (event) => {
4064
+ if (inside(event.target)) {
4065
+ event.preventDefault();
4066
+ return;
4067
+ }
4068
+ onClose();
4069
+ };
4070
+ const onScroll = () => onClose();
4071
+ const onResize = () => onClose();
4072
+ const onBlur = () => onClose();
4073
+ document.addEventListener("pointerdown", onPointerDown, true);
4074
+ document.addEventListener("contextmenu", onContextMenu, true);
4075
+ document.addEventListener("scroll", onScroll, true);
4076
+ window.addEventListener("resize", onResize);
4077
+ window.addEventListener("blur", onBlur);
4078
+ return () => {
4079
+ document.removeEventListener("pointerdown", onPointerDown, true);
4080
+ document.removeEventListener("contextmenu", onContextMenu, true);
4081
+ document.removeEventListener("scroll", onScroll, true);
4082
+ window.removeEventListener("resize", onResize);
4083
+ window.removeEventListener("blur", onBlur);
4084
+ };
4085
+ }, [onClose]);
4086
+ const handleKeyDown = (0, react.useCallback)((event) => {
4087
+ if (event.key === "Escape") {
4088
+ event.preventDefault();
4089
+ event.stopPropagation();
4090
+ onClose();
4091
+ return;
4092
+ }
4093
+ if (event.key === "Tab") {
4094
+ onClose();
4095
+ return;
4096
+ }
4097
+ const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
4098
+ if (step === 0 && event.key !== "Home" && event.key !== "End") return;
4099
+ const buttons = [...rootRef.current?.querySelectorAll("[role=\"menuitem\"]") ?? []];
4100
+ if (buttons.length === 0) return;
4101
+ event.preventDefault();
4102
+ const current = buttons.indexOf(document.activeElement);
4103
+ const fromMenu = current === -1;
4104
+ buttons[event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : fromMenu ? step > 0 ? 0 : buttons.length - 1 : (current + step + buttons.length) % buttons.length]?.focus({ preventScroll: true });
4105
+ }, [onClose]);
4106
+ const keepSelection = (0, react.useCallback)((event) => {
4107
+ event.preventDefault();
4108
+ event.currentTarget.focus({ preventScroll: true });
4109
+ }, []);
4110
+ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4111
+ ref: rootRef,
4112
+ className: cx(A.col, styles.ctxMenu),
4113
+ role: "menu",
4114
+ "aria-label": t("menu.aria"),
4115
+ "data-dsq-menu": "true",
4116
+ "data-side": placement?.side ?? "bottom",
4117
+ tabIndex: -1,
4118
+ style: {
4119
+ top: placement?.top ?? 0,
4120
+ left: placement?.left ?? 0,
4121
+ visibility: placement ? "visible" : "hidden"
4122
+ },
4123
+ onKeyDown: handleKeyDown,
4124
+ children: items.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4125
+ ref: index === 0 ? firstRef : void 0,
4126
+ type: "button",
4127
+ role: "menuitem",
4128
+ className: cx(A.plain, A.row, styles.ctxItem),
4129
+ tabIndex: -1,
4130
+ onPointerDown: keepSelection,
4131
+ onPointerEnter: (event) => event.currentTarget.focus({ preventScroll: true }),
4132
+ onClick: () => onAction(item.key),
4133
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
4134
+ name: item.icon,
4135
+ size: 15
4136
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: item.label })]
4137
+ }, item.key))
4138
+ }), document.body);
4139
+ }
4140
+ //#endregion
4141
+ //#region src/client/selection/ToolbarView.tsx
4142
+ /**
4143
+ * Paint-only halves of the selection UI: the anchored desktop toolbar/popover
4144
+ * and the touch bottom bar/sheet. All state, persistence and placement stay in
4145
+ * `SelectionToolbar`; this module owns markup, portals and ARIA only.
4146
+ */
4147
+ function ActionRow({ actions, stacked }) {
4148
+ return actions.map((action, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [!stacked && index > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4149
+ className: styles.divider,
4150
+ "aria-hidden": "true"
4151
+ }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4152
+ type: "button",
4153
+ className: cx(A.plain, stacked ? A.col : A.inlineRow, stacked ? styles.touchAction : styles.button),
4154
+ onClick: action.onClick,
4155
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
4156
+ name: action.icon,
4157
+ size: stacked ? 18 : 14
4158
+ }), stacked ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: action.label }) : action.label]
4159
+ })] }, action.key));
4160
+ }
4161
+ function CommentField({ comment, touch, commentRef, onChange, onKeyDown }) {
4162
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
4163
+ ref: commentRef,
4164
+ className: cx(A.box, styles.commentInput, touch && styles.touchComment),
4165
+ placeholder: t("common.commentPlaceholder"),
4166
+ value: comment,
4167
+ onChange: (event) => onChange(event.target.value),
4168
+ onKeyDown
4169
+ });
4170
+ }
4171
+ function CommentActions({ comment, touch, onFinishComment }) {
4172
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4173
+ className: cx(A.row, styles.commentActions, touch && styles.touchActions),
4174
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4175
+ type: "button",
4176
+ className: cx(A.plain, A.inlineRow, styles.button, touch && styles.touchSecondary),
4177
+ onClick: () => onFinishComment(false),
4178
+ children: t("toolbar.skipComment")
4179
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4180
+ type: "button",
4181
+ className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
4182
+ disabled: !comment.trim(),
4183
+ onClick: () => onFinishComment(true),
4184
+ children: t("toolbar.addComment")
4185
+ })]
4186
+ });
4187
+ }
4188
+ function ToolbarView({ touch, phase, active, status, viewport, actions, comment, menuOpen, toolbarStyle, toolbarRef, commentWrapRef, commentRef, onToolbarPointerDown, onCommentChange, onCommentKeyDown, onFinishComment, onCloseSheet }) {
4189
+ const commentField = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CommentField, {
4190
+ comment,
4191
+ touch,
4192
+ commentRef,
4193
+ onChange: onCommentChange,
4194
+ onKeyDown: onCommentKeyDown
4195
+ });
4196
+ const commentActions = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CommentActions, {
4197
+ comment,
4198
+ touch,
4199
+ onFinishComment
4200
+ });
4201
+ if (touch) {
4202
+ const bottom = `calc(${viewport.inset}px + 8px + env(safe-area-inset-bottom, 0px))`;
4203
+ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
4204
+ phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4205
+ ref: toolbarRef,
4206
+ className: cx(A.row, styles.touchBar),
4207
+ style: { bottom },
4208
+ role: "toolbar",
4209
+ "aria-label": t("toolbar.aria"),
4210
+ "data-dsq-toolbar": "true",
4211
+ "data-touch": "true",
4212
+ onPointerDown: onToolbarPointerDown,
4213
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionRow, {
4214
+ actions,
4215
+ stacked: true
4216
+ })
4217
+ }) : null,
4218
+ phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4219
+ className: styles.touchScrim,
4220
+ "aria-hidden": "true"
4221
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4222
+ ref: commentWrapRef,
4223
+ className: cx(A.col, styles.touchSheet),
4224
+ style: {
4225
+ bottom,
4226
+ maxHeight: `${Math.max(240, Math.round(viewport.height * .72))}px`
4227
+ },
4228
+ role: "dialog",
4229
+ "aria-label": t("toolbar.commentDialogAria"),
4230
+ children: [
4231
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4232
+ className: cx(A.row, styles.touchSheetHead),
4233
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4234
+ className: styles.touchSheetTitle,
4235
+ children: t("toolbar.commentHint")
4236
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4237
+ type: "button",
4238
+ className: cx(A.plain, A.gridMid, styles.touchSheetClose),
4239
+ "aria-label": t("common.close"),
4240
+ onClick: onCloseSheet,
4241
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
4242
+ name: "x",
4243
+ size: 18
4244
+ })
4245
+ })]
4246
+ }),
4247
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4248
+ className: styles.touchQuote,
4249
+ children: active.text
4250
+ }),
4251
+ commentField,
4252
+ commentActions
4253
+ ]
4254
+ })] }) : null,
4255
+ status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4256
+ className: styles.touchStatus,
4257
+ style: { bottom: `calc(${viewport.inset}px + 72px + env(safe-area-inset-bottom, 0px))` },
4258
+ role: "status",
4259
+ children: status
4260
+ }) : null
4261
+ ] }), document.body);
4262
+ }
4263
+ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
4264
+ menuOpen ? null : phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4265
+ ref: toolbarRef,
4266
+ className: cx(A.row, styles.toolbar),
4267
+ style: toolbarStyle,
4268
+ role: "toolbar",
4269
+ "aria-label": t("toolbar.aria"),
4270
+ "data-dsq-toolbar": "true",
4271
+ "data-anchor": active.below ? "below" : "above",
4272
+ onPointerDown: onToolbarPointerDown,
4273
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4274
+ className: cx(A.inlineRow, styles.toolbarInner),
4275
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActionRow, {
4276
+ actions,
4277
+ stacked: false
4278
+ })
4279
+ })
4280
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4281
+ ref: toolbarRef,
4282
+ className: cx(A.row, styles.toolbar),
4283
+ role: "toolbar",
4284
+ "aria-label": t("toolbar.aria"),
4285
+ "data-dsq-toolbar": "true",
4286
+ "data-anchor": "popover-top",
4287
+ onPointerDown: onToolbarPointerDown,
4288
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4289
+ className: cx(A.inlineRow, styles.toolbarInner),
4290
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4291
+ style: {
4292
+ padding: "0 8px",
4293
+ fontSize: 13,
4294
+ opacity: .8,
4295
+ whiteSpace: "nowrap"
4296
+ },
4297
+ children: t("toolbar.commentHint")
4298
+ })
4299
+ })
4300
+ }),
4301
+ !menuOpen && phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4302
+ ref: commentWrapRef,
4303
+ className: cx(A.col, styles.commentPop),
4304
+ role: "dialog",
4305
+ "aria-label": t("toolbar.commentDialogAria"),
4306
+ onPointerDown: onToolbarPointerDown,
4307
+ children: [commentField, commentActions]
4308
+ }) : null,
4309
+ status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4310
+ className: styles.status,
4311
+ style: toolbarStyle,
4312
+ role: "status",
4313
+ children: status
4314
+ }) : null
4315
+ ] }), active.layer.host);
4316
+ }
4317
+ //#endregion
4318
+ //#region src/client/dom/comment-placement.ts
4319
+ const GAP$1 = 8;
4320
+ /** Horizontal slack around the popover, and the minimum margin to the box. */
4321
+ const EDGE = 8;
4322
+ /** Gap between the popover corner and the "add a comment" toolbar. */
4323
+ const TOOLBAR_GAP = 6;
4324
+ function placeCommentSurfaces({ layer, selection, toolbar, panel }) {
4325
+ const bounds = layer.bounds();
4326
+ const home = layer.fromContent(selection.left, selection.top);
4327
+ const anchor = {
4328
+ left: home.left - toolbar.width / 2,
4329
+ top: home.top - (selection.below ? 0 : toolbar.height),
4330
+ width: toolbar.width,
4331
+ height: toolbar.height
4332
+ };
4333
+ const belowTop = layer.fromContent(0, selection.selectionBottom).top + GAP$1;
4334
+ const aboveTop = anchor.top - panel.height - GAP$1;
4335
+ const top = belowTop <= bounds.bottom - EDGE - panel.height ? belowTop : aboveTop >= bounds.top + EDGE ? aboveTop : Math.max(bounds.top + EDGE, Math.min(belowTop, bounds.bottom - EDGE - panel.height));
4336
+ const half = panel.width / 2 + EDGE;
4337
+ const min = bounds.left + half;
4338
+ const center = Math.min(Math.max(anchor.left + anchor.width / 2, min), Math.max(min, bounds.right - half));
4339
+ const popover = layer.toContent(center, top);
4340
+ let leftView = layer.fromContent(popover.left, popover.top).left - panel.width / 2;
4341
+ let topView = top - TOOLBAR_GAP - toolbar.height;
4342
+ if (topView < bounds.top + EDGE) topView = top + panel.height + TOOLBAR_GAP;
4343
+ leftView = Math.max(bounds.left + EDGE, Math.min(leftView, bounds.right - EDGE - toolbar.width));
4344
+ topView = Math.max(bounds.top + EDGE, Math.min(topView, bounds.bottom - EDGE - toolbar.height));
4345
+ return {
4346
+ popover,
4347
+ toolbar: layer.toContent(leftView, topView)
4348
+ };
4349
+ }
4350
+ //#endregion
4351
+ //#region src/client/selection/useCommentPlacement.ts
4352
+ /**
4353
+ * Measures the comment popover and its hint toolbar, then writes the geometry
4354
+ * from `dom/comment-placement.ts` back as content coordinates. The React
4355
+ * component keeps the refs; this hook owns the measure/place lifecycle.
4356
+ */
4357
+ function useCommentPlacement({ touch, phase, active, toolbarRef, commentWrapRef }) {
4358
+ const place = (0, react.useCallback)(() => {
4359
+ if (touch) return;
4360
+ const layer = active?.layer;
4361
+ const toolbar = toolbarRef.current;
4362
+ const pop = commentWrapRef.current;
4363
+ if (!active || !layer || !toolbar || !pop) return;
4364
+ pop.style.display = "flex";
4365
+ pop.style.visibility = "hidden";
4366
+ const panel = {
4367
+ width: pop.offsetWidth || 320,
4368
+ height: pop.offsetHeight || 180
4369
+ };
4370
+ pop.style.visibility = "";
4371
+ const at = placeCommentSurfaces({
4372
+ layer,
4373
+ selection: active,
4374
+ toolbar: {
4375
+ width: toolbar.offsetWidth,
4376
+ height: toolbar.offsetHeight
4377
+ },
4378
+ panel
4379
+ });
4380
+ pop.style.top = `${at.popover.top}px`;
4381
+ pop.style.left = `${at.popover.left}px`;
4382
+ toolbar.style.left = `${at.toolbar.left}px`;
4383
+ toolbar.style.top = `${at.toolbar.top}px`;
4384
+ }, [
4385
+ active,
4386
+ commentWrapRef,
4387
+ toolbarRef,
4388
+ touch
4389
+ ]);
4390
+ (0, react.useLayoutEffect)(() => {
4391
+ if (phase === "comment") place();
4392
+ }, [phase, place]);
4393
+ (0, react.useEffect)(() => {
4394
+ if (touch) return;
4395
+ window.addEventListener("resize", place);
4396
+ return () => window.removeEventListener("resize", place);
4397
+ }, [place, touch]);
4398
+ return place;
4399
+ }
4400
+ //#endregion
4401
+ //#region src/client/dom/menu-trigger.ts
4402
+ function shouldOpenSelectionMenu(signal) {
4403
+ return signal.contextMenuEnabled && !signal.touch && !signal.defaultPrevented && signal.targetInTranscript && signal.pointInSelection && signal.hasSelection;
4404
+ }
4405
+ /**
4406
+ * True when `(x, y)` falls inside any of the selection's line boxes.
4407
+ *
4408
+ * The union box is wrong here: a short first line leaves empty space to its
4409
+ * right that belongs to no word, and a right-click there is not "on the
4410
+ * selection". Each line rect plus a small tolerance matches what the user sees.
4411
+ */
4412
+ function pointInRects(x, y, rects, tolerance = 2) {
4413
+ for (const rect of rects) if (x >= rect.left - tolerance && x <= rect.right + tolerance && y >= rect.top - tolerance && y <= rect.bottom + tolerance) return true;
4414
+ return false;
4415
+ }
4416
+ //#endregion
4417
+ //#region src/client/selection/useSelectionWatcher.ts
4418
+ /**
4419
+ * Transcript-wide pointer/selection observation for the toolbar.
4420
+ *
4421
+ * The controller owns the state; this hook owns *when* to re-read it. Keeping
4422
+ * the listener choreography here means the toolbar component does not have to
4423
+ * know about the drag/context-menu/scroll ordering rules.
4424
+ */
4425
+ /** The menu's own chrome; a press inside it must not dismiss the selection. */
4426
+ const MENU_SELECTOR = "[data-dsq-menu]";
4427
+ function useSelectionWatcher({ toolbarRef, commentWrapRef, phaseRef, touch, dismissMenu, refresh, flushComment, hide, openMenu }) {
4428
+ (0, react.useEffect)(() => {
4429
+ let scheduled = false;
4430
+ const schedule = () => {
4431
+ if (scheduled) return;
4432
+ scheduled = true;
4433
+ window.setTimeout(() => {
4434
+ scheduled = false;
4435
+ refresh();
4436
+ }, 0);
4437
+ };
4438
+ let dragging = false;
4439
+ const onPointerDown = (event) => {
4440
+ const target = event.target;
4441
+ if (!(target instanceof Node)) return;
4442
+ const inToolbar = toolbarRef.current?.contains(target) === true;
4443
+ const inComment = commentWrapRef.current?.contains(target) === true;
4444
+ const inMenu = target instanceof Element && target.closest(MENU_SELECTOR) !== null;
4445
+ if (inToolbar || inComment || inMenu) return;
4446
+ dragging = true;
4447
+ if (phaseRef.current === "comment") {
4448
+ flushComment();
4449
+ hide();
4450
+ return;
4451
+ }
4452
+ const selection = window.getSelection();
4453
+ if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
4454
+ schedule();
4455
+ return;
4456
+ }
4457
+ hide();
4458
+ };
4459
+ const onPointerUp = () => {
4460
+ dragging = false;
4461
+ schedule();
4462
+ };
4463
+ const onSelectionChange = () => {
4464
+ if (dragging) return;
4465
+ schedule();
4466
+ };
4467
+ const onContextMenu = (event) => {
4468
+ if (dismissMenu()) refresh();
4469
+ if (event.defaultPrevented) return;
4470
+ const target = event.target;
4471
+ if (!(target instanceof Node)) return;
4472
+ if (toolbarRef.current?.contains(target)) return;
4473
+ if (target instanceof Element && target.closest(MENU_SELECTOR)) return;
4474
+ if (touch || phaseRef.current === "comment") return;
4475
+ const contextMenuEnabled = resolveTriggers(getConfig()).contextMenu;
4476
+ if (!contextMenuEnabled) return;
4477
+ const selection = window.getSelection();
4478
+ const hasSelection = selection !== null && !selection.isCollapsed && selection.rangeCount > 0;
4479
+ const pointInSelection = selection !== null && hasSelection && pointInRects(event.clientX, event.clientY, selection.getRangeAt(0).getClientRects());
4480
+ if (!shouldOpenSelectionMenu({
4481
+ defaultPrevented: event.defaultPrevented,
4482
+ contextMenuEnabled,
4483
+ touch,
4484
+ targetInTranscript: isInsideTranscript(target),
4485
+ pointInSelection,
4486
+ hasSelection
4487
+ })) return;
4488
+ const snapshot = readSelectionSnapshot(toolbarRef.current);
4489
+ if (!snapshot) return;
4490
+ event.preventDefault();
4491
+ event.stopPropagation();
4492
+ openMenu(snapshot, event.clientX, event.clientY, "menu");
4493
+ };
4494
+ const onScroll = (event) => {
4495
+ const target = event.target;
4496
+ if (target instanceof Node && (toolbarRef.current?.contains(target) === true || commentWrapRef.current?.contains(target) === true)) return;
4497
+ schedule();
4498
+ };
4499
+ document.addEventListener("pointerdown", onPointerDown, true);
4500
+ document.addEventListener("pointerup", onPointerUp, true);
4501
+ document.addEventListener("keyup", schedule, true);
4502
+ document.addEventListener("selectionchange", onSelectionChange);
4503
+ document.addEventListener("contextmenu", onContextMenu);
4504
+ window.addEventListener("resize", schedule);
4505
+ document.addEventListener("scroll", onScroll, true);
4506
+ return () => {
4507
+ document.removeEventListener("pointerdown", onPointerDown, true);
4508
+ document.removeEventListener("pointerup", onPointerUp, true);
4509
+ document.removeEventListener("keyup", schedule, true);
4510
+ document.removeEventListener("selectionchange", onSelectionChange);
4511
+ document.removeEventListener("contextmenu", onContextMenu);
4512
+ window.removeEventListener("resize", schedule);
4513
+ document.removeEventListener("scroll", onScroll, true);
4514
+ };
4515
+ }, [
4516
+ commentWrapRef,
4517
+ dismissMenu,
4518
+ flushComment,
4519
+ hide,
4520
+ openMenu,
4521
+ phaseRef,
4522
+ refresh,
4523
+ toolbarRef,
4524
+ touch
4525
+ ]);
4526
+ }
4527
+ //#endregion
4528
+ //#region src/client/ui/touch.ts
4529
+ /**
4530
+ * The same query the coarse-pointer CSS uses. Keeping JS and CSS on one
4531
+ * criterion means a device never gets the bottom bar with desktop positioning
4532
+ * (or the reverse).
4533
+ */
4534
+ const COARSE_POINTER = "(hover: none), (pointer: coarse)";
4535
+ function coarsePointer() {
4536
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
4537
+ return window.matchMedia(COARSE_POINTER).matches;
4538
+ }
4539
+ function useCoarsePointer() {
4540
+ const [coarse, setCoarse] = (0, react.useState)(coarsePointer);
4541
+ (0, react.useEffect)(() => {
4542
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
4543
+ const query = window.matchMedia(COARSE_POINTER);
4544
+ const onChange = (event) => setCoarse(event.matches);
4545
+ setCoarse(query.matches);
4546
+ query.addEventListener("change", onChange);
4547
+ return () => query.removeEventListener("change", onChange);
4548
+ }, []);
4549
+ return coarse;
4550
+ }
4551
+ function measure() {
3521
4552
  if (typeof window === "undefined") return {
3522
4553
  inset: 0,
3523
4554
  height: 0
@@ -3557,7 +4588,7 @@ function useVisualViewport(enabled) {
3557
4588
  return metrics;
3558
4589
  }
3559
4590
  //#endregion
3560
- //#region src/client/SelectionToolbar.tsx
4591
+ //#region src/client/selection/SelectionToolbar.tsx
3561
4592
  /** Cap for the auto-growing comment box; beyond this it scrolls internally. */
3562
4593
  const COMMENT_MAX_HEIGHT = 220;
3563
4594
  function SelectionToolbar({ sessionId }) {
@@ -3569,8 +4600,13 @@ function SelectionToolbar({ sessionId }) {
3569
4600
  const [phase, setPhase] = (0, react.useState)("idle");
3570
4601
  const [comment, setComment] = (0, react.useState)("");
3571
4602
  const [status, setStatus] = (0, react.useState)(null);
4603
+ const [menu, setMenu] = (0, react.useState)(null);
3572
4604
  const activeRef = (0, react.useRef)(null);
3573
4605
  activeRef.current = active;
4606
+ const menuRef = (0, react.useRef)(null);
4607
+ menuRef.current = menu;
4608
+ const phaseRef = (0, react.useRef)(phase);
4609
+ phaseRef.current = phase;
3574
4610
  /** Annotation saved when the comment step opened; the box only edits it. */
3575
4611
  const pendingIdRef = (0, react.useRef)(null);
3576
4612
  const touch = useCoarsePointer();
@@ -3579,13 +4615,25 @@ function SelectionToolbar({ sessionId }) {
3579
4615
  ensureToolbarStyles();
3580
4616
  if (sessionId) bindAnnotationSession(sessionId);
3581
4617
  }, [sessionId]);
4618
+ /**
4619
+ * Drop the menu state **and** its ref in one tick. A refresh that runs right
4620
+ * after a dismissal must see the menu as closed; reading the stale ref made
4621
+ * it keep `active`, which raised the auto toolbar in contextmenu mode.
4622
+ */
4623
+ const dismissMenu = (0, react.useCallback)(() => {
4624
+ if (!menuRef.current) return false;
4625
+ menuRef.current = null;
4626
+ setMenu(null);
4627
+ return true;
4628
+ }, []);
3582
4629
  const hide = (0, react.useCallback)(() => {
3583
4630
  pendingIdRef.current = null;
4631
+ dismissMenu();
3584
4632
  setActive(null);
3585
4633
  setPhase("idle");
3586
4634
  setComment("");
3587
4635
  setStatus(null);
3588
- }, []);
4636
+ }, [dismissMenu]);
3589
4637
  /**
3590
4638
  * Keep what was typed when the comment popover goes away without an explicit
3591
4639
  * cancel. The annotation itself was saved when the step opened, so dropping
@@ -3599,9 +4647,7 @@ function SelectionToolbar({ sessionId }) {
3599
4647
  if (typed.trim()) updateAnnotationComment(void 0, id, typed);
3600
4648
  }, []);
3601
4649
  /** 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;
4650
+ const saveSelection = (0, react.useCallback)((snapshot) => {
3605
4651
  return saveAnnotation(void 0, {
3606
4652
  text: snapshot.text,
3607
4653
  sources: snapshot.source ? [{
@@ -3613,139 +4659,107 @@ function SelectionToolbar({ sessionId }) {
3613
4659
  }] : void 0
3614
4660
  }).id;
3615
4661
  }, []);
3616
- const refresh = (0, react.useCallback)(() => {
3617
- const next = readSelectionSnapshot(toolbarRef.current);
3618
- if (!next) {
3619
- if (phaseRef.current === "idle") setActive(null);
3620
- return;
3621
- }
3622
- const current = activeRef.current;
3623
- if (phaseRef.current === "comment" && current && current.text !== next.text) {
3624
- flushComment();
3625
- setPhase("idle");
3626
- setComment("");
3627
- }
3628
- 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
- };
4662
+ /**
4663
+ * "Add to task" is the commit: the quote is saved immediately, with or
4664
+ * without a comment. The popover only edits that saved entry, so dismissing
4665
+ * it (outside click, new selection) can never undo the annotation.
4666
+ */
4667
+ const beginComment = (0, react.useCallback)((snapshot) => {
4668
+ pendingIdRef.current = saveSelection(snapshot);
4669
+ setActive(snapshot);
4670
+ dismissMenu();
4671
+ setPhase("comment");
4672
+ setComment("");
4673
+ setStatus(null);
4674
+ }, [dismissMenu, saveSelection]);
4675
+ const refresh = (0, react.useCallback)(() => {
4676
+ const next = readSelectionSnapshot(toolbarRef.current);
4677
+ if (!next) {
4678
+ dismissMenu();
4679
+ if (phaseRef.current === "idle") setActive(null);
4680
+ return;
4681
+ }
4682
+ if (menuRef.current && menuRef.current.snapshot.text !== next.text) dismissMenu();
4683
+ if (!touch && !resolveTriggers(getConfig()).auto) {
4684
+ if (phaseRef.current === "idle" && !menuRef.current) setActive(null);
4685
+ return;
4686
+ }
4687
+ const current = activeRef.current;
4688
+ if (phaseRef.current === "comment" && current && current.text !== next.text) {
4689
+ flushComment();
4690
+ setPhase("idle");
4691
+ setComment("");
4692
+ }
4693
+ setActive((prev) => prev && prev.text === next.text && prev.left === next.left && prev.top === next.top && prev.below === next.below && prev.layer === next.layer ? prev : next);
3689
4694
  }, [
4695
+ dismissMenu,
3690
4696
  flushComment,
3691
- hide,
3692
- refresh
4697
+ touch
3693
4698
  ]);
3694
- const placeCommentPop = (0, react.useCallback)(() => {
4699
+ /** Latch the selection and anchor; the menu outlives later refresh passes. */
4700
+ const openMenu = (0, react.useCallback)((snapshot, x, y, initialFocus) => {
4701
+ setStatus(null);
4702
+ setActive(snapshot);
4703
+ setMenu({
4704
+ snapshot,
4705
+ x,
4706
+ y,
4707
+ initialFocus
4708
+ });
4709
+ }, []);
4710
+ (0, react.useEffect)(() => subscribeConfig(() => {
4711
+ dismissMenu();
4712
+ refresh();
4713
+ }), [dismissMenu, refresh]);
4714
+ useSelectionWatcher({
4715
+ toolbarRef,
4716
+ commentWrapRef,
4717
+ phaseRef,
4718
+ touch,
4719
+ dismissMenu,
4720
+ refresh,
4721
+ flushComment,
4722
+ hide,
4723
+ openMenu
4724
+ });
4725
+ (0, react.useEffect)(() => {
3695
4726
  if (touch) return;
3696
- const snapshot = activeRef.current;
3697
- const layer = snapshot?.layer;
3698
- const toolbar = toolbarRef.current;
3699
- const pop = commentWrapRef.current;
3700
- if (!snapshot || !layer || !toolbar || !pop) return;
3701
- const bounds = layer.bounds();
3702
- const toolbarW = toolbar.offsetWidth;
3703
- const toolbarH = toolbar.offsetHeight;
3704
- const home = layer.fromContent(snapshot.left, snapshot.top);
3705
- const anchor = {
3706
- left: home.left - toolbarW / 2,
3707
- top: home.top - (snapshot.below ? 0 : toolbarH),
3708
- width: toolbarW,
3709
- height: toolbarH
4727
+ const bridge = {
4728
+ state: () => {
4729
+ if (phaseRef.current !== "idle") return "busy";
4730
+ return readSelectionSnapshot(toolbarRef.current) ? "ready" : "no-selection";
4731
+ },
4732
+ open: () => {
4733
+ if (phaseRef.current !== "idle") return;
4734
+ const snapshot = readSelectionSnapshot(toolbarRef.current);
4735
+ if (!snapshot) return;
4736
+ const rect = selectionFirstLineRect();
4737
+ if (!rect) return;
4738
+ openMenu(snapshot, rect.left, rect.bottom, "first");
4739
+ },
4740
+ add: () => {
4741
+ if (phaseRef.current !== "idle") return;
4742
+ const snapshot = readSelectionSnapshot(toolbarRef.current);
4743
+ if (!snapshot) return;
4744
+ beginComment(snapshot);
4745
+ }
3710
4746
  };
3711
- pop.style.display = "flex";
3712
- pop.style.visibility = "hidden";
3713
- const panel = {
3714
- width: pop.offsetWidth || 320,
3715
- height: pop.offsetHeight || 180
4747
+ runtime.selection = bridge;
4748
+ return () => {
4749
+ if (runtime.selection === bridge) delete runtime.selection;
3716
4750
  };
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
4751
  }, [
3744
- active,
3745
- phase,
3746
- placeCommentPop,
4752
+ beginComment,
4753
+ openMenu,
3747
4754
  touch
3748
4755
  ]);
4756
+ const placeCommentPop = useCommentPlacement({
4757
+ touch,
4758
+ phase,
4759
+ active,
4760
+ toolbarRef,
4761
+ commentWrapRef
4762
+ });
3749
4763
  const layer = active?.layer;
3750
4764
  (0, react.useLayoutEffect)(() => {
3751
4765
  if (!layer || touch) return;
@@ -3759,12 +4773,6 @@ function SelectionToolbar({ sessionId }) {
3759
4773
  (0, react.useEffect)(() => {
3760
4774
  if (phase === "comment" && !touch) commentRef.current?.focus();
3761
4775
  }, [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
4776
  (0, react.useLayoutEffect)(() => {
3769
4777
  if (phase !== "comment") return;
3770
4778
  const area = commentRef.current;
@@ -3782,9 +4790,8 @@ function SelectionToolbar({ sessionId }) {
3782
4790
  event.preventDefault();
3783
4791
  event.stopPropagation();
3784
4792
  }, []);
3785
- const handleCopy = (0, react.useCallback)(async () => {
3786
- const snapshot = activeRef.current;
3787
- if (!snapshot) return;
4793
+ /** Copy a latched quote; a failure leaves the caller's surface in place. */
4794
+ const copySnapshot = (0, react.useCallback)(async (snapshot) => {
3788
4795
  try {
3789
4796
  await copyText(snapshot.text);
3790
4797
  clearNativeSelection();
@@ -3793,18 +4800,14 @@ function SelectionToolbar({ sessionId }) {
3793
4800
  setStatus(t("toolbar.copyFailed"));
3794
4801
  }
3795
4802
  }, [hide]);
3796
- /**
3797
- * "Add to task" is the commit: the quote is saved immediately, with or
3798
- * without a comment. The popover only edits that saved entry, so dismissing
3799
- * it (outside click, new selection) can never undo the annotation.
3800
- */
4803
+ const handleCopy = (0, react.useCallback)(() => {
4804
+ const snapshot = activeRef.current;
4805
+ if (snapshot) copySnapshot(snapshot);
4806
+ }, [copySnapshot]);
3801
4807
  const openComment = (0, react.useCallback)(() => {
3802
- if (!activeRef.current) return;
3803
- pendingIdRef.current = saveSelection();
3804
- setPhase("comment");
3805
- setComment("");
3806
- setStatus(null);
3807
- }, [saveSelection]);
4808
+ const snapshot = activeRef.current;
4809
+ if (snapshot) beginComment(snapshot);
4810
+ }, [beginComment]);
3808
4811
  /**
3809
4812
  * Hand the selected link to the system browser.
3810
4813
  *
@@ -3814,14 +4817,33 @@ function SelectionToolbar({ sessionId }) {
3814
4817
  * arbitrary URLs there is what wedges it). Only http(s) selections get this
3815
4818
  * button — every other scheme is refused by that handler.
3816
4819
  */
3817
- const handleOpenLink = (0, react.useCallback)(() => {
3818
- const snapshot = activeRef.current;
3819
- const url = snapshot ? linkTarget(snapshot.text, snapshot.anchorHref) : null;
4820
+ const openLinkSnapshot = (0, react.useCallback)((snapshot) => {
4821
+ const url = linkTarget(snapshot.text, snapshot.anchorHref);
3820
4822
  if (!url) return;
3821
4823
  window.open(url, "_blank", "noopener,noreferrer");
3822
4824
  clearNativeSelection();
3823
4825
  hide();
3824
4826
  }, [hide]);
4827
+ const handleOpenLink = (0, react.useCallback)(() => {
4828
+ const snapshot = activeRef.current;
4829
+ if (snapshot) openLinkSnapshot(snapshot);
4830
+ }, [openLinkSnapshot]);
4831
+ /** Dismissal without an action hands control back to the normal toolbar rules. */
4832
+ const closeMenu = (0, react.useCallback)(() => {
4833
+ if (!dismissMenu()) return;
4834
+ refresh();
4835
+ }, [dismissMenu, refresh]);
4836
+ const handleMenuAction = (0, react.useCallback)((action) => {
4837
+ const current = menuRef.current;
4838
+ if (!current) return;
4839
+ if (action === "add") beginComment(current.snapshot);
4840
+ else if (action === "copy") copySnapshot(current.snapshot);
4841
+ else openLinkSnapshot(current.snapshot);
4842
+ }, [
4843
+ beginComment,
4844
+ copySnapshot,
4845
+ openLinkSnapshot
4846
+ ]);
3825
4847
  /** Close the optional comment step; the annotation stays saved either way. */
3826
4848
  const finishComment = (0, react.useCallback)((keepComment) => {
3827
4849
  if (keepComment) flushComment();
@@ -3839,6 +4861,18 @@ function SelectionToolbar({ sessionId }) {
3839
4861
  flushComment();
3840
4862
  hide();
3841
4863
  }, [flushComment, hide]);
4864
+ const handleCommentKeyDown = (0, react.useCallback)((event) => {
4865
+ if (event.key === "Escape") {
4866
+ event.preventDefault();
4867
+ pendingIdRef.current = null;
4868
+ hide();
4869
+ return;
4870
+ }
4871
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
4872
+ event.preventDefault();
4873
+ finishComment(true);
4874
+ }
4875
+ }, [finishComment, hide]);
3842
4876
  if (!active) return null;
3843
4877
  const toolbarStyle = {
3844
4878
  left: active.left,
@@ -3855,7 +4889,7 @@ function SelectionToolbar({ sessionId }) {
3855
4889
  key: "copy",
3856
4890
  icon: "copy",
3857
4891
  label: t("toolbar.copy"),
3858
- onClick: () => void handleCopy()
4892
+ onClick: handleCopy
3859
4893
  },
3860
4894
  {
3861
4895
  key: "add",
@@ -3864,154 +4898,344 @@ function SelectionToolbar({ sessionId }) {
3864
4898
  onClick: openComment
3865
4899
  }
3866
4900
  ];
3867
- const actionRow = (stacked) => actions.map((action, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [!stacked && index > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3868
- className: styles.divider,
3869
- "aria-hidden": "true"
3870
- }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3871
- type: "button",
3872
- className: cx(A.plain, stacked ? A.col : A.inlineRow, stacked ? styles.touchAction : styles.button),
3873
- onClick: action.onClick,
3874
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3875
- name: action.icon,
3876
- size: stacked ? 18 : 14
3877
- }), stacked ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: action.label }) : action.label]
3878
- })] }, action.key));
3879
- const commentField = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
3880
- ref: commentRef,
3881
- className: cx(A.box, styles.commentInput, touch && styles.touchComment),
3882
- placeholder: t("common.commentPlaceholder"),
3883
- value: comment,
3884
- onChange: (e) => setComment(e.target.value),
3885
- onKeyDown: (e) => {
3886
- if (e.key === "Escape") {
3887
- e.preventDefault();
3888
- pendingIdRef.current = null;
3889
- hide();
3890
- return;
3891
- }
3892
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
3893
- e.preventDefault();
3894
- finishComment(true);
3895
- }
4901
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolbarView, {
4902
+ touch,
4903
+ phase,
4904
+ active,
4905
+ status,
4906
+ viewport,
4907
+ actions,
4908
+ comment,
4909
+ toolbarStyle,
4910
+ toolbarRef,
4911
+ commentWrapRef,
4912
+ commentRef,
4913
+ menuOpen: menu !== null,
4914
+ onToolbarPointerDown: handleToolbarPointerDown,
4915
+ onCommentChange: setComment,
4916
+ onCommentKeyDown: handleCommentKeyDown,
4917
+ onFinishComment: finishComment,
4918
+ onCloseSheet: closeCommentSheet
4919
+ }), menu ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SelectionMenu, {
4920
+ snapshot: menu.snapshot,
4921
+ x: menu.x,
4922
+ y: menu.y,
4923
+ initialFocus: menu.initialFocus,
4924
+ onAction: handleMenuAction,
4925
+ onClose: closeMenu
4926
+ }) : null] });
4927
+ }
4928
+ //#endregion
4929
+ //#region src/client/shortcut-menu.ts
4930
+ const SELECTION_MENU_COMMAND_ID = "select-quote.openMenu";
4931
+ const ADD_TO_TASK_COMMAND_ID = "select-quote.addToTask";
4932
+ /**
4933
+ * Defaults per device profile.
4934
+ *
4935
+ * The two commands never share a binding. Web Linux admits only
4936
+ * `primary+Slash`, `primary+shift+Comma` and `primary+shift+Period`, and the
4937
+ * Host owns `primary+Slash` for its shortcut panel, so the menu takes comma and
4938
+ * add-to-task takes period. Web macOS/Windows accept two-modifier combinations;
4939
+ * theirs avoid the browser's own menu keys. Desktop Windows/Linux also take
4940
+ * period for add-to-task: `Ctrl+Alt+L` is AltGr+L on several layouts, and on
4941
+ * Windows the shell dispatches configured shortcuts before input regions.
4942
+ */
4943
+ const SELECTION_MENU_DEFAULTS = Object.freeze({
4944
+ "desktop:macos": Object.freeze({
4945
+ code: "KeyL",
4946
+ modifiers: Object.freeze(["primary"])
4947
+ }),
4948
+ "desktop:windows": Object.freeze({
4949
+ code: "KeyL",
4950
+ modifiers: Object.freeze(["primary", "shift"])
4951
+ }),
4952
+ "desktop:linux": Object.freeze({
4953
+ code: "KeyL",
4954
+ modifiers: Object.freeze(["primary", "shift"])
4955
+ }),
4956
+ "web:macos": Object.freeze({
4957
+ code: "KeyL",
4958
+ modifiers: Object.freeze(["primary", "shift"])
4959
+ }),
4960
+ "web:windows": Object.freeze({
4961
+ code: "KeyL",
4962
+ modifiers: Object.freeze(["primary", "shift"])
4963
+ }),
4964
+ "web:linux": Object.freeze({
4965
+ code: "Comma",
4966
+ modifiers: Object.freeze(["primary", "shift"])
4967
+ })
4968
+ });
4969
+ const ADD_TO_TASK_DEFAULTS = Object.freeze({
4970
+ "desktop:macos": Object.freeze({
4971
+ code: "KeyL",
4972
+ modifiers: Object.freeze(["primary", "shift"])
4973
+ }),
4974
+ "desktop:windows": Object.freeze({
4975
+ code: "Period",
4976
+ modifiers: Object.freeze(["primary", "shift"])
4977
+ }),
4978
+ "desktop:linux": Object.freeze({
4979
+ code: "Period",
4980
+ modifiers: Object.freeze(["primary", "shift"])
4981
+ }),
4982
+ "web:macos": Object.freeze({
4983
+ code: "KeyL",
4984
+ modifiers: Object.freeze(["primary", "alt"])
4985
+ }),
4986
+ "web:windows": Object.freeze({
4987
+ code: "Period",
4988
+ modifiers: Object.freeze(["primary", "shift"])
4989
+ }),
4990
+ "web:linux": Object.freeze({
4991
+ code: "Period",
4992
+ modifiers: Object.freeze(["primary", "shift"])
4993
+ })
4994
+ });
4995
+ function resolveSelectionAction(action) {
4996
+ const bridge = runtime.selection;
4997
+ if (!bridge) return {
4998
+ status: "blocked",
4999
+ reason: t("menu.noSelection")
5000
+ };
5001
+ const state = bridge.state();
5002
+ if (state === "busy") return {
5003
+ status: "blocked",
5004
+ reason: t("menu.busy")
5005
+ };
5006
+ if (state === "no-selection") return {
5007
+ status: "blocked",
5008
+ reason: t("menu.noSelection")
5009
+ };
5010
+ return {
5011
+ status: "handled",
5012
+ run: () => bridge[action]()
5013
+ };
5014
+ }
5015
+ function selectionMenuCommand() {
5016
+ return {
5017
+ id: SELECTION_MENU_COMMAND_ID,
5018
+ label: () => t("menu.shortcutLabel"),
5019
+ aliases: ["selection menu", "quote menu"],
5020
+ defaults: SELECTION_MENU_DEFAULTS,
5021
+ regions: ["page"],
5022
+ modals: [],
5023
+ resolve: () => resolveSelectionAction("open")
5024
+ };
5025
+ }
5026
+ function addToTaskCommand() {
5027
+ return {
5028
+ id: ADD_TO_TASK_COMMAND_ID,
5029
+ label: () => t("menu.addShortcutLabel"),
5030
+ aliases: ["add to task", "annotate selection"],
5031
+ defaults: ADD_TO_TASK_DEFAULTS,
5032
+ regions: ["page"],
5033
+ modals: [],
5034
+ resolve: () => resolveSelectionAction("add")
5035
+ };
5036
+ }
5037
+ let shortcutRows = [];
5038
+ const shortcutListeners = /* @__PURE__ */ new Set();
5039
+ function getShortcutRows() {
5040
+ return shortcutRows;
5041
+ }
5042
+ function subscribeShortcutRows(listener) {
5043
+ shortcutListeners.add(listener);
5044
+ return () => {
5045
+ shortcutListeners.delete(listener);
5046
+ };
5047
+ }
5048
+ function publishShortcutRows(next) {
5049
+ if (next.length === shortcutRows.length && next.every((row, index) => {
5050
+ const previous = shortcutRows[index];
5051
+ return previous.id === row.id && previous.label === row.label && previous.enabled === row.enabled && previous.keys.length === row.keys.length && previous.keys.every((key, keyIndex) => key === row.keys[keyIndex]);
5052
+ })) return;
5053
+ shortcutRows = next;
5054
+ for (const listener of [...shortcutListeners]) listener();
5055
+ }
5056
+ /**
5057
+ * Mirror the two live catalog rows. The Host drops a command from the catalog
5058
+ * while it is unregistered, and marks a rebound conflict; the settings page
5059
+ * reads this instead of restating the defaults.
5060
+ */
5061
+ function publishShortcutCatalog(ctx) {
5062
+ const catalog = ctx.shortcuts.catalog.getSnapshot();
5063
+ const next = [];
5064
+ for (const id of [SELECTION_MENU_COMMAND_ID, ADD_TO_TASK_COMMAND_ID]) {
5065
+ const row = catalog.find((candidate) => candidate.id === id);
5066
+ if (!row) continue;
5067
+ next.push({
5068
+ id,
5069
+ label: row.label,
5070
+ keys: row.keys,
5071
+ enabled: row.issue === null && row.conflicts.length === 0
5072
+ });
5073
+ }
5074
+ publishShortcutRows(next);
5075
+ }
5076
+ /** Keep both commands registered exactly while the setting is on. */
5077
+ function installSelectionShortcut(ctx) {
5078
+ const disposers = [];
5079
+ const unregisterAll = () => {
5080
+ while (disposers.length > 0) disposers.pop()?.();
5081
+ };
5082
+ const sync = () => {
5083
+ unregisterAll();
5084
+ if (!getConfig().shortcut) return;
5085
+ for (const command of [selectionMenuCommand(), addToTaskCommand()]) try {
5086
+ disposers.push(ctx.shortcuts.register(command));
5087
+ } catch (error) {
5088
+ console.warn(`[dsh-select-quote] shortcut registration failed for ${command.id}:`, error);
3896
5089
  }
5090
+ };
5091
+ ctx.effect(() => {
5092
+ const syncCatalog = () => publishShortcutCatalog(ctx);
5093
+ syncCatalog();
5094
+ const offCatalog = ctx.shortcuts.catalog.subscribe(syncCatalog);
5095
+ sync();
5096
+ const off = subscribeConfig(sync);
5097
+ return () => {
5098
+ off();
5099
+ offCatalog();
5100
+ unregisterAll();
5101
+ publishShortcutRows([]);
5102
+ };
5103
+ }, "dsh-select-quote: shortcut commands");
5104
+ }
5105
+ //#endregion
5106
+ //#region src/client/settings/ConfigRow.tsx
5107
+ const CHOICES = [
5108
+ "auto",
5109
+ "contextmenu",
5110
+ "both"
5111
+ ];
5112
+ function ConfigRow({ view, form, t: slotTranslate }) {
5113
+ useLocale();
5114
+ if (view !== "page" || form === void 0) return null;
5115
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConfigSection, {
5116
+ form,
5117
+ slotTranslate
3897
5118
  });
3898
- const commentActions = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3899
- className: cx(A.row, styles.commentActions, touch && styles.touchActions),
3900
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3901
- type: "button",
3902
- className: cx(A.plain, A.inlineRow, styles.button, touch && styles.touchSecondary),
3903
- onClick: () => finishComment(false),
3904
- children: t("toolbar.skipComment")
3905
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3906
- type: "button",
3907
- className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
3908
- disabled: !comment.trim(),
3909
- onClick: () => finishComment(true),
3910
- children: t("toolbar.addComment")
3911
- })]
3912
- });
3913
- if (touch) {
3914
- const bottom = `calc(${viewport.inset}px + 8px + env(safe-area-inset-bottom, 0px))`;
3915
- return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3916
- phase === "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3917
- ref: toolbarRef,
3918
- className: cx(A.row, styles.touchBar),
3919
- style: { bottom },
3920
- role: "toolbar",
3921
- "aria-label": t("toolbar.aria"),
3922
- "data-dsq-toolbar": "true",
3923
- "data-touch": "true",
3924
- onPointerDown: handleToolbarPointerDown,
3925
- children: actionRow(true)
3926
- }) : null,
3927
- phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3928
- className: styles.touchScrim,
5119
+ }
5120
+ /**
5121
+ * The bundle config entry gets no `form` prop from the Host (unlike a row
5122
+ * page), so it subscribes to the row's form controller itself. The controller
5123
+ * owns write ordering and revisions; `set` is the one-field convenience.
5124
+ */
5125
+ function ConfigSection({ form, slotTranslate }) {
5126
+ const subscribe = (0, react.useCallback)((listener) => form.subscribe(listener), [form]);
5127
+ const getSnapshot = (0, react.useCallback)(() => form.getSnapshot(), [form]);
5128
+ const current = normalizeConfig((0, react.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot).value);
5129
+ const shortcuts = (0, react.useSyncExternalStore)(subscribeShortcutRows, getShortcutRows, getShortcutRows);
5130
+ const t$1 = slotTranslate ?? t;
5131
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5132
+ className: styles.settings,
5133
+ children: [
5134
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5135
+ className: styles.settingsSection,
5136
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5137
+ className: styles.settingsCopy,
5138
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5139
+ className: styles.settingsLabel,
5140
+ children: t$1("settings.trigger")
5141
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5142
+ className: styles.settingsHint,
5143
+ children: t$1("settings.triggerHint")
5144
+ })]
5145
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5146
+ className: styles.seg,
5147
+ role: "radiogroup",
5148
+ "aria-label": t$1("settings.trigger"),
5149
+ children: CHOICES.map((mode) => {
5150
+ const active = mode === current.triggerMode;
5151
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5152
+ className: styles.segOption,
5153
+ "data-active": active,
5154
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5155
+ type: "radio",
5156
+ className: styles.srOnly,
5157
+ name: "dsq-trigger-mode",
5158
+ value: mode,
5159
+ checked: active,
5160
+ onChange: () => {
5161
+ if (active) return;
5162
+ form.set("triggerMode", mode);
5163
+ }
5164
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t$1(`settings.trigger.${mode}`) })]
5165
+ }, mode);
5166
+ })
5167
+ })]
5168
+ }),
5169
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5170
+ className: styles.settingsDivider,
3929
5171
  "aria-hidden": "true"
3930
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3931
- ref: commentWrapRef,
3932
- className: cx(A.col, styles.touchSheet),
3933
- style: {
3934
- bottom,
3935
- maxHeight: `${Math.max(240, Math.round(viewport.height * .72))}px`
3936
- },
3937
- role: "dialog",
3938
- "aria-label": t("toolbar.commentDialogAria"),
3939
- children: [
3940
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3941
- className: cx(A.row, styles.touchSheetHead),
3942
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3943
- className: styles.touchSheetTitle,
3944
- children: t("toolbar.commentHint")
3945
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3946
- type: "button",
3947
- className: cx(A.plain, A.gridMid, styles.touchSheetClose),
3948
- "aria-label": t("common.close"),
3949
- onClick: closeCommentSheet,
3950
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LucideIcon, {
3951
- name: "x",
3952
- size: 18
3953
- })
3954
- })]
3955
- }),
3956
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3957
- className: styles.touchQuote,
3958
- children: active.text
3959
- }),
3960
- commentField,
3961
- commentActions
3962
- ]
3963
- })] }) : null,
3964
- status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3965
- className: styles.touchStatus,
3966
- style: { bottom: `calc(${viewport.inset}px + 72px + env(safe-area-inset-bottom, 0px))` },
3967
- role: "status",
3968
- children: status
5172
+ }),
5173
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5174
+ className: styles.settingsSwitchRow,
5175
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5176
+ className: styles.settingsCopy,
5177
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5178
+ className: styles.settingsLabel,
5179
+ children: t$1("settings.shortcut")
5180
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5181
+ className: styles.settingsHint,
5182
+ children: t$1("settings.shortcutHint")
5183
+ })]
5184
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5185
+ className: styles.switch,
5186
+ "data-on": current.shortcut,
5187
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5188
+ type: "checkbox",
5189
+ className: styles.srOnly,
5190
+ "aria-label": t$1("settings.shortcut"),
5191
+ checked: current.shortcut,
5192
+ onChange: (event) => {
5193
+ const next = event.target.checked;
5194
+ if (next === current.shortcut) return;
5195
+ form.set("shortcut", next);
5196
+ }
5197
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5198
+ className: styles.switchTrack,
5199
+ "aria-hidden": "true"
5200
+ })]
5201
+ })]
5202
+ }),
5203
+ current.shortcut ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
5204
+ className: styles.settingsShortcuts,
5205
+ "aria-label": t$1("settings.shortcutListAria"),
5206
+ children: shortcuts.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
5207
+ className: styles.settingsHint,
5208
+ children: t$1("settings.shortcutUnavailable")
5209
+ }) : shortcuts.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
5210
+ className: styles.settingsShortcutRow,
5211
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5212
+ className: styles.settingsShortcutName,
5213
+ children: row.label
5214
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5215
+ className: styles.settingsShortcutKeys,
5216
+ children: row.enabled ? row.keys.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("kbd", {
5217
+ className: styles.keycap,
5218
+ children: row.keys.join("")
5219
+ }) : t$1("settings.shortcutUnbound") : t$1("settings.shortcutConflict")
5220
+ })]
5221
+ }, row.id))
3969
5222
  }) : null
3970
- ] }), document.body);
3971
- }
3972
- return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3973
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3974
- ref: toolbarRef,
3975
- className: cx(A.row, styles.toolbar),
3976
- style: phase === "comment" ? void 0 : toolbarStyle,
3977
- role: "toolbar",
3978
- "aria-label": t("toolbar.aria"),
3979
- "data-dsq-toolbar": "true",
3980
- "data-anchor": phase === "comment" ? "popover-top" : active.below ? "below" : "above",
3981
- onPointerDown: handleToolbarPointerDown,
3982
- children: phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3983
- className: cx(A.inlineRow, styles.toolbarInner),
3984
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3985
- style: {
3986
- padding: "0 8px",
3987
- fontSize: 13,
3988
- opacity: .8,
3989
- whiteSpace: "nowrap"
3990
- },
3991
- children: t("toolbar.commentHint")
3992
- })
3993
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3994
- className: cx(A.inlineRow, styles.toolbarInner),
3995
- children: actionRow(false)
3996
- })
3997
- }),
3998
- phase === "comment" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3999
- ref: commentWrapRef,
4000
- className: cx(A.col, styles.commentPop),
4001
- role: "dialog",
4002
- "aria-label": t("toolbar.commentDialogAria"),
4003
- onPointerDown: handleToolbarPointerDown,
4004
- children: [commentField, commentActions]
4005
- }) : null,
4006
- status ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4007
- className: styles.status,
4008
- style: toolbarStyle,
4009
- role: "status",
4010
- children: status
4011
- }) : null
4012
- ] }), active.layer.host);
5223
+ ]
5224
+ });
4013
5225
  }
4014
5226
  //#endregion
5227
+ //#region src/shared/identity.ts
5228
+ /**
5229
+ * Names the Loader and the settings layer agree on.
5230
+ *
5231
+ * The row id lives in `cordis.patch.yml` (and the local `dev.patch.yml`); the
5232
+ * Host config form is keyed by it, so renaming either without this constant
5233
+ * would silently orphan the form. The bundle config section is keyed by the
5234
+ * package name instead.
5235
+ */
5236
+ const PACKAGE_NAME = "dsh-select-quote";
5237
+ const ROW_ID = "select-quote";
5238
+ //#endregion
4015
5239
  //#region src/client/transcript/quote-registry.ts
4016
5240
  /**
4017
5241
  * Quotes rendered for a user message, keyed by that message's flow key.
@@ -5030,6 +6254,19 @@ function apply(ctx) {
5030
6254
  subscribe: (listener) => ctx.locale.subscribe(listener)
5031
6255
  });
5032
6256
  ctx.effect(() => subscribeLocale(() => refreshDirectiveChips()), "dsh-select-quote: chip language");
6257
+ ctx.inject(["configForms"], (scope) => {
6258
+ scope.effect(() => attachConfigForm(scope.configForms.get(ROW_ID)), "dsh-select-quote: config");
6259
+ const form = scope.configForms.get(ROW_ID);
6260
+ scope.effect(() => scope.configForms.whileServed([ROW_ID], () => scope.slots.inject("plugins.bundle.config", () => scope.slots.register({
6261
+ name: "plugins.bundle.config",
6262
+ key: PACKAGE_NAME,
6263
+ locale: LOCALE_NS,
6264
+ inject: () => ({ form })
6265
+ }, ConfigRow))), "dsh-select-quote: settings section");
6266
+ });
6267
+ ctx.inject(["shortcuts"], (scope) => {
6268
+ installSelectionShortcut(scope);
6269
+ });
5033
6270
  ctx.effect(() => watchAnnotationDirectives(), "dsh-select-quote: annotation directives");
5034
6271
  ctx.effect(() => watchAnnotationMarks(), "dsh-select-quote: annotation marks");
5035
6272
  ctx.effect(() => watchSubmissionEcho(), "dsh-select-quote: submission echo");