@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,99 @@
|
|
|
1
|
+
// The plugin's window onto AppKit, run with `osascript -l JavaScript` (always present, no Xcode).
|
|
2
|
+
// screen → the display under the pointer and the windows on it, front to back
|
|
3
|
+
// copy <png> → put a PNG (plus TIFF for older apps) on the general pasteboard
|
|
4
|
+
// copy-text <file> → put the UTF-8 text in <file> on the pasteboard
|
|
5
|
+
// ocr <png> → text lines (Vision, all languages) and QR/barcode payloads as JSON
|
|
6
|
+
// choose-folder <prompt> <start> → a folder chosen by the user, or "" when cancelled
|
|
7
|
+
// Coordinates are top-left global points, the ones `screencapture -R` and Electron use.
|
|
8
|
+
ObjC.import('AppKit');
|
|
9
|
+
ObjC.import('CoreGraphics');
|
|
10
|
+
ObjC.import('Vision');
|
|
11
|
+
|
|
12
|
+
function screen() {
|
|
13
|
+
const m = $.NSEvent.mouseLocation, screens = $.NSScreen.screens;
|
|
14
|
+
const mainH = screens.objectAtIndex(0).frame.size.height;
|
|
15
|
+
let display = null;
|
|
16
|
+
for (let i = 0; i < screens.count; i++) {
|
|
17
|
+
const s = screens.objectAtIndex(i), f = s.frame;
|
|
18
|
+
const d = { x: f.origin.x, y: mainH - (f.origin.y + f.size.height), width: f.size.width, height: f.size.height, scale: s.backingScaleFactor };
|
|
19
|
+
if (!display) display = d;
|
|
20
|
+
if (m.x >= f.origin.x && m.x <= f.origin.x + f.size.width && m.y >= f.origin.y && m.y <= f.origin.y + f.size.height) { display = d; break; }
|
|
21
|
+
}
|
|
22
|
+
const raw = $.CGWindowListCopyWindowInfo($.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements, 0);
|
|
23
|
+
const list = ObjC.deepUnwrap(ObjC.castRefToObject(raw)) || [];
|
|
24
|
+
const windows = [];
|
|
25
|
+
for (const w of list) {
|
|
26
|
+
const b = w.kCGWindowBounds;
|
|
27
|
+
if (!b || w.kCGWindowLayer !== 0 || w.kCGWindowAlpha === 0 || b.Width < 24 || b.Height < 24) continue;
|
|
28
|
+
const x = Math.max(b.X, display.x), y = Math.max(b.Y, display.y);
|
|
29
|
+
const r = Math.min(b.X + b.Width, display.x + display.width), btm = Math.min(b.Y + b.Height, display.y + display.height);
|
|
30
|
+
if (r - x < 24 || btm - y < 24) continue;
|
|
31
|
+
windows.push({ x: x - display.x, y: y - display.y, w: r - x, h: btm - y });
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify({ display, windows: windows.slice(0, 200) });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function copy(file) {
|
|
37
|
+
const data = $.NSData.dataWithContentsOfFile(file);
|
|
38
|
+
if (data.isNil()) return 'missing';
|
|
39
|
+
const pb = $.NSPasteboard.generalPasteboard;
|
|
40
|
+
pb.clearContents;
|
|
41
|
+
pb.declareTypesOwner($(['public.png', 'public.tiff']), $());
|
|
42
|
+
pb.setDataForType(data, 'public.png');
|
|
43
|
+
const rep = $.NSBitmapImageRep.imageRepWithData(data);
|
|
44
|
+
if (!rep.isNil()) pb.setDataForType(rep.TIFFRepresentation, 'public.tiff');
|
|
45
|
+
return 'ok';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function copyText(file) {
|
|
49
|
+
const text = $.NSString.stringWithContentsOfFileEncodingError(file, $.NSUTF8StringEncoding, $());
|
|
50
|
+
if (text.isNil()) return 'missing';
|
|
51
|
+
const pb = $.NSPasteboard.generalPasteboard;
|
|
52
|
+
pb.clearContents;
|
|
53
|
+
pb.setStringForType(text, 'public.utf8-plain-text');
|
|
54
|
+
return 'ok';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ocr(file) {
|
|
58
|
+
const handler = $.VNImageRequestHandler.alloc.initWithURLOptions($.NSURL.fileURLWithPath(file), $());
|
|
59
|
+
const text = $.VNRecognizeTextRequest.alloc.init;
|
|
60
|
+
text.recognitionLevel = 0; // accurate
|
|
61
|
+
text.usesLanguageCorrection = true;
|
|
62
|
+
if (text.respondsToSelector('setAutomaticallyDetectsLanguage:')) text.automaticallyDetectsLanguage = true;
|
|
63
|
+
const codes = $.VNDetectBarcodesRequest.alloc.init;
|
|
64
|
+
handler.performRequestsError($([text, codes]), $());
|
|
65
|
+
const lines = [], payloads = [];
|
|
66
|
+
const found = text.results;
|
|
67
|
+
if (!found.isNil()) for (let i = 0; i < found.count; i++) {
|
|
68
|
+
const o = found.objectAtIndex(i), top = o.topCandidates(1);
|
|
69
|
+
if (top.count === 0) continue;
|
|
70
|
+
const b = o.boundingBox;
|
|
71
|
+
lines.push({ t: top.objectAtIndex(0).string.js, x: b.origin.x, y: b.origin.y, h: b.size.height });
|
|
72
|
+
}
|
|
73
|
+
const bars = codes.results;
|
|
74
|
+
if (!bars.isNil()) for (let i = 0; i < bars.count; i++) {
|
|
75
|
+
const v = bars.objectAtIndex(i).payloadStringValue;
|
|
76
|
+
if (!v.isNil() && !payloads.includes(v.js)) payloads.push(v.js);
|
|
77
|
+
}
|
|
78
|
+
return JSON.stringify({ lines, codes: payloads });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function chooseFolder(prompt, start) {
|
|
82
|
+
const app = Application.currentApplication();
|
|
83
|
+
app.includeStandardAdditions = true;
|
|
84
|
+
app.activate();
|
|
85
|
+
try {
|
|
86
|
+
const options = { withPrompt: prompt };
|
|
87
|
+
if (start && $.NSFileManager.defaultManager.fileExistsAtPath(start)) options.defaultLocation = Path(start);
|
|
88
|
+
return app.chooseFolder(options).toString();
|
|
89
|
+
} catch (e) { return ''; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function run(argv) {
|
|
93
|
+
if (argv[0] === 'screen') return screen();
|
|
94
|
+
if (argv[0] === 'copy') return copy(argv[1]);
|
|
95
|
+
if (argv[0] === 'copy-text') return copyText(argv[1]);
|
|
96
|
+
if (argv[0] === 'ocr') return ocr(argv[1]);
|
|
97
|
+
if (argv[0] === 'choose-folder') return chooseFolder(argv[1] || '', argv[2] || '');
|
|
98
|
+
return 'unknown';
|
|
99
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4v22h22"/><path d="M4 10h22v22"/><circle cx="18" cy="18" r="3.2" fill="#000" stroke="none"/></svg>
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// Annotations as plain data in display points, and how to draw, hit-test and edit them on any 2D
|
|
2
|
+
// context. The live editor draws with a devicePixelRatio transform and the export with the
|
|
3
|
+
// capture's scale, so both come out identical.
|
|
4
|
+
import type { Point, Rect } from './geometry.ts';
|
|
5
|
+
|
|
6
|
+
export type Tool = 'select' | 'arrow' | 'line' | 'pencil' | 'marker' | 'rect' | 'ellipse' | 'text' | 'number' | 'censor' | 'highlight' | 'stamp' | 'measure' | 'sampler';
|
|
7
|
+
export const TOOLS: Tool[] = ['select', 'arrow', 'line', 'pencil', 'marker', 'rect', 'ellipse', 'text', 'number', 'censor', 'highlight', 'stamp', 'measure', 'sampler'];
|
|
8
|
+
/** Single-key tool shortcuts once a region is selected. */
|
|
9
|
+
export const TOOL_KEYS: Record<string, Tool> = { s: 'select', a: 'arrow', l: 'line', p: 'pencil', m: 'marker', r: 'rect', o: 'ellipse', t: 'text', n: 'number', b: 'censor', h: 'highlight', g: 'stamp', u: 'measure', i: 'sampler' };
|
|
10
|
+
export const KEY_OF: Partial<Record<Tool, string>> = Object.fromEntries(Object.entries(TOOL_KEYS).map(([k, v]) => [v, k.toUpperCase()]));
|
|
11
|
+
|
|
12
|
+
export const COLORS = ['#FF3B30', '#FF9500', '#FFCC00', '#34C759', '#00C7BE', '#007AFF', '#5856D6', '#AF52DE', '#FF2D55', '#FFFFFF', '#8E8E93', '#000000'];
|
|
13
|
+
export const EMOJIS = ['👍', '👎', '✅', '❌', '⚠️', '❓', '❗', '💡', '🔥', '⭐', '❤️', '😀', '😂', '😍', '🤔', '😢', '😡', '🎉', '👀', '👉', '🐛'];
|
|
14
|
+
|
|
15
|
+
export type LineStyle = 'solid' | 'dashed' | 'dotted';
|
|
16
|
+
export type ArrowStyle = 'single' | 'double' | 'open' | 'thick' | 'tail';
|
|
17
|
+
export type Fill = 'stroke' | 'strokeFill' | 'fill';
|
|
18
|
+
export type NumberFormat = 'decimal' | 'roman' | 'alpha' | 'alphaLower';
|
|
19
|
+
export type CensorMode = 'pixelate' | 'blur' | 'solid';
|
|
20
|
+
export type Align = 'left' | 'center' | 'right';
|
|
21
|
+
|
|
22
|
+
/** Everything a tool can be set to; each annotation keeps the values it was drawn with. */
|
|
23
|
+
export interface Options {
|
|
24
|
+
color: string; width: number; opacity: number; lineStyle: LineStyle;
|
|
25
|
+
arrow: ArrowStyle; fill: Fill; radius: number;
|
|
26
|
+
fontSize: number; bold: boolean; italic: boolean; underline: boolean; align: Align; textBg: boolean;
|
|
27
|
+
numberFormat: NumberFormat; censor: CensorMode; emoji: string; stampSize: number;
|
|
28
|
+
}
|
|
29
|
+
export const DEFAULT_OPTIONS: Options = {
|
|
30
|
+
color: COLORS[0], width: 4, opacity: 1, lineStyle: 'solid', arrow: 'single', fill: 'stroke', radius: 0,
|
|
31
|
+
fontSize: 22, bold: false, italic: false, underline: false, align: 'left', textBg: false,
|
|
32
|
+
numberFormat: 'decimal', censor: 'pixelate', emoji: '👍', stampSize: 48,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
interface Base { id: number; o: Options }
|
|
36
|
+
export type Stroke = Base & { kind: 'pencil' | 'marker'; points: Point[] };
|
|
37
|
+
export type Segment = Base & { kind: 'line' | 'arrow' | 'measure'; a: Point; b: Point; c: Point | null };
|
|
38
|
+
export type Box = Base & { kind: 'rect' | 'ellipse' | 'censor' | 'highlight'; a: Point; b: Point };
|
|
39
|
+
export type Text = Base & { kind: 'text'; at: Point; text: string };
|
|
40
|
+
export type Num = Base & { kind: 'number'; at: Point; tip: Point | null; n: number };
|
|
41
|
+
export type Stamp = Base & { kind: 'stamp'; at: Point; size: number };
|
|
42
|
+
export type Ann = Stroke | Segment | Box | Text | Num | Stamp;
|
|
43
|
+
|
|
44
|
+
/** What drawing needs besides the annotation: pixelated/blurred copies of the display, the capture scale. */
|
|
45
|
+
export interface Env { mosaic: CanvasImageSource | null; blur: CanvasImageSource | null; width: number; height: number; scale: number }
|
|
46
|
+
|
|
47
|
+
export const FONT = '-apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Helvetica Neue", sans-serif';
|
|
48
|
+
const LINE = 1.25;
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------- small helpers
|
|
51
|
+
export const boxOf = (a: Point, b: Point): Rect => ({ x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) });
|
|
52
|
+
const contrast = (hex: string) => {
|
|
53
|
+
const v = parseInt(hex.slice(1), 16), r = v >> 16 & 255, g = v >> 8 & 255, b = v & 255;
|
|
54
|
+
return (r * 299 + g * 587 + b * 114) / 1000 > 160 ? '#000000' : '#FFFFFF';
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function formatNumber(n: number, f: NumberFormat): string {
|
|
58
|
+
if (f === 'decimal') return String(n);
|
|
59
|
+
if (f === 'roman') {
|
|
60
|
+
const table: [number, string][] = [[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'], [100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'], [10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']];
|
|
61
|
+
let out = '', v = Math.max(1, Math.min(3999, n));
|
|
62
|
+
for (const [k, s] of table) while (v >= k) { out += s; v -= k; }
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
// A…Z, AA…AZ, BA… (spreadsheet columns)
|
|
66
|
+
let out = '', v = Math.max(1, n);
|
|
67
|
+
while (v > 0) { v--; out = String.fromCharCode(65 + (v % 26)) + out; v = Math.floor(v / 26); }
|
|
68
|
+
return f === 'alpha' ? out : out.toLowerCase();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Shift: lines snap to 45°, boxes become squares. */
|
|
72
|
+
export function constrain(a: Point, p: Point, kind: Ann['kind']): Point {
|
|
73
|
+
const dx = p.x - a.x, dy = p.y - a.y;
|
|
74
|
+
if (kind === 'line' || kind === 'arrow' || kind === 'measure') {
|
|
75
|
+
const ang = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4), len = Math.hypot(dx, dy);
|
|
76
|
+
return { x: a.x + Math.cos(ang) * len, y: a.y + Math.sin(ang) * len };
|
|
77
|
+
}
|
|
78
|
+
const d = Math.max(Math.abs(dx), Math.abs(dy));
|
|
79
|
+
return { x: a.x + Math.sign(dx || 1) * d, y: a.y + Math.sign(dy || 1) * d };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const badgeRadius = (o: Options) => 10 + o.width * 1.6;
|
|
83
|
+
export const arrowHead = (o: Options) => 9 + o.width * 2.6;
|
|
84
|
+
|
|
85
|
+
let scratch: CanvasRenderingContext2D | null = null;
|
|
86
|
+
const measureCtx = () => scratch ??= document.createElement('canvas').getContext('2d')!;
|
|
87
|
+
const textFont = (o: Options) => `${o.italic ? 'italic ' : ''}${o.bold ? '700 ' : '400 '}${o.fontSize}px ${FONT}`;
|
|
88
|
+
export function textBox(s: Text): Rect {
|
|
89
|
+
const ctx = measureCtx(); ctx.font = textFont(s.o);
|
|
90
|
+
const lines = s.text.split('\n'), pad = s.o.textBg ? s.o.fontSize * 0.35 : 0;
|
|
91
|
+
const w = Math.max(...lines.map(l => ctx.measureText(l).width), s.o.fontSize * 0.5);
|
|
92
|
+
return { x: s.at.x - pad, y: s.at.y - pad, w: w + pad * 2, h: lines.length * s.o.fontSize * LINE + pad * 2 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The bounding box, for selection outlines and hit tests. */
|
|
96
|
+
export function bounds(s: Ann): Rect {
|
|
97
|
+
switch (s.kind) {
|
|
98
|
+
case 'pencil': case 'marker': {
|
|
99
|
+
const xs = s.points.map(p => p.x), ys = s.points.map(p => p.y);
|
|
100
|
+
return { x: Math.min(...xs), y: Math.min(...ys), w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
|
|
101
|
+
}
|
|
102
|
+
case 'line': case 'arrow': case 'measure': {
|
|
103
|
+
const pts = [s.a, s.b, ...(s.c ? [curveMid(s)] : [])];
|
|
104
|
+
const xs = pts.map(p => p.x), ys = pts.map(p => p.y);
|
|
105
|
+
return { x: Math.min(...xs), y: Math.min(...ys), w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
|
|
106
|
+
}
|
|
107
|
+
case 'rect': case 'ellipse': case 'censor': case 'highlight': return boxOf(s.a, s.b);
|
|
108
|
+
case 'text': return textBox(s);
|
|
109
|
+
case 'number': { const r = badgeRadius(s.o); return { x: s.at.x - r, y: s.at.y - r, w: r * 2, h: r * 2 }; }
|
|
110
|
+
case 'stamp': return { x: s.at.x - s.size / 2, y: s.at.y - s.size / 2, w: s.size, h: s.size };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------- drawing
|
|
115
|
+
function dash(ctx: CanvasRenderingContext2D, o: Options) {
|
|
116
|
+
ctx.setLineDash(o.lineStyle === 'dashed' ? [o.width * 3, o.width * 2] : o.lineStyle === 'dotted' ? [0.1, o.width * 2] : []);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function smooth(ctx: CanvasRenderingContext2D, points: Point[]) {
|
|
120
|
+
ctx.beginPath();
|
|
121
|
+
if (points.length === 1) { ctx.moveTo(points[0].x, points[0].y); ctx.lineTo(points[0].x + 0.01, points[0].y); return; }
|
|
122
|
+
ctx.moveTo(points[0].x, points[0].y);
|
|
123
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
124
|
+
const m = { x: (points[i].x + points[i + 1].x) / 2, y: (points[i].y + points[i + 1].y) / 2 };
|
|
125
|
+
ctx.quadraticCurveTo(points[i].x, points[i].y, m.x, m.y);
|
|
126
|
+
}
|
|
127
|
+
const last = points[points.length - 1]; ctx.lineTo(last.x, last.y);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function head(ctx: CanvasRenderingContext2D, tip: Point, from: Point, size: number, open = false) {
|
|
131
|
+
const ang = Math.atan2(tip.y - from.y, tip.x - from.x), spread = Math.PI / 7;
|
|
132
|
+
const l = { x: tip.x - size * Math.cos(ang - spread), y: tip.y - size * Math.sin(ang - spread) };
|
|
133
|
+
const r = { x: tip.x - size * Math.cos(ang + spread), y: tip.y - size * Math.sin(ang + spread) };
|
|
134
|
+
ctx.beginPath();
|
|
135
|
+
if (open) { ctx.setLineDash([]); ctx.moveTo(l.x, l.y); ctx.lineTo(tip.x, tip.y); ctx.lineTo(r.x, r.y); ctx.stroke(); return; }
|
|
136
|
+
ctx.moveTo(tip.x, tip.y); ctx.lineTo(l.x, l.y);
|
|
137
|
+
ctx.lineTo(tip.x - size * 0.72 * Math.cos(ang), tip.y - size * 0.72 * Math.sin(ang));
|
|
138
|
+
ctx.lineTo(r.x, r.y); ctx.closePath(); ctx.fill();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The point halfway along a segment; dragging it bends the shaft through the pointer. */
|
|
142
|
+
export function curveMid(s: Segment): Point {
|
|
143
|
+
const c = s.c ?? { x: (s.a.x + s.b.x) / 2, y: (s.a.y + s.b.y) / 2 };
|
|
144
|
+
return { x: 0.25 * s.a.x + 0.5 * c.x + 0.25 * s.b.x, y: 0.25 * s.a.y + 0.5 * c.y + 0.25 * s.b.y };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Pull `p` back towards `from` by `by` points (so a round cap never pokes out of an arrowhead). */
|
|
148
|
+
const back = (p: Point, from: Point, by: number): Point => { const d = Math.hypot(p.x - from.x, p.y - from.y) || 1; return { x: p.x - (p.x - from.x) / d * by, y: p.y - (p.y - from.y) / d * by }; };
|
|
149
|
+
|
|
150
|
+
function segment(ctx: CanvasRenderingContext2D, s: Segment, env: Env) {
|
|
151
|
+
const o = s.o;
|
|
152
|
+
if (s.kind === 'arrow' && o.arrow === 'thick') { thickArrow(ctx, s.a, s.b, o); return; }
|
|
153
|
+
const size = arrowHead(o);
|
|
154
|
+
const endFrom = s.c ?? s.a, startFrom = s.c ?? s.b;
|
|
155
|
+
const headEnd = s.kind === 'arrow', headStart = s.kind === 'arrow' && o.arrow === 'double';
|
|
156
|
+
const filled = s.kind === 'arrow' && o.arrow !== 'open';
|
|
157
|
+
const a = headStart && filled ? back(s.a, startFrom, size * 0.6) : s.a;
|
|
158
|
+
const b = headEnd && filled ? back(s.b, endFrom, size * 0.6) : s.b;
|
|
159
|
+
dash(ctx, o);
|
|
160
|
+
ctx.beginPath(); ctx.moveTo(a.x, a.y);
|
|
161
|
+
if (s.c) ctx.quadraticCurveTo(s.c.x, s.c.y, b.x, b.y); else ctx.lineTo(b.x, b.y);
|
|
162
|
+
ctx.stroke(); ctx.setLineDash([]);
|
|
163
|
+
if (headEnd) head(ctx, s.b, endFrom, size, o.arrow === 'open');
|
|
164
|
+
if (headStart) head(ctx, s.a, startFrom, size, false);
|
|
165
|
+
if (s.kind === 'arrow' && o.arrow === 'tail') { ctx.beginPath(); ctx.arc(s.a.x, s.a.y, Math.max(3, o.width * 1.2), 0, Math.PI * 2); ctx.fill(); }
|
|
166
|
+
if (s.kind === 'measure') {
|
|
167
|
+
// Ticks at both ends and the length in pixels of the captured image.
|
|
168
|
+
const ang = Math.atan2(s.b.y - s.a.y, s.b.x - s.a.x), t = 6 + o.width;
|
|
169
|
+
for (const p of [s.a, s.b]) { ctx.beginPath(); ctx.moveTo(p.x - Math.sin(ang) * t, p.y + Math.cos(ang) * t); ctx.lineTo(p.x + Math.sin(ang) * t, p.y - Math.cos(ang) * t); ctx.stroke(); }
|
|
170
|
+
const len = Math.round(Math.hypot(s.b.x - s.a.x, s.b.y - s.a.y) * env.scale), label = `${len} px`;
|
|
171
|
+
const m = { x: (s.a.x + s.b.x) / 2, y: (s.a.y + s.b.y) / 2 };
|
|
172
|
+
ctx.font = `600 12px ${FONT}`; const w = ctx.measureText(label).width + 10;
|
|
173
|
+
ctx.globalAlpha = 1; ctx.fillStyle = 'rgba(17,24,39,0.85)'; ctx.beginPath(); ctx.roundRect(m.x - w / 2, m.y - 20 - t, w, 18, 4); ctx.fill();
|
|
174
|
+
ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, m.x, m.y - 11 - t);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A solid banner arrow: a tapered body with a wide head. */
|
|
179
|
+
function thickArrow(ctx: CanvasRenderingContext2D, a: Point, b: Point, o: Options) {
|
|
180
|
+
const len = Math.hypot(b.x - a.x, b.y - a.y); if (len < 1) return;
|
|
181
|
+
const ux = (b.x - a.x) / len, uy = (b.y - a.y) / len, nx = -uy, ny = ux;
|
|
182
|
+
const w = 3 + o.width * 1.6, hw = w * 2.4, hl = Math.min(len * 0.6, w * 3.2);
|
|
183
|
+
const base = { x: b.x - ux * hl, y: b.y - uy * hl };
|
|
184
|
+
ctx.beginPath();
|
|
185
|
+
ctx.moveTo(a.x + nx * w * 0.25, a.y + ny * w * 0.25);
|
|
186
|
+
ctx.lineTo(base.x + nx * w, base.y + ny * w); ctx.lineTo(base.x + nx * hw, base.y + ny * hw);
|
|
187
|
+
ctx.lineTo(b.x, b.y);
|
|
188
|
+
ctx.lineTo(base.x - nx * hw, base.y - ny * hw); ctx.lineTo(base.x - nx * w, base.y - ny * w);
|
|
189
|
+
ctx.lineTo(a.x - nx * w * 0.25, a.y - ny * w * 0.25); ctx.closePath(); ctx.fill();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function box(ctx: CanvasRenderingContext2D, s: Box, env: Env) {
|
|
193
|
+
const r = boxOf(s.a, s.b), o = s.o;
|
|
194
|
+
if (s.kind === 'censor') {
|
|
195
|
+
ctx.save(); ctx.beginPath(); ctx.rect(r.x, r.y, r.w, r.h); ctx.clip(); ctx.globalAlpha = 1;
|
|
196
|
+
if (o.censor === 'solid') { ctx.fillStyle = o.color; ctx.fillRect(r.x, r.y, r.w, r.h); }
|
|
197
|
+
else { const src = o.censor === 'pixelate' ? env.mosaic : env.blur; if (src) ctx.drawImage(src, 0, 0, env.width, env.height); }
|
|
198
|
+
ctx.restore(); return;
|
|
199
|
+
}
|
|
200
|
+
if (s.kind === 'highlight') return; // painted together with the others by `spotlight`
|
|
201
|
+
ctx.beginPath();
|
|
202
|
+
if (s.kind === 'rect') ctx.roundRect(r.x, r.y, r.w, r.h, Math.min(o.radius, r.w / 2, r.h / 2));
|
|
203
|
+
else ctx.ellipse(r.x + r.w / 2, r.y + r.h / 2, r.w / 2, r.h / 2, 0, 0, Math.PI * 2);
|
|
204
|
+
if (o.fill !== 'stroke') { ctx.save(); ctx.globalAlpha = o.opacity * (o.fill === 'strokeFill' ? 0.3 : 1); ctx.fill(); ctx.restore(); }
|
|
205
|
+
if (o.fill !== 'fill') { dash(ctx, o); ctx.stroke(); ctx.setLineDash([]); }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function text(ctx: CanvasRenderingContext2D, s: Text) {
|
|
209
|
+
const o = s.o, r = textBox(s), lines = s.text.split('\n');
|
|
210
|
+
ctx.font = textFont(o); ctx.textBaseline = 'top';
|
|
211
|
+
const inner = { x: s.at.x, w: r.w - (o.textBg ? o.fontSize * 0.7 : 0) };
|
|
212
|
+
if (o.textBg) { ctx.beginPath(); ctx.roundRect(r.x, r.y, r.w, r.h, o.fontSize * 0.3); ctx.fill(); ctx.fillStyle = contrast(o.color); }
|
|
213
|
+
lines.forEach((line, i) => {
|
|
214
|
+
const w = ctx.measureText(line).width;
|
|
215
|
+
const x = o.align === 'center' ? inner.x + (inner.w - w) / 2 : o.align === 'right' ? inner.x + inner.w - w : inner.x;
|
|
216
|
+
const y = s.at.y + i * o.fontSize * LINE + o.fontSize * 0.1;
|
|
217
|
+
ctx.fillText(line, x, y);
|
|
218
|
+
if (o.underline && w) ctx.fillRect(x, y + o.fontSize * 1.02, w, Math.max(1, o.fontSize / 14));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function number(ctx: CanvasRenderingContext2D, s: Num) {
|
|
223
|
+
const o = s.o, r = badgeRadius(o);
|
|
224
|
+
if (s.tip) {
|
|
225
|
+
// A cone from the badge towards what it points at.
|
|
226
|
+
const d = Math.hypot(s.tip.x - s.at.x, s.tip.y - s.at.y);
|
|
227
|
+
if (d > r) {
|
|
228
|
+
const ang = Math.atan2(s.tip.y - s.at.y, s.tip.x - s.at.x), spread = Math.asin(Math.min(0.95, r * 0.55 / r));
|
|
229
|
+
ctx.beginPath(); ctx.moveTo(s.at.x + Math.cos(ang - spread) * r, s.at.y + Math.sin(ang - spread) * r);
|
|
230
|
+
ctx.lineTo(s.tip.x, s.tip.y); ctx.lineTo(s.at.x + Math.cos(ang + spread) * r, s.at.y + Math.sin(ang + spread) * r); ctx.closePath(); ctx.fill();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
ctx.beginPath(); ctx.arc(s.at.x, s.at.y, r, 0, Math.PI * 2); ctx.fill();
|
|
234
|
+
const label = formatNumber(s.n, o.numberFormat);
|
|
235
|
+
ctx.fillStyle = contrast(o.color); ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
236
|
+
ctx.font = `700 ${Math.round(r * (label.length > 2 ? 0.75 : label.length > 1 ? 0.9 : 1.1))}px ${FONT}`;
|
|
237
|
+
ctx.fillText(label, s.at.x, s.at.y + r * 0.05);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function draw(ctx: CanvasRenderingContext2D, s: Ann, env: Env) {
|
|
241
|
+
const o = s.o;
|
|
242
|
+
ctx.save();
|
|
243
|
+
ctx.globalAlpha = o.opacity; ctx.strokeStyle = o.color; ctx.fillStyle = o.color;
|
|
244
|
+
ctx.lineWidth = o.width; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
|
245
|
+
switch (s.kind) {
|
|
246
|
+
case 'pencil': dash(ctx, o); smooth(ctx, s.points); ctx.stroke(); break;
|
|
247
|
+
case 'marker':
|
|
248
|
+
ctx.globalAlpha = 0.4 * o.opacity; ctx.globalCompositeOperation = 'multiply'; ctx.lineWidth = o.width * 3 + 8; ctx.lineCap = 'butt';
|
|
249
|
+
smooth(ctx, s.points); ctx.stroke(); break;
|
|
250
|
+
case 'line': case 'arrow': case 'measure': segment(ctx, s, env); break;
|
|
251
|
+
case 'rect': case 'ellipse': case 'censor': case 'highlight': box(ctx, s, env); break;
|
|
252
|
+
case 'text': text(ctx, s); break;
|
|
253
|
+
case 'number': number(ctx, s); break;
|
|
254
|
+
case 'stamp': ctx.globalAlpha = o.opacity; ctx.font = `${s.size}px "Apple Color Emoji", ${FONT}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(o.emoji, s.at.x, s.at.y + s.size * 0.06); break;
|
|
255
|
+
}
|
|
256
|
+
ctx.restore();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let spot: HTMLCanvasElement | null = null;
|
|
260
|
+
/** Spotlight: everything in `area` outside the highlight boxes is dimmed (the Highlight tool). */
|
|
261
|
+
export function spotlight(ctx: CanvasRenderingContext2D, list: Ann[], area: Rect) {
|
|
262
|
+
const boxes = list.filter((s): s is Box => s.kind === 'highlight');
|
|
263
|
+
if (!boxes.length) return;
|
|
264
|
+
const c = spot ??= document.createElement('canvas');
|
|
265
|
+
if (c.width !== ctx.canvas.width || c.height !== ctx.canvas.height) { c.width = ctx.canvas.width; c.height = ctx.canvas.height; }
|
|
266
|
+
const m = c.getContext('2d')!;
|
|
267
|
+
m.setTransform(1, 0, 0, 1, 0, 0); m.globalCompositeOperation = 'source-over'; m.clearRect(0, 0, c.width, c.height);
|
|
268
|
+
m.setTransform(ctx.getTransform());
|
|
269
|
+
m.fillStyle = 'rgba(0,0,0,0.55)'; m.fillRect(area.x, area.y, area.w, area.h);
|
|
270
|
+
m.globalCompositeOperation = 'destination-out';
|
|
271
|
+
for (const b of boxes) { const r = boxOf(b.a, b.b); m.beginPath(); m.roundRect(r.x, r.y, r.w, r.h, 6); m.fill(); }
|
|
272
|
+
ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1; ctx.drawImage(c, 0, 0); ctx.restore();
|
|
273
|
+
for (const b of boxes) {
|
|
274
|
+
const r = boxOf(b.a, b.b);
|
|
275
|
+
ctx.save(); ctx.strokeStyle = b.o.color; ctx.lineWidth = 2; ctx.globalAlpha = b.o.opacity; dash(ctx, b.o);
|
|
276
|
+
ctx.beginPath(); ctx.roundRect(r.x, r.y, r.w, r.h, 6); ctx.stroke(); ctx.restore();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------- editing
|
|
281
|
+
export type HandleId = 'a' | 'b' | 'c' | 'tip' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
|
|
282
|
+
|
|
283
|
+
export function handles(s: Ann): { id: HandleId; p: Point }[] {
|
|
284
|
+
switch (s.kind) {
|
|
285
|
+
case 'rect': case 'ellipse': case 'censor': case 'highlight': case 'stamp': {
|
|
286
|
+
const r = bounds(s), cx = r.x + r.w / 2, cy = r.y + r.h / 2, R = r.x + r.w, B = r.y + r.h;
|
|
287
|
+
const corners: { id: HandleId; p: Point }[] = [{ id: 'nw', p: { x: r.x, y: r.y } }, { id: 'ne', p: { x: R, y: r.y } }, { id: 'se', p: { x: R, y: B } }, { id: 'sw', p: { x: r.x, y: B } }];
|
|
288
|
+
if (s.kind === 'stamp') return corners;
|
|
289
|
+
return [...corners, { id: 'n', p: { x: cx, y: r.y } }, { id: 'e', p: { x: R, y: cy } }, { id: 's', p: { x: cx, y: B } }, { id: 'w', p: { x: r.x, y: cy } }];
|
|
290
|
+
}
|
|
291
|
+
case 'line': case 'measure': return [{ id: 'a', p: s.a }, { id: 'b', p: s.b }];
|
|
292
|
+
case 'arrow': return s.o.arrow === 'thick' ? [{ id: 'a', p: s.a }, { id: 'b', p: s.b }] : [{ id: 'a', p: s.a }, { id: 'c', p: curveMid(s) }, { id: 'b', p: s.b }];
|
|
293
|
+
case 'number': return s.tip ? [{ id: 'tip', p: s.tip }] : [];
|
|
294
|
+
default: return [];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function dragHandle(s: Ann, h: HandleId, p: Point, shift = false): Ann {
|
|
299
|
+
switch (s.kind) {
|
|
300
|
+
case 'line': case 'arrow': case 'measure':
|
|
301
|
+
if (h === 'a') return { ...s, a: shift ? constrain(s.b, p, s.kind) : p };
|
|
302
|
+
if (h === 'b') return { ...s, b: shift ? constrain(s.a, p, s.kind) : p };
|
|
303
|
+
if (h === 'c') return { ...s, c: { x: 2 * p.x - (s.a.x + s.b.x) / 2, y: 2 * p.y - (s.a.y + s.b.y) / 2 } };
|
|
304
|
+
return s;
|
|
305
|
+
case 'number': return h === 'tip' ? { ...s, tip: p } : s;
|
|
306
|
+
case 'stamp': {
|
|
307
|
+
const size = Math.max(16, Math.min(400, Math.max(Math.abs(p.x - s.at.x), Math.abs(p.y - s.at.y)) * 2));
|
|
308
|
+
return { ...s, size };
|
|
309
|
+
}
|
|
310
|
+
case 'rect': case 'ellipse': case 'censor': case 'highlight': {
|
|
311
|
+
const r = boxOf(s.a, s.b); let x1 = r.x, y1 = r.y, x2 = r.x + r.w, y2 = r.y + r.h;
|
|
312
|
+
if (h.includes('w')) x1 = p.x; if (h.includes('e')) x2 = p.x; if (h.includes('n')) y1 = p.y; if (h.includes('s')) y2 = p.y;
|
|
313
|
+
return { ...s, a: { x: x1, y: y1 }, b: { x: x2, y: y2 } };
|
|
314
|
+
}
|
|
315
|
+
default: return s;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function translate(s: Ann, dx: number, dy: number): Ann {
|
|
320
|
+
const t = (p: Point) => ({ x: p.x + dx, y: p.y + dy });
|
|
321
|
+
switch (s.kind) {
|
|
322
|
+
case 'pencil': case 'marker': return { ...s, points: s.points.map(t) };
|
|
323
|
+
case 'line': case 'arrow': case 'measure': return { ...s, a: t(s.a), b: t(s.b), c: s.c && t(s.c) };
|
|
324
|
+
case 'rect': case 'ellipse': case 'censor': case 'highlight': return { ...s, a: t(s.a), b: t(s.b) };
|
|
325
|
+
case 'text': case 'stamp': return { ...s, at: t(s.at) };
|
|
326
|
+
case 'number': return { ...s, at: t(s.at), tip: s.tip && t(s.tip) };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function distToSegment(p: Point, a: Point, b: Point) {
|
|
331
|
+
const dx = b.x - a.x, dy = b.y - a.y, l = dx * dx + dy * dy;
|
|
332
|
+
const k = l ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l)) : 0;
|
|
333
|
+
return Math.hypot(p.x - (a.x + k * dx), p.y - (a.y + k * dy));
|
|
334
|
+
}
|
|
335
|
+
const nearPolyline = (p: Point, pts: Point[], tol: number) => pts.length === 1 ? Math.hypot(p.x - pts[0].x, p.y - pts[0].y) <= tol : pts.some((q, i) => i > 0 && distToSegment(p, pts[i - 1], q) <= tol);
|
|
336
|
+
const inside = (r: Rect, p: Point, pad: number) => p.x >= r.x - pad && p.x <= r.x + r.w + pad && p.y >= r.y - pad && p.y <= r.y + r.h + pad;
|
|
337
|
+
|
|
338
|
+
/** Whether `p` grabs `s`. Outlined shapes are grabbed by their edge so things inside stay clickable. */
|
|
339
|
+
export function hit(s: Ann, p: Point): boolean {
|
|
340
|
+
const tol = Math.max(6, s.o.width / 2 + 5);
|
|
341
|
+
switch (s.kind) {
|
|
342
|
+
case 'pencil': return nearPolyline(p, s.points, tol);
|
|
343
|
+
case 'marker': return nearPolyline(p, s.points, (s.o.width * 3 + 8) / 2 + 3);
|
|
344
|
+
case 'line': case 'arrow': case 'measure': {
|
|
345
|
+
if (!s.c) return distToSegment(p, s.a, s.b) <= tol + (s.kind === 'arrow' && s.o.arrow === 'thick' ? s.o.width * 2 : 2);
|
|
346
|
+
const pts: Point[] = [];
|
|
347
|
+
for (let i = 0; i <= 24; i++) { const k = i / 24, u = 1 - k; pts.push({ x: u * u * s.a.x + 2 * u * k * s.c.x + k * k * s.b.x, y: u * u * s.a.y + 2 * u * k * s.c.y + k * k * s.b.y }); }
|
|
348
|
+
return nearPolyline(p, pts, tol + 2);
|
|
349
|
+
}
|
|
350
|
+
case 'rect': case 'ellipse': {
|
|
351
|
+
const r = boxOf(s.a, s.b);
|
|
352
|
+
if (s.o.fill !== 'stroke') return inside(r, p, tol / 2);
|
|
353
|
+
if (!inside(r, p, tol)) return false;
|
|
354
|
+
if (s.kind === 'rect') return !inside({ x: r.x + tol, y: r.y + tol, w: r.w - tol * 2, h: r.h - tol * 2 }, p, 0);
|
|
355
|
+
const cx = r.x + r.w / 2, cy = r.y + r.h / 2, rx = Math.max(1, r.w / 2), ry = Math.max(1, r.h / 2);
|
|
356
|
+
const d = Math.hypot((p.x - cx) / rx, (p.y - cy) / ry);
|
|
357
|
+
return Math.abs(d - 1) * Math.min(rx, ry) <= tol;
|
|
358
|
+
}
|
|
359
|
+
case 'censor': case 'stamp': case 'text': return inside(bounds(s), p, 3);
|
|
360
|
+
case 'highlight': { const r = boxOf(s.a, s.b); return inside(r, p, tol) && !inside({ x: r.x + tol, y: r.y + tol, w: r.w - tol * 2, h: r.h - tol * 2 }, p, 0); }
|
|
361
|
+
case 'number': return Math.hypot(p.x - s.at.x, p.y - s.at.y) <= badgeRadius(s.o) + 3 || (!!s.tip && distToSegment(p, s.at, s.tip) <= tol);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Mouse wheel over a selected annotation: stroke width, font size or stamp size. */
|
|
366
|
+
export function adjust(s: Ann, delta: number): Ann {
|
|
367
|
+
if (s.kind === 'text') return { ...s, o: { ...s.o, fontSize: Math.max(10, Math.min(160, s.o.fontSize + delta * 2)) } };
|
|
368
|
+
if (s.kind === 'stamp') return { ...s, size: Math.max(16, Math.min(400, s.size + delta * 4)) };
|
|
369
|
+
return { ...s, o: { ...s.o, width: Math.max(1, Math.min(40, s.o.width + delta)) } };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** A drag that never really moved draws nothing (a number badge is placed by a click). */
|
|
373
|
+
export function meaningful(s: Ann): boolean {
|
|
374
|
+
switch (s.kind) {
|
|
375
|
+
case 'rect': case 'ellipse': case 'censor': case 'highlight': return Math.abs(s.a.x - s.b.x) > 3 && Math.abs(s.a.y - s.b.y) > 3;
|
|
376
|
+
case 'line': case 'arrow': case 'measure': return Math.hypot(s.a.x - s.b.x, s.a.y - s.b.y) > 4;
|
|
377
|
+
case 'text': return s.text.trim().length > 0;
|
|
378
|
+
default: return true;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Which option controls a tool shows (besides colour, width and opacity). */
|
|
383
|
+
export function optionsFor(kind: Tool | Ann['kind']): (keyof Options)[] {
|
|
384
|
+
switch (kind) {
|
|
385
|
+
case 'arrow': return ['arrow', 'lineStyle'];
|
|
386
|
+
case 'line': case 'pencil': return ['lineStyle'];
|
|
387
|
+
case 'rect': return ['fill', 'radius', 'lineStyle'];
|
|
388
|
+
case 'ellipse': return ['fill', 'lineStyle'];
|
|
389
|
+
case 'text': return ['fontSize', 'bold', 'italic', 'underline', 'align', 'textBg'];
|
|
390
|
+
case 'number': return ['numberFormat'];
|
|
391
|
+
case 'censor': return ['censor'];
|
|
392
|
+
case 'stamp': return ['emoji'];
|
|
393
|
+
default: return [];
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/** Tools without a meaningful colour or stroke width. */
|
|
397
|
+
export const NO_COLOR = new Set<Tool | Ann['kind']>(['select', 'sampler', 'stamp']);
|
|
398
|
+
export const NO_WIDTH = new Set<Tool | Ann['kind']>(['select', 'sampler', 'stamp', 'text', 'censor', 'highlight']);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Beautify: the capture on a gradient with padding, rounded corners, a soft shadow and an optional
|
|
2
|
+
// macOS window frame. Used for the export and for the live preview around the selection.
|
|
3
|
+
export interface Beauty { on: boolean; style: number; padding: number; frame: boolean }
|
|
4
|
+
export const DEFAULT_BEAUTY: Beauty = { on: false, style: 0, padding: 48, frame: false };
|
|
5
|
+
export const GRADIENTS: string[][] = [
|
|
6
|
+
['#6366F1', '#A855F7', '#EC4899'], ['#0EA5E9', '#6366F1'], ['#F97316', '#F43F5E'], ['#10B981', '#06B6D4'],
|
|
7
|
+
['#FDE68A', '#F9A8D4', '#C4B5FD'], ['#1E293B', '#334155'], ['#F1F5F9', '#CBD5E1'], ['#FB7185', '#FDBA74', '#FDE047'],
|
|
8
|
+
];
|
|
9
|
+
export const FRAME_BAR = 28;
|
|
10
|
+
export const RADIUS = 10;
|
|
11
|
+
|
|
12
|
+
export function fillGradient(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, style: number) {
|
|
13
|
+
const stops = GRADIENTS[style] ?? GRADIENTS[0];
|
|
14
|
+
const g = ctx.createLinearGradient(x, y, x + w, y + h);
|
|
15
|
+
stops.forEach((c, i) => g.addColorStop(stops.length === 1 ? 0 : i / (stops.length - 1), c));
|
|
16
|
+
ctx.fillStyle = g; ctx.fillRect(x, y, w, h);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Draws the window chrome (title bar with the three lights) at `x,y`, `w` wide, in points. */
|
|
20
|
+
export function drawFrame(ctx: CanvasRenderingContext2D, x: number, y: number, w: number) {
|
|
21
|
+
ctx.fillStyle = '#ECECEC'; ctx.fillRect(x, y, w, FRAME_BAR);
|
|
22
|
+
ctx.fillStyle = 'rgba(0,0,0,0.08)'; ctx.fillRect(x, y + FRAME_BAR - 1, w, 1);
|
|
23
|
+
['#FF5F57', '#FEBC2E', '#28C840'].forEach((c, i) => { ctx.fillStyle = c; ctx.beginPath(); ctx.arc(x + 16 + i * 20, y + FRAME_BAR / 2, 6, 0, Math.PI * 2); ctx.fill(); });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The beautified image; `src` is the flattened capture at `scale` pixels per point. */
|
|
27
|
+
export function beautify(src: HTMLCanvasElement, b: Beauty, scale: number): HTMLCanvasElement {
|
|
28
|
+
const w = src.width / scale, h = src.height / scale, bar = b.frame ? FRAME_BAR : 0, pad = b.padding;
|
|
29
|
+
const out = document.createElement('canvas');
|
|
30
|
+
out.width = Math.round((w + pad * 2) * scale); out.height = Math.round((h + bar + pad * 2) * scale);
|
|
31
|
+
const ctx = out.getContext('2d')!; ctx.scale(scale, scale);
|
|
32
|
+
fillGradient(ctx, 0, 0, w + pad * 2, h + bar + pad * 2, b.style);
|
|
33
|
+
const x = pad, y = pad;
|
|
34
|
+
ctx.save(); ctx.shadowColor = 'rgba(0,0,0,0.35)'; ctx.shadowBlur = 30; ctx.shadowOffsetY = 12;
|
|
35
|
+
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.roundRect(x, y, w, h + bar, RADIUS); ctx.fill(); ctx.restore();
|
|
36
|
+
ctx.save(); ctx.beginPath(); ctx.roundRect(x, y, w, h + bar, RADIUS); ctx.clip();
|
|
37
|
+
if (bar) drawFrame(ctx, x, y, w);
|
|
38
|
+
ctx.drawImage(src, x, y + bar, w, h);
|
|
39
|
+
ctx.restore();
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Selection geometry as plain data (display points, top-left origin): dragging out a rectangle,
|
|
2
|
+
// the eight resize handles, moving inside the display and placing the toolbar. No DOM, no SDK.
|
|
3
|
+
|
|
4
|
+
export interface Point { x: number; y: number }
|
|
5
|
+
export interface Rect { x: number; y: number; w: number; h: number }
|
|
6
|
+
export type Handle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
|
|
7
|
+
export const HANDLES: Handle[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
|
|
8
|
+
|
|
9
|
+
export const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);
|
|
10
|
+
|
|
11
|
+
/** The rectangle between two points, kept inside `bounds`; `square` makes the shorter side follow the longer. */
|
|
12
|
+
export function fromPoints(a: Point, b: Point, bounds: Rect, square = false): Rect {
|
|
13
|
+
let bx = clamp(b.x, bounds.x, bounds.x + bounds.w), by = clamp(b.y, bounds.y, bounds.y + bounds.h);
|
|
14
|
+
if (square) {
|
|
15
|
+
const dx = bx - a.x, dy = by - a.y;
|
|
16
|
+
// The side is limited by how far the display reaches in both drag directions.
|
|
17
|
+
const room = Math.min(dx < 0 ? a.x - bounds.x : bounds.x + bounds.w - a.x, dy < 0 ? a.y - bounds.y : bounds.y + bounds.h - a.y);
|
|
18
|
+
const side = Math.min(Math.max(Math.abs(dx), Math.abs(dy)), room);
|
|
19
|
+
bx = a.x + Math.sign(dx || 1) * side; by = a.y + Math.sign(dy || 1) * side;
|
|
20
|
+
}
|
|
21
|
+
return { x: Math.min(a.x, bx), y: Math.min(a.y, by), w: Math.abs(bx - a.x), h: Math.abs(by - a.y) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Rounds to whole points so the exported crop lands on pixel boundaries. */
|
|
25
|
+
export const round = (r: Rect): Rect => {
|
|
26
|
+
const x = Math.round(r.x), y = Math.round(r.y);
|
|
27
|
+
return { x, y, w: Math.round(r.x + r.w) - x, h: Math.round(r.y + r.h) - y };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function intersect(a: Rect, b: Rect): Rect | null {
|
|
31
|
+
const x = Math.max(a.x, b.x), y = Math.max(a.y, b.y);
|
|
32
|
+
const r = Math.min(a.x + a.w, b.x + b.w), btm = Math.min(a.y + a.h, b.y + b.h);
|
|
33
|
+
return r > x && btm > y ? { x, y, w: r - x, h: btm - y } : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const contains = (r: Rect, p: Point, pad = 0) => p.x >= r.x - pad && p.x <= r.x + r.w + pad && p.y >= r.y - pad && p.y <= r.y + r.h + pad;
|
|
37
|
+
|
|
38
|
+
export function handlePoint(r: Rect, h: Handle): Point {
|
|
39
|
+
const cx = r.x + r.w / 2, cy = r.y + r.h / 2, R = r.x + r.w, B = r.y + r.h;
|
|
40
|
+
return { nw: { x: r.x, y: r.y }, n: { x: cx, y: r.y }, ne: { x: R, y: r.y }, e: { x: R, y: cy }, se: { x: R, y: B }, s: { x: cx, y: B }, sw: { x: r.x, y: B }, w: { x: r.x, y: cy } }[h];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The handle under `p` (within `radius`), corners before edges. */
|
|
44
|
+
export function hitHandle(r: Rect, p: Point, radius = 6): Handle | null {
|
|
45
|
+
for (const h of ['nw', 'ne', 'se', 'sw', 'n', 'e', 's', 'w'] as Handle[]) {
|
|
46
|
+
const q = handlePoint(r, h);
|
|
47
|
+
if (Math.abs(q.x - p.x) <= radius && Math.abs(q.y - p.y) <= radius) return h;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Drags `h` of the rectangle as it was at the start of the drag to `p`; flips past the opposite side. */
|
|
53
|
+
export function resize(start: Rect, h: Handle, p: Point, bounds: Rect): Rect {
|
|
54
|
+
let x1 = start.x, y1 = start.y, x2 = start.x + start.w, y2 = start.y + start.h;
|
|
55
|
+
const px = clamp(p.x, bounds.x, bounds.x + bounds.w), py = clamp(p.y, bounds.y, bounds.y + bounds.h);
|
|
56
|
+
if (h.includes('w')) x1 = px; if (h.includes('e')) x2 = px;
|
|
57
|
+
if (h.includes('n')) y1 = py; if (h.includes('s')) y2 = py;
|
|
58
|
+
return { x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Moves without changing the size, stopping at the display edges. */
|
|
62
|
+
export function move(start: Rect, dx: number, dy: number, bounds: Rect): Rect {
|
|
63
|
+
return { ...start, x: clamp(start.x + dx, bounds.x, bounds.x + bounds.w - start.w), y: clamp(start.y + dy, bounds.y, bounds.y + bounds.h - start.h) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const CURSORS: Record<Handle, string> = { nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize', n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize' };
|
|
67
|
+
|
|
68
|
+
/** The frontmost window under the pointer (windows arrive front to back), else the whole display. */
|
|
69
|
+
export function windowAt(windows: Rect[], p: Point, display: Rect): Rect {
|
|
70
|
+
return windows.find(w => contains(w, p)) ?? display;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Below the selection, else above it, else inside its bottom edge; always inside the display. */
|
|
74
|
+
export function placeToolbar(sel: Rect, size: { w: number; h: number }, view: Rect, gap = 8): Point & { above: boolean } {
|
|
75
|
+
const x = clamp(sel.x + sel.w - size.w, view.x + 4, view.x + view.w - size.w - 4);
|
|
76
|
+
if (sel.y + sel.h + gap + size.h <= view.y + view.h - 4) return { x, y: sel.y + sel.h + gap, above: false };
|
|
77
|
+
if (sel.y - gap - size.h >= view.y + 4) return { x, y: sel.y - gap - size.h, above: true };
|
|
78
|
+
return { x, y: Math.max(view.y + 4, sel.y + sel.h - gap - size.h), above: false };
|
|
79
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// `Medeo_20261109_140501.png`; a second file in the same second gets `_1`, `_2`… never overwriting.
|
|
2
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
3
|
+
export function stamp(d: Date): string {
|
|
4
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
5
|
+
}
|
|
6
|
+
export function fileName(prefix: string, d: Date, exists: (name: string) => boolean, ext = 'png'): string {
|
|
7
|
+
const base = `${prefix}_${stamp(d)}`;
|
|
8
|
+
for (let i = 0; ; i++) {
|
|
9
|
+
const name = i ? `${base}_${i}.${ext}` : `${base}.${ext}`;
|
|
10
|
+
if (!exists(name)) return name;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** `~/x` → `<home>/x`; anything that is not absolute falls back to `fallback`. */
|
|
14
|
+
export function expandHome(dir: string, home: string, fallback: string): string {
|
|
15
|
+
const v = dir.trim();
|
|
16
|
+
if (v === '~') return home;
|
|
17
|
+
if (v.startsWith('~/')) return home + v.slice(1);
|
|
18
|
+
return v.startsWith('/') ? v : fallback;
|
|
19
|
+
}
|