@medeo-plugin/plugin-screenshot 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +8 -5
  2. package/package.json +18 -5
  3. package/plugin.medeoplugin +0 -0
  4. package/src/README.md +28 -0
  5. package/src/README.zh-CN.md +20 -0
  6. package/src/about.json +7 -0
  7. package/src/icon.svg +1 -0
  8. package/src/locales/en.json +141 -0
  9. package/src/locales/zh-CN.json +141 -0
  10. package/src/locales/zh-TW.json +141 -0
  11. package/src/manifest.json +125 -0
  12. package/src/media/en/editor.png +0 -0
  13. package/src/media/en/settings.png +0 -0
  14. package/src/media/zh-CN/editor.png +0 -0
  15. package/src/media/zh-CN/settings.png +0 -0
  16. package/src/resources/helper.js +99 -0
  17. package/src/resources/trayTemplate.png +0 -0
  18. package/src/resources/trayTemplate.svg +1 -0
  19. package/src/src/annotations.ts +398 -0
  20. package/src/src/beautify.ts +41 -0
  21. package/src/src/geometry.ts +79 -0
  22. package/src/src/naming.ts +19 -0
  23. package/src/src/protocol.ts +53 -0
  24. package/src/src/settings.ts +48 -0
  25. package/src/src/shortcuts.ts +25 -0
  26. package/src/src/ui/overlay.ts +874 -0
  27. package/src/src/ui/settings.ts +140 -0
  28. package/src/tests/logic.test.mjs +101 -0
  29. package/src/ui/entry.ts +4 -0
  30. package/src/ui/icons/align-center.svg +1 -0
  31. package/src/ui/icons/align-left.svg +1 -0
  32. package/src/ui/icons/align-right.svg +1 -0
  33. package/src/ui/icons/arrow-double.svg +1 -0
  34. package/src/ui/icons/arrow-open.svg +1 -0
  35. package/src/ui/icons/arrow-single.svg +1 -0
  36. package/src/ui/icons/arrow-tail.svg +1 -0
  37. package/src/ui/icons/arrow-thick.svg +1 -0
  38. package/src/ui/icons/arrow.svg +1 -0
  39. package/src/ui/icons/beautify.svg +1 -0
  40. package/src/ui/icons/bold.svg +1 -0
  41. package/src/ui/icons/censor.svg +1 -0
  42. package/src/ui/icons/close.svg +1 -0
  43. package/src/ui/icons/copy-doc.svg +1 -0
  44. package/src/ui/icons/copy.svg +1 -0
  45. package/src/ui/icons/ellipse.svg +1 -0
  46. package/src/ui/icons/fill-fill.svg +1 -0
  47. package/src/ui/icons/fill-stroke.svg +1 -0
  48. package/src/ui/icons/fill-strokeFill.svg +1 -0
  49. package/src/ui/icons/highlight.svg +1 -0
  50. package/src/ui/icons/invert.svg +1 -0
  51. package/src/ui/icons/italic.svg +1 -0
  52. package/src/ui/icons/line-dashed.svg +1 -0
  53. package/src/ui/icons/line-dotted.svg +1 -0
  54. package/src/ui/icons/line-solid.svg +1 -0
  55. package/src/ui/icons/line.svg +1 -0
  56. package/src/ui/icons/marker.svg +1 -0
  57. package/src/ui/icons/measure.svg +1 -0
  58. package/src/ui/icons/number.svg +1 -0
  59. package/src/ui/icons/ocr.svg +1 -0
  60. package/src/ui/icons/open.svg +1 -0
  61. package/src/ui/icons/pencil.svg +1 -0
  62. package/src/ui/icons/rect.svg +1 -0
  63. package/src/ui/icons/redo.svg +1 -0
  64. package/src/ui/icons/sampler.svg +1 -0
  65. package/src/ui/icons/save.svg +1 -0
  66. package/src/ui/icons/select.svg +1 -0
  67. package/src/ui/icons/stamp.svg +1 -0
  68. package/src/ui/icons/text-bg.svg +1 -0
  69. package/src/ui/icons/text.svg +1 -0
  70. package/src/ui/icons/trash.svg +1 -0
  71. package/src/ui/icons/underline.svg +1 -0
  72. package/src/ui/icons/undo.svg +1 -0
  73. package/src/ui/index.html +12 -0
  74. package/src/ui/settings.html +5 -0
  75. package/src/ui/style.css +105 -0
  76. package/src/worker.ts +285 -0
