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