@devaccel-labs/app-comments-toolbar 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -30
- package/dist/react.js +417 -64
- package/dist/server.js +342 -14
- package/dist/types/capture/api.d.ts +30 -0
- package/dist/types/capture/editor.d.ts +10 -0
- package/dist/types/capture/ignore.d.ts +19 -0
- package/dist/types/capture/index.d.ts +1 -0
- package/dist/types/capture/picker.d.ts +9 -0
- package/dist/types/capture/picture.d.ts +29 -0
- package/dist/types/capture/surface.d.ts +39 -0
- package/dist/types/core/index.d.ts +2 -2
- package/dist/types/core/resolve.d.ts +2 -1
- package/dist/types/core/types.d.ts +11 -0
- package/dist/types/debug-context.d.ts +7 -0
- package/dist/types/dictation.d.ts +9 -0
- package/dist/types/embed/controller.d.ts +61 -0
- package/dist/types/embed/index.d.ts +1 -0
- package/dist/types/embed/options.d.ts +35 -0
- package/dist/types/help/index.d.ts +1 -0
- package/dist/types/help/panel-index.d.ts +1 -0
- package/dist/types/help/panel-styles.d.ts +1 -0
- package/dist/types/help/panel.d.ts +4 -0
- package/dist/types/help/pill-styles.d.ts +11 -0
- package/dist/types/help/pill.d.ts +44 -0
- package/dist/types/help/react.d.ts +12 -0
- package/dist/types/help/requests.d.ts +2 -0
- package/dist/types/help/types.d.ts +119 -0
- package/dist/types/help/wire.d.ts +61 -0
- package/dist/types/native-wire.d.ts +63 -0
- package/dist/types/native.d.ts +43 -0
- package/dist/types/react.d.ts +7 -0
- package/dist/types/replay.d.ts +6 -0
- package/dist/types/runtime/api.d.ts +250 -0
- package/dist/types/runtime/attachment-model.d.ts +16 -0
- package/dist/types/runtime/attachments.d.ts +43 -7
- package/dist/types/runtime/boot.d.ts +39 -0
- package/dist/types/runtime/browser-label.d.ts +5 -0
- package/dist/types/runtime/build-id.d.ts +1 -0
- package/dist/types/runtime/capture-ui.d.ts +45 -21
- package/dist/types/runtime/clarity-model.d.ts +260 -0
- package/dist/types/runtime/clarity-ui.d.ts +62 -0
- package/dist/types/runtime/client.d.ts +6 -197
- package/dist/types/runtime/composer-size.d.ts +30 -0
- package/dist/types/runtime/dashboard.d.ts +17 -2
- package/dist/types/runtime/debug-context-core.d.ts +88 -0
- package/dist/types/runtime/debug-context-host.d.ts +39 -0
- package/dist/types/runtime/debug-context-model.d.ts +352 -0
- package/dist/types/runtime/debug-context-redact.d.ts +48 -0
- package/dist/types/runtime/debug-context.d.ts +36 -0
- package/dist/types/runtime/details-model.d.ts +7 -0
- package/dist/types/runtime/details.d.ts +7 -5
- package/dist/types/runtime/dictation-engine.d.ts +98 -0
- package/dist/types/runtime/dictation-model.d.ts +28 -0
- package/dist/types/runtime/dictation-ui.d.ts +56 -0
- package/dist/types/runtime/dictation.d.ts +66 -0
- package/dist/types/runtime/draft-dock.d.ts +32 -0
- package/dist/types/runtime/draft-model.d.ts +104 -0
- package/dist/types/runtime/draft-store.d.ts +15 -0
- package/dist/types/runtime/edits.d.ts +1 -1
- package/dist/types/runtime/font.d.ts +16 -0
- package/dist/types/runtime/format.d.ts +30 -1
- package/dist/types/runtime/host-watch.d.ts +20 -0
- package/dist/types/runtime/icons.d.ts +16 -2
- package/dist/types/runtime/index.d.ts +2 -0
- package/dist/types/runtime/ink.d.ts +8 -0
- package/dist/types/runtime/kind-model.d.ts +14 -0
- package/dist/types/runtime/live-model.d.ts +25 -0
- package/dist/types/runtime/loader.d.ts +5 -3
- package/dist/types/runtime/markup-editor.d.ts +54 -0
- package/dist/types/runtime/markup-model.d.ts +126 -0
- package/dist/types/runtime/markup.d.ts +69 -0
- package/dist/types/runtime/mention-parse.d.ts +18 -0
- package/dist/types/runtime/mentions.d.ts +4 -4
- package/dist/types/runtime/page-background.d.ts +9 -0
- package/dist/types/runtime/page-image.d.ts +64 -11
- package/dist/types/runtime/placement.d.ts +6 -3
- package/dist/types/runtime/record.d.ts +18 -2
- package/dist/types/runtime/related.d.ts +1 -1
- package/dist/types/runtime/replay-model.d.ts +80 -0
- package/dist/types/runtime/replay.d.ts +5 -38
- package/dist/types/runtime/screen.d.ts +47 -0
- package/dist/types/runtime/screenshot.d.ts +60 -8
- package/dist/types/runtime/session-source.d.ts +45 -0
- package/dist/types/runtime/session.d.ts +20 -18
- package/dist/types/runtime/shield.d.ts +9 -0
- package/dist/types/runtime/shot.d.ts +45 -0
- package/dist/types/runtime/sketch.d.ts +26 -0
- package/dist/types/runtime/styles.d.ts +1 -1
- package/dist/types/runtime/sync.d.ts +1 -0
- package/dist/types/runtime/thread-model.d.ts +18 -0
- package/dist/types/runtime/ticket-form-model.d.ts +57 -0
- package/dist/types/runtime/ticket-form.d.ts +9 -0
- package/dist/types/runtime/toolbar.d.ts +37 -1
- package/dist/types/runtime/ui-scale.d.ts +17 -0
- package/dist/types/runtime/update.d.ts +32 -8
- package/dist/types/runtime/use-debug-context.d.ts +27 -0
- package/dist/types/server/index.d.ts +22 -0
- package/dist/types/server/support.d.ts +20 -0
- package/dist/types/server.d.ts +2 -0
- package/dist/types/vite/plugin.d.ts +15 -0
- package/dist/vite.js +121 -41
- package/package.json +24 -11
- package/src/capture/api.ts +128 -0
- package/src/capture/editor.tsx +178 -0
- package/src/capture/ignore.ts +38 -0
- package/src/capture/index.ts +27 -0
- package/src/capture/picker.tsx +171 -0
- package/src/capture/picture.ts +90 -0
- package/src/capture/surface.tsx +185 -0
- package/src/compiler/annotate.ts +202 -0
- package/src/compiler/babel.d.ts +11 -0
- package/src/compiler/enabled.ts +55 -0
- package/src/compiler/index.ts +2 -0
- package/src/core/index.ts +8 -0
- package/src/core/resolve.ts +307 -0
- package/src/core/types.ts +58 -0
- package/src/debug-context.ts +47 -0
- package/src/dictation.ts +17 -0
- package/src/embed/controller.ts +215 -0
- package/src/embed/index.ts +68 -0
- package/src/embed/options.ts +100 -0
- package/src/help/index.ts +38 -0
- package/src/help/panel-index.ts +17 -0
- package/src/help/panel-styles.ts +123 -0
- package/src/help/panel.ts +840 -0
- package/src/help/pill-styles.ts +75 -0
- package/src/help/pill.ts +338 -0
- package/src/help/react.tsx +143 -0
- package/src/help/requests.ts +111 -0
- package/src/help/types.ts +124 -0
- package/src/help/wire.ts +55 -0
- package/src/native-wire.ts +124 -0
- package/src/native.ts +282 -0
- package/src/react.ts +27 -0
- package/src/replay.ts +13 -0
- package/src/runtime/api.ts +531 -0
- package/src/runtime/attachment-model.ts +42 -0
- package/src/runtime/attachments.tsx +1260 -0
- package/src/runtime/boot.ts +95 -0
- package/src/runtime/browser-label.ts +31 -0
- package/src/runtime/build-id.ts +10 -0
- package/src/runtime/capture-ui.tsx +306 -0
- package/src/runtime/capture.ts +508 -0
- package/src/runtime/clarity-model.ts +634 -0
- package/src/runtime/clarity-ui.tsx +454 -0
- package/src/runtime/client.ts +151 -0
- package/src/runtime/composer-size.tsx +146 -0
- package/src/runtime/dashboard.tsx +1130 -0
- package/src/runtime/debug-context-core.ts +681 -0
- package/src/runtime/debug-context-host.ts +56 -0
- package/src/runtime/debug-context-model.ts +1319 -0
- package/src/runtime/debug-context-redact.ts +259 -0
- package/src/runtime/debug-context.ts +613 -0
- package/src/runtime/details-model.ts +30 -0
- package/src/runtime/details.tsx +395 -0
- package/src/runtime/dictation-engine.ts +616 -0
- package/src/runtime/dictation-model.ts +79 -0
- package/src/runtime/dictation-ui.tsx +363 -0
- package/src/runtime/dictation.ts +271 -0
- package/src/runtime/dock.ts +108 -0
- package/src/runtime/draft-dock.tsx +124 -0
- package/src/runtime/draft-model.ts +251 -0
- package/src/runtime/draft-store.ts +149 -0
- package/src/runtime/edits.tsx +165 -0
- package/src/runtime/font.ts +42 -0
- package/src/runtime/format.ts +129 -0
- package/src/runtime/freshness.ts +23 -0
- package/src/runtime/handoff.ts +76 -0
- package/src/runtime/host-watch.ts +64 -0
- package/src/runtime/icons.tsx +295 -0
- package/src/runtime/idle.ts +20 -0
- package/src/runtime/index.ts +11 -0
- package/src/runtime/ink.ts +6 -0
- package/src/runtime/kind-model.ts +20 -0
- package/src/runtime/live-model.ts +32 -0
- package/src/runtime/loader.tsx +87 -0
- package/src/runtime/markup-editor.tsx +1333 -0
- package/src/runtime/markup-model.ts +485 -0
- package/src/runtime/markup.ts +382 -0
- package/src/runtime/members.ts +58 -0
- package/src/runtime/mention-parse.ts +56 -0
- package/src/runtime/mentions.tsx +205 -0
- package/src/runtime/page-background.ts +51 -0
- package/src/runtime/page-image.ts +1592 -0
- package/src/runtime/placement.ts +336 -0
- package/src/runtime/record.ts +402 -0
- package/src/runtime/redact.ts +30 -0
- package/src/runtime/related.ts +34 -0
- package/src/runtime/render-page.ts +2 -0
- package/src/runtime/replay-model.ts +232 -0
- package/src/runtime/replay.ts +173 -0
- package/src/runtime/runtime-version.ts +7 -0
- package/src/runtime/runtime.tsx +51 -0
- package/src/runtime/screen.ts +182 -0
- package/src/runtime/screenshot.ts +260 -0
- package/src/runtime/session-source.ts +112 -0
- package/src/runtime/session.ts +108 -0
- package/src/runtime/shield.ts +34 -0
- package/src/runtime/shot.tsx +422 -0
- package/src/runtime/sketch.ts +139 -0
- package/src/runtime/styles.ts +1541 -0
- package/src/runtime/sync.ts +300 -0
- package/src/runtime/thread-model.ts +71 -0
- package/src/runtime/ticket-form-model.ts +128 -0
- package/src/runtime/ticket-form.tsx +342 -0
- package/src/runtime/toolbar.tsx +4427 -0
- package/src/runtime/ui-scale.ts +75 -0
- package/src/runtime/update.ts +98 -0
- package/src/runtime/use-debug-context.ts +81 -0
- package/src/server/index.ts +133 -0
- package/src/server/support.ts +499 -0
- package/src/server.ts +4 -0
- package/src/vite/plugin.ts +102 -0
- package/src/vite.ts +1 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The markup model: shapes in the image's own pixel space, sizes, colours, label layout, hit-testing, moves and
|
|
3
|
+
* undo history. DOM-free, so native clients draw the same shapes with their own renderer; the web toolbar's
|
|
4
|
+
* canvas renderer and flattening are in `./markup.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import { INK, type Point } from "./ink.js";
|
|
7
|
+
|
|
8
|
+
export type { Point };
|
|
9
|
+
export type Box = { x: number; y: number; w: number; h: number };
|
|
10
|
+
|
|
11
|
+
export type MarkupTool = "select" | "pen" | "highlight" | "arrow" | "rect" | "ellipse" | "text";
|
|
12
|
+
export type MarkupSize = "s" | "m" | "l";
|
|
13
|
+
|
|
14
|
+
type ShapeBase = { id: string; color: string; size: MarkupSize };
|
|
15
|
+
export type StrokeShape = ShapeBase & { kind: "pen" | "highlight"; points: Point[] };
|
|
16
|
+
export type ArrowShape = ShapeBase & { kind: "arrow"; from: Point; to: Point };
|
|
17
|
+
export type BoxShape = ShapeBase & { kind: "rect" | "ellipse"; box: Box };
|
|
18
|
+
export type TextShape = ShapeBase & { kind: "text"; at: Point; text: string };
|
|
19
|
+
export type MarkupShape = StrokeShape | ArrowShape | BoxShape | TextShape;
|
|
20
|
+
|
|
21
|
+
/** Colours offered in the editor. The first is the toolbar's sketch ink. */
|
|
22
|
+
export const MARKUP_COLORS: ReadonlyArray<{ name: string; value: string }> = [
|
|
23
|
+
{ name: "Red", value: INK },
|
|
24
|
+
{ name: "Orange", value: "#ff9500" },
|
|
25
|
+
{ name: "Yellow", value: "#ffcc00" },
|
|
26
|
+
{ name: "Green", value: "#34c759" },
|
|
27
|
+
{ name: "Blue", value: "#007aff" },
|
|
28
|
+
{ name: "Purple", value: "#af52de" },
|
|
29
|
+
{ name: "Black", value: "#1d1d1f" },
|
|
30
|
+
{ name: "White", value: "#ffffff" },
|
|
31
|
+
];
|
|
32
|
+
export const DEFAULT_INK = INK;
|
|
33
|
+
export const DEFAULT_HIGHLIGHT = "#ffcc00";
|
|
34
|
+
/** Highlighter strokes are drawn at this opacity so the page under them stays readable. */
|
|
35
|
+
export const HIGHLIGHT_ALPHA = 0.38;
|
|
36
|
+
/** Most undo steps kept. */
|
|
37
|
+
export const HISTORY_LIMIT = 100;
|
|
38
|
+
/** Stroke widths in `unit` for each size: s 2u, m 4u, l 8u. */
|
|
39
|
+
const STROKE_SCALE: Record<MarkupSize, number> = { s: 2, m: 4, l: 8 };
|
|
40
|
+
/** Font sizes in `unit`: s 14u, m 20u, l 30u. */
|
|
41
|
+
const FONT_SCALE: Record<MarkupSize, number> = { s: 14, m: 20, l: 30 };
|
|
42
|
+
/** A highlighter is this many pen strokes wide. */
|
|
43
|
+
const HIGHLIGHT_RATIO = 5;
|
|
44
|
+
/** Labels are set in the system UI stack so they match the app they are drawn over. */
|
|
45
|
+
const FONT_STACK =
|
|
46
|
+
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
|
|
47
|
+
/** Label text on a light fill is this near-black; on a dark fill it is white. */
|
|
48
|
+
const LABEL_ON_LIGHT = "#1d1d1f";
|
|
49
|
+
const LABEL_ON_DARK = "#ffffff";
|
|
50
|
+
/** Stripped off the original name before the flattened extension is added. */
|
|
51
|
+
const KNOWN_EXTENSION = /\.(?:png|jpe?g|gif|webp)$/i;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sizes in image pixels for one image. `unit` is max(1, longest side / 1000), so markup looks the same on a 1x
|
|
55
|
+
* screenshot and a 3x one. Pen and arrow strokes: s 2u, m 4u, l 8u. Highlighter: 5x the pen width. Text font:
|
|
56
|
+
* s 14u, m 20u, l 30u.
|
|
57
|
+
*/
|
|
58
|
+
export type MarkupMetrics = {
|
|
59
|
+
unit: number;
|
|
60
|
+
stroke: (size: MarkupSize) => number;
|
|
61
|
+
highlight: (size: MarkupSize) => number;
|
|
62
|
+
font: (size: MarkupSize) => number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Width in px of one line of text at `fontPx`, set in `markupFont(fontPx)`. */
|
|
66
|
+
export type MeasureText = (text: string, fontPx: number) => number;
|
|
67
|
+
|
|
68
|
+
/** Laid-out text label: the filled box in image px, its lines, and where each line's top-left sits. */
|
|
69
|
+
export type TextLayout = {
|
|
70
|
+
box: Box;
|
|
71
|
+
lines: string[];
|
|
72
|
+
font: number;
|
|
73
|
+
pad: number;
|
|
74
|
+
lineHeight: number;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function markupMetrics(width: number, height: number): MarkupMetrics {
|
|
78
|
+
const usable = Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0;
|
|
79
|
+
const unit = usable ? Math.max(1, Math.max(width, height) / 1000) : 1;
|
|
80
|
+
return {
|
|
81
|
+
unit,
|
|
82
|
+
stroke: (size) => unit * STROKE_SCALE[size],
|
|
83
|
+
highlight: (size) => unit * STROKE_SCALE[size] * HIGHLIGHT_RATIO,
|
|
84
|
+
font: (size) => unit * FONT_SCALE[size],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** CSS font for labels: `600 <px>px` in the system UI stack. */
|
|
89
|
+
export function markupFont(px: number): string {
|
|
90
|
+
return `600 ${px}px ${FONT_STACK}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Label text for a fill: `#ffffff` while it keeps at least 3:1 contrast (WCAG AA for large bold text, which
|
|
95
|
+
* labels are), else whichever of white and `#1d1d1f` reads better. Red, blue, and purple keep white text;
|
|
96
|
+
* orange, green, yellow, and white get dark text.
|
|
97
|
+
*/
|
|
98
|
+
export function contrastText(color: string): string {
|
|
99
|
+
const rgb = parseHexColor(color);
|
|
100
|
+
if (!rgb) return LABEL_ON_DARK;
|
|
101
|
+
const fill = luminance(rgb);
|
|
102
|
+
const onWhite = contrastRatio(fill, 1);
|
|
103
|
+
if (onWhite >= 3) return LABEL_ON_DARK;
|
|
104
|
+
const dark = parseHexColor(LABEL_ON_LIGHT)!;
|
|
105
|
+
return contrastRatio(fill, luminance(dark)) > onWhite ? LABEL_ON_LIGHT : LABEL_ON_DARK;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Lays a label out: lines split on "\n", line height 1.25 em, padding 0.4 em, top-left at `shape.at`. */
|
|
109
|
+
export function textLayout(
|
|
110
|
+
shape: TextShape,
|
|
111
|
+
metrics: MarkupMetrics,
|
|
112
|
+
measure: MeasureText,
|
|
113
|
+
): TextLayout {
|
|
114
|
+
const font = metrics.font(shape.size);
|
|
115
|
+
const lineHeight = font * 1.25;
|
|
116
|
+
const pad = font * 0.4;
|
|
117
|
+
const lines = shape.text.split("\n");
|
|
118
|
+
let widest = 0;
|
|
119
|
+
for (const line of lines) widest = Math.max(widest, measure(line, font));
|
|
120
|
+
// An empty label still gets a box wide enough to click and type into.
|
|
121
|
+
if (shape.text === "") widest = Math.max(widest, font * 0.5);
|
|
122
|
+
return {
|
|
123
|
+
box: {
|
|
124
|
+
x: shape.at.x,
|
|
125
|
+
y: shape.at.y,
|
|
126
|
+
w: widest + 2 * pad,
|
|
127
|
+
h: lines.length * lineHeight + 2 * pad,
|
|
128
|
+
},
|
|
129
|
+
lines,
|
|
130
|
+
font,
|
|
131
|
+
pad,
|
|
132
|
+
lineHeight,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The area a shape paints, stroke width included, in image px. */
|
|
137
|
+
export function shapeBounds(shape: MarkupShape, metrics: MarkupMetrics, measure: MeasureText): Box {
|
|
138
|
+
switch (shape.kind) {
|
|
139
|
+
case "pen":
|
|
140
|
+
case "highlight": {
|
|
141
|
+
const width = strokeWidth(shape, metrics);
|
|
142
|
+
return boxAroundPoints(shape.points, width / 2);
|
|
143
|
+
}
|
|
144
|
+
case "arrow": {
|
|
145
|
+
const width = metrics.stroke(shape.size);
|
|
146
|
+
// The shaft needs half its width and the head reaches a full head length past its base.
|
|
147
|
+
const expand = width / 2 + arrowHead(width, metrics.unit).length;
|
|
148
|
+
return boxAroundPoints([shape.from, shape.to], expand);
|
|
149
|
+
}
|
|
150
|
+
case "rect":
|
|
151
|
+
case "ellipse": {
|
|
152
|
+
const half = metrics.stroke(shape.size) / 2;
|
|
153
|
+
const rect = toRect(shape.box);
|
|
154
|
+
return { x: rect.x - half, y: rect.y - half, w: rect.w + 2 * half, h: rect.h + 2 * half };
|
|
155
|
+
}
|
|
156
|
+
case "text":
|
|
157
|
+
return textLayout(shape, metrics, measure).box;
|
|
158
|
+
default: {
|
|
159
|
+
const unknown: never = shape;
|
|
160
|
+
throw new Error(`Unknown markup shape: ${JSON.stringify(unknown)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Id of the topmost shape under `point` (later shapes are on top), or null. Strokes and arrows hit within half
|
|
167
|
+
* their width plus `tolerance` of the line; rectangles and ellipses within that of their outline (not their
|
|
168
|
+
* inside, so a box drawn around something never hides it); text anywhere in its label box.
|
|
169
|
+
*/
|
|
170
|
+
export function hitTest(
|
|
171
|
+
shapes: MarkupShape[],
|
|
172
|
+
point: Point,
|
|
173
|
+
tolerance: number,
|
|
174
|
+
metrics: MarkupMetrics,
|
|
175
|
+
measure: MeasureText,
|
|
176
|
+
): string | null {
|
|
177
|
+
for (let i = shapes.length - 1; i >= 0; i -= 1) {
|
|
178
|
+
const shape = shapes[i]!;
|
|
179
|
+
if (hitShape(shape, point, tolerance, metrics, measure)) return shape.id;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The same shape moved by (dx, dy); a new object, the input is left alone. */
|
|
185
|
+
export function moveShape<T extends MarkupShape>(shape: T, dx: number, dy: number): T {
|
|
186
|
+
const moved = (point: Point): Point => ({ x: point.x + dx, y: point.y + dy });
|
|
187
|
+
switch (shape.kind) {
|
|
188
|
+
case "pen":
|
|
189
|
+
case "highlight":
|
|
190
|
+
return { ...shape, points: shape.points.map(moved) };
|
|
191
|
+
case "arrow":
|
|
192
|
+
return { ...shape, from: moved(shape.from), to: moved(shape.to) };
|
|
193
|
+
case "rect":
|
|
194
|
+
case "ellipse":
|
|
195
|
+
return { ...shape, box: { ...shape.box, x: shape.box.x + dx, y: shape.box.y + dy } };
|
|
196
|
+
case "text":
|
|
197
|
+
return { ...shape, at: moved(shape.at) };
|
|
198
|
+
default: {
|
|
199
|
+
const unknown: never = shape;
|
|
200
|
+
throw new Error(`Unknown markup shape: ${JSON.stringify(unknown)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The box between two drag points, any direction. `square` makes it w = h = the larger side, kept on the drag side. */
|
|
206
|
+
export function normalizeBox(a: Point, b: Point, square: boolean): Box {
|
|
207
|
+
let dx = b.x - a.x;
|
|
208
|
+
let dy = b.y - a.y;
|
|
209
|
+
if (square) {
|
|
210
|
+
const side = Math.max(Math.abs(dx), Math.abs(dy));
|
|
211
|
+
// A zero delta keeps the positive direction, so a straight drag still makes a box.
|
|
212
|
+
dx = dx < 0 ? -side : side;
|
|
213
|
+
dy = dy < 0 ? -side : side;
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
x: Math.min(a.x, a.x + dx),
|
|
217
|
+
y: Math.min(a.y, a.y + dy),
|
|
218
|
+
w: Math.abs(dx),
|
|
219
|
+
h: Math.abs(dy),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** `to` moved onto the nearest 45° ray from `from`, keeping its distance. */
|
|
224
|
+
export function snapAngle(from: Point, to: Point): Point {
|
|
225
|
+
const dx = to.x - from.x;
|
|
226
|
+
const dy = to.y - from.y;
|
|
227
|
+
const distance = Math.hypot(dx, dy);
|
|
228
|
+
if (distance === 0) return { x: to.x, y: to.y };
|
|
229
|
+
const step = Math.PI / 4;
|
|
230
|
+
const angle = Math.round(Math.atan2(dy, dx) / step) * step;
|
|
231
|
+
// cos(π/2) is 6e-17, not 0; snap those dust values so a snapped arrow lands exactly on the axis.
|
|
232
|
+
const cos = tidy(Math.cos(angle));
|
|
233
|
+
const sin = tidy(Math.sin(angle));
|
|
234
|
+
return { x: from.x + cos * distance, y: from.y + sin * distance };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Whether a just-drawn shape is worth keeping: a stroke must span `minSize` px in at least one direction, an
|
|
239
|
+
* arrow must be at least `minSize` long, a box must be at least `minSize` both ways, and text must have
|
|
240
|
+
* non-blank text.
|
|
241
|
+
*/
|
|
242
|
+
export function isMeaningful(shape: MarkupShape, minSize: number): boolean {
|
|
243
|
+
switch (shape.kind) {
|
|
244
|
+
case "pen":
|
|
245
|
+
case "highlight": {
|
|
246
|
+
if (shape.points.length === 0) return false;
|
|
247
|
+
const bounds = boxAroundPoints(shape.points, 0);
|
|
248
|
+
// A tap spans nothing either way; a flick spans one way and is kept.
|
|
249
|
+
return bounds.w >= minSize || bounds.h >= minSize;
|
|
250
|
+
}
|
|
251
|
+
case "arrow":
|
|
252
|
+
return Math.hypot(shape.to.x - shape.from.x, shape.to.y - shape.from.y) >= minSize;
|
|
253
|
+
case "rect":
|
|
254
|
+
case "ellipse": {
|
|
255
|
+
const rect = toRect(shape.box);
|
|
256
|
+
return rect.w >= minSize && rect.h >= minSize;
|
|
257
|
+
}
|
|
258
|
+
case "text":
|
|
259
|
+
return shape.text.trim().length > 0;
|
|
260
|
+
default: {
|
|
261
|
+
const unknown: never = shape;
|
|
262
|
+
throw new Error(`Unknown markup shape: ${JSON.stringify(unknown)}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The original's base name with `.png` or `.jpg` for the flattened type ("shot.jpeg" → "shot.png"). */
|
|
268
|
+
export function flattenedName(name: string, type: "image/png" | "image/jpeg"): string {
|
|
269
|
+
const base = name.replace(KNOWN_EXTENSION, "");
|
|
270
|
+
return `${base || "image"}${type === "image/jpeg" ? ".jpg" : ".png"}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Undo history. `present` is what is on screen. */
|
|
274
|
+
export type MarkupHistory = {
|
|
275
|
+
past: MarkupShape[][];
|
|
276
|
+
present: MarkupShape[];
|
|
277
|
+
future: MarkupShape[][];
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
export function createHistory(shapes: MarkupShape[]): MarkupHistory {
|
|
281
|
+
return { past: [], present: shapes, future: [] };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Pushes `next` as a new step and clears redo; returns `history` itself when `next === history.present`. */
|
|
285
|
+
export function commitHistory(history: MarkupHistory, next: MarkupShape[]): MarkupHistory {
|
|
286
|
+
if (next === history.present) return history;
|
|
287
|
+
const past = [...history.past, history.present];
|
|
288
|
+
return {
|
|
289
|
+
past: past.length > HISTORY_LIMIT ? past.slice(past.length - HISTORY_LIMIT) : past,
|
|
290
|
+
present: next,
|
|
291
|
+
future: [],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Returns `history` itself when there is nothing to undo. */
|
|
296
|
+
export function undoHistory(history: MarkupHistory): MarkupHistory {
|
|
297
|
+
if (history.past.length === 0) return history;
|
|
298
|
+
return {
|
|
299
|
+
past: history.past.slice(0, -1),
|
|
300
|
+
present: history.past[history.past.length - 1]!,
|
|
301
|
+
future: [history.present, ...history.future],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Returns `history` itself when there is nothing to redo. */
|
|
306
|
+
export function redoHistory(history: MarkupHistory): MarkupHistory {
|
|
307
|
+
if (history.future.length === 0) return history;
|
|
308
|
+
const [present, ...future] = history.future;
|
|
309
|
+
return { past: [...history.past, history.present], present: present!, future };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ----------------------------------------------------------------------------
|
|
313
|
+
// Geometry and colour helpers (the stroke, arrow-head and box ones are shared with renderers)
|
|
314
|
+
// ----------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
/** `#rgb` / `#rrggbb` to 0–1 channels; null for anything else, including `rgba()` and named colours. */
|
|
317
|
+
function parseHexColor(color: string): [number, number, number] | null {
|
|
318
|
+
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
|
|
319
|
+
if (!match) return null;
|
|
320
|
+
const hex = match[1]!;
|
|
321
|
+
const channel = (index: number) => {
|
|
322
|
+
// Three-digit hex repeats each digit; six-digit takes the pair.
|
|
323
|
+
const pair =
|
|
324
|
+
hex.length === 3 ? `${hex[index]!}${hex[index]!}` : hex.slice(index * 2, index * 2 + 2);
|
|
325
|
+
return Number.parseInt(pair, 16) / 255;
|
|
326
|
+
};
|
|
327
|
+
return [channel(0), channel(1), channel(2)];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** WCAG relative luminance of 0–1 sRGB channels. */
|
|
331
|
+
function luminance([r, g, b]: [number, number, number]): number {
|
|
332
|
+
return 0.2126 * linearChannel(r) + 0.7152 * linearChannel(g) + 0.0722 * linearChannel(b);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** WCAG contrast ratio between two relative luminances. */
|
|
336
|
+
function contrastRatio(a: number, b: number): number {
|
|
337
|
+
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** One sRGB channel (0–1) to its linear-light value, per WCAG relative luminance. */
|
|
341
|
+
function linearChannel(channel: number): number {
|
|
342
|
+
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Drops floating-point dust so snapped rays land on exact axis values. */
|
|
346
|
+
function tidy(value: number): number {
|
|
347
|
+
return Math.abs(value) < 1e-12 ? 0 : value;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Width a stroke paints, before any hit tolerance. Highlighter is wider by design. */
|
|
351
|
+
export function strokeWidth(shape: StrokeShape, metrics: MarkupMetrics): number {
|
|
352
|
+
return shape.kind === "highlight" ? metrics.highlight(shape.size) : metrics.stroke(shape.size);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Arrow head length and half width, both from the shaft width and the image's unit. */
|
|
356
|
+
export function arrowHead(lineWidth: number, unit: number): { length: number; halfWidth: number } {
|
|
357
|
+
const length = Math.max(4 * lineWidth, 10 * unit);
|
|
358
|
+
return { length, halfWidth: length * 0.55 };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A box as left/top with non-negative sides, so negative widths from odd input still draw. */
|
|
362
|
+
export function toRect(box: Box): Box {
|
|
363
|
+
return {
|
|
364
|
+
x: Math.min(box.x, box.x + box.w),
|
|
365
|
+
y: Math.min(box.y, box.y + box.h),
|
|
366
|
+
w: Math.abs(box.w),
|
|
367
|
+
h: Math.abs(box.h),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The bounding box of `points`, grown by `expand` on every side. */
|
|
372
|
+
function boxAroundPoints(points: Point[], expand: number): Box {
|
|
373
|
+
if (points.length === 0) return { x: 0, y: 0, w: 0, h: 0 };
|
|
374
|
+
let left = Infinity;
|
|
375
|
+
let top = Infinity;
|
|
376
|
+
let right = -Infinity;
|
|
377
|
+
let bottom = -Infinity;
|
|
378
|
+
for (const { x, y } of points) {
|
|
379
|
+
left = Math.min(left, x);
|
|
380
|
+
top = Math.min(top, y);
|
|
381
|
+
right = Math.max(right, x);
|
|
382
|
+
bottom = Math.max(bottom, y);
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
x: left - expand,
|
|
386
|
+
y: top - expand,
|
|
387
|
+
w: right - left + 2 * expand,
|
|
388
|
+
h: bottom - top + 2 * expand,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Distance from `point` to the closest spot on segment `a`–`b`. */
|
|
393
|
+
function distanceToSegment(point: Point, a: Point, b: Point): number {
|
|
394
|
+
const dx = b.x - a.x;
|
|
395
|
+
const dy = b.y - a.y;
|
|
396
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
397
|
+
if (lengthSquared === 0) return Math.hypot(point.x - a.x, point.y - a.y);
|
|
398
|
+
const t = Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSquared));
|
|
399
|
+
return Math.hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Distance from `point` to a pen/highlighter stroke's polyline. */
|
|
403
|
+
function distanceToStroke(points: Point[], point: Point): number {
|
|
404
|
+
if (points.length === 0) return Infinity;
|
|
405
|
+
if (points.length === 1) return Math.hypot(point.x - points[0]!.x, point.y - points[0]!.y);
|
|
406
|
+
let best = Infinity;
|
|
407
|
+
for (let i = 0; i + 1 < points.length; i += 1) {
|
|
408
|
+
best = Math.min(best, distanceToSegment(point, points[i]!, points[i + 1]!));
|
|
409
|
+
}
|
|
410
|
+
return best;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Distance from `point` to a rectangle's outline; a zero-width box is its own segment. */
|
|
414
|
+
function distanceToRectOutline(point: Point, box: Box): number {
|
|
415
|
+
const rect = toRect(box);
|
|
416
|
+
const corners: Point[] = [
|
|
417
|
+
{ x: rect.x, y: rect.y },
|
|
418
|
+
{ x: rect.x + rect.w, y: rect.y },
|
|
419
|
+
{ x: rect.x + rect.w, y: rect.y + rect.h },
|
|
420
|
+
{ x: rect.x, y: rect.y + rect.h },
|
|
421
|
+
];
|
|
422
|
+
let best = Infinity;
|
|
423
|
+
for (let i = 0; i < 4; i += 1) {
|
|
424
|
+
best = Math.min(best, distanceToSegment(point, corners[i]!, corners[(i + 1) % 4]!));
|
|
425
|
+
}
|
|
426
|
+
return best;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Distance from `point` to an ellipse's outline, approximated from the normalised radius. */
|
|
430
|
+
function distanceToEllipseOutline(point: Point, box: Box): number {
|
|
431
|
+
const rect = toRect(box);
|
|
432
|
+
const rx = rect.w / 2;
|
|
433
|
+
const ry = rect.h / 2;
|
|
434
|
+
const cx = rect.x + rx;
|
|
435
|
+
const cy = rect.y + ry;
|
|
436
|
+
if (rx < 1e-6 || ry < 1e-6) {
|
|
437
|
+
// A collapsed ellipse is a line (or a point); its outline is that segment.
|
|
438
|
+
return distanceToSegment(point, { x: cx - rx, y: cy - ry }, { x: cx + rx, y: cy + ry });
|
|
439
|
+
}
|
|
440
|
+
const nx = (point.x - cx) / rx;
|
|
441
|
+
const ny = (point.y - cy) / ry;
|
|
442
|
+
return Math.abs(Math.hypot(nx, ny) - 1) * Math.min(rx, ry);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Whether `point` lands on `shape`, given the tolerance the editor adds for touch and imprecision. */
|
|
446
|
+
function hitShape(
|
|
447
|
+
shape: MarkupShape,
|
|
448
|
+
point: Point,
|
|
449
|
+
tolerance: number,
|
|
450
|
+
metrics: MarkupMetrics,
|
|
451
|
+
measure: MeasureText,
|
|
452
|
+
): boolean {
|
|
453
|
+
switch (shape.kind) {
|
|
454
|
+
case "pen":
|
|
455
|
+
case "highlight": {
|
|
456
|
+
const reach = strokeWidth(shape, metrics) / 2 + tolerance;
|
|
457
|
+
return distanceToStroke(shape.points, point) <= reach;
|
|
458
|
+
}
|
|
459
|
+
case "arrow": {
|
|
460
|
+
const reach = metrics.stroke(shape.size) / 2 + tolerance;
|
|
461
|
+
return distanceToSegment(point, shape.from, shape.to) <= reach;
|
|
462
|
+
}
|
|
463
|
+
case "rect": {
|
|
464
|
+
const reach = metrics.stroke(shape.size) / 2 + tolerance;
|
|
465
|
+
return distanceToRectOutline(point, shape.box) <= reach;
|
|
466
|
+
}
|
|
467
|
+
case "ellipse": {
|
|
468
|
+
const reach = metrics.stroke(shape.size) / 2 + tolerance;
|
|
469
|
+
return distanceToEllipseOutline(point, shape.box) <= reach;
|
|
470
|
+
}
|
|
471
|
+
case "text": {
|
|
472
|
+
const { box } = textLayout(shape, metrics, measure);
|
|
473
|
+
return (
|
|
474
|
+
point.x >= box.x - tolerance &&
|
|
475
|
+
point.x <= box.x + box.w + tolerance &&
|
|
476
|
+
point.y >= box.y - tolerance &&
|
|
477
|
+
point.y <= box.y + box.h + tolerance
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
default: {
|
|
481
|
+
const unknown: never = shape;
|
|
482
|
+
throw new Error(`Unknown markup shape: ${JSON.stringify(unknown)}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|