dsh-plugin-read-image-preview 1.4.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +130 -33
  3. package/client.js +2599 -717
  4. package/index.js +584 -119
  5. package/package.json +11 -2
package/client.js CHANGED
@@ -1,27 +1,29 @@
1
1
  /**
2
- * Browser half of the generic image tool-preview plugin.
2
+ * Browser half of the image preview / screenshot workspace plugin.
3
3
  *
4
- * One card claims the keyed presentation cell of every tool that puts a picture
5
- * in front of the model, at priority -1 (shadowing shipped rows and any dynamic
6
- * fallback cards). Two data sources feed the same interaction suite (thumbnail,
7
- * pixel/byte/format line, click-to-open zoom with Ctrl+wheel, drag pan,
8
- * double-click fit):
4
+ * Three surfaces share one full-screen viewer (zoom, pan, annotate, then send
5
+ * to the composer, copy, or save):
9
6
  *
10
- * - `file` mode — the call takes a `file_path` (the read family). Bytes come
11
- * from the host half over `GET /read-image-preview/…`, and the card previews
12
- * before the call settles. This is the original read_image behaviour.
13
- * - `attachment` mode — the settled result content carries `{type:'image',
14
- * attachment}` blocks (computer-use screenshots today; any future image
15
- * tool). Bytes come from the session-authorized `props.loadImage`, every
16
- * stat rides the attachment reference, and no extra request is made.
7
+ * - Image galleries. The official image positions — user and assistant message
8
+ * images, trajectory images — are filled at priority -1 with this plugin's
9
+ * gallery, so the official rows keep their own chrome (title, path, sizes,
10
+ * Inspect) and only the pictures and the viewer are ours. Older and newer
11
+ * hosts name the message slots differently; registering every name is safe
12
+ * because `slots.inject` waits for a declaration that never comes.
13
+ * - The whole `read_image` card (1.7): the official row renders its picture
14
+ * inside a collapsed-by-default body, so the plugin claims the `read_image`
15
+ * key of `tool.call.toolview` and renders the image expanded with a
16
+ * path/size bar. The result content already carries the durable image
17
+ * attachment, so the same card renders it without the host route 1.4 used.
18
+ * - Tool cards for image-bearing tools that have no official image row
19
+ * (computer-use, generate_image): their keyed `tool.call.toolview` cell is
20
+ * claimed at priority -1 and renders the same gallery plus one metadata line.
21
+ * - The composer's ✂ button: grab the desktop, drag a region, press Enter and
22
+ * the picture lands in the composer as a pasted attachment.
17
23
  *
18
- * Adding a new image-bearing tool is one key in `TOOL_LABELS` — nothing else.
19
- *
20
- * v1.4.1: both modes carry the picture's pixel size before the bytes arrive
21
- * (resolve reply / attachment ref), so the img element gets width+height and
22
- * the metadata bar its width up front. A late-loading picture then lands with
23
- * zero layout delta — the fix for the chat's stick-to-bottom logic reading an
24
- * image-driven reflow as a reader scroll and dropping auto-follow.
24
+ * The viewer never re-renders React on a pointer move or a wheel tick: the
25
+ * view lives in a ref and one animation frame writes the picture's transform
26
+ * and repaints a viewport-sized annotation canvas.
25
27
  *
26
28
  * @module dsh-plugin-read-image-preview/client
27
29
  */