@@ -0,0 +1,874 @@
1
+ // The capture editor: a transparent overlay over the display under the
2
+ // pointer shows the frozen screenshot. Pick a region (drag, Shift for a square, click a window,
3
+ // Tab toggles window snapping, F takes the whole display), annotate with the bottom toolbar, then
4
+ // copy / save / read text / beautify from the right-hand bar.
5
+ import { appWindows, channel, i18n } from '@medeo-plugin/sdk';
6
+ import { CURSORS, HANDLES, clamp, contains, fromPoints, handlePoint, hitHandle, move, resize, round, windowAt, type Handle, type Point, type Rect } from '../geometry.ts';
7
+ import { split, type Action, type FromOverlay, type OcrResult, type Shot, type ToOverlay } from '../protocol.ts';
8
+ import {
9
+ COLORS, DEFAULT_OPTIONS, EMOJIS, FONT, KEY_OF, NO_COLOR, NO_WIDTH, TOOLS, TOOL_KEYS, adjust, badgeRadius, bounds, constrain, draw, dragHandle,
10
+ handles, hit, meaningful, optionsFor, spotlight, translate, type Ann, type Env, type HandleId, type Options, type Text, type Tool,
11
+ } from '../annotations.ts';
12
+ import { DEFAULT_BEAUTY, FRAME_BAR, GRADIENTS, beautify, drawFrame, fillGradient, type Beauty } from '../beautify.ts';
13
+
14
+ const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
15
+ const post = (m: FromOverlay) => channel.post(m);
16
+ let t: Record<string, string> = {};
17
+ const tr = (key: string, params: Record<string, string | number> = {}) => (t[key] ?? key).replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? ''));
18
+ const el = <K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, text?: string) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
19
+
20
+ // ---------------------------------------------------------------- preferences kept across captures
21
+ interface Prefs { tool: Tool; opts: Record<Tool, Options>; beauty: Beauty; snap: boolean; recent: string[] }
22
+ const PREFS = 'screenshot.prefs.v1';
23
+ function loadPrefs(): Prefs {
24
+ const base: Prefs = { tool: 'arrow', opts: Object.fromEntries(TOOLS.map(k => [k, { ...DEFAULT_OPTIONS }])) as Record<Tool, Options>, beauty: { ...DEFAULT_BEAUTY }, snap: true, recent: [] };
25
+ try {
26
+ const v = JSON.parse(localStorage.getItem(PREFS) ?? 'null');
27
+ if (!v || typeof v !== 'object') return base;
28
+ if (TOOLS.includes(v.tool) && v.tool !== 'sampler') base.tool = v.tool;
29
+ for (const k of TOOLS) if (v.opts?.[k] && typeof v.opts[k] === 'object') base.opts[k] = { ...DEFAULT_OPTIONS, ...v.opts[k] };
30
+ // Style, padding and frame are remembered; beautifying itself is chosen per capture, so one
31
+ // beautified screenshot never turns every later one into a framed picture.
32
+ if (v.beauty && typeof v.beauty === 'object') base.beauty = { ...DEFAULT_BEAUTY, ...v.beauty, on: false };
33
+ if (typeof v.snap === 'boolean') base.snap = v.snap;
34
+ if (Array.isArray(v.recent)) base.recent = v.recent.filter((c: unknown) => typeof c === 'string' && /^#[0-9A-F]{6}$/i.test(c as string)).slice(0, 6);
35
+ } catch {}
36
+ return base;
37
+ }
38
+ let prefs = loadPrefs();
39
+ const savePrefs = () => { try { localStorage.setItem(PREFS, JSON.stringify({ ...prefs, tool: prefs.tool === 'sampler' ? 'arrow' : prefs.tool })); } catch {} };
40
+
41
+ // ---------------------------------------------------------------- capture state
42
+ let shot: Shot | null = null;
43
+ let incoming: { seq: number; parts: string[]; total: number } | null = null;
44
+ let base: ImageBitmap | null = null;
45
+ let pixels: CanvasRenderingContext2D | null = null;
46
+ let effects: { mosaic: HTMLCanvasElement; blur: HTMLCanvasElement } | null = null;
47
+ let view: Rect = { x: 0, y: 0, w: innerWidth, h: innerHeight };
48
+ let phase: 'idle' | 'select' | 'edit' = 'idle';
49
+ let sel: Rect | null = null;
50
+ let hover: Rect | null = null;
51
+ let pointer: Point | null = null;
52
+ let anns: Ann[] = [];
53
+ let undoStack: Ann[][] = [];
54
+ let redoStack: Ann[][] = [];
55
+ let selected: number | null = null;
56
+ let counter = 1;
57
+ let nextId = 1;
58
+ let invert = false;
59
+ let busy = false;
60
+ let space = false;
61
+ let lastWheel = 0;
62
+ let popover: 'palette' | 'beauty' | null = null;
63
+ let ocr: { loading: boolean; result: OcrResult | null } | null = null;
64
+ type Drag =
65
+ | { kind: 'region-new'; origin: Point; moved: boolean }
66
+ | { kind: 'region-move'; origin: Point; start: Rect }
67
+ | { kind: 'region-resize'; handle: Handle; start: Rect }
68
+ | { kind: 'draw'; ann: Ann; origin: Point; last: Point }
69
+ | { kind: 'ann-move'; origin: Point; start: Ann; pushed: boolean }
70
+ | { kind: 'ann-handle'; handle: HandleId; pushed: boolean };
71
+ let drag: Drag | null = null;
72
+ let text: { at: Point; editing: number | null } | null = null;
73
+
74
+ const tool = () => prefs.tool;
75
+ const canvases = () => [$<HTMLCanvasElement>('base'), $<HTMLCanvasElement>('ann'), $<HTMLCanvasElement>('ui')];
76
+
77
+ // ---------------------------------------------------------------- loading a shot
78
+ function b64ToBytes(b64: string): Uint8Array {
79
+ const bin = atob(b64), out = new Uint8Array(bin.length);
80
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
81
+ return out;
82
+ }
83
+
84
+ async function load(seq: number, b64: string) {
85
+ const bitmap = await createImageBitmap(new Blob([b64ToBytes(b64) as BlobPart], { type: 'image/png' }));
86
+ if (!shot || shot.seq !== seq) { bitmap.close(); return; }
87
+ base = bitmap;
88
+ view = { x: 0, y: 0, w: innerWidth, h: innerHeight };
89
+ sizeCanvases(); paintBase();
90
+ const px = document.createElement('canvas'); px.width = bitmap.width; px.height = bitmap.height;
91
+ pixels = px.getContext('2d', { willReadFrequently: true })!; pixels.drawImage(bitmap, 0, 0);
92
+ if (shot.mode === 'full') { sel = { ...view }; phase = 'edit'; showBars(); }
93
+ else { phase = 'select'; hover = pointer ? snapTarget(pointer) : view; }
94
+ showHint(); dirty(true);
95
+ console.info(`shot #${seq} shown: ${bitmap.width}x${bitmap.height}, visible=${document.visibilityState}`);
96
+ }
97
+
98
+ function sizeCanvases() {
99
+ const dpr = devicePixelRatio || 1;
100
+ for (const c of canvases()) { c.width = Math.round(innerWidth * dpr); c.height = Math.round(innerHeight * dpr); c.style.width = `${innerWidth}px`; c.style.height = `${innerHeight}px`; }
101
+ }
102
+ function paintBase() {
103
+ const b = $<HTMLCanvasElement>('base').getContext('2d')!;
104
+ b.setTransform(1, 0, 0, 1, 0, 0); b.clearRect(0, 0, b.canvas.width, b.canvas.height);
105
+ if (base) { b.imageSmoothingQuality = 'high'; b.drawImage(base, 0, 0, b.canvas.width, b.canvas.height); }
106
+ $('base').style.filter = invert ? 'invert(1)' : 'none';
107
+ }
108
+ const snapTarget = (p: Point) => prefs.snap && shot ? windowAt(shot.windows, p, view) : view;
109
+
110
+ /** Pixelated and blurred copies of the display, made on first use of the censor tool. */
111
+ function env(): Env {
112
+ const scale = shot?.scale ?? 1;
113
+ if (!effects && base && anns.concat(drag?.kind === 'draw' ? [drag.ann] : []).some(a => a.kind === 'censor')) {
114
+ const block = Math.max(4, Math.round(12 * scale));
115
+ const small = document.createElement('canvas'); small.width = Math.max(1, Math.ceil(base.width / block)); small.height = Math.max(1, Math.ceil(base.height / block));
116
+ small.getContext('2d')!.drawImage(base, 0, 0, small.width, small.height);
117
+ const mosaic = document.createElement('canvas'); mosaic.width = base.width; mosaic.height = base.height;
118
+ const m = mosaic.getContext('2d')!; m.imageSmoothingEnabled = false; m.drawImage(small, 0, 0, mosaic.width, mosaic.height);
119
+ const blur = document.createElement('canvas'); blur.width = base.width; blur.height = base.height;
120
+ const bl = blur.getContext('2d')!; bl.filter = `blur(${Math.round(10 * scale)}px)`; bl.drawImage(base, 0, 0);
121
+ effects = { mosaic, blur };
122
+ }
123
+ return { mosaic: effects?.mosaic ?? null, blur: effects?.blur ?? null, width: view.w, height: view.h, scale };
124
+ }
125
+
126
+ // ---------------------------------------------------------------- rendering
127
+ let frame = 0, annDirty = false;
128
+ function dirty(annotations = false) { annDirty ||= annotations; if (!frame) frame = requestAnimationFrame(render); }
129
+
130
+ function render() {
131
+ frame = 0;
132
+ const dpr = devicePixelRatio || 1;
133
+ if (annDirty) {
134
+ annDirty = false;
135
+ const a = $<HTMLCanvasElement>('ann').getContext('2d')!;
136
+ a.setTransform(1, 0, 0, 1, 0, 0); a.clearRect(0, 0, a.canvas.width, a.canvas.height);
137
+ if (sel && base) {
138
+ a.setTransform(dpr, 0, 0, dpr, 0, 0);
139
+ a.save(); a.beginPath(); a.rect(sel.x, sel.y, sel.w, sel.h); a.clip();
140
+ const list = drag?.kind === 'draw' ? [...anns, drag.ann] : anns, e = env();
141
+ list.forEach((s, i) => { if (!(text && text.editing === i)) draw(a, s, e); });
142
+ spotlight(a, list, sel);
143
+ a.restore();
144
+ }
145
+ }
146
+ const u = $<HTMLCanvasElement>('ui').getContext('2d')!;
147
+ u.setTransform(1, 0, 0, 1, 0, 0); u.clearRect(0, 0, u.canvas.width, u.canvas.height);
148
+ if (phase === 'idle' || !base) return;
149
+ u.setTransform(dpr, 0, 0, dpr, 0, 0);
150
+ const hole = sel ?? (drag ? null : hover);
151
+ u.fillStyle = 'rgba(0,0,0,0.45)';
152
+ u.beginPath(); u.rect(0, 0, view.w, view.h);
153
+ if (hole) u.rect(hole.x + hole.w, hole.y, -hole.w, hole.h);
154
+ u.fill('evenodd');
155
+ if (sel && prefs.beauty.on && phase === 'edit') beautyPreview(u, sel);
156
+ if (hole) {
157
+ u.strokeStyle = '#007AFF'; u.lineWidth = sel ? 1.5 : 3;
158
+ if (!sel) u.setLineDash([6, 4]);
159
+ u.strokeRect(hole.x + 0.5, hole.y + 0.5, hole.w - 1, hole.h - 1); u.setLineDash([]);
160
+ }
161
+ if (sel) {
162
+ if (phase === 'edit') for (const h of HANDLES) {
163
+ const p = handlePoint(sel, h); u.fillStyle = '#fff'; u.strokeStyle = '#007AFF'; u.lineWidth = 1.5;
164
+ u.beginPath(); u.rect(p.x - 3.5, p.y - 3.5, 7, 7); u.fill(); u.stroke();
165
+ }
166
+ sizeLabel(u, sel);
167
+ }
168
+ const s = selected !== null ? anns[selected] : null;
169
+ if (s && !text) {
170
+ const r = bounds(s);
171
+ u.strokeStyle = 'rgba(0,122,255,0.9)'; u.lineWidth = 1; u.setLineDash([4, 3]);
172
+ u.strokeRect(r.x - 4, r.y - 4, r.w + 8, r.h + 8); u.setLineDash([]);
173
+ u.fillStyle = '#fff'; u.strokeStyle = '#007AFF'; u.lineWidth = 1.5;
174
+ for (const h of handles(s)) { u.beginPath(); u.arc(h.p.x, h.p.y, 4.5, 0, Math.PI * 2); u.fill(); u.stroke(); }
175
+ }
176
+ if (pointer && (phase === 'select' || drag?.kind === 'region-new' || drag?.kind === 'region-resize' || (tool() === 'sampler' && phase === 'edit' && sel && contains(sel, pointer)))) magnifier(u, pointer);
177
+ }
178
+
179
+ function beautyPreview(u: CanvasRenderingContext2D, r: Rect) {
180
+ const b = prefs.beauty, bar = b.frame ? FRAME_BAR : 0, pad = b.padding;
181
+ const ox = r.x - pad, oy = r.y - bar - pad, ow = r.w + pad * 2, oh = r.h + bar + pad * 2;
182
+ u.save(); u.beginPath(); u.rect(ox, oy, ow, oh); u.rect(r.x + r.w, r.y, -r.w, r.h); u.clip('evenodd');
183
+ fillGradient(u, ox, oy, ow, oh, b.style);
184
+ if (bar) drawFrame(u, r.x, r.y - bar, r.w);
185
+ u.restore();
186
+ }
187
+
188
+ function sizeLabel(u: CanvasRenderingContext2D, r: Rect) {
189
+ const s = shot?.output.downscale ? 1 : shot?.scale ?? 1, label = `${Math.round(r.w * s)} × ${Math.round(r.h * s)}`;
190
+ u.font = `12px ${FONT}`;
191
+ const w = u.measureText(label).width + 12, h = 22, top = prefs.beauty.on && phase === 'edit' ? prefs.beauty.padding + (prefs.beauty.frame ? FRAME_BAR : 0) : 0;
192
+ const x = clamp(r.x, 2, view.w - w - 2), y = r.y - top - h - 6 >= 2 ? r.y - top - h - 6 : r.y + 6;
193
+ u.fillStyle = 'rgba(28,28,30,0.85)'; u.beginPath(); u.roundRect(x, y, w, h, 5); u.fill();
194
+ u.fillStyle = '#fff'; u.textBaseline = 'middle'; u.fillText(label, x + 6, y + h / 2 + 0.5);
195
+ }
196
+
197
+ const MAG = 120, ZOOM = 8;
198
+ function pixelAt(p: Point): [number, number, number] {
199
+ const s = shot!.scale, px = clamp(Math.floor(p.x * s), 0, base!.width - 1), py = clamp(Math.floor(p.y * s), 0, base!.height - 1);
200
+ const d = pixels!.getImageData(px, py, 1, 1).data;
201
+ return invert ? [255 - d[0], 255 - d[1], 255 - d[2]] : [d[0], d[1], d[2]];
202
+ }
203
+ const hex = ([r, g, b]: number[]) => '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('').toUpperCase();
204
+
205
+ function magnifier(u: CanvasRenderingContext2D, p: Point) {
206
+ if (!base || !pixels || !shot) return;
207
+ const s = shot.scale, info = 44;
208
+ let x = p.x + 20, y = p.y + 24;
209
+ if (x + MAG > view.w - 4) x = p.x - 20 - MAG;
210
+ if (y + MAG + info > view.h - 4) y = p.y - 24 - MAG - info;
211
+ const src = MAG / ZOOM;
212
+ u.save();
213
+ u.fillStyle = 'rgba(28,28,30,0.92)'; u.beginPath(); u.roundRect(x - 2, y - 2, MAG + 4, MAG + info + 4, 8); u.fill();
214
+ u.beginPath(); u.rect(x, y, MAG, MAG); u.clip();
215
+ if (invert) u.filter = 'invert(1)';
216
+ u.imageSmoothingEnabled = false;
217
+ u.drawImage(base, (p.x - src / 2) * s, (p.y - src / 2) * s, src * s, src * s, x, y, MAG, MAG);
218
+ u.filter = 'none';
219
+ u.strokeStyle = 'rgba(0,122,255,0.9)'; u.lineWidth = 1;
220
+ u.beginPath(); u.moveTo(x + MAG / 2, y); u.lineTo(x + MAG / 2, y + MAG); u.moveTo(x, y + MAG / 2); u.lineTo(x + MAG, y + MAG / 2); u.stroke();
221
+ u.restore();
222
+ const rgb = pixelAt(p);
223
+ u.fillStyle = hex(rgb); u.fillRect(x + 6, y + MAG + 8, 12, 12);
224
+ u.strokeStyle = 'rgba(255,255,255,.6)'; u.strokeRect(x + 6.5, y + MAG + 8.5, 11, 11);
225
+ u.fillStyle = '#fff'; u.font = `11px ${FONT}`; u.textBaseline = 'middle';
226
+ u.fillText(`${hex(rgb)} RGB(${rgb.join(', ')})`, x + 24, y + MAG + 14);
227
+ u.fillStyle = 'rgba(255,255,255,.7)';
228
+ u.fillText(`${Math.round(p.x * s)}, ${Math.round(p.y * s)}`, x + 6, y + MAG + 32);
229
+ }
230
+
231
+ // ---------------------------------------------------------------- history
232
+ const clone = (list: Ann[]) => list.map(s => structuredClone(s));
233
+ function pushHistory() { undoStack.push(clone(anns)); if (undoStack.length > 200) undoStack.shift(); redoStack = []; }
234
+ function restore(from: Ann[][], to: Ann[][]) {
235
+ commitText();
236
+ if (!from.length) return;
237
+ to.push(clone(anns)); anns = from.pop()!; selected = null; syncCounter(); refreshBars(); dirty(true);
238
+ }
239
+ const undo = () => restore(undoStack, redoStack);
240
+ const redo = () => restore(redoStack, undoStack);
241
+ /** Numbers follow undo/redo so badges never skip. */
242
+ function syncCounter() { const ns = anns.filter(a => a.kind === 'number').map(a => (a as Extract<Ann, { kind: 'number' }>).n); counter = ns.length ? Math.max(...ns) + 1 : 1; }
243
+ function removeSelected() { if (selected === null) return; pushHistory(); anns.splice(selected, 1); selected = null; syncCounter(); refreshBars(); dirty(true); }
244
+
245
+ // ---------------------------------------------------------------- pointer
246
+ const pt = (e: MouseEvent): Point => ({ x: clamp(e.clientX, 0, view.w), y: clamp(e.clientY, 0, view.h) });
247
+ const topHit = (p: Point) => { for (let i = anns.length - 1; i >= 0; i--) if (hit(anns[i], p)) return i; return null; };
248
+
249
+ function onDown(e: PointerEvent) {
250
+ if (phase === 'idle' || busy) return;
251
+ const p = pt(e);
252
+ if (e.button === 2) { onRightClick(); return; }
253
+ if (e.button !== 0) return;
254
+ closePopover();
255
+ if (text) {
256
+ const wasText = tool() === 'text';
257
+ commitText();
258
+ if (!(wasText && sel && contains(sel, p) && topHit(p) === null)) return;
259
+ }
260
+ $('ui').setPointerCapture(e.pointerId);
261
+ if (phase === 'select') { drag = { kind: 'region-new', origin: p, moved: false }; dirty(); return; }
262
+ if (!sel) return;
263
+ const current = selected !== null ? anns[selected] : null;
264
+ if (current) {
265
+ const h = handles(current).find(h => Math.hypot(h.p.x - p.x, h.p.y - p.y) <= 7);
266
+ if (h) { drag = { kind: 'ann-handle', handle: h.id, pushed: false }; return; }
267
+ }
268
+ const rh = hitHandle(sel, p, 5);
269
+ if (rh) { drag = { kind: 'region-resize', handle: rh, start: { ...sel } }; return; }
270
+ if (tool() === 'sampler' && contains(sel, p)) { sample(p); return; }
271
+ // Clicking any annotation selects it, whatever the tool.
272
+ const index = topHit(p);
273
+ if (index !== null) {
274
+ selected = index; showOptions(); dirty();
275
+ drag = { kind: 'ann-move', origin: p, start: structuredClone(anns[index]), pushed: false };
276
+ return;
277
+ }
278
+ if (selected !== null) { selected = null; showOptions(); dirty(); }
279
+ if (!contains(sel, p)) {
280
+ if (!anns.length) { sel = null; phase = 'select'; hideBars(); drag = { kind: 'region-new', origin: p, moved: false }; dirty(true); }
281
+ return;
282
+ }
283
+ const k = tool();
284
+ if (k === 'select') { drag = { kind: 'region-move', origin: p, start: { ...sel } }; return; }
285
+ if (k === 'text') { openText(p, null); return; }
286
+ const o = { ...prefs.opts[k] }, id = nextId++;
287
+ let ann: Ann;
288
+ switch (k) {
289
+ case 'pencil': case 'marker': ann = { id, kind: k, o, points: [p] }; break;
290
+ case 'line': case 'arrow': case 'measure': ann = { id, kind: k, o, a: p, b: p, c: null }; break;
291
+ case 'rect': case 'ellipse': case 'censor': case 'highlight': ann = { id, kind: k, o, a: p, b: p }; break;
292
+ case 'number': ann = { id, kind: 'number', o, at: p, tip: null, n: counter }; break;
293
+ case 'stamp': ann = { id, kind: 'stamp', o, at: p, size: o.stampSize }; break;
294
+ default: return;
295
+ }
296
+ drag = { kind: 'draw', ann, origin: p, last: p };
297
+ dirty(true);
298
+ }
299
+
300
+ function onMove(e: PointerEvent) {
301
+ if (phase === 'idle') return;
302
+ const p = pt(e); pointer = p;
303
+ if (!drag) {
304
+ if (phase === 'select') hover = snapTarget(p);
305
+ updateCursor(p); dirty(); return;
306
+ }
307
+ switch (drag.kind) {
308
+ case 'region-new':
309
+ if (!drag.moved && Math.hypot(p.x - drag.origin.x, p.y - drag.origin.y) < 4) break;
310
+ drag.moved = true; sel = fromPoints(drag.origin, p, view, e.shiftKey); break;
311
+ case 'region-move': sel = move(drag.start, p.x - drag.origin.x, p.y - drag.origin.y, view); hideBars(); dirty(true); break;
312
+ case 'region-resize': sel = resize(drag.start, drag.handle, p, view); hideBars(); dirty(true); break;
313
+ case 'draw': {
314
+ const a = drag.ann;
315
+ // Space held: move the shape being drawn instead of resizing it.
316
+ if (space) { drag.ann = translate(a, p.x - drag.last.x, p.y - drag.last.y); drag.origin = { x: drag.origin.x + p.x - drag.last.x, y: drag.origin.y + p.y - drag.last.y }; drag.last = p; dirty(true); break; }
317
+ drag.last = p;
318
+ if (a.kind === 'pencil' || a.kind === 'marker') {
319
+ const last = a.points[a.points.length - 1];
320
+ if (e.shiftKey && a.points.length > 1) a.points = [a.points[0], constrain(a.points[0], p, 'line')];
321
+ else if (Math.hypot(p.x - last.x, p.y - last.y) >= 1.5) a.points.push(p);
322
+ } else if (a.kind === 'line' || a.kind === 'arrow' || a.kind === 'measure') a.b = e.shiftKey ? constrain(a.a, p, a.kind) : p;
323
+ else if (a.kind === 'rect' || a.kind === 'ellipse' || a.kind === 'censor' || a.kind === 'highlight') a.b = e.shiftKey ? constrain(a.a, p, a.kind) : p;
324
+ else if (a.kind === 'number') a.tip = Math.hypot(p.x - a.at.x, p.y - a.at.y) > badgeRadius(a.o) + 4 ? p : null;
325
+ else if (a.kind === 'stamp') drag.ann = { ...a, size: Math.max(a.o.stampSize, Math.max(Math.abs(p.x - a.at.x), Math.abs(p.y - a.at.y)) * 2) };
326
+ dirty(true); break;
327
+ }
328
+ case 'ann-move':
329
+ if (selected === null) break;
330
+ if (!drag.pushed) { pushHistory(); drag.pushed = true; }
331
+ anns[selected] = translate(drag.start, p.x - drag.origin.x, p.y - drag.origin.y); dirty(true); break;
332
+ case 'ann-handle':
333
+ if (selected === null) break;
334
+ if (!drag.pushed) { pushHistory(); drag.pushed = true; }
335
+ anns[selected] = dragHandle(anns[selected], drag.handle, p, e.shiftKey); dirty(true); break;
336
+ }
337
+ dirty();
338
+ }
339
+
340
+ function onUp(e: PointerEvent) {
341
+ if (!drag) return;
342
+ const d = drag; drag = null;
343
+ if ($('ui').hasPointerCapture(e.pointerId)) $('ui').releasePointerCapture(e.pointerId);
344
+ if (d.kind === 'region-new') {
345
+ const picked = d.moved ? sel : hover;
346
+ sel = picked && picked.w >= 2 && picked.h >= 2 ? round(picked) : null;
347
+ if (!sel) { phase = 'select'; dirty(true); return; }
348
+ phase = 'edit';
349
+ if (shot?.mode === 'quick') { void finish(shot.output.enter); return; }
350
+ showBars(); showHint(); dirty(true);
351
+ if (shot?.mode === 'ocr') void runOcr();
352
+ return;
353
+ } else if (d.kind === 'draw') {
354
+ if (meaningful(d.ann)) {
355
+ pushHistory(); anns.push(d.ann);
356
+ if (d.ann.kind === 'number') counter++;
357
+ selected = d.ann.kind === 'pencil' || d.ann.kind === 'marker' ? null : anns.length - 1;
358
+ refreshBars();
359
+ }
360
+ } else if (d.kind === 'region-move' || d.kind === 'region-resize') {
361
+ if (sel) sel = round(sel);
362
+ if (sel && (sel.w < 2 || sel.h < 2)) { sel = null; phase = 'select'; hideBars(); }
363
+ else showBars();
364
+ } else refreshBars();
365
+ updateCursor(pt(e)); showHint(); dirty(true);
366
+ }
367
+
368
+ function onRightClick() {
369
+ if (text) { cancelText(); return; }
370
+ if (selected !== null) { selected = null; showOptions(); dirty(); return; }
371
+ if (phase === 'edit' && !anns.length) { sel = null; phase = 'select'; hideBars(); showHint(); dirty(true); return; }
372
+ if (phase === 'select') cancel();
373
+ }
374
+
375
+ function onWheel(e: WheelEvent) {
376
+ if (selected === null || text) return;
377
+ const p = pt(e), s = anns[selected];
378
+ if (!hit(s, p)) return;
379
+ e.preventDefault();
380
+ const now = Date.now(); if (now - lastWheel > 700) pushHistory(); lastWheel = now;
381
+ anns[selected] = adjust(s, e.deltaY < 0 ? 1 : -1);
382
+ showOptions(); dirty(true);
383
+ }
384
+
385
+ function updateCursor(p: Point) {
386
+ let cursor = 'crosshair';
387
+ if (phase === 'edit' && sel) {
388
+ const current = selected !== null ? anns[selected] : null, rh = hitHandle(sel, p, 5);
389
+ if (current && handles(current).some(h => Math.hypot(h.p.x - p.x, h.p.y - p.y) <= 7)) cursor = 'pointer';
390
+ else if (rh) cursor = CURSORS[rh];
391
+ else if (tool() !== 'sampler' && topHit(p) !== null) cursor = 'move';
392
+ else if (contains(sel, p)) cursor = tool() === 'select' ? 'move' : tool() === 'text' ? 'text' : tool() === 'sampler' ? 'copy' : 'crosshair';
393
+ else cursor = anns.length ? 'default' : 'crosshair';
394
+ }
395
+ $('ui').style.cursor = cursor;
396
+ }
397
+
398
+ function sample(p: Point) {
399
+ const color = hex(pixelAt(p));
400
+ prefs.recent = [color, ...prefs.recent.filter(c => c !== color)].slice(0, 6);
401
+ for (const k of TOOLS) if (!NO_COLOR.has(k)) prefs.opts[k] = { ...prefs.opts[k], color };
402
+ savePrefs(); refreshBars();
403
+ void post({ type: 'copy-text', text: color });
404
+ toast(tr('toast.colorCopied', { color }));
405
+ }
406
+
407
+ // ---------------------------------------------------------------- text
408
+ const textFont = (o: Options) => `${o.italic ? 'italic ' : ''}${o.bold ? '700 ' : '400 '}${o.fontSize}px ${FONT}`;
409
+ function openText(at: Point, index: number | null) {
410
+ const area = $<HTMLTextAreaElement>('text');
411
+ const existing = index !== null ? anns[index] as Text : null, o = existing?.o ?? prefs.opts.text;
412
+ const p = existing ? existing.at : at;
413
+ text = { at: p, editing: index };
414
+ area.value = existing?.text ?? '';
415
+ area.style.left = `${p.x - 4}px`; area.style.top = `${p.y - 4}px`;
416
+ area.style.font = textFont(o); area.style.color = o.textBg ? (o.color.toUpperCase() === '#FFFFFF' ? '#000' : '#fff') : o.color;
417
+ area.style.background = o.textBg ? o.color : 'transparent'; area.style.textAlign = o.align; area.style.textDecoration = o.underline ? 'underline' : 'none';
418
+ area.hidden = false; autosize(); selected = null;
419
+ requestAnimationFrame(() => area.focus());
420
+ showOptions(); dirty(true);
421
+ }
422
+ function autosize() {
423
+ const area = $<HTMLTextAreaElement>('text');
424
+ area.style.width = '0px'; area.style.height = '0px';
425
+ area.style.width = `${Math.min(Math.max(area.scrollWidth + 12, 40), view.w - (text?.at.x ?? 0))}px`;
426
+ area.style.height = `${area.scrollHeight + 2}px`;
427
+ }
428
+ function commitText() {
429
+ if (!text) return;
430
+ const area = $<HTMLTextAreaElement>('text'), value = area.value.replace(/\s+$/, ''), t0 = text;
431
+ text = null; area.hidden = true; area.value = '';
432
+ if (t0.editing !== null) {
433
+ const old = anns[t0.editing] as Text;
434
+ if (value === old.text) { selected = t0.editing; dirty(true); return; }
435
+ pushHistory();
436
+ if (!value) { anns.splice(t0.editing, 1); selected = null; } else { anns[t0.editing] = { ...old, text: value }; selected = t0.editing; }
437
+ } else if (value) {
438
+ pushHistory(); anns.push({ id: nextId++, kind: 'text', o: { ...prefs.opts.text }, at: t0.at, text: value }); selected = anns.length - 1;
439
+ }
440
+ refreshBars(); dirty(true);
441
+ }
442
+ function cancelText() {
443
+ if (!text) return;
444
+ const t0 = text; text = null;
445
+ const area = $<HTMLTextAreaElement>('text'); area.hidden = true; area.value = '';
446
+ selected = t0.editing; dirty(true);
447
+ }
448
+
449
+ // ---------------------------------------------------------------- toolbars
450
+ function icon(name: string) { const i = el('i', 'icon'); i.style.setProperty('--src', `url(icons/${name}.svg)`); return i; }
451
+ function btn(name: string, title: string, testid: string, onClick: () => void, cls = 'tb') {
452
+ const b = el('button', cls); b.title = title; b.setAttribute('aria-label', title); b.dataset.testid = testid;
453
+ b.append(icon(name));
454
+ b.addEventListener('click', e => { e.stopPropagation(); onClick(); });
455
+ return b;
456
+ }
457
+ const sep = () => el('span', 'sep');
458
+
459
+ function buildBars() {
460
+ const tools = $('tools'); tools.replaceChildren();
461
+ for (const k of TOOLS) {
462
+ const key = KEY_OF[k];
463
+ const b = btn(k, `${tr(`tool.${k}`)}${key ? ` (${key})` : ''}`, `screenshot.tool.${k}`, () => setTool(k));
464
+ b.dataset.tool = k; tools.append(b);
465
+ }
466
+ tools.append(sep());
467
+ const color = el('button', 'tb color'); color.dataset.testid = 'screenshot.color'; color.title = tr('option.color');
468
+ color.append(el('span', 'swatch-dot'));
469
+ color.addEventListener('click', e => { e.stopPropagation(); togglePopover('palette'); });
470
+ tools.append(color, sep(),
471
+ btn('undo', tr('action.undo'), 'screenshot.action.undo', undo),
472
+ btn('redo', tr('action.redo'), 'screenshot.action.redo', redo));
473
+ const actions = $('actions'); actions.replaceChildren();
474
+ actions.append(
475
+ btn('copy', tr('action.copy'), 'screenshot.action.copy', () => void finish('copy'), 'tb primary'),
476
+ btn('save', tr('action.save'), 'screenshot.action.save', () => void finish('save')),
477
+ btn('ocr', tr('action.ocr'), 'screenshot.action.ocr', () => void runOcr()),
478
+ btn('beautify', tr('action.beautify'), 'screenshot.action.beautify', () => togglePopover('beauty')),
479
+ btn('invert', tr('action.invert'), 'screenshot.action.invert', () => { invert = !invert; paintBase(); refreshBars(); dirty(); }),
480
+ sep(),
481
+ btn('close', tr('action.cancel'), 'screenshot.action.cancel', cancel));
482
+ }
483
+
484
+ function refreshBars() {
485
+ for (const b of $('tools').querySelectorAll<HTMLButtonElement>('[data-tool]')) b.classList.toggle('on', b.dataset.tool === tool());
486
+ const q = (id: string) => document.querySelector<HTMLButtonElement>(`[data-testid="${id}"]`);
487
+ if (q('screenshot.action.undo')) q('screenshot.action.undo')!.disabled = !undoStack.length;
488
+ if (q('screenshot.action.redo')) q('screenshot.action.redo')!.disabled = !redoStack.length;
489
+ q('screenshot.action.beautify')?.classList.toggle('lit', prefs.beauty.on);
490
+ q('screenshot.action.invert')?.classList.toggle('on', invert);
491
+ const current = selected !== null ? anns[selected] : null;
492
+ const dot = document.querySelector<HTMLElement>('#tools .swatch-dot');
493
+ if (dot) dot.style.background = (current?.o ?? prefs.opts[tool()]).color;
494
+ showOptions();
495
+ if (popover) placePopover();
496
+ }
497
+
498
+ function setTool(k: Tool) {
499
+ commitText();
500
+ prefs.tool = k; selected = null; savePrefs();
501
+ refreshBars(); showHint(); dirty(true);
502
+ }
503
+
504
+ /** Options for the selected annotation if there is one, else for the current tool. */
505
+ function optionTarget(): { kind: Tool | Ann['kind']; o: Options; apply: (patch: Partial<Options>) => void } {
506
+ const current = selected !== null ? anns[selected] : null;
507
+ if (current) return {
508
+ kind: current.kind, o: current.o,
509
+ apply: patch => {
510
+ pushHistory(); const s = anns[selected!];
511
+ anns[selected!] = { ...s, o: { ...s.o, ...patch }, ...(s.kind === 'stamp' && patch.stampSize ? { size: patch.stampSize } : {}) } as Ann;
512
+ // The tool remembers the change too, so the next one looks the same.
513
+ const k = s.kind as Tool; prefs.opts[k] = { ...prefs.opts[k], ...patch }; savePrefs(); dirty(true); refreshBars();
514
+ },
515
+ };
516
+ const k = tool();
517
+ return { kind: k, o: prefs.opts[k], apply: patch => { prefs.opts[k] = { ...prefs.opts[k], ...patch }; savePrefs(); if (text && k === 'text') restyleText(); refreshBars(); } };
518
+ }
519
+ function restyleText() {
520
+ if (!text) return;
521
+ const o = prefs.opts.text, area = $<HTMLTextAreaElement>('text');
522
+ area.style.font = textFont(o); area.style.color = o.textBg ? (o.color.toUpperCase() === '#FFFFFF' ? '#000' : '#fff') : o.color;
523
+ area.style.background = o.textBg ? o.color : 'transparent'; area.style.textAlign = o.align; area.style.textDecoration = o.underline ? 'underline' : 'none';
524
+ autosize();
525
+ }
526
+
527
+ function segmented<T extends string>(testid: string, value: T, items: { v: T; label: string; icon?: string }[], set: (v: T) => void) {
528
+ const g = el('div', 'seg'); g.dataset.testid = testid;
529
+ for (const it of items) {
530
+ const b = el('button', `seg-item${it.v === value ? ' on' : ''}`); b.title = it.label; b.dataset.value = it.v;
531
+ if (it.icon) b.append(icon(it.icon)); else b.textContent = it.label;
532
+ b.addEventListener('click', e => { e.stopPropagation(); set(it.v); });
533
+ g.append(b);
534
+ }
535
+ return g;
536
+ }
537
+ function toggle(testid: string, on: boolean, label: string, iconName: string, set: (v: boolean) => void) {
538
+ const b = el('button', `seg-item solo${on ? ' on' : ''}`); b.title = label; b.dataset.testid = testid; b.append(icon(iconName));
539
+ b.addEventListener('click', e => { e.stopPropagation(); set(!on); });
540
+ return b;
541
+ }
542
+ function slider(testid: string, label: string, min: number, max: number, step: number, value: number, fmt: (v: number) => string, set: (v: number) => void) {
543
+ const w = el('label', 'slider'); w.title = label;
544
+ const name = el('span', 'slider-name', label);
545
+ const r = el('input') as HTMLInputElement; r.type = 'range'; r.min = String(min); r.max = String(max); r.step = String(step); r.value = String(value); r.dataset.testid = testid;
546
+ const out = el('span', 'slider-value', fmt(value));
547
+ r.addEventListener('input', () => { out.textContent = fmt(Number(r.value)); });
548
+ r.addEventListener('change', () => set(Number(r.value)));
549
+ w.append(name, r, out); return w;
550
+ }
551
+
552
+ function showOptions() {
553
+ const row = $('options');
554
+ if (phase !== 'edit' || $('tools').hidden) { row.hidden = true; return; }
555
+ const { kind, o, apply } = optionTarget();
556
+ const parts: HTMLElement[] = [];
557
+ if (!NO_WIDTH.has(kind)) parts.push(slider('screenshot.option.width', tr('option.width'), 1, 24, 1, o.width, v => `${v}`, v => apply({ width: v })));
558
+ if (!NO_COLOR.has(kind) && kind !== 'censor') parts.push(slider('screenshot.option.opacity', tr('option.opacity'), 10, 100, 5, Math.round(o.opacity * 100), v => `${v}%`, v => apply({ opacity: v / 100 })));
559
+ for (const key of optionsFor(kind)) {
560
+ switch (key) {
561
+ case 'arrow': parts.push(segmented('screenshot.option.arrow', o.arrow, (['single', 'double', 'open', 'thick', 'tail'] as const).map(v => ({ v, label: tr(`arrow.${v}`), icon: `arrow-${v}` })), v => apply({ arrow: v }))); break;
562
+ case 'lineStyle': parts.push(segmented('screenshot.option.line', o.lineStyle, (['solid', 'dashed', 'dotted'] as const).map(v => ({ v, label: tr(`line.${v}`), icon: `line-${v}` })), v => apply({ lineStyle: v }))); break;
563
+ case 'fill': parts.push(segmented('screenshot.option.fill', o.fill, (['stroke', 'strokeFill', 'fill'] as const).map(v => ({ v, label: tr(`fill.${v}`), icon: `fill-${v}` })), v => apply({ fill: v }))); break;
564
+ case 'radius': parts.push(slider('screenshot.option.radius', tr('option.radius'), 0, 40, 2, o.radius, v => `${v}`, v => apply({ radius: v }))); break;
565
+ case 'fontSize': parts.push(slider('screenshot.option.font', tr('option.fontSize'), 10, 96, 2, o.fontSize, v => `${v}`, v => apply({ fontSize: v }))); break;
566
+ case 'bold': parts.push(toggle('screenshot.option.bold', o.bold, tr('option.bold'), 'bold', v => apply({ bold: v }))); break;
567
+ case 'italic': parts.push(toggle('screenshot.option.italic', o.italic, tr('option.italic'), 'italic', v => apply({ italic: v }))); break;
568
+ case 'underline': parts.push(toggle('screenshot.option.underline', o.underline, tr('option.underline'), 'underline', v => apply({ underline: v }))); break;
569
+ case 'align': parts.push(segmented('screenshot.option.align', o.align, (['left', 'center', 'right'] as const).map(v => ({ v, label: tr(`align.${v}`), icon: `align-${v}` })), v => apply({ align: v }))); break;
570
+ case 'textBg': parts.push(toggle('screenshot.option.textBg', o.textBg, tr('option.textBg'), 'text-bg', v => apply({ textBg: v }))); break;
571
+ case 'numberFormat': parts.push(segmented('screenshot.option.number', o.numberFormat, [{ v: 'decimal', label: '1' }, { v: 'roman', label: 'I' }, { v: 'alpha', label: 'A' }, { v: 'alphaLower', label: 'a' }], v => apply({ numberFormat: v }))); break;
572
+ case 'censor': parts.push(segmented('screenshot.option.censor', o.censor, (['pixelate', 'blur', 'solid'] as const).map(v => ({ v, label: tr(`censor.${v}`) })), v => apply({ censor: v }))); break;
573
+ case 'emoji': {
574
+ const g = el('div', 'emojis'); g.dataset.testid = 'screenshot.option.emoji';
575
+ for (const e of EMOJIS) { const b = el('button', `emoji${e === o.emoji ? ' on' : ''}`, e); b.addEventListener('click', ev => { ev.stopPropagation(); apply({ emoji: e }); }); g.append(b); }
576
+ parts.push(g); break;
577
+ }
578
+ }
579
+ }
580
+ if (kind === 'sampler') parts.push(el('span', 'note', tr('option.samplerNote')));
581
+ if (kind === 'select') parts.push(el('span', 'note', tr('option.selectNote')));
582
+ if (selected !== null) parts.push(btn('trash', tr('action.delete'), 'screenshot.action.delete', removeSelected, 'tb small'));
583
+ row.replaceChildren(...parts);
584
+ row.hidden = parts.length === 0;
585
+ placeBars();
586
+ }
587
+
588
+ function showBars() { $('tools').hidden = false; $('actions').hidden = false; refreshBars(); placeBars(); }
589
+ function hideBars() { $('tools').hidden = true; $('actions').hidden = true; $('options').hidden = true; closePopover(); }
590
+
591
+ /** Bottom bar under the selection (or above, or inside); options row next to it; actions on the right. */
592
+ function placeBars() {
593
+ if (!sel || $('tools').hidden) return;
594
+ const tools = $('tools'), options = $('options'), actions = $('actions');
595
+ const extra = prefs.beauty.on ? prefs.beauty.padding : 0;
596
+ const tr0 = tools.getBoundingClientRect(), orow = options.hidden ? { width: 0, height: 0 } : options.getBoundingClientRect();
597
+ const block = tr0.height + (options.hidden ? 0 : orow.height + 6), gap = 8 + extra;
598
+ let y: number, above = false;
599
+ if (sel.y + sel.h + gap + block <= view.h - 4) y = sel.y + sel.h + gap;
600
+ else if (sel.y - gap - block >= 4) { y = sel.y - gap - block; above = true; }
601
+ else y = Math.max(4, sel.y + sel.h - 8 - block);
602
+ const x = clamp(sel.x + sel.w / 2 - tr0.width / 2, 4, view.w - tr0.width - 4);
603
+ if (above && !options.hidden) { options.style.top = `${y}px`; tools.style.top = `${y + orow.height + 6}px`; }
604
+ else { tools.style.top = `${y}px`; options.style.top = `${y + tr0.height + 6}px`; }
605
+ tools.style.left = `${x}px`;
606
+ options.style.left = `${clamp(sel.x + sel.w / 2 - orow.width / 2, 4, view.w - orow.width - 4)}px`;
607
+ const ar = actions.getBoundingClientRect();
608
+ let ax = sel.x + sel.w + gap;
609
+ if (ax + ar.width > view.w - 4) ax = sel.x - gap - ar.width;
610
+ if (ax < 4) ax = sel.x + sel.w - ar.width - 8;
611
+ actions.style.left = `${ax}px`;
612
+ actions.style.top = `${clamp(sel.y, 4, view.h - ar.height - 4)}px`;
613
+ if (popover) placePopover();
614
+ }
615
+
616
+ // ---------------------------------------------------------------- popovers (palette, beautify)
617
+ function closePopover() { popover = null; $('popover').hidden = true; }
618
+ function togglePopover(kind: 'palette' | 'beauty') { if (popover === kind) closePopover(); else openPopover(kind); }
619
+
620
+ function openPopover(kind: 'palette' | 'beauty') {
621
+ popover = kind;
622
+ const box = $('popover'); box.replaceChildren();
623
+ if (kind === 'palette') {
624
+ const { o, apply } = optionTarget();
625
+ const grid = el('div', 'palette');
626
+ for (const c of [...COLORS, ...prefs.recent.filter(c => !COLORS.includes(c))]) {
627
+ const s = el('button', `swatch${o.color.toUpperCase() === c ? ' on' : ''}`); s.style.background = c; s.title = c; s.dataset.testid = `screenshot.color.${c.slice(1)}`;
628
+ s.addEventListener('click', e => { e.stopPropagation(); apply({ color: c }); openPopover('palette'); });
629
+ grid.append(s);
630
+ }
631
+ const custom = el('input') as HTMLInputElement; custom.type = 'color'; custom.value = o.color.toLowerCase(); custom.className = 'custom'; custom.title = tr('option.customColor');
632
+ custom.addEventListener('change', () => { const c = custom.value.toUpperCase(); prefs.recent = [c, ...prefs.recent.filter(x => x !== c)].slice(0, 6); apply({ color: c }); openPopover('palette'); });
633
+ box.append(grid, custom);
634
+ } else {
635
+ const b = prefs.beauty;
636
+ const set = (patch: Partial<Beauty>) => { prefs.beauty = { ...prefs.beauty, ...patch }; savePrefs(); refreshBars(); placeBars(); dirty(); openPopover('beauty'); };
637
+ const head = el('label', 'row-line');
638
+ const on = el('input') as HTMLInputElement; on.type = 'checkbox'; on.checked = b.on; on.dataset.testid = 'screenshot.beauty.on';
639
+ on.addEventListener('change', () => set({ on: on.checked }));
640
+ head.append(on, el('span', '', tr('beauty.enable')));
641
+ const grads = el('div', 'gradients');
642
+ GRADIENTS.forEach((stops, i) => {
643
+ const g = el('button', `gradient${b.style === i ? ' on' : ''}`); g.style.background = `linear-gradient(135deg, ${stops.join(', ')})`; g.dataset.testid = `screenshot.beauty.style.${i}`;
644
+ g.addEventListener('click', e => { e.stopPropagation(); set({ style: i, on: true }); });
645
+ grads.append(g);
646
+ });
647
+ const frameRow = el('label', 'row-line');
648
+ const frame = el('input') as HTMLInputElement; frame.type = 'checkbox'; frame.checked = b.frame; frame.dataset.testid = 'screenshot.beauty.frame';
649
+ frame.addEventListener('change', () => set({ frame: frame.checked, on: true }));
650
+ frameRow.append(frame, el('span', '', tr('beauty.frame')));
651
+ box.append(head, grads, slider('screenshot.beauty.padding', tr('beauty.padding'), 16, 120, 4, b.padding, v => `${v}`, v => set({ padding: v, on: true })), frameRow);
652
+ }
653
+ box.hidden = false;
654
+ placePopover();
655
+ }
656
+
657
+ function placePopover() {
658
+ const box = $('popover');
659
+ const anchor = popover === 'palette' ? document.querySelector<HTMLElement>('[data-testid="screenshot.color"]') : document.querySelector<HTMLElement>('[data-testid="screenshot.action.beautify"]');
660
+ if (!anchor || $('tools').hidden) { box.hidden = true; return; }
661
+ const a = anchor.getBoundingClientRect(), r = box.getBoundingClientRect();
662
+ if (popover === 'beauty') {
663
+ // Beside the vertical action bar.
664
+ let x = a.right + 8; if (x + r.width > view.w - 4) x = a.left - 8 - r.width;
665
+ box.style.left = `${x}px`; box.style.top = `${clamp(a.top, 4, view.h - r.height - 4)}px`;
666
+ } else {
667
+ const below = a.bottom + 8 + r.height <= view.h - 4;
668
+ box.style.left = `${clamp(a.left + a.width / 2 - r.width / 2, 4, view.w - r.width - 4)}px`;
669
+ box.style.top = `${below ? a.bottom + 8 : a.top - 8 - r.height}px`;
670
+ }
671
+ }
672
+
673
+ // ---------------------------------------------------------------- OCR panel
674
+ async function runOcr() {
675
+ if (!shot || !sel || busy) return;
676
+ commitText();
677
+ ocr = { loading: true, result: null }; renderOcr();
678
+ try {
679
+ const blob = await encode(flatten(false, true), 'image/png');
680
+ const chunks = split(await toBase64(blob)), seq = shot.seq;
681
+ for (let i = 0; i < chunks.length; i++) await post({ type: 'result', seq, action: 'ocr', mime: 'image/png', i, parts: chunks.length, data: chunks[i] });
682
+ } catch (error) { console.error('ocr export failed:', error); ocr = { loading: false, result: null }; renderOcr(); }
683
+ }
684
+
685
+ function renderOcr() {
686
+ const panel = $('ocr');
687
+ if (!ocr || !sel) { panel.hidden = true; return; }
688
+ panel.replaceChildren();
689
+ const head = el('div', 'ocr-head'); head.append(el('b', '', tr('ocr.title')));
690
+ const close = btn('close', tr('action.close'), 'screenshot.ocr.close', () => { ocr = null; renderOcr(); if (shot?.mode === 'ocr') cancel(); }, 'tb small');
691
+ head.append(close); panel.append(head);
692
+ if (ocr.loading) panel.append(el('p', 'muted', tr('ocr.loading')));
693
+ else if (!ocr.result) panel.append(el('p', 'muted', tr('ocr.failed')));
694
+ else {
695
+ const { text: found, codes } = ocr.result;
696
+ for (const code of codes) {
697
+ const row = el('div', 'code'); row.dataset.testid = 'screenshot.ocr.code';
698
+ row.append(el('span', 'code-label', tr('ocr.qr')), el('span', 'code-value', code));
699
+ row.append(btn('copy-doc', tr('ocr.copy'), 'screenshot.ocr.code.copy', () => { void post({ type: 'copy-text', text: code }); toast(tr('toast.copied')); }, 'tb small'));
700
+ if (/^https?:\/\//i.test(code)) row.append(btn('open', tr('ocr.open'), 'screenshot.ocr.code.open', () => { void post({ type: 'open-url', url: code }); cancel(); }, 'tb small'));
701
+ panel.append(row);
702
+ }
703
+ if (found) {
704
+ const area = el('textarea', 'ocr-text') as HTMLTextAreaElement; area.value = found; area.spellcheck = false; area.dataset.testid = 'screenshot.ocr.text';
705
+ area.addEventListener('keydown', e => e.stopPropagation());
706
+ const copy = el('button', 'cta', tr('ocr.copyAll')); copy.dataset.testid = 'screenshot.ocr.copy';
707
+ copy.addEventListener('click', () => { void post({ type: 'copy-text', text: area.value }); toast(tr('toast.copied')); if (shot?.mode === 'ocr') setTimeout(cancel, 400); });
708
+ panel.append(area, copy);
709
+ } else if (!codes.length) panel.append(el('p', 'muted', tr('ocr.empty')));
710
+ }
711
+ panel.hidden = false;
712
+ const r = panel.getBoundingClientRect();
713
+ let x = sel.x + sel.w + 60; if (x + r.width > view.w - 4) x = sel.x - r.width - 60; if (x < 4) x = clamp(sel.x + sel.w - r.width - 8, 4, view.w - r.width - 4);
714
+ panel.style.left = `${x}px`; panel.style.top = `${clamp(sel.y, 4, view.h - r.height - 4)}px`;
715
+ }
716
+
717
+ // ---------------------------------------------------------------- output
718
+ /** The selection as an image: optionally with annotations, optionally beautified. */
719
+ function flatten(withAnnotations: boolean, raw = false): HTMLCanvasElement {
720
+ const r = sel!, s = shot!.output.downscale && !raw ? 1 : shot!.scale, src = shot!.scale;
721
+ const c = document.createElement('canvas'); c.width = Math.max(1, Math.round(r.w * s)); c.height = Math.max(1, Math.round(r.h * s));
722
+ const ctx = c.getContext('2d')!;
723
+ if (invert) ctx.filter = 'invert(1)';
724
+ ctx.imageSmoothingQuality = 'high';
725
+ ctx.drawImage(base!, r.x * src, r.y * src, r.w * src, r.h * src, 0, 0, c.width, c.height);
726
+ ctx.filter = 'none';
727
+ if (withAnnotations) {
728
+ ctx.setTransform(s, 0, 0, s, -r.x * s, -r.y * s);
729
+ ctx.beginPath(); ctx.rect(r.x, r.y, r.w, r.h); ctx.clip();
730
+ const e = { ...env(), scale: s };
731
+ for (const a of anns) draw(ctx, a, e);
732
+ spotlight(ctx, anns, r);
733
+ }
734
+ return withAnnotations && prefs.beauty.on ? beautify(c, prefs.beauty, s) : c;
735
+ }
736
+ const encode = (c: HTMLCanvasElement, mime: string) => new Promise<Blob>((ok, no) => c.toBlob(b => b ? ok(b) : no(new Error('encode_failed')), mime, 0.92));
737
+ async function toBase64(blob: Blob): Promise<string> {
738
+ const bytes = new Uint8Array(await blob.arrayBuffer());
739
+ let bin = '';
740
+ for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
741
+ return btoa(bin);
742
+ }
743
+
744
+ async function finish(action: Exclude<Action, 'ocr'>) {
745
+ if (!shot || !sel || busy) return;
746
+ commitText();
747
+ busy = true;
748
+ const seq = shot.seq, mime = action === 'save' && shot.output.format === 'jpeg' ? 'image/jpeg' : 'image/png';
749
+ try {
750
+ const chunks = split(await toBase64(await encode(flatten(true), mime)));
751
+ // Back to the user's app at once; the worker copies or saves afterwards.
752
+ reset(); await appWindows.hide('overlay').catch(() => {});
753
+ for (let i = 0; i < chunks.length; i++) await post({ type: 'result', seq, action, mime, i, parts: chunks.length, data: chunks[i] });
754
+ } catch (error) {
755
+ console.error('screenshot export failed:', error);
756
+ await post({ type: 'cancel', seq }); reset(); await appWindows.hide('overlay').catch(() => {});
757
+ } finally { busy = false; }
758
+ }
759
+
760
+ function cancel() {
761
+ const seq = shot?.seq;
762
+ reset(); void appWindows.hide('overlay').catch(() => {});
763
+ if (seq !== undefined) void post({ type: 'cancel', seq });
764
+ }
765
+
766
+ /** Clears the capture so the next one never flashes this one; tool settings stay. */
767
+ function reset() {
768
+ shot = null; incoming = null; base?.close(); base = null; pixels = null; effects = null;
769
+ phase = 'idle'; sel = null; hover = null; drag = null; anns = []; undoStack = []; redoStack = [];
770
+ selected = null; counter = 1; text = null; invert = false; ocr = null; space = false;
771
+ const area = $<HTMLTextAreaElement>('text'); area.hidden = true; area.value = '';
772
+ hideBars(); $('hint').hidden = true; $('ocr').hidden = true; $('toast').hidden = true;
773
+ for (const c of canvases()) { const x = c.getContext('2d')!; x.setTransform(1, 0, 0, 1, 0, 0); x.clearRect(0, 0, c.width, c.height); }
774
+ $('base').style.filter = 'none';
775
+ }
776
+
777
+ // ---------------------------------------------------------------- hint and toast
778
+ function showHint() {
779
+ const box = $('hint');
780
+ if (phase === 'idle') { box.hidden = true; return; }
781
+ const keys = phase === 'select'
782
+ ? ['hint.drag', 'hint.square', prefs.snap ? 'hint.snapOn' : 'hint.snapOff', 'hint.full', 'hint.esc']
783
+ : ['hint.tools', 'hint.select', 'hint.space', 'hint.enter', 'hint.esc'];
784
+ box.replaceChildren(...keys.map(k => el('div', '', tr(k))));
785
+ box.hidden = false;
786
+ box.classList.remove('top'); const r = box.getBoundingClientRect();
787
+ const covers = (y: number) => !!sel && !(r.right < sel.x || r.left > sel.x + sel.w || y + r.height < sel.y || y > sel.y + sel.h);
788
+ if (covers(r.top)) { box.classList.add('top'); if (covers(16)) box.hidden = true; }
789
+ }
790
+ let toastTimer = 0;
791
+ function toast(message: string) {
792
+ const box = $('toast'); box.textContent = message; box.hidden = false;
793
+ clearTimeout(toastTimer); toastTimer = setTimeout(() => { box.hidden = true; }, 1600) as unknown as number;
794
+ }
795
+
796
+ // ---------------------------------------------------------------- keyboard
797
+ function onKey(e: KeyboardEvent) {
798
+ if (phase === 'idle') return;
799
+ const mod = e.metaKey || e.ctrlKey, key = e.key.toLowerCase();
800
+ if (e.target === $('text')) {
801
+ if (e.key === 'Escape') { e.preventDefault(); cancelText(); }
802
+ else if (e.key === 'Enter' && mod) { e.preventDefault(); commitText(); }
803
+ return;
804
+ }
805
+ if (e.target instanceof HTMLInputElement) return;
806
+ if (e.key === ' ') { space = true; e.preventDefault(); return; }
807
+ if (e.key === 'Escape') {
808
+ e.preventDefault();
809
+ if (popover) closePopover(); else if (ocr && shot?.mode !== 'ocr') { ocr = null; renderOcr(); } else if (selected !== null) { selected = null; showOptions(); dirty(); } else cancel();
810
+ return;
811
+ }
812
+ if (phase === 'select') {
813
+ if (e.key === 'Tab') { e.preventDefault(); prefs.snap = !prefs.snap; savePrefs(); if (pointer) hover = snapTarget(pointer); showHint(); dirty(); }
814
+ else if (key === 'f' && !mod) { e.preventDefault(); sel = { ...view }; phase = 'edit'; if (shot?.mode === 'quick') { void finish(shot.output.enter); return; } if (shot?.mode === 'ocr') { showBars(); void runOcr(); return; } showBars(); showHint(); dirty(true); }
815
+ return;
816
+ }
817
+ if (e.key === 'Enter') { e.preventDefault(); void finish(shot?.output.enter ?? 'copy'); return; }
818
+ if (mod && key === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); return; }
819
+ if (mod && key === 'c') { e.preventDefault(); void finish('copy'); return; }
820
+ if (mod && key === 's') { e.preventDefault(); void finish('save'); return; }
821
+ if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); removeSelected(); return; }
822
+ if (e.key.startsWith('Arrow') && sel) {
823
+ e.preventDefault();
824
+ const step = e.shiftKey ? 10 : 1;
825
+ const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0, dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
826
+ if (selected !== null) { pushHistory(); anns[selected] = translate(anns[selected], dx, dy); dirty(true); }
827
+ else { sel = move(sel, dx, dy, view); placeBars(); dirty(true); }
828
+ return;
829
+ }
830
+ if (!mod && !e.altKey && TOOL_KEYS[key]) { e.preventDefault(); setTool(TOOL_KEYS[key]); }
831
+ }
832
+
833
+ // ---------------------------------------------------------------- wiring
834
+ export async function startOverlay() {
835
+ t = await i18n.catalog().catch(() => ({}));
836
+ i18n.onDidChangeLocale(async () => { t = await i18n.catalog().catch(() => t); buildBars(); refreshBars(); });
837
+ buildBars();
838
+ const ui = $('ui');
839
+ ui.addEventListener('pointerdown', onDown);
840
+ ui.addEventListener('pointermove', onMove);
841
+ ui.addEventListener('pointerup', onUp);
842
+ ui.addEventListener('pointercancel', onUp);
843
+ ui.addEventListener('dblclick', e => {
844
+ if (phase !== 'edit' || !sel) return;
845
+ const p = pt(e), i = topHit(p);
846
+ if (i !== null && anns[i].kind === 'text') { openText(p, i); return; }
847
+ if (i === null && tool() === 'select' && contains(sel, p)) void finish(shot?.output.enter ?? 'copy');
848
+ });
849
+ ui.addEventListener('wheel', onWheel, { passive: false });
850
+ for (const id of ['tools', 'options', 'actions', 'popover', 'ocr', 'text']) $(id).addEventListener('pointerdown', e => e.stopPropagation());
851
+ addEventListener('contextmenu', e => e.preventDefault());
852
+ addEventListener('keydown', onKey);
853
+ addEventListener('keyup', e => { if (e.key === ' ') space = false; });
854
+ $('text').addEventListener('input', autosize);
855
+ // The overlay hides when it loses focus (another app came forward): that ends the capture.
856
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden' && shot && !busy) { console.info('overlay hidden (lost focus): cancelling'); cancel(); } });
857
+ addEventListener('resize', () => { if (base) { view = { x: 0, y: 0, w: innerWidth, h: innerHeight }; sizeCanvases(); paintBase(); effects = null; dirty(true); placeBars(); } });
858
+ channel.onMessage<ToOverlay>(m => {
859
+ if (m?.type === 'shot') {
860
+ reset(); prefs = loadPrefs();
861
+ shot = m.shot; incoming = { seq: m.shot.seq, parts: [], total: m.parts };
862
+ $('ui').style.cursor = 'crosshair';
863
+ } else if (m?.type === 'chunk' && incoming && m.seq === incoming.seq) {
864
+ incoming.parts[m.i] = m.data;
865
+ if (incoming.parts.filter(p => p !== undefined).length === incoming.total) {
866
+ const b64 = incoming.parts.join(''), seq = incoming.seq; incoming = null;
867
+ void load(seq, b64).catch(error => { console.error('screenshot load failed:', error); cancel(); });
868
+ }
869
+ } else if (m?.type === 'ocr' && shot && m.seq === shot.seq && ocr) {
870
+ ocr = { loading: false, result: m.result }; renderOcr();
871
+ } else if (m?.type === 'overlay-toast' && shot) toast(tr(m.key, m.params ?? {}));
872
+ });
873
+ await post({ type: 'overlay-ready' });
874
+ }