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/README.md +33 -4
- package/lib/client.js +1580 -343
- package/lib/index.js +911 -2
- package/package.json +8 -2
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
|
-
两个坑,改之前先读:①
|
|
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
|
|
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$
|
|
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$
|
|
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/
|
|
3498
|
-
/**
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
const
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
}
|
|
3520
|
-
|
|
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
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
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
|
-
|
|
3692
|
-
refresh
|
|
4697
|
+
touch
|
|
3693
4698
|
]);
|
|
3694
|
-
|
|
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
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
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
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
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
|
-
|
|
3745
|
-
|
|
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
|
-
|
|
3786
|
-
|
|
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
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
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
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
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
|
|
3818
|
-
const
|
|
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:
|
|
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
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
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
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
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
|
-
}),
|
|
3931
|
-
|
|
3932
|
-
className:
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
}
|
|
3955
|
-
}),
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
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
|
-
]
|
|
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");
|