@@ -33,19 +35,47 @@ window.__ModuleLoader__.load({
33
35
  const exports = module.exports
34
36
  const React = require('react')
35
37
 
36
- const HOST_ROUTE = '/read-image-preview/resolve'
38
+ /** The screenshot workspace's routes, all owned by this plugin's host half. */
39
+ const SHOT_ROUTE = '/read-image-preview'
40
+ /** Draw tools, in toolbar order. `move` pans and selects instead of drawing. */
41
+ const DRAW_TOOLS = ['move', 'rect', 'ellipse', 'arrow', 'pen', 'text', 'crop']
42
+ /** One-key shortcuts for the draw tools. */
43
+ const TOOL_KEYS = { v: 'move', r: 'rect', e: 'ellipse', a: 'arrow', p: 'pen', t: 'text', c: 'crop' }
44
+ /** The annotation palette. Red first: it is what a callout is usually drawn in. */
45
+ const COLORS = ['#ff2d2d', '#ffb300', '#12b76a', '#2f6fed', '#111111', '#ffffff']
46
+ /** Stroke widths, in source-image pixels so they survive a crop or a resize. */
47
+ const WIDTHS = [2, 4, 8]
48
+ /** Zoom bounds. 16× is for reading single pixels of a UI screenshot. */
49
+ const MIN_SCALE = 0.05
50
+ const MAX_SCALE = 16
51
+ /** How close, in screen pixels, a press must land to grab a mark or a handle. */
52
+ const GRAB_PX = 8
53
+ /** The composer is a Lexical editor; its root carries this attribute. */
54
+ const EDITOR_SELECTOR = '[data-lexical-editor="true"]'
55
+ /** An empty annotation state. Shared so "nothing was ever drawn" compares by identity. */
56
+ const EMPTY_SHOT = { marks: [], crop: null }
37
57
 
38
58
  const CSS = `
39
59
  .dsh-imgvw-root {
40
60
  display: flex;
41
61
  flex-direction: column;
42
- align-items: center;
62
+ align-items: flex-start;
43
63
  gap: 6px;
44
64
  min-width: 0;
45
65
  }
46
66
  .dsh-imgvw-root ~ [class*="_root"] {
47
67
  display: none !important;
48
68
  }
69
+ .dsh-imgvw-gallery {
70
+ display: flex;
71
+ flex-wrap: wrap;
72
+ gap: 6px;
73
+ max-width: 100%;
74
+ min-width: 0;
75
+ }
76
+ .dsh-imgvw-gallery[data-align='end'] {
77
+ justify-content: flex-end;
78
+ }
49
79
  .dsh-imgvw-frame {
50
80
  position: relative;
51
81
  display: flex;
@@ -53,13 +83,21 @@ window.__ModuleLoader__.load({
53
83
  justify-content: center;
54
84
  width: fit-content;
55
85
  max-width: 100%;
86
+ margin: 0;
87
+ padding: 0;
56
88
  border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.22));
57
89
  border-radius: 10px;
58
90
  overflow: hidden;
59
91
  background: var(--dsw-alias-bg-base, rgba(128, 128, 128, 0.05));
92
+ color: inherit;
93
+ font: inherit;
60
94
  line-height: 0;
61
95
  cursor: zoom-in;
62
96
  }
97
+ .dsh-imgvw-frame:focus-visible {
98
+ outline: 2px solid var(--dsw-static-deepseek-500, #4d6bfe);
99
+ outline-offset: 2px;
100
+ }
63
101
  .dsh-imgvw-img {
64
102
  display: block;
65
103
  width: auto;
@@ -67,6 +105,27 @@ window.__ModuleLoader__.load({
67
105
  max-width: min(420px, 100%);
68
106
  max-height: 300px;
69
107
  }
108
+ .dsh-imgvw-frame[data-variant='tile'] {
109
+ width: 120px;
110
+ height: 90px;
111
+ }
112
+ .dsh-imgvw-frame[data-variant='thumb'] {
113
+ width: 56px;
114
+ height: 56px;
115
+ }
116
+ .dsh-imgvw-frame[data-variant='tile'] .dsh-imgvw-img,
117
+ .dsh-imgvw-frame[data-variant='thumb'] .dsh-imgvw-img {
118
+ width: 100%;
119
+ height: 100%;
120
+ max-width: none;
121
+ max-height: none;
122
+ }
123
+ .dsh-imgvw-frame[data-variant='tile'] .dsh-imgvw-img {
124
+ object-fit: cover;
125
+ }
126
+ .dsh-imgvw-frame[data-variant='thumb'] .dsh-imgvw-img {
127
+ object-fit: contain;
128
+ }
70
129
  .dsh-imgvw-img[data-checker='true'] {
71
130
  background-image:
72
131
  linear-gradient(45deg, rgba(128, 128, 128, 0.18) 25%, transparent 25%),
@@ -76,30 +135,9 @@ window.__ModuleLoader__.load({
76
135
  background-size: 16px 16px;
77
136
  background-position: 0 0, 0 8px, 8px -8px, -8px 0;
78
137
  }
79
- .dsh-imgvw-img:hover {
138
+ .dsh-imgvw-frame:hover .dsh-imgvw-img {
80
139
  filter: brightness(1.04);
81
140
  }
82
- .dsh-imgvw-zoom {
83
- position: absolute;
84
- top: 6px;
85
- right: 6px;
86
- width: 20px;
87
- height: 20px;
88
- display: flex;
89
- align-items: center;
90
- justify-content: center;
91
- border-radius: 6px;
92
- background: rgba(20, 20, 20, 0.55);
93
- color: #fff;
94
- font-size: 12px;
95
- line-height: 1;
96
- opacity: 0;
97
- pointer-events: none;
98
- transition: opacity 0.15s ease;
99
- }
100
- .dsh-imgvw-frame:hover .dsh-imgvw-zoom {
101
- opacity: 1;
102
- }
103
141
  .dsh-imgvw-skel {
104
142
  position: relative;
105
143
  width: 100%;
@@ -109,6 +147,12 @@ window.__ModuleLoader__.load({
109
147
  overflow: hidden;
110
148
  background: var(--dsw-alias-bg-base, rgba(128, 128, 128, 0.07));
111
149
  }
150
+ .dsh-imgvw-frame[data-variant='tile'] .dsh-imgvw-skel,
151
+ .dsh-imgvw-frame[data-variant='thumb'] .dsh-imgvw-skel {
152
+ min-width: 0;
153
+ min-height: 0;
154
+ height: 100%;
155
+ }
112
156
  .dsh-imgvw-skel::after {
113
157
  content: '';
114
158
  position: absolute;
@@ -143,7 +187,6 @@ window.__ModuleLoader__.load({
143
187
  .dsh-imgvw-bar {
144
188
  display: flex;
145
189
  align-items: center;
146
- justify-content: center;
147
190
  flex-wrap: wrap;
148
191
  gap: 6px;
149
192
  max-width: 100%;
@@ -201,14 +244,67 @@ window.__ModuleLoader__.load({
201
244
  @keyframes dsh-imgvw-pulse {
202
245
  50% { opacity: 0.25; }
203
246
  }
247
+ /* ── 全屏查看 / 标注 ──────────────────────────────────────────────────────
248
+ * 原生窗口标题栏(Windows 的 WCO 三个按钮、macOS 的红绿灯)画在网页之上,
249
+ * 任何 z-index 都盖不住、也压不掉它的高亮。所以这里的做法是几何上的避让:
250
+ * 顶栏净空用宿主公布的 --dsh-frame-overlay-top,图片、按钮、提示一律待在
251
+ * 净空以下,右上区域里不出现任何本插件的控件。
252
+ * 不用 backdrop-filter:全屏模糊在每一帧平移时都要重算,是拖动卡顿的来源之一。 */
204
253
  .dsh-imgvw-backdrop {
205
254
  position: fixed;
206
255
  inset: 0;
207
256
  z-index: 2147483000;
208
257
  display: flex;
209
258
  flex-direction: column;
210
- background: rgba(8, 8, 10, 0.86);
211
- backdrop-filter: blur(2px);
259
+ padding: var(--dsh-frame-overlay-top, 8px) 0 0;
260
+ background: rgba(12, 12, 14, 0.94);
261
+ color: rgba(255, 255, 255, 0.86);
262
+ font-size: 12px;
263
+ line-height: 1.5;
264
+ outline: none;
265
+ -webkit-app-region: no-drag;
266
+ }
267
+ .dsh-imgvw-top {
268
+ flex: none;
269
+ display: flex;
270
+ align-items: center;
271
+ justify-content: center;
272
+ gap: 8px;
273
+ min-height: 30px;
274
+ padding: 0 16px 4px;
275
+ font-size: 11px;
276
+ }
277
+ .dsh-imgvw-top-name {
278
+ max-width: 46vw;
279
+ overflow: hidden;
280
+ text-overflow: ellipsis;
281
+ white-space: nowrap;
282
+ font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
283
+ }
284
+ .dsh-imgvw-hint {
285
+ padding: 2px 10px;
286
+ border-radius: 999px;
287
+ background: rgba(255, 255, 255, 0.08);
288
+ color: rgba(255, 255, 255, 0.66);
289
+ white-space: nowrap;
290
+ overflow: hidden;
291
+ text-overflow: ellipsis;
292
+ min-width: 0;
293
+ }
294
+ .dsh-imgvw-close {
295
+ display: flex;
296
+ align-items: center;
297
+ gap: 5px;
298
+ border: 1px solid rgba(255, 255, 255, 0.3);
299
+ border-radius: 7px;
300
+ background: rgba(255, 255, 255, 0.1);
301
+ color: #fff;
302
+ font: inherit;
303
+ padding: 3px 10px;
304
+ cursor: pointer;
305
+ }
306
+ .dsh-imgvw-close:hover {
307
+ background: rgba(255, 255, 255, 0.22);
212
308
  }
213
309
  .dsh-imgvw-stage {
214
310
  position: relative;
@@ -221,415 +317,2180 @@ window.__ModuleLoader__.load({
221
317
  touch-action: none;
222
318
  user-select: none;
223
319
  cursor: grab;
320
+ contain: layout paint;
224
321
  }
225
322
  .dsh-imgvw-stage[data-drag='true'] {
226
323
  cursor: grabbing;
227
324
  }
325
+ .dsh-imgvw-stage[data-draw='true'] {
326
+ cursor: crosshair;
327
+ }
328
+ .dsh-imgvw-canvas {
329
+ position: relative;
330
+ flex: none;
331
+ will-change: transform;
332
+ transform-origin: 50% 50%;
333
+ }
334
+ .dsh-imgvw-canvas[data-pixel='true'] .dsh-imgvw-big {
335
+ image-rendering: pixelated;
336
+ }
228
337
  .dsh-imgvw-big {
338
+ display: block;
229
339
  max-width: none;
230
340
  max-height: none;
231
- will-change: transform;
232
341
  }
233
- .dsh-imgvw-x {
342
+ .dsh-imgvw-layer {
234
343
  position: absolute;
235
- top: 14px;
236
- right: 18px;
237
- z-index: 1;
238
- width: 32px;
239
- height: 32px;
344
+ inset: 0;
345
+ width: 100%;
346
+ height: 100%;
347
+ pointer-events: none;
348
+ }
349
+ .dsh-imgvw-input {
350
+ position: absolute;
351
+ z-index: 3;
352
+ min-width: 120px;
353
+ border: 1px dashed rgba(255, 255, 255, 0.8);
354
+ border-radius: 3px;
355
+ background: rgba(0, 0, 0, 0.35);
356
+ font-family: system-ui, "Microsoft YaHei", "PingFang SC", sans-serif;
357
+ line-height: 1.1;
358
+ padding: 0 3px;
359
+ outline: none;
360
+ }
361
+ .dsh-imgvw-float {
362
+ position: absolute;
363
+ z-index: 4;
240
364
  display: flex;
241
365
  align-items: center;
366
+ flex-wrap: wrap;
242
367
  justify-content: center;
368
+ gap: 8px;
369
+ max-width: 90%;
370
+ padding: 5px 12px;
371
+ border-radius: 9px;
372
+ background: rgba(20, 20, 24, 0.92);
373
+ box-shadow: 0 8px 26px rgba(0, 0, 0, 0.35);
374
+ font-size: 12px;
375
+ }
376
+ .dsh-imgvw-toast {
377
+ left: 50%;
378
+ bottom: 14px;
379
+ transform: translateX(-50%);
380
+ }
381
+ .dsh-imgvw-toast[data-bad='true'] {
382
+ color: #ff9a9a;
383
+ }
384
+ .dsh-imgvw-confirm {
385
+ left: 50%;
386
+ top: 14px;
387
+ transform: translateX(-50%);
388
+ }
389
+ .dsh-imgvw-snip {
390
+ flex-wrap: nowrap;
391
+ gap: 4px;
392
+ padding: 4px;
393
+ max-width: none;
394
+ }
395
+ .dsh-imgvw-nav {
396
+ position: absolute;
397
+ z-index: 2;
398
+ top: 50%;
399
+ width: 34px;
400
+ height: 56px;
401
+ margin-top: -28px;
243
402
  border: 0;
244
403
  border-radius: 8px;
245
- background: rgba(255, 255, 255, 0.12);
404
+ background: rgba(255, 255, 255, 0.1);
246
405
  color: #fff;
247
- font-size: 18px;
406
+ font-size: 22px;
248
407
  line-height: 1;
249
408
  cursor: pointer;
250
409
  }
251
- .dsh-imgvw-x:hover {
410
+ .dsh-imgvw-nav:hover {
252
411
  background: rgba(255, 255, 255, 0.22);
253
412
  }
254
- .dsh-imgvw-legend {
255
- position: absolute;
256
- left: 50%;
257
- top: 20px;
258
- transform: translateX(-50%);
259
- z-index: 1;
260
- padding: 5px 12px;
261
- border-radius: 999px;
262
- background: rgba(0, 0, 0, 0.45);
263
- color: rgba(255, 255, 255, 0.82);
264
- font-size: 11px;
265
- line-height: 1.6;
266
- white-space: nowrap;
267
- pointer-events: none;
413
+ .dsh-imgvw-nav[data-side='prev'] {
414
+ left: 10px;
415
+ }
416
+ .dsh-imgvw-nav[data-side='next'] {
417
+ right: 10px;
268
418
  }
269
- .dsh-imgvw-caption {
419
+ .dsh-imgvw-tools {
270
420
  flex: none;
271
421
  display: flex;
272
422
  align-items: center;
273
423
  justify-content: center;
274
424
  flex-wrap: wrap;
275
- gap: 10px;
276
- padding: 10px 16px;
277
- color: rgba(255, 255, 255, 0.8);
278
- font-size: 11px;
279
- line-height: 1.6;
425
+ gap: 6px 0;
426
+ padding: 6px 12px 10px;
427
+ }
428
+ .dsh-imgvw-group {
429
+ display: flex;
430
+ align-items: center;
431
+ gap: 3px;
432
+ padding: 0 8px;
433
+ border-left: 1px solid rgba(255, 255, 255, 0.14);
280
434
  }
281
- .dsh-imgvw-caption b {
282
- font-weight: 600;
435
+ .dsh-imgvw-group:first-child {
436
+ border-left: 0;
437
+ }
438
+ .dsh-imgvw-tools button,
439
+ .dsh-imgvw-float button {
440
+ border: 1px solid transparent;
441
+ border-radius: 6px;
442
+ background: rgba(255, 255, 255, 0.07);
443
+ color: inherit;
444
+ font: inherit;
445
+ font-size: 12px;
446
+ padding: 3px 8px;
447
+ cursor: pointer;
448
+ white-space: nowrap;
449
+ }
450
+ .dsh-imgvw-tools button:hover,
451
+ .dsh-imgvw-float button:hover {
452
+ background: rgba(255, 255, 255, 0.16);
453
+ }
454
+ .dsh-imgvw-tools button[data-on='true'] {
455
+ border-color: rgba(255, 255, 255, 0.6);
456
+ background: rgba(255, 255, 255, 0.24);
457
+ color: #fff;
458
+ }
459
+ .dsh-imgvw-tools button[data-strong='true'],
460
+ .dsh-imgvw-float button[data-strong='true'] {
461
+ border-color: var(--dsw-static-deepseek-500, #4d6bfe);
462
+ background: var(--dsw-static-deepseek-500, #4d6bfe);
283
463
  color: #fff;
464
+ }
465
+ .dsh-imgvw-tools button:disabled,
466
+ .dsh-imgvw-float button:disabled {
467
+ opacity: 0.4;
468
+ cursor: default;
469
+ }
470
+ .dsh-imgvw-tools .dsh-imgvw-swatch {
471
+ width: 18px;
472
+ height: 18px;
473
+ padding: 0;
474
+ border-radius: 50%;
475
+ border: 1px solid rgba(255, 255, 255, 0.45);
476
+ }
477
+ .dsh-imgvw-tools .dsh-imgvw-swatch[data-on='true'] {
478
+ border-color: #fff;
479
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35);
480
+ }
481
+ .dsh-imgvw-zoomval {
482
+ min-width: 46px;
483
+ text-align: center;
284
484
  font-variant-numeric: tabular-nums;
285
485
  }
286
- .dsh-imgvw-caption button {
287
- background: none;
288
- border: 1px solid rgba(255, 255, 255, 0.28);
486
+ /* ── 截图管理页 ──────────────────────────────────────────────────────────── */
487
+ .dsh-shot-page {
488
+ display: flex;
489
+ flex-direction: column;
490
+ gap: 14px;
491
+ max-width: 900px;
492
+ color: var(--dsw-alias-label-primary, inherit);
493
+ font-size: 13px;
494
+ line-height: 1.6;
495
+ }
496
+ .dsh-shot-head {
497
+ display: flex;
498
+ align-items: flex-start;
499
+ justify-content: space-between;
500
+ flex-wrap: wrap;
501
+ gap: 10px;
502
+ }
503
+ .dsh-shot-note {
504
+ margin: 0;
505
+ opacity: 0.75;
506
+ font-size: 12px;
507
+ }
508
+ .dsh-shot-dir {
509
+ font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
510
+ font-size: 11px;
511
+ word-break: break-all;
512
+ }
513
+ .dsh-shot-actions {
514
+ display: flex;
515
+ align-items: center;
516
+ gap: 8px;
517
+ flex-wrap: wrap;
518
+ }
519
+ .dsh-shot-grid {
520
+ display: grid;
521
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
522
+ gap: 10px;
523
+ }
524
+ .dsh-shot-card {
525
+ display: flex;
526
+ flex-direction: column;
527
+ gap: 6px;
528
+ padding: 8px;
529
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.22));
530
+ border-radius: 10px;
531
+ background: var(--dsw-alias-bg-base, rgba(128, 128, 128, 0.05));
532
+ }
533
+ .dsh-shot-thumb {
534
+ display: block;
535
+ width: 100%;
536
+ aspect-ratio: 16 / 10;
537
+ object-fit: contain;
289
538
  border-radius: 6px;
539
+ background: rgba(128, 128, 128, 0.1);
540
+ cursor: zoom-in;
541
+ }
542
+ .dsh-shot-meta {
543
+ font-size: 11px;
544
+ opacity: 0.8;
545
+ word-break: break-all;
546
+ }
547
+ .dsh-shot-row {
548
+ display: flex;
549
+ align-items: center;
550
+ justify-content: space-between;
551
+ gap: 6px;
552
+ }
553
+ .dsh-shot-empty {
554
+ padding: 26px 0;
555
+ text-align: center;
556
+ opacity: 0.6;
557
+ }
558
+ .dsh-shot-capture {
559
+ display: inline-flex;
560
+ align-items: center;
561
+ gap: 4px;
562
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.28));
563
+ border-radius: 8px;
564
+ background: transparent;
565
+ color: inherit;
566
+ font: inherit;
567
+ font-size: 12px;
568
+ padding: 3px 9px;
569
+ cursor: pointer;
570
+ }
571
+ .dsh-shot-capture:hover {
572
+ background: var(--dsw-alias-bg-hover, rgba(128, 128, 128, 0.12));
573
+ }
574
+ .dsh-shot-menu {
575
+ position: absolute;
576
+ z-index: 5;
577
+ display: flex;
578
+ flex-direction: column;
579
+ min-width: 148px;
580
+ padding: 4px;
581
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.24));
582
+ border-radius: 9px;
583
+ background: var(--dsw-alias-bg-elevated, rgba(24, 24, 28, 0.96));
584
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
585
+ }
586
+ .dsh-shot-menu button {
587
+ border: 0;
588
+ background: none;
290
589
  color: inherit;
291
590
  font: inherit;
292
- padding: 1px 8px;
591
+ font-size: 12px;
592
+ text-align: left;
593
+ padding: 5px 8px;
594
+ border-radius: 6px;
293
595
  cursor: pointer;
294
596
  }
295
- .dsh-imgvw-caption button:hover {
296
- background: rgba(255, 255, 255, 0.14);
597
+ .dsh-shot-menu button:hover {
598
+ background: var(--dsw-alias-bg-hover, rgba(128, 128, 128, 0.16));
297
599
  }
298
600
  `
299
601
 
300
- /** Narrow an unknown wire value to a plain record. */
301
- function isRecord(value) {
302
- return typeof value === 'object' && value !== null && Array.isArray(value) === false
602
+ /**
603
+ * The error text for a failed request, preferring what the host said.
604
+ * @param {unknown} error - the caught value.
605
+ * @returns {string} a one-line message.
606
+ */
607
+ function failureOf(error) {
608
+ return error instanceof Error ? error.message : String(error)
303
609
  }
304
610
 
305
611
  /**
306
- * Read the `file_path` argument out of a Tool call block.
612
+ * GET or POST one plugin route and unwrap `{ok:false}` answers.
307
613
  *
308
- * The block is either a running call or a settled result; both carry the call
309
- * head, whose `argsRaw` is the JSON the model produced. A settled call with a
310
- * malformed head simply renders nothing rather than a broken card.
614
+ * The write routes answer POST only, so a missing body still means a POST
615
+ * here — passing `null` is how a caller says "no payload, but it is a write".
616
+ * @param {string} url - the route, same-origin.
617
+ * @param {BodyInit | null} [body] - the request body, or `null` for a bodyless POST.
618
+ * @returns {Promise<object>} the parsed answer, already checked for `ok`.
311
619
  */
312
- function imagePathOf(block) {
313
- const call = isRecord(block) && isRecord(block.call) ? block.call : block
314
- if (isRecord(call) === false) return null
315
- const raw = call.argsRaw
316
- if (typeof raw !== 'string' || raw === '') return null
317
- let args
620
+ async function requestJson(url, body) {
621
+ const write = body !== undefined
622
+ const response = await fetch(url, write
623
+ ? {
624
+ method: 'POST',
625
+ body: body === null ? null : body,
626
+ headers: { 'content-type': 'application/octet-stream' },
627
+ }
628
+ : { method: 'GET' })
629
+ let value = null
318
630
  try {
319
- args = JSON.parse(raw)
631
+ value = await response.json()
320
632
  } catch {
321
- return null
633
+ value = null
322
634
  }
323
- if (isRecord(args) === false) return null
324
- const path = args.file_path
325
- if (typeof path !== 'string' || path.trim() === '') return null
326
- return path
635
+ if (response.ok !== true || value === null || value.ok !== true) {
636
+ const said = value !== null && typeof value.error === 'string' ? value.error : ''
637
+ throw new Error(said === '' ? '请求失败 (' + response.status + ')' : said)
638
+ }
639
+ return value
327
640
  }
328
641
 
329
- /** Strip the workspace root from a workspace-rooted path, for display only. */
330
- function displayPathOf(path, cwd) {
331
- if (typeof cwd !== 'string' || cwd === '') return path
332
- const root = cwd.replace(/[/\\]+$/, '')
333
- if (root === '') return path
334
- if (path.startsWith(root + '/') || path.startsWith(root + '\\')) return path.slice(root.length + 1)
335
- return path
642
+ /**
643
+ * Grab the desktop through the host half.
644
+ *
645
+ * The answer is the PNG itself rather than a JSON wrapper: a full-desktop
646
+ * bitmap is a few hundred KB to a few MB, and handing the browser the bytes
647
+ * directly avoids base64-ing them.
648
+ * @param {number} delayMs - how long the host waits before it grabs.
649
+ * @returns {Promise<{ url: string, bytes: number }>} an object URL for the grab.
650
+ */
651
+ async function captureScreen(delayMs) {
652
+ const response = await fetch(SHOT_ROUTE + '/capture?delay=' + Math.max(0, Math.round(delayMs)), { method: 'POST' })
653
+ const type = response.headers.get('content-type') ?? ''
654
+ if (response.ok !== true || type.indexOf('image/') !== 0) {
655
+ let said = ''
656
+ try {
657
+ const value = await response.json()
658
+ if (value !== null && typeof value.error === 'string') said = value.error
659
+ } catch {
660
+ said = ''
661
+ }
662
+ throw new Error(said === '' ? '抓屏失败 (' + response.status + ')' : said)
663
+ }
664
+ const blob = await response.blob()
665
+ return { url: URL.createObjectURL(blob), bytes: blob.size }
336
666
  }
337
667
 
338
- /** Render a byte count at reading size. */
339
- function formatBytes(bytes) {
340
- if (typeof bytes !== 'number' || bytes <= 0) return null
341
- if (bytes < 1024) return bytes + ' B'
342
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
343
- return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
668
+ /**
669
+ * Put a PNG on the clipboard as an image.
670
+ *
671
+ * This is the one clipboard write the page can do without a host helper: the
672
+ * desktop app runs in a secure context with sanitized writes granted.
673
+ * @param {Blob} blob - the PNG to hand over.
674
+ */
675
+ async function copyImageBlob(blob) {
676
+ const clip = navigator.clipboard
677
+ if (clip === undefined || typeof clip.write !== 'function' || typeof ClipboardItem !== 'function') {
678
+ throw new Error('当前环境不能复制图片,请改用「保存」')
679
+ }
680
+ await clip.write([new ClipboardItem({ 'image/png': blob })])
681
+ }
682
+
683
+ /** Copy plain text, reporting the same failures the caller shows. */
684
+ async function copyText(text) {
685
+ const clip = navigator.clipboard
686
+ if (clip === undefined || typeof clip.writeText !== 'function') throw new Error('当前环境不能复制')
687
+ await clip.writeText(text)
344
688
  }
345
689
 
690
+ /** The composer editor that last had focus, kept by a focusin listener `apply` installs. */
691
+ let lastEditor = null
692
+
346
693
  /**
347
- * The claim list: every tool whose card this plugin owns, with the Chinese
348
- * label shown while the call runs. `read_image` needs no label (the file
349
- * path speaks for itself). Adding an image-bearing tool = adding one line.
694
+ * Find the composer a picture should be sent to.
695
+ *
696
+ * The nearest editor to the element the request came from wins (the ✂
697
+ * button sits inside the composer; a gallery sits in the conversation whose
698
+ * composer it belongs to), then the editor that last had focus, then the
699
+ * last visible one on the page.
700
+ * @param {Element | null | undefined} anchor - where the request came from.
701
+ * @returns {HTMLElement | null} the editor root, or null when there is none.
350
702
  */
351
- const TOOL_LABELS = {
352
- generate_image: '绘图',
353
- computer_screenshot: '屏幕截图',
354
- computer_zoom: '区域放大',
355
- computer_wait: '等待',
356
- computer_move: '移动鼠标',
357
- computer_click: '点击',
358
- computer_drag: '拖拽',
359
- computer_scroll: '滚动',
360
- computer_type: '输入文本',
361
- computer_key: '按键',
362
- computer_open: '启动应用',
363
- computer_sequence: '连续动作',
703
+ function findComposer(anchor) {
704
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return null
705
+ const usable = (node) => node !== null && node !== undefined && node.isContentEditable === true
706
+ && node.isConnected !== false
707
+ for (let node = anchor ?? null; node !== null && node !== undefined; node = node.parentElement) {
708
+ if (typeof node.querySelector !== 'function') continue
709
+ const found = node.querySelector(EDITOR_SELECTOR)
710
+ if (usable(found)) return found
711
+ }
712
+ if (usable(lastEditor)) return lastEditor
713
+ const all = Array.from(document.querySelectorAll(EDITOR_SELECTOR))
714
+ .filter((node) => usable(node) && node.getClientRects().length > 0)
715
+ return all.length === 0 ? null : all[all.length - 1]
364
716
  }
365
717
 
366
- const IMAGE_TOOL_KEYS = ['read_image'].concat(Object.keys(TOOL_LABELS))
367
-
368
718
  /**
369
- * Decide what the card should render for one call.
719
+ * Hand a PNG to the composer the way a user's Ctrl+V would.
370
720
  *
371
- * `file` when the call itself names a path (previewable before settle);
372
- * otherwise `attachment` when the settled content carries image refs —
373
- * collected together with the first text line, which becomes the note row.
721
+ * The composer takes pasted files through its editor's paste command, which
722
+ * is the same intake a real paste and a drop use — a user-facing behaviour,
723
+ * not a private hook — and it cancels the event when it took the files.
724
+ * @param {HTMLElement} editor - the composer's editor root.
725
+ * @param {Blob} blob - the PNG.
726
+ * @param {string} name - the file name the attachment shows.
727
+ * @returns {boolean} whether the composer accepted the file.
374
728
  */
375
- function imageSpecOf(block) {
376
- const path = imagePathOf(block)
377
- if (path !== null) return { mode: 'file', path, atts: [], text: '' }
378
- const content = isRecord(block) && Array.isArray(block.content) ? block.content : null
379
- if (content === null) return null
380
- const atts = []
381
- let text = ''
729
+ function pasteImage(editor, blob, name) {
730
+ if (typeof DataTransfer !== 'function' || typeof ClipboardEvent !== 'function' || typeof File !== 'function') {
731
+ return false
732
+ }
733
+ const transfer = new DataTransfer()
734
+ transfer.items.add(new File([blob], name, { type: 'image/png' }))
735
+ try {
736
+ editor.focus({ preventScroll: true })
737
+ } catch {
738
+ /* focus is a courtesy: the paste command does not need it */
739
+ }
740
+ const event = new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true, cancelable: true })
741
+ editor.dispatchEvent(event)
742
+ return event.defaultPrevented === true
743
+ }
744
+
745
+ /** A file name for a picture sent to the composer. */
746
+ function pastedName(now) {
747
+ const at = new Date(now)
748
+ const pad = (value) => String(value).padStart(2, '0')
749
+ return 'screenshot-' + at.getFullYear() + pad(at.getMonth() + 1) + pad(at.getDate())
750
+ + '-' + pad(at.getHours()) + pad(at.getMinutes()) + pad(at.getSeconds()) + '.png'
751
+ }
752
+
753
+ /**
754
+ * Paint one annotation.
755
+ *
756
+ * Coordinates are source-image pixels, so the same mark renders identically
757
+ * on the on-screen layer and in the exported PNG.
758
+ * @param {CanvasRenderingContext2D} context - the target context.
759
+ * @param {object} mark - one entry of the mark list.
760
+ */
761
+ function drawMark(context, mark) {
762
+ if (mark === null || mark === undefined) return
763
+ context.strokeStyle = mark.color
764
+ context.fillStyle = mark.color
765
+ context.lineWidth = mark.width
766
+ context.lineCap = 'round'
767
+ context.lineJoin = 'round'
768
+ context.setLineDash(mark.dashed === true ? [mark.width * 4, mark.width * 3] : [])
769
+ if (mark.type === 'rect') {
770
+ context.strokeRect(Math.min(mark.x0, mark.x1), Math.min(mark.y0, mark.y1),
771
+ Math.abs(mark.x1 - mark.x0), Math.abs(mark.y1 - mark.y0))
772
+ return
773
+ }
774
+ if (mark.type === 'ellipse') {
775
+ context.beginPath()
776
+ context.ellipse((mark.x0 + mark.x1) / 2, (mark.y0 + mark.y1) / 2,
777
+ Math.abs(mark.x1 - mark.x0) / 2, Math.abs(mark.y1 - mark.y0) / 2, 0, 0, Math.PI * 2)
778
+ context.stroke()
779
+ return
780
+ }
781
+ if (mark.type === 'arrow') {
782
+ const dx = mark.x1 - mark.x0
783
+ const dy = mark.y1 - mark.y0
784
+ const length = Math.sqrt(dx * dx + dy * dy)
785
+ if (length < 1) return
786
+ const head = Math.min(length / 2, mark.width * 5)
787
+ const angle = Math.atan2(dy, dx)
788
+ context.beginPath()
789
+ context.moveTo(mark.x0, mark.y0)
790
+ context.lineTo(mark.x1 - Math.cos(angle) * head * 0.6, mark.y1 - Math.sin(angle) * head * 0.6)
791
+ context.stroke()
792
+ context.beginPath()
793
+ context.moveTo(mark.x1, mark.y1)
794
+ context.lineTo(mark.x1 - Math.cos(angle - 0.42) * head, mark.y1 - Math.sin(angle - 0.42) * head)
795
+ context.lineTo(mark.x1 - Math.cos(angle + 0.42) * head, mark.y1 - Math.sin(angle + 0.42) * head)
796
+ context.closePath()
797
+ context.fill()
798
+ return
799
+ }
800
+ if (mark.type === 'pen') {
801
+ const points = mark.points
802
+ if (points.length < 4) return
803
+ context.beginPath()
804
+ context.moveTo(points[0], points[1])
805
+ for (let i = 2; i + 1 < points.length; i += 2) context.lineTo(points[i], points[i + 1])
806
+ context.stroke()
807
+ return
808
+ }
809
+ if (mark.type === 'text') {
810
+ context.font = mark.size + 'px system-ui, "Microsoft YaHei", "PingFang SC", sans-serif'
811
+ context.textBaseline = 'top'
812
+ context.shadowColor = 'rgba(0, 0, 0, 0.6)'
813
+ context.shadowBlur = Math.max(2, mark.size / 4)
814
+ context.fillText(mark.text, mark.x0, mark.y0)
815
+ context.shadowBlur = 0
816
+ return
817
+ }
818
+ context.setLineDash([])
819
+ }
820
+
821
+ /** Paint the whole mark list, then the mark still being dragged. */
822
+ function drawMarks(context, marks, draft) {
823
+ for (const mark of marks) drawMark(context, mark)
824
+ if (draft !== null && draft !== undefined) drawMark(context, draft)
825
+ }
826
+
827
+ /**
828
+ * Paint a pending crop: the picture dims outside it and its edge is drawn.
829
+ * @param {CanvasRenderingContext2D} context - the annotation layer's context.
830
+ * @param {{ x: number, y: number, w: number, h: number }} crop - the rect in source pixels.
831
+ * @param {{ w: number, h: number }} natural - the picture's source size.
832
+ * @param {number} [scale] - the current zoom, so the edge stays one screen pixel wide.
833
+ */
834
+ function paintCrop(context, crop, natural, scale) {
835
+ const s = typeof scale === 'number' && scale > 0 ? scale : 1
836
+ context.save()
837
+ context.setLineDash([])
838
+ context.fillStyle = 'rgba(0, 0, 0, 0.55)'
839
+ context.fillRect(0, 0, natural.w, crop.y)
840
+ context.fillRect(0, crop.y + crop.h, natural.w, Math.max(0, natural.h - crop.y - crop.h))
841
+ context.fillRect(0, crop.y, crop.x, crop.h)
842
+ context.fillRect(crop.x + crop.w, crop.y, Math.max(0, natural.w - crop.x - crop.w), crop.h)
843
+ context.strokeStyle = '#ffffff'
844
+ context.lineWidth = 1.5 / s
845
+ context.setLineDash([7 / s, 5 / s])
846
+ context.strokeRect(crop.x, crop.y, crop.w, crop.h)
847
+ context.restore()
848
+ }
849
+
850
+ /**
851
+ * Render the annotated picture out to a PNG blob.
852
+ * @param {HTMLImageElement} image - the loaded source picture.
853
+ * @param {{ x: number, y: number, w: number, h: number } | null} crop - the crop rect in source pixels, or none.
854
+ * @param {object[]} marks - the annotations, in source pixels.
855
+ * @returns {Promise<Blob>} the PNG.
856
+ */
857
+ function exportPng(image, crop, marks) {
858
+ if (image === null || image === undefined || image.naturalWidth <= 0) {
859
+ return Promise.reject(new Error('图片还没有准备好'))
860
+ }
861
+ const full = crop === null || crop === undefined
862
+ const width = full ? image.naturalWidth : Math.max(1, Math.round(crop.w))
863
+ const height = full ? image.naturalHeight : Math.max(1, Math.round(crop.h))
864
+ const offsetX = full ? 0 : Math.round(crop.x)
865
+ const offsetY = full ? 0 : Math.round(crop.y)
866
+ const canvas = document.createElement('canvas')
867
+ canvas.width = width
868
+ canvas.height = height
869
+ const context = canvas.getContext('2d')
870
+ if (context === null) return Promise.reject(new Error('无法创建导出画布'))
871
+ context.drawImage(image, offsetX, offsetY, width, height, 0, 0, width, height)
872
+ context.translate(-offsetX, -offsetY)
873
+ drawMarks(context, marks, null)
874
+ return new Promise((resolve, reject) => {
875
+ canvas.toBlob((blob) => {
876
+ if (blob === null || blob === undefined) reject(new Error('导出 PNG 失败(图片可能被跨域限制)'))
877
+ else resolve(blob)
878
+ }, 'image/png')
879
+ })
880
+ }
881
+
882
+ /**
883
+ * Map one wheel event onto the stage's gestures.
884
+ *
885
+ * A plain wheel zooms — that is the whole point of opening a picture full
886
+ * screen. Shift drives the vertical axis and Ctrl the horizontal one, which
887
+ * is how an overflowed picture gets read without leaving the wheel. Windows
888
+ * browsers report Shift+wheel as a horizontal delta, so the axis falls back
889
+ * to whichever delta moved.
890
+ *
891
+ * Two touchpad gestures are told apart from the mouse: a pinch arrives as
892
+ * Ctrl+wheel with small or fractional deltas (a mouse notch is ~100px and
893
+ * whole) and zooms; a two-finger swipe carries a horizontal delta without
894
+ * Shift — which a mouse wheel never does — and pans on both axes.
895
+ * @param {{ ctrlKey?: boolean, shiftKey?: boolean, metaKey?: boolean }} mods - the gesture's modifiers.
896
+ * @param {number} deltaX - the horizontal delta.
897
+ * @param {number} deltaY - the vertical delta.
898
+ * @param {number} deltaMode - 0 pixels, 1 lines, 2 pages.
899
+ * @returns {{ axis: 'zoom' | 'x' | 'y' | 'pan', amount: number, x?: number, y?: number, smooth?: boolean }} the gesture.
900
+ */
901
+ function gestureOf(mods, deltaX, deltaY, deltaMode) {
902
+ const unit = deltaMode === 1 ? 33 : deltaMode === 2 ? 400 : 1
903
+ const ctrl = mods.ctrlKey === true || mods.metaKey === true
904
+ const shift = mods.shiftKey === true
905
+ if (ctrl && shift === false && deltaMode === 0 && deltaX === 0 && deltaY !== 0
906
+ && (Math.abs(deltaY) < 40 || Number.isInteger(deltaY) === false)) {
907
+ return { axis: 'zoom', amount: -deltaY * 6, smooth: false }
908
+ }
909
+ const moved = deltaY !== 0 ? deltaY : deltaX
910
+ const amount = moved === 0 ? 0 : -moved * unit
911
+ if (shift) return { axis: 'y', amount }
912
+ if (ctrl) return { axis: 'x', amount }
913
+ if (deltaX !== 0 && deltaMode === 0) {
914
+ return { axis: 'pan', amount: 0, x: -deltaX, y: -deltaY }
915
+ }
916
+ return { axis: 'zoom', amount, smooth: deltaMode !== 0 || Math.abs(moved) >= 40 }
917
+ }
918
+
919
+ /** The zoom factor one wheel amount stands for: about 1.2× per mouse notch. */
920
+ function wheelFactor(amount) {
921
+ return Math.exp(amount / 550)
922
+ }
923
+
924
+ /**
925
+ * Zoom a view about a point, keeping that point still on screen.
926
+ * @param {{ scale: number, x: number, y: number }} view - the current view; x/y offset the picture's centre from the stage's.
927
+ * @param {number} factor - the zoom multiplier.
928
+ * @param {number} cx - the anchor, relative to the stage centre.
929
+ * @param {number} cy - the anchor, relative to the stage centre.
930
+ * @returns {{ scale: number, x: number, y: number }} the new view.
931
+ */
932
+ function zoomAbout(view, factor, cx, cy) {
933
+ const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, view.scale * factor))
934
+ const ratio = scale / view.scale
935
+ return { scale, x: cx - (cx - view.x) * ratio, y: cy - (cy - view.y) * ratio }
936
+ }
937
+
938
+ /** The scale that fits the whole picture in the stage, never above 1:1. */
939
+ function fitScale(natural, vp) {
940
+ if (!(natural.w > 0 && natural.h > 0 && vp.w > 0 && vp.h > 0)) return 1
941
+ return Math.min(1, (vp.w * 0.96) / natural.w, (vp.h * 0.94) / natural.h)
942
+ }
943
+
944
+ /** Where the picture's top-left corner sits in the stage, in screen pixels. */
945
+ function originOf(view, natural, vp) {
946
+ return {
947
+ x: vp.w / 2 + view.x - (natural.w * view.scale) / 2,
948
+ y: vp.h / 2 + view.y - (natural.h * view.scale) / 2,
949
+ }
950
+ }
951
+
952
+ /** Render `node` at the document root so no ancestor's transform can clip it. */
953
+ function toDocument(node) {
954
+ try {
955
+ const dom = require('react-dom')
956
+ if (typeof dom.createPortal === 'function' && typeof document !== 'undefined') {
957
+ return dom.createPortal(node, document.body)
958
+ }
959
+ } catch {
960
+ /* no react-dom in this loader: the overlay renders where it was mounted */
961
+ }
962
+ return node
963
+ }
964
+
965
+ /** Narrow an unknown wire value to a plain record. */
966
+ function isRecord(value) {
967
+ return typeof value === 'object' && value !== null && Array.isArray(value) === false
968
+ }
969
+
970
+ /**
971
+ * Read the `file_path` argument out of a Tool call block.
972
+ *
973
+ * The block is either a running call or a settled result; both carry the call
974
+ * head, whose `argsRaw` is the JSON the model produced. A settled call with a
975
+ * malformed head simply renders nothing rather than a broken card.
976
+ */
977
+ function imagePathOf(block) {
978
+ const call = isRecord(block) && isRecord(block.call) ? block.call : block
979
+ if (isRecord(call) === false) return null
980
+ const raw = call.argsRaw
981
+ if (typeof raw !== 'string' || raw === '') return null
982
+ let args
983
+ try {
984
+ args = JSON.parse(raw)
985
+ } catch {
986
+ return null
987
+ }
988
+ if (isRecord(args) === false) return null
989
+ const path = args.file_path
990
+ if (typeof path !== 'string' || path.trim() === '') return null
991
+ return path
992
+ }
993
+
994
+ /** Strip the workspace root from a workspace-rooted path, for display only. */
995
+ function displayPathOf(path, cwd) {
996
+ if (typeof cwd !== 'string' || cwd === '') return path
997
+ const root = cwd.replace(/[/\\]+$/, '')
998
+ if (root === '') return path
999
+ if (path.startsWith(root + '/') || path.startsWith(root + '\\')) return path.slice(root.length + 1)
1000
+ return path
1001
+ }
1002
+
1003
+ /** Render a byte count at reading size. */
1004
+ function formatBytes(bytes) {
1005
+ if (typeof bytes !== 'number' || bytes <= 0) return null
1006
+ if (bytes < 1024) return bytes + ' B'
1007
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
1008
+ return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
1009
+ }
1010
+
1011
+ /**
1012
+ * The claim list: every tool whose whole card this plugin owns, with the
1013
+ * Chinese label shown while the call runs. `read_image` is prepended (1.7):
1014
+ * the official row shows its picture inside a collapsed-by-default body, so
1015
+ * this plugin claims the whole card to render it expanded. Adding an
1016
+ * image-bearing tool = adding one line.
1017
+ */
1018
+ const TOOL_LABELS = {
1019
+ generate_image: '绘图',
1020
+ computer_screenshot: '屏幕截图',
1021
+ computer_zoom: '区域放大',
1022
+ computer_wait: '等待',
1023
+ computer_move: '移动鼠标',
1024
+ computer_click: '点击',
1025
+ computer_drag: '拖拽',
1026
+ computer_scroll: '滚动',
1027
+ computer_type: '输入文本',
1028
+ computer_key: '按键',
1029
+ computer_open: '启动应用',
1030
+ computer_sequence: '连续动作',
1031
+ }
1032
+
1033
+ const IMAGE_TOOL_KEYS = ['read_image'].concat(Object.keys(TOOL_LABELS))
1034
+
1035
+ /**
1036
+ * Every official image position this plugin's gallery fills. The message
1037
+ * slots were renamed between dsh 0.2.0 and 0.2.1; both names are listed and
1038
+ * the one the running host does not declare simply never activates.
1039
+ */
1040
+ const IMAGE_SLOTS = [
1041
+ 'tool.call.images',
1042
+ 'conversation.chat.user.images',
1043
+ 'conversation.chat.message.images',
1044
+ 'conversation.message.images',
1045
+ 'conversation.trajectory.images',
1046
+ ]
1047
+
1048
+ /** The `<path>…</path>\n<type>image</type>` envelope read_image hands the model. */
1049
+ const IMAGE_ENVELOPE = /^<path>[^\n]*<\/path>\n<type>image<\/type>/
1050
+
1051
+ /**
1052
+ * Decide what the card should render for one call: the settled content's
1053
+ * image refs, the first non-envelope text line (the note row), and the
1054
+ * call's `file_path` when it names one. Null until a result carries an image.
1055
+ */
1056
+ function imageSpecOf(block) {
1057
+ const content = isRecord(block) && Array.isArray(block.content) ? block.content : null
1058
+ if (content === null) return null
1059
+ const atts = []
1060
+ let text = ''
382
1061
  for (const item of content) {
383
1062
  if (!isRecord(item)) continue
384
1063
  if (item.type === 'image' && isRecord(item.attachment)
385
1064
  && typeof item.attachment.attachmentId === 'string') {
386
1065
  atts.push(item.attachment)
387
- } else if (item.type === 'text' && typeof item.text === 'string' && text === '') {
1066
+ } else if (item.type === 'text' && typeof item.text === 'string' && text === ''
1067
+ && IMAGE_ENVELOPE.test(item.text) === false) {
388
1068
  text = String(item.text).split('\n')[0]
389
1069
  }
390
1070
  }
391
1071
  if (atts.length === 0) return null
392
- return { mode: 'attachment', path: null, atts, text }
1072
+ return { mode: 'attachment', path: imagePathOf(block), atts, text }
1073
+ }
1074
+
1075
+ /** The font size a text mark takes, in source pixels, from the stroke-width choice. */
1076
+ function textSize(natural, lineWidth) {
1077
+ const base = natural.h > 0 ? natural.h : 800
1078
+ return Math.max(14, Math.round((base * lineWidth) / 200))
1079
+ }
1080
+
1081
+ /** Normalize two corners into a rectangle in source pixels. */
1082
+ function rectOf(a, b) {
1083
+ return {
1084
+ x: Math.min(a.x, b.x),
1085
+ y: Math.min(a.y, b.y),
1086
+ w: Math.abs(b.x - a.x),
1087
+ h: Math.abs(b.y - a.y),
1088
+ }
1089
+ }
1090
+
1091
+ /** The label of one draw tool. */
1092
+ function toolLabel(tool) {
1093
+ if (tool === 'move') return '移动'
1094
+ if (tool === 'rect') return '矩形'
1095
+ if (tool === 'ellipse') return '椭圆'
1096
+ if (tool === 'arrow') return '箭头'
1097
+ if (tool === 'pen') return '画笔'
1098
+ if (tool === 'text') return '文字'
1099
+ return '裁剪'
1100
+ }
1101
+
1102
+ /** The shortcut key shown next to a tool. */
1103
+ function toolKey(tool) {
1104
+ for (const key of Object.keys(TOOL_KEYS)) if (TOOL_KEYS[key] === tool) return key.toUpperCase()
1105
+ return ''
1106
+ }
1107
+
1108
+ /** The width of a text mark, estimated without a canvas: CJK glyphs are square, Latin about half. */
1109
+ function textWidth(mark) {
1110
+ let units = 0
1111
+ for (const char of String(mark.text)) units += char.codePointAt(0) > 255 ? 1 : 0.58
1112
+ return units * mark.size
1113
+ }
1114
+
1115
+ /** Distance from a point to a segment. */
1116
+ function segmentDistance(p, ax, ay, bx, by) {
1117
+ const dx = bx - ax
1118
+ const dy = by - ay
1119
+ const length = dx * dx + dy * dy
1120
+ const t = length === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - ax) * dx + (p.y - ay) * dy) / length))
1121
+ const x = ax + t * dx - p.x
1122
+ const y = ay + t * dy - p.y
1123
+ return Math.sqrt(x * x + y * y)
1124
+ }
1125
+
1126
+ /**
1127
+ * Whether a point grabs one mark.
1128
+ *
1129
+ * Outlines are grabbed on the line, not inside, so a box drawn around a
1130
+ * region does not swallow every press made inside that region.
1131
+ * @param {object} mark - the mark.
1132
+ * @param {{ x: number, y: number }} p - the point, in source pixels.
1133
+ * @param {number} tol - the grab distance, in source pixels.
1134
+ * @returns {boolean} hit or not.
1135
+ */
1136
+ function hitsMark(mark, p, tol) {
1137
+ const reach = tol + (typeof mark.width === 'number' ? mark.width / 2 : 0)
1138
+ if (mark.type === 'rect') {
1139
+ const r = rectOf({ x: mark.x0, y: mark.y0 }, { x: mark.x1, y: mark.y1 })
1140
+ const inside = p.x >= r.x - reach && p.x <= r.x + r.w + reach && p.y >= r.y - reach && p.y <= r.y + r.h + reach
1141
+ const deep = p.x > r.x + reach && p.x < r.x + r.w - reach && p.y > r.y + reach && p.y < r.y + r.h - reach
1142
+ return inside && !deep
1143
+ }
1144
+ if (mark.type === 'ellipse') {
1145
+ const rx = Math.abs(mark.x1 - mark.x0) / 2
1146
+ const ry = Math.abs(mark.y1 - mark.y0) / 2
1147
+ if (rx < 1 || ry < 1) return false
1148
+ const cx = (mark.x0 + mark.x1) / 2
1149
+ const cy = (mark.y0 + mark.y1) / 2
1150
+ const r = Math.sqrt(((p.x - cx) / rx) ** 2 + ((p.y - cy) / ry) ** 2)
1151
+ return Math.abs(r - 1) * Math.min(rx, ry) <= reach
1152
+ }
1153
+ if (mark.type === 'arrow') return segmentDistance(p, mark.x0, mark.y0, mark.x1, mark.y1) <= reach
1154
+ if (mark.type === 'pen') {
1155
+ const points = mark.points
1156
+ for (let i = 0; i + 3 < points.length; i += 2) {
1157
+ if (segmentDistance(p, points[i], points[i + 1], points[i + 2], points[i + 3]) <= reach) return true
1158
+ }
1159
+ return false
1160
+ }
1161
+ if (mark.type === 'text') {
1162
+ return p.x >= mark.x0 - tol && p.x <= mark.x0 + textWidth(mark) + tol
1163
+ && p.y >= mark.y0 - tol && p.y <= mark.y0 + mark.size * 1.2 + tol
1164
+ }
1165
+ return false
1166
+ }
1167
+
1168
+ /** The topmost mark under a point, or -1. */
1169
+ function hitMark(marks, p, tol) {
1170
+ for (let i = marks.length - 1; i >= 0; i -= 1) if (hitsMark(marks[i], p, tol)) return i
1171
+ return -1
1172
+ }
1173
+
1174
+ /** The draggable handles of a mark, in source pixels: box corners, or an arrow's two ends. */
1175
+ function handlesOf(mark) {
1176
+ if (mark === null || mark === undefined) return []
1177
+ if (mark.type === 'rect' || mark.type === 'ellipse') {
1178
+ return [
1179
+ { x: mark.x0, y: mark.y0 }, { x: mark.x1, y: mark.y0 },
1180
+ { x: mark.x1, y: mark.y1 }, { x: mark.x0, y: mark.y1 },
1181
+ ]
1182
+ }
1183
+ if (mark.type === 'arrow') return [{ x: mark.x0, y: mark.y0 }, { x: mark.x1, y: mark.y1 }]
1184
+ return []
1185
+ }
1186
+
1187
+ /** The four corners of a crop, clockwise from top-left. */
1188
+ function cropCorners(crop) {
1189
+ return [
1190
+ { x: crop.x, y: crop.y }, { x: crop.x + crop.w, y: crop.y },
1191
+ { x: crop.x + crop.w, y: crop.y + crop.h }, { x: crop.x, y: crop.y + crop.h },
1192
+ ]
1193
+ }
1194
+
1195
+ /** The handle under a point, or -1. */
1196
+ function hitHandle(points, p, tol) {
1197
+ for (let i = 0; i < points.length; i += 1) {
1198
+ if (Math.abs(points[i].x - p.x) <= tol && Math.abs(points[i].y - p.y) <= tol) return i
1199
+ }
1200
+ return -1
1201
+ }
1202
+
1203
+ /** A mark shifted by a delta in source pixels. */
1204
+ function moveMark(mark, dx, dy) {
1205
+ if (mark.type === 'pen') return { ...mark, points: mark.points.map((value, i) => value + (i % 2 === 0 ? dx : dy)) }
1206
+ if (mark.type === 'text') return { ...mark, x0: mark.x0 + dx, y0: mark.y0 + dy }
1207
+ return { ...mark, x0: mark.x0 + dx, y0: mark.y0 + dy, x1: mark.x1 + dx, y1: mark.y1 + dy }
1208
+ }
1209
+
1210
+ /** A mark with one handle dragged to a point. */
1211
+ function resizeMark(mark, handle, p) {
1212
+ if (mark.type === 'arrow') return handle === 0 ? { ...mark, x0: p.x, y0: p.y } : { ...mark, x1: p.x, y1: p.y }
1213
+ if (mark.type !== 'rect' && mark.type !== 'ellipse') return mark
1214
+ const next = { ...mark }
1215
+ if (handle === 0 || handle === 3) next.x0 = p.x
1216
+ else next.x1 = p.x
1217
+ if (handle === 0 || handle === 1) next.y0 = p.y
1218
+ else next.y1 = p.y
1219
+ return next
1220
+ }
1221
+
1222
+ /** A crop with one corner dragged to a point; the opposite corner stays. */
1223
+ function resizeCrop(crop, handle, p) {
1224
+ return rectOf(cropCorners(crop)[(handle + 2) % 4], p)
1225
+ }
1226
+
1227
+ /** A crop moved by a delta, kept inside the picture. */
1228
+ function moveCrop(crop, dx, dy, natural) {
1229
+ return {
1230
+ x: Math.max(0, Math.min(natural.w - crop.w, crop.x + dx)),
1231
+ y: Math.max(0, Math.min(natural.h - crop.h, crop.y + dy)),
1232
+ w: crop.w,
1233
+ h: crop.h,
1234
+ }
1235
+ }
1236
+
1237
+ /** Whether a freshly drawn mark is more than a stray click. */
1238
+ function markIsVisible(mark, scale) {
1239
+ if (mark.type === 'pen') return mark.points.length >= 4
1240
+ const min = 3 / (scale > 0 ? scale : 1)
1241
+ return Math.abs(mark.x1 - mark.x0) + Math.abs(mark.y1 - mark.y0) >= min
1242
+ }
1243
+
1244
+ /** Replace one entry of a list without touching the others. */
1245
+ function replaceAt(list, index, value) {
1246
+ return list.map((item, i) => (i === index ? value : item))
1247
+ }
1248
+
1249
+ /**
1250
+ * Whether a released tap on the dark area closes the stage.
1251
+ *
1252
+ * Clicking outside the picture is the oldest way out of a lightbox and it
1253
+ * stays available; the labelled close buttons are the addition, not the
1254
+ * replacement. A tap only closes when it is still (a drag was a pan), when
1255
+ * it landed off the picture, when no drawing tool is armed, and when there is
1256
+ * no unexported work — a stray click must not throw away marks or a crop.
1257
+ * @param {object} facts - what the released gesture was.
1258
+ * @returns {boolean} close on this tap or not.
1259
+ */
1260
+ function closesOnTap(facts) {
1261
+ return facts.moved !== true
1262
+ && facts.onPicture !== true
1263
+ && (facts.tool === undefined || facts.tool === 'move')
1264
+ && facts.crop === null
1265
+ && facts.dirty !== true
1266
+ && facts.snip !== true
1267
+ }
1268
+
1269
+ /** Normalize the viewer's picture list: either `items` or the single `src`. */
1270
+ function itemsOf(props) {
1271
+ if (Array.isArray(props.items) && props.items.length > 0) return props.items
1272
+ return [{ src: props.src, shown: props.shown, pixels: props.pixels }]
393
1273
  }
