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