@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.
- package/README.md +8 -5
- package/package.json +18 -5
- package/plugin.medeoplugin +0 -0
- package/src/README.md +28 -0
- package/src/README.zh-CN.md +20 -0
- package/src/about.json +7 -0
- package/src/icon.svg +1 -0
- package/src/locales/en.json +141 -0
- package/src/locales/zh-CN.json +141 -0
- package/src/locales/zh-TW.json +141 -0
- package/src/manifest.json +125 -0
- package/src/media/en/editor.png +0 -0
- package/src/media/en/settings.png +0 -0
- package/src/media/zh-CN/editor.png +0 -0
- package/src/media/zh-CN/settings.png +0 -0
- package/src/resources/helper.js +99 -0
- package/src/resources/trayTemplate.png +0 -0
- package/src/resources/trayTemplate.svg +1 -0
- package/src/src/annotations.ts +398 -0
- package/src/src/beautify.ts +41 -0
- package/src/src/geometry.ts +79 -0
- package/src/src/naming.ts +19 -0
- package/src/src/protocol.ts +53 -0
- package/src/src/settings.ts +48 -0
- package/src/src/shortcuts.ts +25 -0
- package/src/src/ui/overlay.ts +874 -0
- package/src/src/ui/settings.ts +140 -0
- package/src/tests/logic.test.mjs +101 -0
- package/src/ui/entry.ts +4 -0
- package/src/ui/icons/align-center.svg +1 -0
- package/src/ui/icons/align-left.svg +1 -0
- package/src/ui/icons/align-right.svg +1 -0
- package/src/ui/icons/arrow-double.svg +1 -0
- package/src/ui/icons/arrow-open.svg +1 -0
- package/src/ui/icons/arrow-single.svg +1 -0
- package/src/ui/icons/arrow-tail.svg +1 -0
- package/src/ui/icons/arrow-thick.svg +1 -0
- package/src/ui/icons/arrow.svg +1 -0
- package/src/ui/icons/beautify.svg +1 -0
- package/src/ui/icons/bold.svg +1 -0
- package/src/ui/icons/censor.svg +1 -0
- package/src/ui/icons/close.svg +1 -0
- package/src/ui/icons/copy-doc.svg +1 -0
- package/src/ui/icons/copy.svg +1 -0
- package/src/ui/icons/ellipse.svg +1 -0
- package/src/ui/icons/fill-fill.svg +1 -0
- package/src/ui/icons/fill-stroke.svg +1 -0
- package/src/ui/icons/fill-strokeFill.svg +1 -0
- package/src/ui/icons/highlight.svg +1 -0
- package/src/ui/icons/invert.svg +1 -0
- package/src/ui/icons/italic.svg +1 -0
- package/src/ui/icons/line-dashed.svg +1 -0
- package/src/ui/icons/line-dotted.svg +1 -0
- package/src/ui/icons/line-solid.svg +1 -0
- package/src/ui/icons/line.svg +1 -0
- package/src/ui/icons/marker.svg +1 -0
- package/src/ui/icons/measure.svg +1 -0
- package/src/ui/icons/number.svg +1 -0
- package/src/ui/icons/ocr.svg +1 -0
- package/src/ui/icons/open.svg +1 -0
- package/src/ui/icons/pencil.svg +1 -0
- package/src/ui/icons/rect.svg +1 -0
- package/src/ui/icons/redo.svg +1 -0
- package/src/ui/icons/sampler.svg +1 -0
- package/src/ui/icons/save.svg +1 -0
- package/src/ui/icons/select.svg +1 -0
- package/src/ui/icons/stamp.svg +1 -0
- package/src/ui/icons/text-bg.svg +1 -0
- package/src/ui/icons/text.svg +1 -0
- package/src/ui/icons/trash.svg +1 -0
- package/src/ui/icons/underline.svg +1 -0
- package/src/ui/icons/undo.svg +1 -0
- package/src/ui/index.html +12 -0
- package/src/ui/settings.html +5 -0
- package/src/ui/style.css +105 -0
- 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
|
+
}
|