394
1274
 
395
1275
  /**
396
- * Full-screen zoom and pan stage.
1276
+ * The full-screen stage: view, annotate, then send, copy, or save.
397
1277
  *
398
- * The picture renders at natural size and is moved entirely by one transform,
399
- * so zooming is continuous rather than a set of steps. The wheel is consumed
400
- * only while Ctrl is held and only inside this stage, which is what keeps a
401
- * zoom gesture from fighting the chat's own scrolling. Zoom is anchored on the
402
- * cursor: the point under the pointer stays put, so zooming into a detail does
403
- * not make the user chase it with a drag afterwards.
1278
+ * The picture renders at its natural size inside a wrapper that carries the
1279
+ * one pan/zoom transform; the view lives in a ref and a single animation
1280
+ * frame writes that transform and repaints a canvas the size of the stage
1281
+ * (not of the picture — a 3200×2000 screenshot at 300% would otherwise be a
1282
+ * 9600×6000 bitmap re-rasterized on every move). Marks are kept in
1283
+ * source-image pixels, so the pointer, the layer, and the exported PNG share
1284
+ * one coordinate system.
1285
+ *
1286
+ * There is no separate annotation mode: the tools are always on the bar and
1287
+ * `move` is the default, which pans on empty space and selects, drags, and
1288
+ * resizes existing marks. Gestures: a plain wheel zooms (eased for a mouse),
1289
+ * Shift+wheel moves vertically, Ctrl+wheel horizontally, a touchpad pinch
1290
+ * zooms and a two-finger swipe pans, Space or the middle button pans under
1291
+ * any tool, and a double tap fits.
1292
+ *
1293
+ * The close button is not in the top-right corner. The desktop app draws its
1294
+ * window buttons over the page, so that corner is theirs and a plugin control
1295
+ * placed there reads as one of them; the top row is centered instead, and the
1296
+ * stage starts below the frame clearance the host publishes.
404
1297
  */
405
1298
  function ImageZoomOverlay(props) {
406
- const [view, setView] = React.useState({ scale: 1, x: 0, y: 0 })
407
- const [dragging, setDragging] = React.useState(false)
1299
+ const items = itemsOf(props)
1300
+ const snip = props.snip === true
1301
+ const [index, setIndex] = React.useState(() => Math.max(0, Math.min(items.length - 1, Number(props.index) || 0)))
1302
+ const item = items[Math.min(index, items.length - 1)]
1303
+ const [natural, setNatural] = React.useState({ w: 0, h: 0 })
1304
+ const [tool, setTool] = React.useState(props.initialTool === undefined ? 'move' : props.initialTool)
1305
+ const [color, setColor] = React.useState(COLORS[0])
1306
+ const [lineWidth, setLineWidth] = React.useState(WIDTHS[1])
1307
+ const [track, setTrack] = React.useState([EMPTY_SHOT])
1308
+ const [stepAt, setStepAt] = React.useState(0)
1309
+ const [selected, setSelected] = React.useState(-1)
1310
+ const [pending, setPending] = React.useState(null)
1311
+ const [status, setStatus] = React.useState(null)
1312
+ const [busy, setBusy] = React.useState(false)
1313
+ const [exported, setExported] = React.useState(EMPTY_SHOT)
1314
+ const [confirm, setConfirm] = React.useState(null)
1315
+ const [ready, setReady] = React.useState(false)
1316
+ const rootRef = React.useRef(null)
408
1317
  const stageRef = React.useRef(null)
1318
+ const wrapRef = React.useRef(null)
409
1319
  const imgRef = React.useRef(null)
410
- const dragRef = React.useRef(null)
1320
+ const layerRef = React.useRef(null)
1321
+ const zoomRef = React.useRef(null)
1322
+ const snipRef = React.useRef(null)
1323
+ const inputRef = React.useRef(null)
1324
+ const viewRef = React.useRef({ scale: 1, x: 0, y: 0 })
1325
+ const targetRef = React.useRef(null)
1326
+ const vpRef = React.useRef({ w: 0, h: 0, left: 0, top: 0 })
1327
+ const gestureRef = React.useRef(null)
1328
+ const draftRef = React.useRef(null)
1329
+ const editRef = React.useRef(null)
1330
+ const frameRef = React.useRef(0)
1331
+ const userRef = React.useRef(false)
411
1332
  const tapRef = React.useRef(null)
412
- const [natural, setNatural] = React.useState({ w: 0, h: 0 })
413
- const [vp, setVp] = React.useState({ w: 0, h: 0 })
1333
+ const spaceRef = React.useRef(false)
1334
+ const textSent = React.useRef(null)
1335
+ const liveRef = React.useRef(null)
414
1336
  const close = props.onClose
1337
+ const shot = track[stepAt]
1338
+ const marks = shot.marks
1339
+ const sel = selected >= 0 && selected < marks.length ? selected : -1
1340
+ // Work is "unexported" until the exact state on screen was copied, saved, or sent.
1341
+ const dirty = shot !== exported && (marks.length > 0 || shot.crop !== null)
1342
+ const composer = findComposer(props.anchor)
415
1343
 
416
- // Open at a fit-to-viewport scale: 1:1 is the wrong entry point for a
417
- // screenshot, which is routinely larger than the window it must fit in.
418
- React.useEffect(() => {
419
- const measure = () => {
420
- const stage = stageRef.current
421
- if (stage === null) return
422
- const rect = stage.getBoundingClientRect()
423
- if (rect.width > 0 && rect.height > 0) setVp({ w: rect.width, h: rect.height })
1344
+ /** Push a new annotation state and drop the redo tail it invalidates. */
1345
+ const commit = (next) => {
1346
+ setTrack((prev) => prev.slice(0, stepAt + 1).concat([next]))
1347
+ setStepAt(stepAt + 1)
1348
+ }
1349
+
1350
+ /** Write the view to the DOM: the picture's transform, the zoom readout, the layer. */
1351
+ const paint = () => {
1352
+ frameRef.current = 0
1353
+ const target = targetRef.current
1354
+ let more = false
1355
+ if (target !== null) {
1356
+ // Ease towards the wheel's target: a notched mouse wheel then feels continuous.
1357
+ const view = viewRef.current
1358
+ const k = 0.38
1359
+ const ls = Math.log(view.scale)
1360
+ const lt = Math.log(target.scale)
1361
+ const arrived = Math.abs(lt - ls) < 0.002 && Math.abs(target.x - view.x) < 0.5 && Math.abs(target.y - view.y) < 0.5
1362
+ if (arrived) {
1363
+ viewRef.current = target
1364
+ targetRef.current = null
1365
+ } else {
1366
+ viewRef.current = {
1367
+ scale: Math.exp(ls + (lt - ls) * k),
1368
+ x: view.x + (target.x - view.x) * k,
1369
+ y: view.y + (target.y - view.y) * k,
1370
+ }
1371
+ more = true
1372
+ }
1373
+ }
1374
+ const view = viewRef.current
1375
+ const wrap = wrapRef.current
1376
+ if (wrap !== null) {
1377
+ wrap.style.transform = 'translate(' + view.x + 'px, ' + view.y + 'px) scale(' + view.scale + ')'
1378
+ wrap.dataset.pixel = view.scale >= 2 ? 'true' : 'false'
1379
+ }
1380
+ if (zoomRef.current !== null) zoomRef.current.textContent = Math.round(view.scale * 100) + '%'
1381
+ drawLayer()
1382
+ if (more) schedule()
1383
+ }
1384
+ const paintRef = React.useRef(paint)
1385
+ paintRef.current = paint
1386
+ function schedule() {
1387
+ if (frameRef.current !== 0 || typeof requestAnimationFrame !== 'function') return
1388
+ frameRef.current = requestAnimationFrame(() => paintRef.current())
1389
+ }
1390
+
1391
+ /** Repaint the annotation layer, the selection, and the floating snip bar. */
1392
+ function drawLayer() {
1393
+ const live = liveRef.current
1394
+ const canvas = layerRef.current
1395
+ const vp = vpRef.current
1396
+ const n = live.natural
1397
+ if (canvas === null || vp.w <= 0 || vp.h <= 0) return
1398
+ const dpr = window.devicePixelRatio || 1
1399
+ const width = Math.max(1, Math.round(vp.w * dpr))
1400
+ const height = Math.max(1, Math.round(vp.h * dpr))
1401
+ if (canvas.width !== width || canvas.height !== height) {
1402
+ canvas.width = width
1403
+ canvas.height = height
1404
+ }
1405
+ const context = canvas.getContext('2d')
1406
+ if (context === null) return
1407
+ context.setTransform(1, 0, 0, 1, 0, 0)
1408
+ context.clearRect(0, 0, width, height)
1409
+ const snipBar = snipRef.current
1410
+ if (n.w <= 0 || n.h <= 0) return
1411
+ const view = viewRef.current
1412
+ const s = view.scale
1413
+ const o = originOf(view, n, vp)
1414
+ const edit = editRef.current
1415
+ const shown = edit === null ? live.shot : edit
1416
+ context.setTransform(dpr * s, 0, 0, dpr * s, dpr * o.x, dpr * o.y)
1417
+ drawMarks(context, shown.marks, draftRef.current)
1418
+ if (shown.crop !== null && shown.crop !== undefined) paintCrop(context, shown.crop, n, s)
1419
+ // Handles are drawn in screen pixels so they stay grabbable at any zoom.
1420
+ context.setTransform(dpr, 0, 0, dpr, 0, 0)
1421
+ const handles = []
1422
+ const chosen = live.sel >= 0 ? shown.marks[live.sel] : undefined
1423
+ if (chosen !== undefined) handles.push(...handlesOf(chosen))
1424
+ if (live.tool === 'crop' && shown.crop !== null && shown.crop !== undefined) handles.push(...cropCorners(shown.crop))
1425
+ if (chosen !== undefined && handles.length === 0) {
1426
+ const box = boxOf(chosen)
1427
+ context.strokeStyle = 'rgba(255, 255, 255, 0.85)'
1428
+ context.lineWidth = 1
1429
+ context.setLineDash([4, 3])
1430
+ context.strokeRect(o.x + box.x * s - 4, o.y + box.y * s - 4, box.w * s + 8, box.h * s + 8)
1431
+ context.setLineDash([])
1432
+ }
1433
+ for (const point of handles) {
1434
+ const x = o.x + point.x * s
1435
+ const y = o.y + point.y * s
1436
+ context.fillStyle = '#ffffff'
1437
+ context.strokeStyle = '#2f6fed'
1438
+ context.lineWidth = 1.5
1439
+ context.setLineDash([])
1440
+ context.fillRect(x - 4, y - 4, 8, 8)
1441
+ context.strokeRect(x - 4, y - 4, 8, 8)
1442
+ }
1443
+ if (snipBar !== null) {
1444
+ const crop = shown.crop
1445
+ if (crop === null || crop === undefined || gestureRef.current !== null) {
1446
+ snipBar.style.visibility = 'hidden'
1447
+ } else {
1448
+ const barW = snipBar.offsetWidth || 260
1449
+ const barH = snipBar.offsetHeight || 34
1450
+ const right = o.x + (crop.x + crop.w) * s
1451
+ const bottom = o.y + (crop.y + crop.h) * s
1452
+ const left = Math.max(8, Math.min(vp.w - barW - 8, right - barW))
1453
+ const top = bottom + 8 + barH <= vp.h - 8 ? bottom + 8 : Math.max(8, bottom - barH - 8)
1454
+ snipBar.style.left = left + 'px'
1455
+ snipBar.style.top = top + 'px'
1456
+ snipBar.style.visibility = 'visible'
1457
+ }
1458
+ }
1459
+ const input = inputRef.current
1460
+ if (input !== null && live.pending !== null) {
1461
+ input.style.left = (o.x + live.pending.x * s) + 'px'
1462
+ input.style.top = (o.y + live.pending.y * s) + 'px'
1463
+ input.style.fontSize = Math.max(11, live.pending.size * s) + 'px'
1464
+ }
1465
+ }
1466
+
1467
+ /** The bounding box of a mark that has no handles (pen, text), in source pixels. */
1468
+ function boxOf(mark) {
1469
+ if (mark.type === 'text') return { x: mark.x0, y: mark.y0, w: textWidth(mark), h: mark.size * 1.2 }
1470
+ const points = mark.points ?? []
1471
+ let minX = Infinity
1472
+ let minY = Infinity
1473
+ let maxX = -Infinity
1474
+ let maxY = -Infinity
1475
+ for (let i = 0; i + 1 < points.length; i += 2) {
1476
+ minX = Math.min(minX, points[i])
1477
+ maxX = Math.max(maxX, points[i])
1478
+ minY = Math.min(minY, points[i + 1])
1479
+ maxY = Math.max(maxY, points[i + 1])
1480
+ }
1481
+ if (minX === Infinity) return { x: 0, y: 0, w: 0, h: 0 }
1482
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
1483
+ }
1484
+
1485
+ liveRef.current = { shot, sel, tool, natural, pending }
1486
+
1487
+ /** Re-measure the stage and, while the user has not taken the view, keep it fitted. */
1488
+ const measure = () => {
1489
+ const stage = stageRef.current
1490
+ if (stage === null) return
1491
+ const rect = stage.getBoundingClientRect()
1492
+ if (!(rect.width > 0 && rect.height > 0)) return
1493
+ vpRef.current = { w: rect.width, h: rect.height, left: rect.left, top: rect.top }
1494
+ const n = liveRef.current.natural
1495
+ if (userRef.current !== true && n.w > 0) {
1496
+ viewRef.current = { scale: fitScale(n, vpRef.current), x: 0, y: 0 }
1497
+ targetRef.current = null
424
1498
  }
1499
+ schedule()
1500
+ }
1501
+
1502
+ React.useEffect(() => {
425
1503
  measure()
426
- window.addEventListener('resize', measure)
427
- return () => window.removeEventListener('resize', measure)
1504
+ let observer = null
1505
+ if (typeof ResizeObserver === 'function' && stageRef.current !== null) {
1506
+ observer = new ResizeObserver(() => measure())
1507
+ observer.observe(stageRef.current)
1508
+ } else {
1509
+ window.addEventListener('resize', measure)
1510
+ }
1511
+ // Keystrokes belong to the viewer while it is open, not to the composer underneath.
1512
+ const before = typeof document !== 'undefined' ? document.activeElement : null
1513
+ if (rootRef.current !== null && typeof rootRef.current.focus === 'function') rootRef.current.focus({ preventScroll: true })
1514
+ return () => {
1515
+ if (observer !== null) observer.disconnect()
1516
+ else window.removeEventListener('resize', measure)
1517
+ if (frameRef.current !== 0 && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(frameRef.current)
1518
+ // Hand focus back only if nothing took it on purpose (a send leaves it in the composer).
1519
+ const now = document.activeElement
1520
+ const orphaned = now === null || now === document.body
1521
+ || (rootRef.current !== null && typeof rootRef.current.contains === 'function' && rootRef.current.contains(now))
1522
+ if (orphaned && before !== null && before !== undefined && typeof before.focus === 'function' && before.isConnected !== false) {
1523
+ try {
1524
+ before.focus({ preventScroll: true })
1525
+ } catch {
1526
+ /* the old focus target went away */
1527
+ }
1528
+ }
1529
+ }
428
1530
  }, [])
429
1531
 
430
- const fit = (natural.w > 0 && natural.h > 0 && vp.w > 0 && vp.h > 0)
431
- ? Math.min(1, (vp.w * 0.97) / natural.w, (vp.h * 0.97) / natural.h)
432
- : 1
1532
+ // Every committed render can change what the layer shows; one frame repaints it.
1533
+ React.useEffect(() => {
1534
+ schedule()
1535
+ })
433
1536
 
434
- /** Land on the fit scale once, as soon as both the stage and the picture are measurable. */
435
- const settled = React.useRef(null)
1537
+ // A transient note fades on its own; a note that carries a path stays until replaced.
436
1538
  React.useEffect(() => {
437
- if (natural.w <= 0 || vp.w <= 0 || vp.h <= 0) return
438
- // Follow the window only while the view is still the one this overlay opened with;
439
- // a view the user has zoomed or panned is theirs to keep.
440
- if (settled.current === 'user' || settled.current === 'done') return
441
- settled.current = 'done'
442
- setView({ scale: fit, x: 0, y: 0 })
443
- }, [natural.w, natural.h, vp.w, vp.h, fit])
1539
+ if (status === null || typeof status.path === 'string') return undefined
1540
+ const timer = setTimeout(() => setStatus(null), status.bad === true ? 6000 : 3200)
1541
+ return () => clearTimeout(timer)
1542
+ }, [status])
444
1543
 
445
1544
  React.useEffect(() => {
446
- const onKey = (event) => {
447
- if (event.key === 'Escape') close()
1545
+ if (pending !== null && inputRef.current !== null) inputRef.current.focus({ preventScroll: true })
1546
+ }, [pending === null])
1547
+
1548
+ /** Where a pointer sits in source-image pixels. */
1549
+ const imagePoint = (clientX, clientY) => {
1550
+ const vp = vpRef.current
1551
+ if (natural.w <= 0 || natural.h <= 0 || vp.w <= 0) return null
1552
+ const view = viewRef.current
1553
+ const o = originOf(view, natural, vp)
1554
+ return { x: (clientX - vp.left - o.x) / view.scale, y: (clientY - vp.top - o.y) / view.scale }
1555
+ }
1556
+ const onPictureAt = (p) => p !== null && p.x >= 0 && p.y >= 0 && p.x <= natural.w && p.y <= natural.h
1557
+ const clampPoint = (p) => ({ x: Math.max(0, Math.min(natural.w, p.x)), y: Math.max(0, Math.min(natural.h, p.y)) })
1558
+
1559
+ const openText = (p, at) => {
1560
+ const mark = typeof at === 'number' ? marks[at] : null
1561
+ setSelected(-1)
1562
+ setPending(mark !== null
1563
+ ? { x: mark.x0, y: mark.y0, text: mark.text, color: mark.color, size: mark.size, index: at }
1564
+ : { x: p.x, y: p.y, text: '', color, size: textSize(natural, lineWidth), index: null })
1565
+ }
1566
+
1567
+ const pushText = () => {
1568
+ // Enter commits the text and unmounts the input; removing a focused node
1569
+ // can still deliver a blur, and a second pass would drop a duplicate
1570
+ // mark. The pending object itself is the token: commit it at most once.
1571
+ if (pending === null || textSent.current === pending) return
1572
+ textSent.current = pending
1573
+ const text = pending.text.trim() === '' ? null : {
1574
+ type: 'text', x0: pending.x, y0: pending.y,
1575
+ text: pending.text, color: pending.color, size: pending.size,
1576
+ }
1577
+ if (typeof pending.index === 'number') {
1578
+ const nextMarks = text === null
1579
+ ? marks.filter((_, i) => i !== pending.index)
1580
+ : replaceAt(marks, pending.index, text)
1581
+ commit({ marks: nextMarks, crop: shot.crop })
1582
+ } else if (text !== null) {
1583
+ commit({ marks: marks.concat([text]), crop: shot.crop })
1584
+ }
1585
+ setPending(null)
1586
+ }
1587
+
1588
+ /** Render the annotated picture, honouring the crop, as a PNG blob. */
1589
+ const exportCurrent = () => exportPng(imgRef.current, shot.crop, marks)
1590
+
1591
+ const doCopy = async () => {
1592
+ setBusy(true)
1593
+ try {
1594
+ await copyImageBlob(await exportCurrent())
1595
+ setExported(shot)
1596
+ setStatus({ bad: false, text: '已复制图片,可在输入框 Ctrl+V 粘贴' })
1597
+ setBusy(false)
1598
+ return true
1599
+ } catch (error) {
1600
+ setStatus({ bad: true, text: failureOf(error) })
1601
+ setBusy(false)
1602
+ return false
1603
+ }
1604
+ }
1605
+
1606
+ const doSave = async () => {
1607
+ setBusy(true)
1608
+ try {
1609
+ const reply = await requestJson(SHOT_ROUTE + '/save', await exportCurrent())
1610
+ setExported(shot)
1611
+ setStatus({ bad: false, text: '已保存到 ' + reply.path, path: reply.path })
1612
+ } catch (error) {
1613
+ setStatus({ bad: true, text: failureOf(error) })
1614
+ }
1615
+ setBusy(false)
1616
+ }
1617
+
1618
+ /** Put the picture into the composer as an attachment and get out of the way. */
1619
+ const doSend = async () => {
1620
+ setBusy(true)
1621
+ try {
1622
+ const blob = await exportCurrent()
1623
+ const editor = findComposer(props.anchor)
1624
+ if (editor !== null && pasteImage(editor, blob, pastedName(Date.now()))) {
1625
+ setBusy(false)
1626
+ close()
1627
+ return
1628
+ }
1629
+ await copyImageBlob(blob)
1630
+ setExported(shot)
1631
+ setStatus({ bad: false, text: '没找到可用的输入框,已复制到剪贴板,可手动 Ctrl+V' })
1632
+ } catch (error) {
1633
+ setStatus({ bad: true, text: failureOf(error) })
448
1634
  }
449
- window.addEventListener('keydown', onKey)
450
- return () => window.removeEventListener('keydown', onKey)
451
- }, [close])
1635
+ setBusy(false)
1636
+ }
1637
+
1638
+ /** Load a different picture of the gallery; annotations belong to the picture they were drawn on. */
1639
+ const switchTo = (next) => {
1640
+ setIndex(next)
1641
+ setTrack([EMPTY_SHOT])
1642
+ setStepAt(0)
1643
+ setSelected(-1)
1644
+ setPending(null)
1645
+ setExported(EMPTY_SHOT)
1646
+ setNatural({ w: 0, h: 0 })
1647
+ setReady(false)
1648
+ userRef.current = false
1649
+ }
1650
+
1651
+ const requestClose = () => {
1652
+ if (dirty) setConfirm({ then: 'close' })
1653
+ else close()
1654
+ }
1655
+ const go = (step) => {
1656
+ if (items.length < 2) return
1657
+ const next = (index + step + items.length) % items.length
1658
+ if (dirty) setConfirm({ then: next })
1659
+ else switchTo(next)
1660
+ }
1661
+ const discard = () => {
1662
+ const then = confirm === null ? 'close' : confirm.then
1663
+ setConfirm(null)
1664
+ if (then === 'close') close()
1665
+ else switchTo(then)
1666
+ }
1667
+
1668
+ const toFit = () => {
1669
+ userRef.current = true
1670
+ targetRef.current = { scale: fitScale(natural, vpRef.current), x: 0, y: 0 }
1671
+ schedule()
1672
+ }
1673
+ const toActual = () => {
1674
+ userRef.current = true
1675
+ targetRef.current = zoomAbout(targetRef.current ?? viewRef.current,
1676
+ 1 / (targetRef.current ?? viewRef.current).scale, 0, 0)
1677
+ schedule()
1678
+ }
1679
+ const bump = (factor) => {
1680
+ userRef.current = true
1681
+ targetRef.current = zoomAbout(targetRef.current ?? viewRef.current, factor, 0, 0)
1682
+ schedule()
1683
+ }
1684
+
1685
+ const undo = () => {
1686
+ if (stepAt <= 0) return
1687
+ setStepAt(stepAt - 1)
1688
+ setSelected(-1)
1689
+ }
1690
+ const redo = () => {
1691
+ if (stepAt >= track.length - 1) return
1692
+ setStepAt(stepAt + 1)
1693
+ setSelected(-1)
1694
+ }
1695
+ const removeSelected = () => {
1696
+ if (sel < 0) return
1697
+ commit({ marks: marks.filter((_, i) => i !== sel), crop: shot.crop })
1698
+ setSelected(-1)
1699
+ }
1700
+ const pickTool = (next) => {
1701
+ setTool(next)
1702
+ if (next !== 'move') setSelected(-1)
1703
+ }
1704
+ const pickColor = (value) => {
1705
+ setColor(value)
1706
+ if (sel >= 0) commit({ marks: replaceAt(marks, sel, { ...marks[sel], color: value }), crop: shot.crop })
1707
+ }
1708
+ const pickWidth = (value) => {
1709
+ setLineWidth(value)
1710
+ if (sel < 0) return
1711
+ const mark = marks[sel]
1712
+ const next = mark.type === 'text' ? { ...mark, size: textSize(natural, value) } : { ...mark, width: value }
1713
+ commit({ marks: replaceAt(marks, sel, next), crop: shot.crop })
1714
+ }
452
1715
 
453
- // Registered non-passively: a Ctrl+wheel gesture must not also scroll the page.
1716
+ // Registered non-passively: a wheel over the stage is the stage's own
1717
+ // gesture, and must never scroll the page behind it as well.
454
1718
  React.useEffect(() => {
455
1719
  const stage = stageRef.current
456
1720
  if (stage === null) return undefined
457
1721
  const onWheel = (event) => {
458
- if (event.ctrlKey !== true) return
459
1722
  event.preventDefault()
460
- settled.current = 'user'
461
- const rect = stage.getBoundingClientRect()
462
- const cx = event.clientX - rect.left - rect.width / 2
463
- const cy = event.clientY - rect.top - rect.height / 2
464
- const factor = Math.exp(-event.deltaY / 420)
465
- setView((prev) => {
466
- const scale = Math.min(8, Math.max(0.05, prev.scale * factor))
467
- const ratio = scale / prev.scale
468
- return { scale, x: cx - (cx - prev.x) * ratio, y: cy - (cy - prev.y) * ratio }
469
- })
1723
+ const gesture = gestureOf(event, event.deltaX, event.deltaY, event.deltaMode)
1724
+ userRef.current = true
1725
+ if (gesture.axis === 'zoom') {
1726
+ const vp = vpRef.current
1727
+ const cx = event.clientX - vp.left - vp.w / 2
1728
+ const cy = event.clientY - vp.top - vp.h / 2
1729
+ const base = targetRef.current ?? viewRef.current
1730
+ const next = zoomAbout(base, wheelFactor(gesture.amount), cx, cy)
1731
+ if (gesture.smooth === true) {
1732
+ targetRef.current = next
1733
+ } else {
1734
+ targetRef.current = null
1735
+ viewRef.current = next
1736
+ }
1737
+ } else {
1738
+ const base = targetRef.current ?? viewRef.current
1739
+ targetRef.current = null
1740
+ viewRef.current = gesture.axis === 'pan'
1741
+ ? { scale: base.scale, x: base.x + gesture.x, y: base.y + gesture.y }
1742
+ : gesture.axis === 'x'
1743
+ ? { scale: base.scale, x: base.x + gesture.amount, y: base.y }
1744
+ : { scale: base.scale, x: base.x, y: base.y + gesture.amount }
1745
+ }
1746
+ schedule()
470
1747
  }
471
1748
  stage.addEventListener('wheel', onWheel, { passive: false })
472
1749
  return () => stage.removeEventListener('wheel', onWheel)
473
1750
  }, [])
474
1751
 
1752
+ /** Point the cursor at what a press would do: resize, move, or the tool's own cursor. */
1753
+ const hoverCursor = (event) => {
1754
+ const stage = stageRef.current
1755
+ if (stage === null) return
1756
+ const p = imagePoint(event.clientX, event.clientY)
1757
+ let cursor = ''
1758
+ if (p !== null && spaceRef.current !== true) {
1759
+ const tol = GRAB_PX / viewRef.current.scale
1760
+ const corner = (i) => (i % 2 === 0 ? 'nwse-resize' : 'nesw-resize')
1761
+ if (tool === 'crop' && shot.crop !== null) {
1762
+ const h = hitHandle(cropCorners(shot.crop), p, tol)
1763
+ if (h >= 0) cursor = corner(h)
1764
+ else if (p.x >= shot.crop.x && p.x <= shot.crop.x + shot.crop.w
1765
+ && p.y >= shot.crop.y && p.y <= shot.crop.y + shot.crop.h) cursor = 'move'
1766
+ }
1767
+ if (cursor === '' && sel >= 0) {
1768
+ const h = hitHandle(handlesOf(marks[sel]), p, tol)
1769
+ if (h >= 0) cursor = marks[sel].type === 'arrow' ? 'crosshair' : corner(h)
1770
+ }
1771
+ if (cursor === '' && tool === 'move' && hitMark(marks, p, tol) >= 0) cursor = 'move'
1772
+ if (cursor === '' && tool === 'text' && hitMark(marks, p, tol) >= 0) cursor = 'text'
1773
+ }
1774
+ if (stage.style.cursor !== cursor) stage.style.cursor = cursor
1775
+ }
1776
+
475
1777
  const onPointerDown = (event) => {
476
- dragRef.current = {
477
- id: event.pointerId,
478
- fromX: event.clientX,
479
- fromY: event.clientY,
480
- baseX: view.x,
481
- baseY: view.y,
482
- moved: false,
1778
+ if (event.button !== 0 && event.button !== 1) return
1779
+ if (confirm !== null) setConfirm(null)
1780
+ // The first press away from an open text box commits it and does nothing else.
1781
+ if (pending !== null) {
1782
+ event.preventDefault()
1783
+ pushText()
1784
+ return
483
1785
  }
484
- try {
485
- event.currentTarget.setPointerCapture(event.pointerId)
486
- } catch {
487
- /* capture is a nicety; dragging still works without it */
1786
+ const stage = stageRef.current
1787
+ if (stage !== null) {
1788
+ const rect = stage.getBoundingClientRect()
1789
+ vpRef.current = { w: rect.width, h: rect.height, left: rect.left, top: rect.top }
1790
+ }
1791
+ const p = imagePoint(event.clientX, event.clientY)
1792
+ const tol = GRAB_PX / viewRef.current.scale
1793
+ const target = event.currentTarget
1794
+ const capture = () => {
1795
+ try {
1796
+ target.setPointerCapture(event.pointerId)
1797
+ } catch {
1798
+ /* capture is a nicety; the gesture still works without it */
1799
+ }
1800
+ }
1801
+ const start = (gesture) => {
1802
+ gestureRef.current = { fromX: event.clientX, fromY: event.clientY, moved: false, ...gesture }
1803
+ capture()
1804
+ }
1805
+ const pan = () => start({ kind: 'pan', base: { ...(targetRef.current ?? viewRef.current) }, onPicture: onPictureAt(p) })
1806
+ if (event.button === 1 || spaceRef.current === true || p === null) {
1807
+ event.preventDefault()
1808
+ pan()
1809
+ return
1810
+ }
1811
+ if (tool === 'crop') {
1812
+ const crop = shot.crop
1813
+ if (crop !== null) {
1814
+ const h = hitHandle(cropCorners(crop), p, tol)
1815
+ if (h >= 0) {
1816
+ editRef.current = { marks, crop }
1817
+ start({ kind: 'crop-resize', handle: h })
1818
+ return
1819
+ }
1820
+ if (p.x >= crop.x && p.x <= crop.x + crop.w && p.y >= crop.y && p.y <= crop.y + crop.h) {
1821
+ editRef.current = { marks, crop }
1822
+ start({ kind: 'crop-move', from: p, base: crop })
1823
+ return
1824
+ }
1825
+ }
1826
+ editRef.current = { marks, crop: null }
1827
+ start({ kind: 'crop-new', from: clampPoint(p) })
1828
+ return
1829
+ }
1830
+ if (sel >= 0) {
1831
+ const h = hitHandle(handlesOf(marks[sel]), p, tol)
1832
+ if (h >= 0) {
1833
+ editRef.current = { marks, crop: shot.crop }
1834
+ start({ kind: 'resize', index: sel, handle: h })
1835
+ return
1836
+ }
1837
+ }
1838
+ if (tool === 'move') {
1839
+ const hit = hitMark(marks, p, tol)
1840
+ if (hit >= 0) {
1841
+ setSelected(hit)
1842
+ editRef.current = { marks, crop: shot.crop }
1843
+ start({ kind: 'drag-mark', index: hit, from: p, base: marks[hit] })
1844
+ return
1845
+ }
1846
+ if (sel >= 0) setSelected(-1)
1847
+ pan()
1848
+ return
1849
+ }
1850
+ if (tool === 'text') {
1851
+ // Without this the mousedown that follows moves focus off the new input.
1852
+ event.preventDefault()
1853
+ const hit = hitMark(marks, p, tol)
1854
+ if (hit >= 0 && marks[hit].type === 'text') openText(p, hit)
1855
+ else if (onPictureAt(p)) openText(p)
1856
+ return
1857
+ }
1858
+ if (onPictureAt(p) === false) {
1859
+ pan()
1860
+ return
488
1861
  }
1862
+ setSelected(-1)
1863
+ draftRef.current = tool === 'pen'
1864
+ ? { type: 'pen', points: [p.x, p.y], color, width: lineWidth }
1865
+ : { type: tool, x0: p.x, y0: p.y, x1: p.x, y1: p.y, color, width: lineWidth }
1866
+ start({ kind: 'draw' })
1867
+ schedule()
489
1868
  }
490
1869
 
491
1870
  const onPointerMove = (event) => {
492
- const drag = dragRef.current
493
- if (drag === null || drag.id !== event.pointerId) return
494
- const dx = event.clientX - drag.fromX
495
- const dy = event.clientY - drag.fromY
496
- if (drag.moved === false && Math.abs(dx) + Math.abs(dy) > 3) {
497
- drag.moved = true
498
- setDragging(true)
1871
+ const gesture = gestureRef.current
1872
+ if (gesture === null) {
1873
+ hoverCursor(event)
1874
+ return
1875
+ }
1876
+ const dx = event.clientX - gesture.fromX
1877
+ const dy = event.clientY - gesture.fromY
1878
+ if (gesture.moved === false && Math.abs(dx) + Math.abs(dy) > 3) {
1879
+ gesture.moved = true
1880
+ if (gesture.kind === 'pan' && stageRef.current !== null) stageRef.current.dataset.drag = 'true'
1881
+ }
1882
+ if (gesture.kind === 'pan') {
1883
+ if (gesture.moved !== true) return
1884
+ userRef.current = true
1885
+ targetRef.current = null
1886
+ viewRef.current = { scale: gesture.base.scale, x: gesture.base.x + dx, y: gesture.base.y + dy }
1887
+ schedule()
1888
+ return
1889
+ }
1890
+ const p = imagePoint(event.clientX, event.clientY)
1891
+ if (p === null) return
1892
+ if (gesture.kind === 'draw') {
1893
+ const draft = draftRef.current
1894
+ if (draft === null) return
1895
+ if (draft.type === 'pen') draft.points.push(p.x, p.y)
1896
+ else {
1897
+ draft.x1 = p.x
1898
+ draft.y1 = p.y
1899
+ }
1900
+ schedule()
1901
+ return
1902
+ }
1903
+ const edit = editRef.current
1904
+ if (edit === null || gesture.moved !== true) return
1905
+ if (gesture.kind === 'crop-new') edit.crop = rectOf(gesture.from, clampPoint(p))
1906
+ else if (gesture.kind === 'crop-resize') edit.crop = resizeCrop(shot.crop, gesture.handle, clampPoint(p))
1907
+ else if (gesture.kind === 'crop-move') edit.crop = moveCrop(gesture.base, p.x - gesture.from.x, p.y - gesture.from.y, natural)
1908
+ else if (gesture.kind === 'drag-mark') {
1909
+ edit.marks = replaceAt(marks, gesture.index, moveMark(gesture.base, p.x - gesture.from.x, p.y - gesture.from.y))
1910
+ } else if (gesture.kind === 'resize') {
1911
+ edit.marks = replaceAt(marks, gesture.index, resizeMark(marks[gesture.index], gesture.handle, p))
499
1912
  }
500
- if (drag.moved === true) {
501
- settled.current = 'user'
502
- setView((prev) => ({ scale: prev.scale, x: drag.baseX + dx, y: drag.baseY + dy }))
1913
+ schedule()
1914
+ }
1915
+
1916
+ /** A second still tap within 400ms and 20px of the first. */
1917
+ const doubleTap = (event) => {
1918
+ const now = Date.now()
1919
+ const last = tapRef.current
1920
+ if (last !== null && now - last.at < 400
1921
+ && Math.abs(event.clientX - last.x) < 20 && Math.abs(event.clientY - last.y) < 20) {
1922
+ tapRef.current = null
1923
+ return true
503
1924
  }
1925
+ tapRef.current = { at: now, x: event.clientX, y: event.clientY }
1926
+ return false
504
1927
  }
505
1928
 
506
1929
  /**
507
- * Whether a pointer position lands on the picture itself.
508
- *
509
- * Testing the live rect rather than the event target is the point: a
510
- * letterboxed picture leaves stage area above and below (and sometimes
511
- * beside) itself, and a hit test there reports the stage, so target-based
512
- * emptiness would treat a click on the picture's own margin as a click on
513
- * the backdrop.
1930
+ * End a gesture. A still tap is either a click on the dark (which closes),
1931
+ * half of a double tap (which fits, or edits a text mark), or nothing.
1932
+ * Double tap is detected here rather than left to the native `dblclick`
1933
+ * because pointer capture is exactly the kind of retargeting that stops
1934
+ * the browser synthesising that event.
514
1935
  */
515
- const landsOnPicture = (event) => {
516
- const node = imgRef.current
517
- if (node === null) return false
518
- const rect = node.getBoundingClientRect()
519
- return event.clientX >= rect.left && event.clientX <= rect.right
520
- && event.clientY >= rect.top && event.clientY <= rect.bottom
1936
+ const onPointerUp = (event) => {
1937
+ const gesture = gestureRef.current
1938
+ gestureRef.current = null
1939
+ if (stageRef.current !== null) stageRef.current.dataset.drag = 'false'
1940
+ if (gesture === null) return
1941
+ if (gesture.kind === 'draw') {
1942
+ const draft = draftRef.current
1943
+ draftRef.current = null
1944
+ if (draft !== null && markIsVisible(draft, viewRef.current.scale)) {
1945
+ commit({ marks: marks.concat([draft]), crop: shot.crop })
1946
+ // A fresh box or arrow is usually adjusted right away: keep its handles up.
1947
+ if (draft.type !== 'pen') setSelected(marks.length)
1948
+ }
1949
+ schedule()
1950
+ return
1951
+ }
1952
+ if (gesture.kind !== 'pan') {
1953
+ const edit = editRef.current
1954
+ editRef.current = null
1955
+ if (gesture.moved === true && edit !== null) {
1956
+ if (gesture.kind === 'crop-new') {
1957
+ if (edit.crop !== null && edit.crop.w >= 4 && edit.crop.h >= 4) {
1958
+ commit({ marks, crop: edit.crop })
1959
+ setStatus({ bad: false, text: '已框选 ' + Math.round(edit.crop.w) + '×' + Math.round(edit.crop.h)
1960
+ + (snip ? ' · Enter 发到输入框' : '') })
1961
+ }
1962
+ } else {
1963
+ commit({ marks: edit.marks, crop: edit.crop })
1964
+ }
1965
+ } else if (gesture.kind === 'drag-mark' && doubleTap(event) && marks[gesture.index]?.type === 'text') {
1966
+ openText(null, gesture.index)
1967
+ }
1968
+ schedule()
1969
+ return
1970
+ }
1971
+ if (gesture.moved === true) return
1972
+ if (doubleTap(event)) {
1973
+ toFit()
1974
+ return
1975
+ }
1976
+ if (closesOnTap({ tool, moved: false, crop: shot.crop, onPicture: gesture.onPicture, dirty, snip })) close()
1977
+ }
1978
+
1979
+ const onKey = (event) => {
1980
+ const tag = event.target instanceof Element ? event.target.tagName : ''
1981
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return
1982
+ const mod = event.ctrlKey === true || event.metaKey === true
1983
+ const key = typeof event.key === 'string' ? event.key : ''
1984
+ // A focused toolbar button keeps its own Enter / Space activation.
1985
+ if (tag === 'BUTTON' && (key === 'Enter' || key === ' ')) return
1986
+ const handled = () => {
1987
+ event.preventDefault()
1988
+ event.stopPropagation()
1989
+ }
1990
+ if (key === ' ') {
1991
+ spaceRef.current = true
1992
+ handled()
1993
+ return
1994
+ }
1995
+ if (key === 'Escape') {
1996
+ handled()
1997
+ if (confirm !== null) discard()
1998
+ else if (gestureRef.current !== null) {
1999
+ gestureRef.current = null
2000
+ draftRef.current = null
2001
+ editRef.current = null
2002
+ schedule()
2003
+ } else if (pending !== null) setPending(null)
2004
+ else if (sel >= 0) setSelected(-1)
2005
+ else requestClose()
2006
+ return
2007
+ }
2008
+ if (mod) {
2009
+ const lower = key.toLowerCase()
2010
+ if (lower === 'z') {
2011
+ handled()
2012
+ if (event.shiftKey === true) redo()
2013
+ else undo()
2014
+ } else if (lower === 'y') {
2015
+ handled()
2016
+ redo()
2017
+ } else if (lower === 's') {
2018
+ handled()
2019
+ void doSave()
2020
+ } else if (lower === 'c') {
2021
+ const text = typeof window.getSelection === 'function' ? String(window.getSelection() ?? '') : ''
2022
+ if (text !== '') return
2023
+ handled()
2024
+ void doCopy()
2025
+ }
2026
+ return
2027
+ }
2028
+ if (key === 'Enter') {
2029
+ handled()
2030
+ if (busy !== true) void doSend()
2031
+ return
2032
+ }
2033
+ if (key === 'Delete' || key === 'Backspace') {
2034
+ handled()
2035
+ removeSelected()
2036
+ return
2037
+ }
2038
+ if (key === 'ArrowLeft' || key === 'ArrowRight') {
2039
+ if (items.length < 2) return
2040
+ handled()
2041
+ go(key === 'ArrowLeft' ? -1 : 1)
2042
+ return
2043
+ }
2044
+ if (key === '+' || key === '=') bump(1.25)
2045
+ else if (key === '-' || key === '_') bump(1 / 1.25)
2046
+ else if (key === '0') toActual()
2047
+ else if (key === 'f' || key === 'F') toFit()
2048
+ else if (TOOL_KEYS[key.toLowerCase()] !== undefined) pickTool(TOOL_KEYS[key.toLowerCase()])
2049
+ else return
2050
+ handled()
2051
+ }
2052
+ const keyRef = React.useRef(onKey)
2053
+ keyRef.current = onKey
2054
+
2055
+ // Capture phase, so Esc and the tool letters never also reach the
2056
+ // conversation underneath (where Esc may stop a running turn).
2057
+ React.useEffect(() => {
2058
+ const down = (event) => keyRef.current(event)
2059
+ const up = (event) => {
2060
+ if (event.key === ' ') spaceRef.current = false
2061
+ }
2062
+ window.addEventListener('keydown', down, true)
2063
+ window.addEventListener('keyup', up, true)
2064
+ return () => {
2065
+ window.removeEventListener('keydown', down, true)
2066
+ window.removeEventListener('keyup', up, true)
2067
+ }
2068
+ }, [])
2069
+
2070
+ const button = (key, label, onClick, options = {}) => React.createElement('button', {
2071
+ key,
2072
+ type: 'button',
2073
+ onClick,
2074
+ disabled: (options.busy === true && busy === true) || options.disabled === true,
2075
+ 'data-on': options.on === true ? 'true' : 'false',
2076
+ 'data-strong': options.strong === true ? 'true' : 'false',
2077
+ title: options.title,
2078
+ }, label)
2079
+
2080
+ const sendTitle = composer === null
2081
+ ? '没找到输入框:会改为复制到剪贴板(Enter)'
2082
+ : '把当前图片(含标注、按裁剪区域)放进输入框作为附件(Enter)'
2083
+ const toolbar = React.createElement('div', { key: 'tools', className: 'dsh-imgvw-tools' }, [
2084
+ React.createElement('span', { key: 'draw', className: 'dsh-imgvw-group' },
2085
+ DRAW_TOOLS.map((id) => button(id, toolLabel(id), () => pickTool(id), {
2086
+ on: tool === id,
2087
+ title: toolLabel(id) + '(' + toolKey(id) + ')' + (id === 'move' ? ':拖空白处平移,点标记可选中、拖动、拉角改大小' : ''),
2088
+ }))),
2089
+ React.createElement('span', { key: 'colors', className: 'dsh-imgvw-group' },
2090
+ COLORS.map((value) => React.createElement('button', {
2091
+ key: value,
2092
+ type: 'button',
2093
+ className: 'dsh-imgvw-swatch',
2094
+ style: { background: value },
2095
+ 'data-on': color === value ? 'true' : 'false',
2096
+ title: sel >= 0 ? '改选中标记的颜色' : value,
2097
+ onClick: () => pickColor(value),
2098
+ }))),
2099
+ React.createElement('span', { key: 'widths', className: 'dsh-imgvw-group' },
2100
+ WIDTHS.map((value, i) => button('w' + value, ['细', '中', '粗'][i], () => pickWidth(value), {
2101
+ on: lineWidth === value, title: '线宽 / 字号',
2102
+ }))),
2103
+ React.createElement('span', { key: 'steps', className: 'dsh-imgvw-group' }, [
2104
+ button('undo', '撤销', undo, { disabled: stepAt <= 0, title: 'Ctrl+Z' }),
2105
+ button('redo', '重做', redo, { disabled: stepAt >= track.length - 1, title: 'Ctrl+Shift+Z / Ctrl+Y' }),
2106
+ button('delete', '删除', removeSelected, { disabled: sel < 0, title: '删除选中的标记(Delete)' }),
2107
+ button('clear', '清空', () => {
2108
+ commit(EMPTY_SHOT)
2109
+ setSelected(-1)
2110
+ }, { disabled: marks.length === 0 && shot.crop === null, title: '清掉所有标记和裁剪' }),
2111
+ ]),
2112
+ React.createElement('span', { key: 'zoom', className: 'dsh-imgvw-group' }, [
2113
+ button('out', '−', () => bump(1 / 1.25), { title: '缩小(-)' }),
2114
+ React.createElement('span', { key: 'value', ref: zoomRef, className: 'dsh-imgvw-zoomval' }, '100%'),
2115
+ button('in', '+', () => bump(1.25), { title: '放大(+)' }),
2116
+ button('fit', '适应', toFit, { title: '适应窗口(F,或双击)' }),
2117
+ button('actual', '1:1', toActual, { title: '原始大小(0)' }),
2118
+ ]),
2119
+ React.createElement('span', { key: 'out', className: 'dsh-imgvw-group' }, [
2120
+ button('send', busy === true ? '处理中…' : '发到输入框', () => void doSend(), { strong: true, busy: true, title: sendTitle }),
2121
+ button('copy', '复制', () => void doCopy(), { busy: true, title: '复制图片到剪贴板(Ctrl+C)' }),
2122
+ button('save', '保存', () => void doSave(), { busy: true, title: '存进截图目录(Ctrl+S)' }),
2123
+ button('close', '关闭 (Esc)', requestClose, {}),
2124
+ ]),
2125
+ ])
2126
+
2127
+ const hint = snip
2128
+ ? '拖动框选区域 · 拖角调整 · Enter 发到输入框 · Ctrl+C 复制 · Esc 取消'
2129
+ : '滚轮缩放 · 拖动平移 · Shift/Ctrl+滚轮 上下/左右 · 双击适应 · 点图外或 Esc 关闭'
2130
+ const stopPointer = (event) => event.stopPropagation()
2131
+
2132
+ // The overlay is portalled to the body, but React still bubbles its
2133
+ // events through the tree it was declared in — a tool row, a message, the
2134
+ // composer. None of them may react to a click made inside the viewer
2135
+ // (a row header toggling would even unmount it).
2136
+ return toDocument(React.createElement('div', {
2137
+ ref: rootRef,
2138
+ className: 'dsh-imgvw-backdrop',
2139
+ tabIndex: -1,
2140
+ role: 'dialog',
2141
+ 'aria-label': '图片查看器',
2142
+ onPointerDown: stopPointer,
2143
+ onPointerUp: stopPointer,
2144
+ onMouseDown: stopPointer,
2145
+ onMouseUp: stopPointer,
2146
+ onDoubleClick: stopPointer,
2147
+ onKeyDown: stopPointer,
2148
+ onKeyUp: stopPointer,
2149
+ // The bars own their own space, so only a click that lands on the
2150
+ // backdrop itself — its padding — counts as the dark.
2151
+ onClick: (event) => {
2152
+ event.stopPropagation()
2153
+ if (event.target !== event.currentTarget) return
2154
+ if (closesOnTap({ tool, moved: false, crop: shot.crop, onPicture: false, dirty, snip })) close()
2155
+ },
2156
+ }, [
2157
+ React.createElement('div', { key: 'top', className: 'dsh-imgvw-top' }, [
2158
+ React.createElement('button', {
2159
+ key: 'close',
2160
+ type: 'button',
2161
+ className: 'dsh-imgvw-close',
2162
+ onClick: requestClose,
2163
+ title: '关闭查看器,回到对话(Esc)',
2164
+ }, '✕ 关闭'),
2165
+ React.createElement('span', { key: 'name', className: 'dsh-imgvw-top-name', title: item.shown }, item.shown),
2166
+ items.length > 1
2167
+ ? React.createElement('span', { key: 'count', className: 'dsh-imgvw-hint' }, (index + 1) + ' / ' + items.length)
2168
+ : null,
2169
+ item.pixels ? React.createElement('span', { key: 'pixels', className: 'dsh-imgvw-hint' }, item.pixels) : null,
2170
+ React.createElement('span', {
2171
+ key: 'hint',
2172
+ className: 'dsh-imgvw-hint',
2173
+ title: '空格或中键按住拖动:任何工具下都能平移 · V/R/E/A/P/T/C 切工具 · Delete 删除选中 · '
2174
+ + 'Ctrl+Z 撤销 · Ctrl+S 保存 · ←/→ 切换图片',
2175
+ }, hint),
2176
+ ]),
2177
+ React.createElement('div', {
2178
+ key: 'stage',
2179
+ ref: stageRef,
2180
+ className: 'dsh-imgvw-stage',
2181
+ 'data-draw': tool !== 'move' ? 'true' : 'false',
2182
+ onPointerDown,
2183
+ onPointerMove,
2184
+ onPointerUp,
2185
+ onPointerCancel: onPointerUp,
2186
+ }, [
2187
+ React.createElement('div', { key: 'wrap', ref: wrapRef, className: 'dsh-imgvw-canvas' }, [
2188
+ React.createElement('img', {
2189
+ key: 'img:' + index,
2190
+ ref: imgRef,
2191
+ className: 'dsh-imgvw-big',
2192
+ src: item.src,
2193
+ alt: item.shown,
2194
+ draggable: false,
2195
+ // Hidden until the first fit is written, which is what a flash of an oversized image is.
2196
+ style: { opacity: ready ? 1 : 0 },
2197
+ onLoad: (event) => {
2198
+ const w = event.target.naturalWidth
2199
+ const h = event.target.naturalHeight
2200
+ if (typeof w !== 'number' || typeof h !== 'number' || w <= 0 || h <= 0) return
2201
+ const n = { w, h }
2202
+ setNatural(n)
2203
+ liveRef.current = { ...liveRef.current, natural: n }
2204
+ if (userRef.current !== true) {
2205
+ viewRef.current = { scale: fitScale(n, vpRef.current), x: 0, y: 0 }
2206
+ targetRef.current = null
2207
+ }
2208
+ paintRef.current()
2209
+ setReady(true)
2210
+ },
2211
+ }),
2212
+ ]),
2213
+ React.createElement('canvas', { key: 'layer', ref: layerRef, className: 'dsh-imgvw-layer' }),
2214
+ items.length > 1
2215
+ ? [
2216
+ React.createElement('button', {
2217
+ key: 'prev', type: 'button', className: 'dsh-imgvw-nav', 'data-side': 'prev',
2218
+ title: '上一张(←)', onPointerDown: stopPointer, onClick: () => go(-1),
2219
+ }, '‹'),
2220
+ React.createElement('button', {
2221
+ key: 'next', type: 'button', className: 'dsh-imgvw-nav', 'data-side': 'next',
2222
+ title: '下一张(→)', onPointerDown: stopPointer, onClick: () => go(1),
2223
+ }, '›'),
2224
+ ]
2225
+ : null,
2226
+ shot.crop !== null && (snip || tool === 'crop')
2227
+ ? React.createElement('div', {
2228
+ key: 'snip',
2229
+ ref: snipRef,
2230
+ className: 'dsh-imgvw-float dsh-imgvw-snip',
2231
+ style: { visibility: 'hidden', left: '0px', top: '0px' },
2232
+ onPointerDown: stopPointer,
2233
+ }, [
2234
+ button('send', '发到输入框 ⏎', () => void doSend(), { strong: true, busy: true, title: sendTitle }),
2235
+ button('copy', '复制', () => void doCopy(), { busy: true }),
2236
+ button('save', '保存', () => void doSave(), { busy: true }),
2237
+ button('uncrop', '取消框选', () => commit({ marks, crop: null }), {}),
2238
+ ])
2239
+ : null,
2240
+ pending === null
2241
+ ? null
2242
+ : React.createElement('input', {
2243
+ key: 'input',
2244
+ ref: inputRef,
2245
+ className: 'dsh-imgvw-input',
2246
+ value: pending.text,
2247
+ placeholder: '输入文字后回车',
2248
+ style: (() => {
2249
+ const view = viewRef.current
2250
+ const o = originOf(view, natural, vpRef.current)
2251
+ return {
2252
+ color: pending.color,
2253
+ left: (o.x + pending.x * view.scale) + 'px',
2254
+ top: (o.y + pending.y * view.scale) + 'px',
2255
+ fontSize: Math.max(11, pending.size * view.scale) + 'px',
2256
+ }
2257
+ })(),
2258
+ onPointerDown: stopPointer,
2259
+ onChange: (event) => setPending({ ...pending, text: event.target.value }),
2260
+ onKeyDown: (event) => {
2261
+ if (event.key === 'Enter') {
2262
+ event.preventDefault()
2263
+ pushText()
2264
+ } else if (event.key === 'Escape') {
2265
+ event.preventDefault()
2266
+ event.stopPropagation()
2267
+ setPending(null)
2268
+ }
2269
+ },
2270
+ onBlur: pushText,
2271
+ }),
2272
+ confirm === null
2273
+ ? null
2274
+ : React.createElement('div', { key: 'confirm', className: 'dsh-imgvw-float dsh-imgvw-confirm', onPointerDown: stopPointer }, [
2275
+ React.createElement('span', { key: 'text' }, '标注还没有复制、保存或发出'),
2276
+ button('keep', '继续编辑', () => setConfirm(null), {}),
2277
+ button('copy', '复制后离开', () => {
2278
+ void doCopy().then((ok) => {
2279
+ if (ok === true) discard()
2280
+ })
2281
+ }, { strong: true }),
2282
+ button('drop', '放弃(Esc)', discard, {}),
2283
+ ]),
2284
+ status === null
2285
+ ? null
2286
+ : React.createElement('div', {
2287
+ key: 'status',
2288
+ className: 'dsh-imgvw-float dsh-imgvw-toast',
2289
+ 'data-bad': status.bad === true ? 'true' : 'false',
2290
+ onPointerDown: stopPointer,
2291
+ }, [
2292
+ React.createElement('span', { key: 'text' }, status.text),
2293
+ typeof status.path === 'string'
2294
+ ? button('copypath', '复制路径', () => {
2295
+ void copyText(status.path).then(
2296
+ () => setStatus({ bad: false, text: '已复制路径' }),
2297
+ (error) => setStatus({ bad: true, text: failureOf(error) }),
2298
+ )
2299
+ }, {})
2300
+ : null,
2301
+ button('dismiss', '✕', () => setStatus(null), { title: '关掉提示' }),
2302
+ ]),
2303
+ ]),
2304
+ toolbar,
2305
+ ]))
2306
+ }
2307
+
2308
+ /** The label of one gallery source. */
2309
+ function labelOf(image) {
2310
+ if (isRecord(image.attachment)) {
2311
+ if (typeof image.label === 'string' && image.label !== '') return image.label
2312
+ if (typeof image.attachment.name === 'string' && image.attachment.name !== '') return image.attachment.name
2313
+ }
2314
+ if (isRecord(image.preview) && typeof image.preview.name === 'string' && image.preview.name !== '') return image.preview.name
2315
+ return '图片'
2316
+ }
2317
+
2318
+ /** The intrinsic size a gallery source already knows, before any byte arrives. */
2319
+ function dimsOf(image) {
2320
+ const source = isRecord(image.attachment) ? image.attachment : isRecord(image.preview) ? image.preview : null
2321
+ if (source === null) return null
2322
+ return typeof source.width === 'number' && typeof source.height === 'number' && source.width > 0 && source.height > 0
2323
+ ? { w: source.width, h: source.height }
2324
+ : null
2325
+ }
2326
+
2327
+ /** "W×H", plus the original size when the store downscaled the picture before the model saw it. */
2328
+ function pixelsOf(image) {
2329
+ const dims = dimsOf(image)
2330
+ if (dims === null) return null
2331
+ const original = isRecord(image.attachment) && isRecord(image.attachment.originalDimensions)
2332
+ && typeof image.attachment.originalDimensions.width === 'number'
2333
+ ? image.attachment.originalDimensions.width + '×' + image.attachment.originalDimensions.height
2334
+ : null
2335
+ return dims.w + '×' + dims.h + (original === null ? '' : '(原图 ' + original + ')')
2336
+ }
2337
+
2338
+ /** A stable identity for one gallery source. */
2339
+ function sourceKey(image) {
2340
+ if (isRecord(image.attachment)) return 'a:' + image.attachment.attachmentId
2341
+ if (isRecord(image.preview)) return 'p:' + image.preview.url
2342
+ return 'x'
2343
+ }
2344
+
2345
+ /** The URL a source can show without waiting: a local preview's own, or the loader's warm cache. */
2346
+ function warmUrl(image, load) {
2347
+ if (isRecord(image.preview) && typeof image.preview.url === 'string') return image.preview.url
2348
+ if (isRecord(image.attachment) && typeof load === 'function' && typeof load.peek === 'function') {
2349
+ const peeked = load.peek(image.attachment)
2350
+ return typeof peeked === 'string' ? peeked : null
521
2351
  }
2352
+ return null
2353
+ }
522
2354
 
523
- /**
524
- * A gesture that never moved: either the first half of a double tap or a
525
- * click outside the picture, which closes.
526
- *
527
- * Double tap is detected here rather than left to the native `dblclick`
528
- * because the pointer capture this stage takes for dragging is exactly the
529
- * kind of retargeting that stops the browser synthesising that event.
530
- */
531
- const onPointerUp = (event) => {
532
- const drag = dragRef.current
533
- dragRef.current = null
534
- setDragging(false)
535
- if (drag === null) return
536
- if (drag.moved === true) return
537
- const now = Date.now()
538
- const last = tapRef.current
539
- if (last !== null
540
- && now - last.at < 400
541
- && Math.abs(event.clientX - last.x) < 20
542
- && Math.abs(event.clientY - last.y) < 20) {
543
- tapRef.current = null
544
- toFit()
545
- return
2355
+ /** Mark a transparent container so it renders over a checkerboard. */
2356
+ function detectChecker(node) {
2357
+ try {
2358
+ const size = 24
2359
+ const canvas = document.createElement('canvas')
2360
+ canvas.width = size
2361
+ canvas.height = size
2362
+ const context = canvas.getContext('2d')
2363
+ if (context === null) return
2364
+ context.drawImage(node, 0, 0, size, size)
2365
+ const data = context.getImageData(0, 0, size, size).data
2366
+ for (let i = 3; i < data.length; i += 4) {
2367
+ if (data[i] < 250) {
2368
+ node.dataset.checker = 'true'
2369
+ return
2370
+ }
546
2371
  }
547
- tapRef.current = { at: now, x: event.clientX, y: event.clientY }
548
- if (landsOnPicture(event) === false) close()
2372
+ } catch {
2373
+ /* an unreadable canvas just means no checkerboard */
549
2374
  }
2375
+ }
550
2376
 
551
- // Double-click and the fit button land on the same view the card opened with.
552
- // Declared before the pointer handlers, which call it from a tap they own.
553
- function toFit() {
554
- settled.current = 'user'
555
- setView({ scale: fit, x: 0, y: 0 })
556
- }
557
- const toActual = () => {
558
- settled.current = 'user'
559
- setView({ scale: 1, x: 0, y: 0 })
560
- }
561
- // Before the first fit is known the picture is hidden rather than shown at a
562
- // scale it is about to leave, which is what a flash of an oversized image is.
563
- const focused = settled.current !== null
564
-
565
- const step = (factor) => setView((prev) => {
566
- settled.current = 'user'
567
- const scale = Math.min(8, Math.max(0.05, prev.scale * factor))
568
- const ratio = scale / prev.scale
569
- return { scale, x: prev.x * ratio, y: prev.y * ratio }
2377
+ /**
2378
+ * One gallery of images: thumbnails in the conversation, the viewer on click.
2379
+ *
2380
+ * Sources are durable attachment references (resolved through the
2381
+ * session-authorized loader, `peek` first) or local submission previews.
2382
+ * A lone image renders large with its known width/height attributes, so a
2383
+ * late-loading picture lands with zero layout shift — the v1.4.1 fix for the
2384
+ * chat's stick-to-bottom logic reading an image reflow as a reader scroll.
2385
+ */
2386
+ function ImageGallery(props) {
2387
+ const images = Array.isArray(props.images)
2388
+ ? props.images.filter((image) => isRecord(image) && (isRecord(image.attachment) || isRecord(image.preview)))
2389
+ : []
2390
+ const load = props.loadImage
2391
+ const key = images.map(sourceKey).join('|')
2392
+ const [urls, setUrls] = React.useState(() => images.map((image) => warmUrl(image, load)))
2393
+ const [failed, setFailed] = React.useState(() => images.map(() => false))
2394
+ const [attempt, setAttempt] = React.useState(0)
2395
+ const [open, setOpen] = React.useState(-1)
2396
+ const rootRef = React.useRef(null)
2397
+
2398
+ React.useEffect(() => {
2399
+ let alive = true
2400
+ const warm = images.map((image) => warmUrl(image, load))
2401
+ setUrls(warm)
2402
+ setFailed(images.map(() => false))
2403
+ images.forEach((image, i) => {
2404
+ if (warm[i] !== null || isRecord(image.attachment) === false) return
2405
+ const fail = () => {
2406
+ if (alive) setFailed((prev) => replaceAt(prev, i, true))
2407
+ }
2408
+ if (typeof load !== 'function') {
2409
+ fail()
2410
+ return
2411
+ }
2412
+ Promise.resolve(load(image.attachment)).then((value) => {
2413
+ if (alive === false) return
2414
+ if (typeof value === 'string') setUrls((prev) => replaceAt(prev, i, value))
2415
+ else fail()
2416
+ }, fail)
2417
+ })
2418
+ return () => { alive = false }
2419
+ }, [key, attempt])
2420
+
2421
+ if (images.length === 0) return null
2422
+ const variant = props.thumbnail === true ? 'thumb' : props.compact === true || images.length > 1 ? 'tile' : 'single'
2423
+ const shownOf = typeof props.shownOf === 'function' ? props.shownOf : labelOf
2424
+ const viewable = []
2425
+ images.forEach((image, i) => {
2426
+ if (typeof urls[i] === 'string') viewable.push({ at: i, src: urls[i], shown: shownOf(image), pixels: pixelsOf(image) })
2427
+ })
2428
+
2429
+ const frames = images.map((image, i) => {
2430
+ const src = failed[i] === true ? null : urls[i] ?? null
2431
+ const dims = dimsOf(image)
2432
+ const label = labelOf(image)
2433
+ if (src === null) {
2434
+ return React.createElement('button', {
2435
+ key: sourceKey(image) + ':' + i,
2436
+ type: 'button',
2437
+ className: 'dsh-imgvw-frame',
2438
+ 'data-variant': variant,
2439
+ title: failed[i] === true ? '加载失败,点击重试' : '正在载入',
2440
+ onClick: () => {
2441
+ if (failed[i] === true) setAttempt((value) => value + 1)
2442
+ },
2443
+ }, React.createElement('div', {
2444
+ className: 'dsh-imgvw-skel',
2445
+ style: variant === 'single' && dims !== null ? { aspectRatio: String(dims.w / dims.h) } : undefined,
2446
+ }, React.createElement('span', {
2447
+ className: 'dsh-imgvw-skel-label',
2448
+ 'data-bad': failed[i] === true ? 'true' : 'false',
2449
+ }, failed[i] === true ? (variant === 'single' ? '图片加载失败,点击重试' : '重试') : (variant === 'single' ? '正在载入画面…' : ''))))
2450
+ }
2451
+ return React.createElement('button', {
2452
+ key: sourceKey(image) + ':' + i,
2453
+ type: 'button',
2454
+ className: 'dsh-imgvw-frame',
2455
+ 'data-variant': variant,
2456
+ title: label + ' · 点击放大查看、标注',
2457
+ 'aria-label': '查看 ' + label,
2458
+ onClick: () => setOpen(viewable.findIndex((entry) => entry.at === i)),
2459
+ }, React.createElement('img', {
2460
+ className: 'dsh-imgvw-img',
2461
+ src,
2462
+ alt: label,
2463
+ width: variant === 'single' && dims !== null ? dims.w : undefined,
2464
+ height: variant === 'single' && dims !== null ? dims.h : undefined,
2465
+ onLoad: variant === 'single' && (typeof image.attachment?.mediaType !== 'string'
2466
+ || /png|webp|gif/i.test(image.attachment.mediaType))
2467
+ ? (event) => detectChecker(event.target)
2468
+ : undefined,
2469
+ }))
570
2470
  })
571
2471
 
572
2472
  return React.createElement('div', {
573
- className: 'dsh-imgvw-backdrop',
574
- onDoubleClick: toFit,
2473
+ ref: rootRef,
2474
+ className: 'dsh-imgvw-gallery',
2475
+ 'data-align': props.align === 'end' ? 'end' : 'start',
575
2476
  }, [
576
- React.createElement('button', {
577
- key: 'x',
578
- type: 'button',
579
- className: 'dsh-imgvw-x',
580
- title: '关闭 (Esc)',
581
- onClick: close,
582
- }, '×'),
583
- React.createElement('div', { key: 'legend', className: 'dsh-imgvw-legend' },
584
- 'Ctrl + 滚轮缩放 · 按住拖动 · 双击适应窗口 · 点图外关闭'),
585
- React.createElement('div', {
586
- key: 'stage',
587
- ref: stageRef,
588
- className: 'dsh-imgvw-stage',
589
- 'data-drag': dragging ? 'true' : 'false',
590
- onPointerDown: onPointerDown,
591
- onPointerMove: onPointerMove,
592
- onPointerUp: onPointerUp,
593
- onPointerCancel: onPointerUp,
594
- }, [
595
- React.createElement('img', {
596
- key: 'img',
597
- ref: imgRef,
598
- className: 'dsh-imgvw-big',
599
- src: props.src,
600
- alt: props.shown,
601
- draggable: false,
602
- style: {
603
- transform: 'translate(' + view.x + 'px, ' + view.y + 'px) scale(' + view.scale + ')',
604
- opacity: focused ? undefined : 0,
605
- },
606
- onLoad: (event) => {
607
- const w = event.target.naturalWidth
608
- const h = event.target.naturalHeight
609
- if (typeof w !== 'number' || typeof h !== 'number' || w <= 0 || h <= 0) return
610
- setNatural({ w, h })
611
- },
612
- }),
613
- ]),
614
- React.createElement('div', { key: 'caption', className: 'dsh-imgvw-caption' }, [
615
- React.createElement('span', { key: 'name' }, props.shown),
616
- props.pixels === null ? null : React.createElement('span', { key: 'dims' }, props.pixels),
617
- React.createElement('span', { key: 'scale' }, [
618
- '缩放 ',
619
- React.createElement('b', { key: 'v' }, Math.round(view.scale * 100) + '%'),
620
- ]),
621
- React.createElement('button', { key: 'out', type: 'button', onClick: () => step(1 / 1.25) }, '−'),
622
- React.createElement('button', { key: 'in', type: 'button', onClick: () => step(1.25) }, '+'),
623
- React.createElement('button', { key: 'fit', type: 'button', onClick: toFit, title: '双击图片同样回到适应窗口' }, '适应窗口'),
624
- React.createElement('button', { key: 'actual', type: 'button', onClick: toActual }, '100%'),
625
- ]),
2477
+ ...frames,
2478
+ open >= 0 && open < viewable.length
2479
+ ? React.createElement(ImageZoomOverlay, {
2480
+ key: 'overlay',
2481
+ items: viewable,
2482
+ index: open,
2483
+ anchor: rootRef.current,
2484
+ onClose: () => setOpen(-1),
2485
+ })
2486
+ : null,
626
2487
  ])
627
2488
  }
628
2489
 
629
2490
  /**
630
- * The one metadata line under the picture: identity, size, format, actions.
2491
+ * The one metadata line under a tool card's picture: identity, size, format, actions.
631
2492
  *
632
- * It is a separate component so the image's own re-render never rebuilds the
2493
+ * It is a separate component so the gallery's own re-render never rebuilds the
633
2494
  * action buttons.
634
2495
  */
635
2496
  function MediaBar(props) {
@@ -639,7 +2500,7 @@ window.__ModuleLoader__.load({
639
2500
  className: 'dsh-imgvw-dot',
640
2501
  'data-live': props.live ? 'true' : 'false',
641
2502
  'data-bad': props.bad ? 'true' : 'false',
642
- 'data-ok': props.ok && props.live === false ? 'true' : 'false',
2503
+ 'data-ok': props.ok && props.live !== true ? 'true' : 'false',
643
2504
  }))
644
2505
  parts.push(React.createElement('span', {
645
2506
  key: 'name',
@@ -647,48 +2508,21 @@ window.__ModuleLoader__.load({
647
2508
  title: props.shown,
648
2509
  }, props.shown))
649
2510
  const add = (key, value) => {
650
- if (value === null) return
651
- parts.push(React.createElement(React.Fragment, { key: key + '-sep' },
652
- React.createElement('span', { className: 'dsh-imgvw-sep' }, '·')))
653
- parts.push(React.createElement('span', { key: key, className: 'dsh-imgvw-stat' }, value))
2511
+ if (value === null || value === undefined) return
2512
+ parts.push(React.createElement('span', { key: key + '-sep', className: 'dsh-imgvw-sep' }, '·'))
2513
+ parts.push(React.createElement('span', { key, className: 'dsh-imgvw-stat' }, value))
654
2514
  }
655
2515
  add('pixels', props.pixels)
656
2516
  add('bytes', props.bytes)
657
2517
  add('format', props.format)
658
- if (props.onZoomIn !== null) {
659
- parts.push(React.createElement(React.Fragment, { key: 'zoom-sep' },
660
- React.createElement('span', { className: 'dsh-imgvw-sep' }, '·')))
661
- parts.push(React.createElement('button', {
662
- key: 'zoom',
663
- type: 'button',
664
- className: 'dsh-imgvw-link',
665
- onClick: props.onZoomIn,
666
- }, '缩放查看'))
667
- }
668
- if (props.onCopy !== null) {
669
- parts.push(React.createElement(React.Fragment, { key: 'copy-sep' },
670
- React.createElement('span', { className: 'dsh-imgvw-sep' }, '·')))
671
- parts.push(React.createElement('button', {
672
- key: 'copy',
673
- type: 'button',
674
- className: 'dsh-imgvw-link',
675
- onClick: props.onCopy,
676
- }, props.copied ? '已复制路径' : '复制路径'))
677
- }
678
- if (props.onOpenFile !== null) {
679
- parts.push(React.createElement(React.Fragment, { key: 'open-sep' },
680
- React.createElement('span', { className: 'dsh-imgvw-sep' }, '·')))
681
- parts.push(React.createElement('button', {
682
- key: 'open',
683
- type: 'button',
684
- className: 'dsh-imgvw-link',
685
- onClick: props.onOpenFile,
686
- }, '在编辑器中打开'))
2518
+ const link = (key, label, onClick) => {
2519
+ if (onClick === null || onClick === undefined) return
2520
+ parts.push(React.createElement('span', { key: key + '-sep', className: 'dsh-imgvw-sep' }, '·'))
2521
+ parts.push(React.createElement('button', { key, type: 'button', className: 'dsh-imgvw-link', onClick }, label))
687
2522
  }
688
- return React.createElement('div', {
689
- className: 'dsh-imgvw-bar',
690
- style: props.width === null ? undefined : { width: props.width + 'px' },
691
- }, parts)
2523
+ link('copy', props.copied ? '已复制路径' : '复制路径', props.onCopy)
2524
+ link('open', '在编辑器中打开', props.onOpenFile)
2525
+ return React.createElement('div', { className: 'dsh-imgvw-bar' }, parts)
692
2526
  }
693
2527
 
694
2528
  /**
@@ -716,376 +2550,91 @@ window.__ModuleLoader__.load({
716
2550
  }
717
2551
 
718
2552
  /**
719
- * The preview card in `file` mode: frame, metadata line, one transient note.
720
- *
721
- * The host resolves the path once per call and answers with everything the
722
- * card needs, so there is exactly one request per rendered call and no state
723
- * to keep beyond this component's lifetime.
2553
+ * A claimed tool's settled card: the gallery of its result images plus one
2554
+ * metadata line. Every stat renders straight off the attachment reference.
724
2555
  */
725
- function FilePreview(props) {
726
- const block = props.block
727
- const path = imagePathOf(block)
728
- const live = isRunningBlock(block)
729
- const [info, setInfo] = React.useState(null)
730
- const [open, setOpen] = React.useState(false)
731
- const [geometry, setGeometry] = React.useState(null)
732
- const [checker, setChecker] = React.useState(false)
733
- const [note, setNote] = React.useState(null)
2556
+ function AttachmentPreview(props) {
2557
+ const spec = props.spec
2558
+ const first = spec.atts[0]
2559
+ const path = spec.path
2560
+ const label = TOOL_LABELS[props.toolName] === undefined ? props.toolName : TOOL_LABELS[props.toolName]
734
2561
  const [copied, setCopied] = React.useState(false)
2562
+ const [copyNote, setCopyNote] = React.useState(null)
2563
+ const shown = path !== null
2564
+ ? displayPathOf(path, props.cwd)
2565
+ : typeof first.name === 'string' && first.name !== '' ? first.name : label
2566
+ const single = spec.atts.length === 1
2567
+ const images = spec.atts.map((attachment) => ({ attachment }))
2568
+ const format = typeof first.mediaType === 'string' && first.mediaType.startsWith('image/')
2569
+ ? first.mediaType.slice('image/'.length).toUpperCase()
2570
+ : null
2571
+ const noteParts = []
2572
+ if (spec.text !== '') noteParts.push(spec.text)
2573
+ if (copyNote !== null) noteParts.push(copyNote)
735
2574
 
736
- React.useEffect(() => {
737
- if (path === null) return undefined
738
- let alive = true
739
- setInfo(null)
740
- const params = new URLSearchParams()
741
- params.set('path', path)
742
- if (typeof props.cwd === 'string' && props.cwd !== '') params.set('cwd', props.cwd)
743
- fetch(HOST_ROUTE + '?' + params.toString()).then(
744
- (response) => response.json(),
745
- (error) => ({ ok: false, error: String(error) }),
746
- ).then((value) => {
747
- if (alive !== true) return
748
- setInfo(isRecord(value) ? value : { ok: false, error: 'unexpected reply' })
749
- }, (error) => {
750
- if (alive === true) setInfo({ ok: false, error: String(error) })
751
- })
752
- return () => { alive = false }
753
- }, [path, props.cwd])
754
-
755
- if (path === null) return null
756
-
757
- const cwd = typeof props.cwd === 'string' ? props.cwd : ''
758
- const shown = displayPathOf(path, cwd)
759
- const ok = info !== null && info.ok === true
760
- const bad = info !== null && info.ok === false
761
- const failure = bad ? String(info.error) : ''
762
- const src = ok && typeof info.url === 'string' ? info.url : null
763
- const bytes = ok ? formatBytes(info.bytes) : null
764
- const pw = ok && typeof info.width === 'number' ? info.width : null
765
- const ph = ok && typeof info.height === 'number' ? info.height : null
766
- const pixels = pw !== null && ph !== null ? pw + '×' + ph : null
767
- const mediaType = ok && typeof info.mediaType === 'string' ? info.mediaType : ''
768
- const format = mediaType === '' ? null : mediaType.replace('image/', '').toUpperCase()
769
- const aspect = pw !== null && ph !== null && ph > 0 ? pw / ph : null
770
- // v1.4.1:图片未加载就按已知尺寸定死一切布局。晚到的图片在已滚走的区域
771
- // 加载时不再撑高内容——那会让聊天流的吸底逻辑把浏览器自发的布局位移
772
- // 误判成用户上滚,从而停止自动跟随最新记录。
773
- const known = geometry !== null ? geometry : pw !== null && ph !== null && ph > 0 ? { w: pw, h: ph } : null
774
- const barWidth = known === null
775
- ? null
776
- : Math.max(320, Math.min(720, Math.round(160 * (known.w / known.h))))
777
-
778
- /** Size the metadata line to the picture and detect a transparent container. */
779
- const onImageLoad = (event) => {
780
- const node = event.target
781
- const w = node.naturalWidth
782
- const h = node.naturalHeight
783
- if (typeof w !== 'number' || typeof h !== 'number' || w <= 0 || h <= 0) return
784
- setGeometry({ w, h })
785
- if (format !== 'PNG' && format !== 'WEBP' && format !== 'GIF') return
786
- try {
787
- const size = 24
788
- const canvas = document.createElement('canvas')
789
- canvas.width = size
790
- canvas.height = size
791
- const context = canvas.getContext('2d')
792
- if (context === null) return
793
- context.drawImage(node, 0, 0, size, size)
794
- const data = context.getImageData(0, 0, size, size).data
795
- for (let i = 3; i < data.length; i += 4) {
796
- if (data[i] < 250) {
797
- setChecker(true)
798
- return
799
- }
800
- }
801
- } catch {
802
- /* an unreadable canvas just means no checkerboard */
803
- }
804
- }
805
-
806
- const copyPath = () => {
807
- console.log('read_image preview path', shown)
808
- try {
809
- const clip = navigator.clipboard
810
- if (clip && typeof clip.writeText === 'function') {
811
- clip.writeText(shown).then(
812
- () => setCopied(true),
813
- () => setNote('无法自动复制,路径已打到控制台'),
814
- )
815
- return
816
- }
817
- } catch {
818
- /* fall through to the console note */
819
- }
820
- setNote('无法自动复制,路径已打到控制台')
2575
+ const copyPath = path === null ? null : () => {
2576
+ copyText(shown).then(
2577
+ () => setCopied(true),
2578
+ () => {
2579
+ console.log('image preview path', shown)
2580
+ setCopyNote('无法自动复制,路径已打到控制台')
2581
+ },
2582
+ )
821
2583
  }
822
2584
 
823
- const frame = src !== null
824
- ? React.createElement('div', {
825
- key: 'frame',
826
- className: 'dsh-imgvw-frame',
827
- title: '点击打开缩放视图',
828
- onClick: () => setOpen(true),
829
- }, [
830
- React.createElement('img', {
831
- key: 'img',
832
- className: 'dsh-imgvw-img',
833
- src,
834
- alt: shown,
835
- width: pw === null ? undefined : pw,
836
- height: ph === null ? undefined : ph,
837
- 'data-checker': checker ? 'true' : 'false',
838
- onLoad: onImageLoad,
839
- }),
840
- React.createElement('span', {
841
- key: 'zoom',
842
- className: 'dsh-imgvw-zoom',
843
- 'aria-hidden': 'true',
844
- }, '⤢'),
845
- ])
846
- : React.createElement('div', { key: 'frame', className: 'dsh-imgvw-frame' }, [
847
- React.createElement('div', {
848
- key: 'skel',
849
- className: 'dsh-imgvw-skel',
850
- style: aspect === null ? undefined : { aspectRatio: String(aspect) },
851
- }, [
852
- React.createElement('span', {
853
- key: 'label',
854
- className: 'dsh-imgvw-skel-label',
855
- 'data-bad': bad ? 'true' : 'false',
856
- }, bad
857
- ? '无法预览:' + failure
858
- : live
859
- ? (TOOL_LABELS[props.toolName] === undefined
860
- ? 'AI 正在查看这张图片…'
861
- : TOOL_LABELS[props.toolName] + ' 执行中,画面稍后呈现…')
862
- : '正在载入预览…'),
863
- ]),
864
- ])
865
-
866
2585
  return React.createElement('div', { className: 'dsh-imgvw-root' }, [
867
- frame,
2586
+ React.createElement(ImageGallery, {
2587
+ key: 'gallery',
2588
+ images,
2589
+ loadImage: props.loadImage,
2590
+ align: 'start',
2591
+ shownOf: (image) => (single ? shown : labelOf(image) === '图片' ? label : labelOf(image)),
2592
+ }),
868
2593
  React.createElement(MediaBar, {
869
2594
  key: 'bar',
870
- shown,
871
- live,
872
- bad,
873
- ok,
874
- pixels,
875
- bytes,
876
- format,
2595
+ shown: single ? shown : shown + ' · 共 ' + spec.atts.length + ' 张',
2596
+ live: false,
2597
+ bad: false,
2598
+ ok: true,
2599
+ pixels: single ? pixelsOf({ attachment: first }) : null,
2600
+ bytes: single ? formatBytes(first.bytes) : null,
2601
+ format: single ? format : null,
877
2602
  copied,
878
- width: barWidth,
879
- onZoomIn: src === null ? null : () => setOpen(true),
880
2603
  onCopy: copyPath,
881
- onOpenFile: typeof props.openFile === 'function' ? () => props.openFile(path) : null,
2604
+ onOpenFile: path !== null && typeof props.openFile === 'function' ? () => props.openFile(path) : null,
882
2605
  }),
883
- note === null
2606
+ noteParts.length === 0
884
2607
  ? null
885
2608
  : React.createElement('div', {
886
2609
  key: 'note',
887
2610
  className: 'dsh-imgvw-bar',
888
2611
  style: { opacity: 0.75 },
889
- }, note),
890
- open && src !== null
891
- ? React.createElement(ImageZoomOverlay, {
892
- key: 'overlay',
893
- src,
894
- shown,
895
- pixels,
896
- onClose: () => setOpen(false),
897
- })
898
- : null,
2612
+ }, noteParts.join(' · ')),
899
2613
  ])
900
2614
  }
901
2615
 
902
- /**
903
- * The preview card in `attachment` mode: the settled result owns the picture.
904
- *
905
- * Every stat renders straight off the attachment reference, and the URL
906
- * comes from the session-authorized loader (`peek` first — chat keeps a warm
907
- * cache of images it already resolved). There is no path semantics, so the
908
- * copy-path and open-in-editor actions fall away.
909
- */
910
- function AttachmentPreview(props) {
911
- const att = props.spec.atts[0]
912
- const extra = props.spec.atts.length - 1
913
- const load = props.loadImage
914
- const label = TOOL_LABELS[props.toolName] === undefined ? props.toolName : TOOL_LABELS[props.toolName]
915
- const [url, setUrl] = React.useState(() => (
916
- typeof load === 'function' && typeof load.peek === 'function' ? load.peek(att) ?? null : null
917
- ))
918
- const [failed, setFailed] = React.useState(false)
919
- const [open, setOpen] = React.useState(false)
920
- const [geometry, setGeometry] = React.useState(null)
921
- const [checker, setChecker] = React.useState(false)
922
-
923
- React.useEffect(() => {
924
- if (typeof load !== 'function') {
925
- setFailed(true)
926
- return undefined
927
- }
928
- let alive = true
929
- const peeked = typeof load.peek === 'function' ? load.peek(att) : null
930
- if (peeked !== null && peeked !== undefined) {
931
- setUrl(peeked)
932
- return undefined
933
- }
934
- setUrl(null)
935
- setFailed(false)
936
- Promise.resolve(load(att)).then(
937
- (value) => { if (alive === true) setUrl(typeof value === 'string' ? value : null) },
938
- () => { if (alive === true) setFailed(true) },
939
- )
940
- return () => { alive = false }
941
- }, [att.attachmentId])
942
-
943
- if (props.spec.atts.length === 0) return null
944
-
945
- const shown = typeof att.name === 'string' && att.name !== '' ? att.name : label
946
- const hasDims = typeof att.width === 'number' && typeof att.height === 'number'
947
- && att.width > 0 && att.height > 0
948
- const pixels = hasDims ? att.width + '×' + att.height : null
949
- const bytes = formatBytes(att.bytes)
950
- const format = typeof att.mediaType === 'string' && att.mediaType.startsWith('image/')
951
- ? att.mediaType.slice('image/'.length).toUpperCase()
952
- : null
953
- const aspect = hasDims ? att.width / att.height : null
954
- // v1.4.1:附件的像素尺寸在图片加载前就在引用上,布局提前定死,加载零位移。
955
- const known = geometry !== null ? geometry : hasDims ? { w: att.width, h: att.height } : null
956
- const barWidth = known === null
957
- ? null
958
- : Math.max(320, Math.min(720, Math.round(160 * (known.w / known.h))))
959
- const noteParts = []
960
- if (props.spec.text !== '') noteParts.push(props.spec.text)
961
- if (extra > 0) noteParts.push('另有 ' + extra + ' 张未展开')
962
- const note = noteParts.length === 0 ? null : noteParts.join(' · ')
963
-
964
- /** Size the metadata line to the picture and detect a transparent container. */
965
- const onImageLoad = (event) => {
966
- const node = event.target
967
- const w = node.naturalWidth
968
- const h = node.naturalHeight
969
- if (typeof w !== 'number' || typeof h !== 'number' || w <= 0 || h <= 0) return
970
- setGeometry({ w, h })
971
- if (format !== 'PNG' && format !== 'WEBP' && format !== 'GIF') return
972
- try {
973
- const size = 24
974
- const canvas = document.createElement('canvas')
975
- canvas.width = size
976
- canvas.height = size
977
- const context = canvas.getContext('2d')
978
- if (context === null) return
979
- context.drawImage(node, 0, 0, size, size)
980
- const data = context.getImageData(0, 0, size, size).data
981
- for (let i = 3; i < data.length; i += 4) {
982
- if (data[i] < 250) {
983
- setChecker(true)
984
- return
985
- }
986
- }
987
- } catch {
988
- /* an unreadable canvas just means no checkerboard */
989
- }
990
- }
991
-
992
- const src = failed === false && typeof url === 'string' ? url : null
993
- const frame = src !== null
994
- ? React.createElement('div', {
995
- key: 'frame',
996
- className: 'dsh-imgvw-frame',
997
- title: '点击打开缩放视图',
998
- onClick: () => setOpen(true),
999
- }, [
1000
- React.createElement('img', {
1001
- key: 'img',
1002
- className: 'dsh-imgvw-img',
1003
- src,
1004
- alt: shown,
1005
- width: hasDims ? att.width : undefined,
1006
- height: hasDims ? att.height : undefined,
1007
- 'data-checker': checker ? 'true' : 'false',
1008
- onLoad: onImageLoad,
1009
- }),
1010
- React.createElement('span', {
1011
- key: 'zoom',
1012
- className: 'dsh-imgvw-zoom',
1013
- 'aria-hidden': 'true',
1014
- }, '⤢'),
1015
- ])
1016
- : React.createElement('div', { key: 'frame', className: 'dsh-imgvw-frame' }, [
1017
- React.createElement('div', {
1018
- key: 'skel',
1019
- className: 'dsh-imgvw-skel',
1020
- style: aspect === null ? undefined : { aspectRatio: String(aspect) },
1021
- }, [
1022
- React.createElement('span', {
1023
- key: 'label',
1024
- className: 'dsh-imgvw-skel-label',
1025
- 'data-bad': failed ? 'true' : 'false',
1026
- }, failed ? '图片加载失败:附件不可读' : '正在载入画面…'),
1027
- ]),
1028
- ])
1029
-
1030
- return React.createElement('div', { className: 'dsh-imgvw-root' }, [
1031
- frame,
1032
- React.createElement(MediaBar, {
1033
- key: 'bar',
1034
- shown,
1035
- live: false,
1036
- bad: failed,
1037
- ok: src !== null,
1038
- pixels,
1039
- bytes,
1040
- format,
1041
- copied: false,
1042
- width: barWidth,
1043
- onZoomIn: src === null ? null : () => setOpen(true),
1044
- onCopy: null,
1045
- onOpenFile: null,
1046
- }),
1047
- note === null
1048
- ? null
1049
- : React.createElement('div', {
1050
- key: 'note',
1051
- className: 'dsh-imgvw-bar',
1052
- style: { opacity: 0.75 },
1053
- }, note),
1054
- open && src !== null
1055
- ? React.createElement(ImageZoomOverlay, {
1056
- key: 'overlay',
1057
- src,
1058
- shown,
1059
- pixels,
1060
- onClose: () => setOpen(false),
1061
- })
1062
- : null,
1063
- ])
2616
+ /** What the skeleton says while a claimed call runs. */
2617
+ function liveTextOf(toolName) {
2618
+ const label = TOOL_LABELS[toolName] === undefined ? toolName : TOOL_LABELS[toolName]
2619
+ return label + ' 执行中,画面稍后呈现…'
1064
2620
  }
1065
2621
 
1066
2622
  /**
1067
2623
  * The one card for every claimed tool.
1068
2624
  *
1069
- * Dispatch order: file preview (call names a path) → attachment preview
1070
- * (settled content carries image refs) → a live skeleton while the call
1071
- * runs → a plain one-line outcome for imageless/failed settled calls, so a
1072
- * claimed row never renders an empty shell.
2625
+ * Dispatch order: attachment preview (settled content carries image refs) →
2626
+ * a live skeleton while the call runs → a plain one-line outcome for
2627
+ * imageless/failed settled calls, so a claimed row never renders an empty
2628
+ * shell.
1073
2629
  */
1074
2630
  function ImageToolCard(props) {
1075
2631
  const block = props.block
1076
2632
  const spec = imageSpecOf(block)
1077
- if (spec !== null && spec.mode === 'file') {
1078
- return React.createElement(FilePreview, {
1079
- block,
1080
- cwd: props.cwd,
1081
- home: props.home,
1082
- openFile: props.openFile,
1083
- toolName: props.toolName,
1084
- })
1085
- }
1086
2633
  if (spec !== null) {
1087
2634
  return React.createElement(AttachmentPreview, {
1088
2635
  spec,
2636
+ cwd: props.cwd,
2637
+ openFile: props.openFile,
1089
2638
  loadImage: props.loadImage,
1090
2639
  toolName: props.toolName,
1091
2640
  })
@@ -1093,16 +2642,20 @@ window.__ModuleLoader__.load({
1093
2642
  const live = isRunningBlock(block)
1094
2643
  const failed = isFailedBlock(block)
1095
2644
  if (live) {
1096
- const liveLabel = TOOL_LABELS[props.toolName] === undefined ? props.toolName : TOOL_LABELS[props.toolName]
2645
+ const path = imagePathOf(block)
1097
2646
  return React.createElement('div', { className: 'dsh-imgvw-root' }, [
1098
- React.createElement('div', { key: 'frame', className: 'dsh-imgvw-frame' }, [
2647
+ React.createElement('div', { key: 'frame', className: 'dsh-imgvw-frame', 'data-variant': 'single' }, [
1099
2648
  React.createElement('div', { key: 'skel', className: 'dsh-imgvw-skel' }, [
1100
2649
  React.createElement('span', {
1101
2650
  key: 'label',
1102
2651
  className: 'dsh-imgvw-skel-label',
1103
- }, liveLabel + ' 执行中,画面稍后呈现…'),
2652
+ }, liveTextOf(props.toolName)),
1104
2653
  ]),
1105
2654
  ]),
2655
+ path === null
2656
+ ? null
2657
+ : React.createElement('div', { key: 'path', className: 'dsh-imgvw-bar', style: { opacity: 0.75 } },
2658
+ displayPathOf(path, props.cwd)),
1106
2659
  ])
1107
2660
  }
1108
2661
  let line = ''
@@ -1124,16 +2677,305 @@ window.__ModuleLoader__.load({
1124
2677
  ])
1125
2678
  }
1126
2679
 
2680
+ /** Where the composer remembers the last capture delay the user picked. */
2681
+ const DELAY_KEY = 'dsh.read-image-preview.delay'
2682
+
2683
+ /** Read the stored delay, tolerating a storage area that refuses to answer. */
2684
+ function storedDelay() {
2685
+ try {
2686
+ const value = Number(localStorage.getItem(DELAY_KEY))
2687
+ if (Number.isFinite(value) === true && value >= 0 && value <= 60) return value
2688
+ } catch {
2689
+ /* private mode: fall back to the default */
2690
+ }
2691
+ return 0
2692
+ }
2693
+
2694
+ /** Join a directory and a file name the way the host's platform would. */
2695
+ function joinPath(dir, name, platform) {
2696
+ const sep = platform === 'win32' ? '\\' : '/'
2697
+ return dir.replace(/[\\/]+$/, '') + sep + name
2698
+ }
2699
+
2700
+ /**
2701
+ * The composer's ✂ button: freeze the desktop, drag a region, Enter.
2702
+ *
2703
+ * The grab opens straight into region selection; the floating bar next to
2704
+ * the region (or Enter) puts the picture into this composer as an
2705
+ * attachment. The delay menu exists because half of what is worth capturing
2706
+ * is a state that closes the moment the composer gets a click; a
2707
+ * right-click opens it, so the button itself stays one glyph wide.
2708
+ */
2709
+ function ShotCaptureButton() {
2710
+ const [shot, setShot] = React.useState(null)
2711
+ const [busy, setBusy] = React.useState(false)
2712
+ const [note, setNote] = React.useState(null)
2713
+ const [menu, setMenu] = React.useState(false)
2714
+ const [delay, setDelay] = React.useState(storedDelay)
2715
+ const urlRef = React.useRef(null)
2716
+ const anchorRef = React.useRef(null)
2717
+
2718
+ React.useEffect(() => () => {
2719
+ if (urlRef.current !== null) URL.revokeObjectURL(urlRef.current)
2720
+ }, [])
2721
+
2722
+ const grab = async (seconds) => {
2723
+ if (busy === true) return
2724
+ const value = Number(seconds)
2725
+ setBusy(true)
2726
+ setMenu(false)
2727
+ setNote(null)
2728
+ setDelay(value)
2729
+ try {
2730
+ localStorage.setItem(DELAY_KEY, String(value))
2731
+ } catch {
2732
+ /* nothing to persist to */
2733
+ }
2734
+ try {
2735
+ if (urlRef.current !== null) {
2736
+ URL.revokeObjectURL(urlRef.current)
2737
+ urlRef.current = null
2738
+ }
2739
+ const fresh = await captureScreen(value * 1000)
2740
+ urlRef.current = fresh.url
2741
+ setShot(fresh)
2742
+ } catch (error) {
2743
+ setNote(failureOf(error))
2744
+ }
2745
+ setBusy(false)
2746
+ }
2747
+
2748
+ const dismiss = () => {
2749
+ setShot(null)
2750
+ if (urlRef.current !== null) {
2751
+ URL.revokeObjectURL(urlRef.current)
2752
+ urlRef.current = null
2753
+ }
2754
+ }
2755
+
2756
+ return React.createElement('span', { ref: anchorRef, style: { position: 'relative', display: 'inline-flex' } }, [
2757
+ React.createElement('button', {
2758
+ key: 'grab',
2759
+ type: 'button',
2760
+ className: 'dsh-shot-capture',
2761
+ disabled: busy,
2762
+ title: '截图:' + (delay === 0 ? '立即' : delay + ' 秒后') + '抓全屏,框选后回车放进输入框(右键选延时)',
2763
+ onClick: () => void grab(delay),
2764
+ onContextMenu: (event) => {
2765
+ event.preventDefault()
2766
+ setMenu((prev) => prev !== true)
2767
+ },
2768
+ }, busy === true ? '抓屏中…' : '✂ 截图'),
2769
+ menu !== true
2770
+ ? null
2771
+ : React.createElement('span', {
2772
+ key: 'menu',
2773
+ className: 'dsh-shot-menu',
2774
+ style: { bottom: 'calc(100% + 6px)', right: 0 },
2775
+ }, [0, 1, 3, 5].map((seconds) => React.createElement('button', {
2776
+ key: seconds,
2777
+ type: 'button',
2778
+ onClick: () => void grab(seconds),
2779
+ }, seconds === 0 ? '立即抓屏' : seconds + ' 秒后抓屏'))),
2780
+ note === null
2781
+ ? null
2782
+ : React.createElement('span', {
2783
+ key: 'note',
2784
+ title: note,
2785
+ style: { fontSize: '11px', opacity: 0.7, maxWidth: '140px' },
2786
+ }, '截图失败'),
2787
+ shot === null
2788
+ ? null
2789
+ : React.createElement(ImageZoomOverlay, {
2790
+ key: 'stage',
2791
+ src: shot.url,
2792
+ shown: '刚抓取的全屏 · ' + formatBytes(shot.bytes),
2793
+ pixels: null,
2794
+ snip: true,
2795
+ initialTool: 'crop',
2796
+ anchor: anchorRef.current,
2797
+ onClose: dismiss,
2798
+ }),
2799
+ ])
2800
+ }
2801
+
2802
+ /**
2803
+ * 设置 → 截图与标注: the shelf the plugin's own pictures live on.
2804
+ *
2805
+ * Listing, copying a path, deleting one shot, opening the directory, and an
2806
+ * immediate cleanup run. The retention policy itself is edited in this
2807
+ * plugin's generated settings form; this page shows what is in force so the
2808
+ * two never disagree.
2809
+ */
2810
+ function ShotsPage() {
2811
+ const [info, setInfo] = React.useState(null)
2812
+ const [items, setItems] = React.useState([])
2813
+ const [total, setTotal] = React.useState(0)
2814
+ const [used, setUsed] = React.useState(0)
2815
+ const [offset, setOffset] = React.useState(0)
2816
+ const [note, setNote] = React.useState(null)
2817
+ const [busy, setBusy] = React.useState(false)
2818
+ const [peek, setPeek] = React.useState(-1)
2819
+
2820
+ const load = async (next) => {
2821
+ setBusy(true)
2822
+ try {
2823
+ const page = await requestJson(SHOT_ROUTE + '/list?limit=60&offset=' + next)
2824
+ setItems(next === 0 ? page.items : items.concat(page.items))
2825
+ setTotal(page.total)
2826
+ setUsed(page.bytes)
2827
+ setOffset(next)
2828
+ setNote(null)
2829
+ const nextInfo = await requestJson(SHOT_ROUTE + '/state')
2830
+ setInfo(nextInfo)
2831
+ } catch (error) {
2832
+ setNote(failureOf(error))
2833
+ }
2834
+ setBusy(false)
2835
+ }
2836
+
2837
+ React.useEffect(() => {
2838
+ void load(0)
2839
+ return undefined
2840
+ }, [])
2841
+
2842
+ const remove = async (name) => {
2843
+ setBusy(true)
2844
+ try {
2845
+ await requestJson(SHOT_ROUTE + '/delete?name=' + encodeURIComponent(name), null)
2846
+ setNote('已删除 ' + name)
2847
+ await load(0)
2848
+ } catch (error) {
2849
+ setNote(failureOf(error))
2850
+ }
2851
+ setBusy(false)
2852
+ }
2853
+
2854
+ const cleanup = async () => {
2855
+ setBusy(true)
2856
+ try {
2857
+ const reply = await requestJson(SHOT_ROUTE + '/cleanup', null)
2858
+ setNote('清理完成:删除 ' + reply.removed + ' 个,释放 ' + (formatBytes(reply.freed) ?? '0 B'))
2859
+ await load(0)
2860
+ } catch (error) {
2861
+ setNote(failureOf(error))
2862
+ }
2863
+ setBusy(false)
2864
+ }
2865
+
2866
+ const openDir = async () => {
2867
+ try {
2868
+ await requestJson(SHOT_ROUTE + '/open', null)
2869
+ setNote(null)
2870
+ } catch (error) {
2871
+ setNote(failureOf(error))
2872
+ }
2873
+ }
2874
+
2875
+ const copyPath = async (name) => {
2876
+ try {
2877
+ await copyText(joinPath(info === null ? '' : info.dir, name, info === null ? '' : info.platform))
2878
+ setNote('已复制 ' + name + ' 的路径')
2879
+ } catch (error) {
2880
+ setNote(failureOf(error))
2881
+ }
2882
+ }
2883
+
2884
+ const action = (key, label, onClick, title) => React.createElement('button', {
2885
+ key,
2886
+ type: 'button',
2887
+ className: 'dsh-shot-capture',
2888
+ disabled: busy,
2889
+ title,
2890
+ onClick,
2891
+ }, label)
2892
+
2893
+ return React.createElement('div', { className: 'dsh-shot-page' }, [
2894
+ React.createElement('div', { key: 'head', className: 'dsh-shot-head' }, [
2895
+ React.createElement('div', { key: 'text' }, [
2896
+ React.createElement('p', { key: 'note', className: 'dsh-shot-note' },
2897
+ '在查看器里点「保存」的图片都落在这个目录里。清理策略只删除本插件按 shot- 命名的文件,'
2898
+ + '把目录指到别处也不会被误删。保留天数、数量与体积上限在下方本插件的设置项里改。'),
2899
+ React.createElement('p', { key: 'dir', className: 'dsh-shot-dir' },
2900
+ info === null ? '正在读取截图目录…' : info.dir),
2901
+ info === null
2902
+ ? null
2903
+ : React.createElement('p', { key: 'policy', className: 'dsh-shot-note' },
2904
+ '保留 ' + (info.policy.days > 0 ? info.policy.days + ' 天' : '不按天数')
2905
+ + ' · 最多 ' + info.policy.maxFiles + ' 个'
2906
+ + ' · 最多 ' + formatBytes(info.policy.maxBytes)
2907
+ + ' · 当前 ' + total + ' 张 / ' + (formatBytes(used) ?? '0 B')
2908
+ + (info.capture.length > 0 ? ' · 抓屏:' + info.capture[0] : '')),
2909
+ ]),
2910
+ React.createElement('div', { key: 'actions', className: 'dsh-shot-actions' }, [
2911
+ action('refresh', '刷新', () => void load(0)),
2912
+ action('open', '打开目录', () => void openDir(), '在系统文件管理器里打开截图目录'),
2913
+ action('cleanup', '立即清理', () => void cleanup(), '按上面的策略删掉过期的截图'),
2914
+ ]),
2915
+ ]),
2916
+ note === null
2917
+ ? null
2918
+ : React.createElement('p', { key: 'note', className: 'dsh-shot-note' }, note),
2919
+ items.length === 0
2920
+ ? React.createElement('div', { key: 'empty', className: 'dsh-shot-empty' },
2921
+ total === 0 ? '还没有保存过截图。在图片查看器里点「保存」,或用输入框旁的「✂ 截图」。' : '本页没有更多截图。')
2922
+ : React.createElement('div', { key: 'grid', className: 'dsh-shot-grid' }, items.map((item, i) => React.createElement('div', {
2923
+ key: item.name,
2924
+ className: 'dsh-shot-card',
2925
+ }, [
2926
+ React.createElement('img', {
2927
+ key: 'thumb',
2928
+ className: 'dsh-shot-thumb',
2929
+ src: item.url,
2930
+ alt: item.name,
2931
+ loading: 'lazy',
2932
+ title: '点击查看大图',
2933
+ onClick: () => setPeek(i),
2934
+ }),
2935
+ React.createElement('div', { key: 'meta', className: 'dsh-shot-meta' },
2936
+ new Date(item.mtime).toLocaleString() + ' · ' + formatBytes(item.bytes)),
2937
+ React.createElement('div', { key: 'row', className: 'dsh-shot-row' }, [
2938
+ React.createElement('button', {
2939
+ key: 'copy',
2940
+ type: 'button',
2941
+ className: 'dsh-imgvw-link',
2942
+ onClick: () => void copyPath(item.name),
2943
+ }, '复制路径'),
2944
+ React.createElement('button', {
2945
+ key: 'del',
2946
+ type: 'button',
2947
+ className: 'dsh-imgvw-link',
2948
+ disabled: busy,
2949
+ onClick: () => void remove(item.name),
2950
+ }, '删除'),
2951
+ ]),
2952
+ ]))),
2953
+ items.length < total
2954
+ ? React.createElement('div', { key: 'more', className: 'dsh-shot-actions' },
2955
+ action('more', '加载更多(还有 ' + (total - items.length) + ' 张)', () => void load(offset + 60)))
2956
+ : null,
2957
+ peek < 0 || peek >= items.length
2958
+ ? null
2959
+ : React.createElement(ImageZoomOverlay, {
2960
+ key: 'peek',
2961
+ items: items.map((item) => ({ src: item.url, shown: item.name, pixels: null })),
2962
+ index: peek,
2963
+ onClose: () => setPeek(-1),
2964
+ }),
2965
+ ])
2966
+ }
2967
+
1127
2968
  const inject = ['slots']
1128
2969
 
1129
2970
  /**
1130
- * Claim every image-bearing tool's keyed cell at priority -1.
2971
+ * Wire the plugin into the page.
1131
2972
  *
1132
- * Each cell is keyed by the wire Tool name and these keys are either
1133
- * covered by the shipped composition (read_image) or by dynamic fallback
1134
- * cards; the lower priority shadows them, and if one of our entries ever
1135
- * throws, the slot's error boundary abdicates it and the previous card
1136
- * returns.
2973
+ * - the image galleries of the official image positions, at priority -1;
2974
+ * - the whole card of `read_image` and of every image-bearing tool without
2975
+ * an official image row, at priority -1 (if one of our entries ever
2976
+ * throws, the slot's error boundary abdicates it and the previous card
2977
+ * returns);
2978
+ * - the composer's ✂ button and the 截图与标注 settings page.
1137
2979
  */
1138
2980
  function apply(ctx) {
1139
2981
  const style = document.createElement('style')
@@ -1144,6 +2986,27 @@ window.__ModuleLoader__.load({
1144
2986
  style.remove()
1145
2987
  })
1146
2988
 
2989
+ // Remember which composer had focus last: a viewer opened far from any
2990
+ // composer sends its picture there.
2991
+ if (typeof document.addEventListener === 'function') {
2992
+ const onFocus = (event) => {
2993
+ const target = event.target
2994
+ if (target instanceof Element) {
2995
+ const editor = target.closest(EDITOR_SELECTOR)
2996
+ if (editor !== null) lastEditor = editor
2997
+ }
2998
+ }
2999
+ document.addEventListener('focusin', onFocus)
3000
+ ctx.effect(() => () => {
3001
+ document.removeEventListener('focusin', onFocus)
3002
+ lastEditor = null
3003
+ })
3004
+ }
3005
+
3006
+ for (const name of IMAGE_SLOTS) {
3007
+ ctx.slots.inject(name, () => ctx.slots.register({ name, priority: -1 }, ImageGallery))
3008
+ }
3009
+
1147
3010
  ctx.slots.inject('tool.call.toolview', () => {
1148
3011
  IMAGE_TOOL_KEYS.forEach((key) => {
1149
3012
  ctx.slots.register({
@@ -1153,6 +3016,25 @@ window.__ModuleLoader__.load({
1153
3016
  }, ImageToolCard)
1154
3017
  })
1155
3018
  })
3019
+
3020
+ // The capture entry lives in the composer, next to the box the picture is
3021
+ // meant for; the shelf those pictures land on lives in settings.
3022
+ ctx.slots.inject('conversation.input.right', () => {
3023
+ ctx.slots.register({
3024
+ name: 'conversation.input.right',
3025
+ id: 'read-image-preview-capture',
3026
+ order: 8,
3027
+ }, () => React.createElement(ShotCaptureButton, null))
3028
+ })
3029
+
3030
+ ctx.slots.inject('settings.section', () => {
3031
+ ctx.slots.register({
3032
+ name: 'settings.section',
3033
+ id: 'read-image-preview',
3034
+ order: 13,
3035
+ label: () => '截图与标注',
3036
+ }, () => React.createElement(ShotsPage, null))
3037
+ })
1156
3038
  }
1157
3039
 
1158
3040
  exports.apply = apply