@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,1333 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useImperativeHandle,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type CSSProperties,
|
|
9
|
+
type ChangeEvent,
|
|
10
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
type Ref,
|
|
13
|
+
} from "react";
|
|
14
|
+
import * as Icon from "./icons.js";
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_HIGHLIGHT,
|
|
17
|
+
DEFAULT_INK,
|
|
18
|
+
MARKUP_COLORS,
|
|
19
|
+
commitHistory,
|
|
20
|
+
contrastText,
|
|
21
|
+
createHistory,
|
|
22
|
+
createMeasure,
|
|
23
|
+
drawSelection,
|
|
24
|
+
drawShapes,
|
|
25
|
+
flattenMarkup,
|
|
26
|
+
hitTest,
|
|
27
|
+
isMeaningful,
|
|
28
|
+
loadImage,
|
|
29
|
+
markupFont,
|
|
30
|
+
markupMetrics,
|
|
31
|
+
moveShape,
|
|
32
|
+
normalizeBox,
|
|
33
|
+
redoHistory,
|
|
34
|
+
shapeBounds,
|
|
35
|
+
snapAngle,
|
|
36
|
+
textLayout,
|
|
37
|
+
undoHistory,
|
|
38
|
+
type MarkupHistory,
|
|
39
|
+
type MarkupShape,
|
|
40
|
+
type MarkupSize,
|
|
41
|
+
type MarkupTool,
|
|
42
|
+
type MeasureText,
|
|
43
|
+
type Point,
|
|
44
|
+
type TextShape,
|
|
45
|
+
} from "./markup.js";
|
|
46
|
+
|
|
47
|
+
export type MarkupResult =
|
|
48
|
+
| {
|
|
49
|
+
/** The image with the markup flattened in. */
|
|
50
|
+
blob: Blob;
|
|
51
|
+
/** File name for `blob` (`flattenedName` of the original). */
|
|
52
|
+
name: string;
|
|
53
|
+
/** The shapes, kept so the image can be marked up again from the original. */
|
|
54
|
+
shapes: MarkupShape[];
|
|
55
|
+
}
|
|
56
|
+
/** Every shape was removed: the attachment goes back to the original file. */
|
|
57
|
+
| { blob: null; name: string; shapes: [] };
|
|
58
|
+
|
|
59
|
+
/** What an inline editor's owner can ask of it (see `MarkupEditorProps.mode`). */
|
|
60
|
+
export type MarkupEditorHandle = {
|
|
61
|
+
/**
|
|
62
|
+
* Flattens what has been drawn since the editor opened or last saved. Resolves null when nothing changed, and
|
|
63
|
+
* rejects when the image can't be flattened (the reason is shown in the editor).
|
|
64
|
+
*/
|
|
65
|
+
save(): Promise<MarkupResult | null>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type MarkupEditorProps = {
|
|
69
|
+
/**
|
|
70
|
+
* `dialog` (default): a header with Cancel and Done; Done hands the result over once and the owner closes the
|
|
71
|
+
* editor. `inline`: no header, the editor stays open after a save, its owner saves through `ref` (`save`) and
|
|
72
|
+
* Cmd+Enter calls `onSubmit`. Escape with nothing drawn still calls `onCancel`.
|
|
73
|
+
*/
|
|
74
|
+
mode?: "dialog" | "inline";
|
|
75
|
+
/** Inline mode's handle on the editor (`save`), for an owner that flattens on its own schedule. */
|
|
76
|
+
handle?: Ref<MarkupEditorHandle>;
|
|
77
|
+
/** URL of the original, unmarked image (a blob: URL from the draft tray). */
|
|
78
|
+
src: string;
|
|
79
|
+
/** The original's file name, shown in the header and used to name the result. */
|
|
80
|
+
name: string;
|
|
81
|
+
/** Shapes from an earlier markup of this image; the editor starts with them so they stay editable. */
|
|
82
|
+
shapes: MarkupShape[];
|
|
83
|
+
/** Largest file the result may be (the upload limit). */
|
|
84
|
+
maxBytes: number;
|
|
85
|
+
/** Leaves without changing the attachment. */
|
|
86
|
+
onCancel: () => void;
|
|
87
|
+
/** Called once with the flattened image. The editor stays mounted until the parent closes it. */
|
|
88
|
+
onSave: (result: MarkupResult) => void;
|
|
89
|
+
/** Inline mode's Cmd+Enter: the result of saving first (null when nothing changed), then the owner's turn. */
|
|
90
|
+
onSubmit?: (result: MarkupResult | null) => void;
|
|
91
|
+
/** Every change to the shapes, as they stand, so the owner can keep them somewhere safe. */
|
|
92
|
+
onChange?: (shapes: MarkupShape[]) => void;
|
|
93
|
+
/** Inline mode's ribbon draws its own groups; the owner's groups (captures, narration) go after them. */
|
|
94
|
+
ribbon?: ReactNode;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const ZOOM_STEP = 1.25;
|
|
98
|
+
const ZOOM_MIN = 0.5;
|
|
99
|
+
const ZOOM_MAX = 6;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `.cs-markup-stage` pads 9px a side and 6px at the bottom, kept inside its box by `box-sizing: border-box`. The
|
|
103
|
+
* frame fits inside what is left, so a wide image never slides under the padding.
|
|
104
|
+
*/
|
|
105
|
+
const PAD_X = 18;
|
|
106
|
+
const PAD_Y = 6;
|
|
107
|
+
/** How far off a line a press still counts as a hit, in screen px. */
|
|
108
|
+
const HIT_SLOP = 6;
|
|
109
|
+
/** Below this many screen px a shape is a stray tap, not a drawing. */
|
|
110
|
+
const TAP_SIZE = 4;
|
|
111
|
+
const MAX_STROKE_POINTS = 4000;
|
|
112
|
+
|
|
113
|
+
const SIZE_ORDER: MarkupSize[] = ["s", "m", "l"];
|
|
114
|
+
const SIZE_LABELS: Record<MarkupSize, string> = { s: "Small", m: "Medium", l: "Large" };
|
|
115
|
+
const TOOL_KEYS: Record<string, MarkupTool> = {
|
|
116
|
+
v: "select",
|
|
117
|
+
p: "pen",
|
|
118
|
+
h: "highlight",
|
|
119
|
+
a: "arrow",
|
|
120
|
+
r: "rect",
|
|
121
|
+
o: "ellipse",
|
|
122
|
+
t: "text",
|
|
123
|
+
};
|
|
124
|
+
const TOOLS: Array<{ tool: MarkupTool; label: string; icon: ReactNode }> = [
|
|
125
|
+
{ tool: "select", label: "Select", icon: <Icon.Cursor /> },
|
|
126
|
+
{ tool: "pen", label: "Pen", icon: <Icon.Pencil size={12} /> },
|
|
127
|
+
{ tool: "highlight", label: "Highlighter", icon: <Icon.Highlighter /> },
|
|
128
|
+
{ tool: "arrow", label: "Arrow", icon: <Icon.ArrowTool /> },
|
|
129
|
+
{ tool: "rect", label: "Rectangle", icon: <Icon.Square /> },
|
|
130
|
+
{ tool: "ellipse", label: "Ellipse", icon: <Icon.Circle /> },
|
|
131
|
+
{ tool: "text", label: "Text", icon: <Icon.TextTool /> },
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
type DrawTool = "pen" | "highlight" | "arrow" | "rect" | "ellipse";
|
|
135
|
+
type Status = "loading" | "ready" | "failed";
|
|
136
|
+
type Editing = { seq: number; shape: TextShape | null; at: Point; value: string };
|
|
137
|
+
|
|
138
|
+
/** A stroke in flight: the points so far plus the newest cursor position and the Shift state there. */
|
|
139
|
+
type Stroke = {
|
|
140
|
+
mode: "draw";
|
|
141
|
+
pointerId: number;
|
|
142
|
+
tool: DrawTool;
|
|
143
|
+
id: string;
|
|
144
|
+
color: string;
|
|
145
|
+
size: MarkupSize;
|
|
146
|
+
start: Point;
|
|
147
|
+
points: Point[];
|
|
148
|
+
point: Point;
|
|
149
|
+
shift: boolean;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/** A selected shape being dragged. `origin` is where the press found it, so each move is one exact offset. */
|
|
153
|
+
type Slide = {
|
|
154
|
+
mode: "move";
|
|
155
|
+
pointerId: number;
|
|
156
|
+
start: Point;
|
|
157
|
+
origin: MarkupShape;
|
|
158
|
+
shape: MarkupShape;
|
|
159
|
+
moved: boolean;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
type Drag = Stroke | Slide;
|
|
163
|
+
|
|
164
|
+
function nextId() {
|
|
165
|
+
const unique = globalThis.crypto?.randomUUID;
|
|
166
|
+
if (typeof unique === "function") return unique.call(globalThis.crypto);
|
|
167
|
+
return `m-${String(Math.random()).slice(2)}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function clamp(value: number, min: number, max: number) {
|
|
171
|
+
if (!Number.isFinite(value)) return min;
|
|
172
|
+
return Math.min(max, Math.max(min, value));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Text for a shape list that ignores key order, so a re-serialised copy still reads as unchanged. */
|
|
176
|
+
function fingerprint(shapes: MarkupShape[]) {
|
|
177
|
+
return JSON.stringify(shapes, (_key, value: unknown) =>
|
|
178
|
+
value && typeof value === "object" && !Array.isArray(value)
|
|
179
|
+
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
180
|
+
: value,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** One measuring context for the editor's life; jsdom has none, and `createMeasure` estimates without it. */
|
|
185
|
+
function makeMeasure(): MeasureText {
|
|
186
|
+
let ctx: CanvasRenderingContext2D | null = null;
|
|
187
|
+
try {
|
|
188
|
+
ctx = document.createElement("canvas").getContext("2d");
|
|
189
|
+
} catch {
|
|
190
|
+
ctx = null;
|
|
191
|
+
}
|
|
192
|
+
return createMeasure(ctx);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The shape a stroke would commit as it stands. */
|
|
196
|
+
function strokeShape(stroke: Stroke): MarkupShape {
|
|
197
|
+
const base = { id: stroke.id, color: stroke.color, size: stroke.size };
|
|
198
|
+
if (stroke.tool === "pen" || stroke.tool === "highlight") {
|
|
199
|
+
// Shift turns a highlighter drag into one straight segment from where it started.
|
|
200
|
+
const straight = stroke.shift && stroke.tool === "highlight";
|
|
201
|
+
return {
|
|
202
|
+
...base,
|
|
203
|
+
kind: stroke.tool,
|
|
204
|
+
points: straight ? [stroke.start, stroke.point] : stroke.points,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (stroke.tool === "arrow") {
|
|
208
|
+
const to = stroke.shift ? snapAngle(stroke.start, stroke.point) : stroke.point;
|
|
209
|
+
return { ...base, kind: "arrow", from: stroke.start, to };
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
...base,
|
|
213
|
+
kind: stroke.tool,
|
|
214
|
+
box: normalizeBox(stroke.start, stroke.point, stroke.shift),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The shape list a finished label produces: added, edited in place, or dropped when emptied. */
|
|
219
|
+
function applyText(
|
|
220
|
+
shapes: MarkupShape[],
|
|
221
|
+
edit: Editing,
|
|
222
|
+
ink: string,
|
|
223
|
+
size: MarkupSize,
|
|
224
|
+
): MarkupShape[] {
|
|
225
|
+
const value = edit.value.trim();
|
|
226
|
+
const existing = edit.shape;
|
|
227
|
+
if (!existing) {
|
|
228
|
+
if (!value) return shapes;
|
|
229
|
+
const label: TextShape = {
|
|
230
|
+
id: nextId(),
|
|
231
|
+
kind: "text",
|
|
232
|
+
at: edit.at,
|
|
233
|
+
text: value,
|
|
234
|
+
color: ink,
|
|
235
|
+
size,
|
|
236
|
+
};
|
|
237
|
+
return [...shapes, label];
|
|
238
|
+
}
|
|
239
|
+
// An emptied label goes; anything else keeps the shape's id, and the colour and size it has now (a swatch
|
|
240
|
+
// pressed while typing restyles the shape under the input, not the snapshot taken when it opened).
|
|
241
|
+
const current = shapes.find((shape) => shape.id === existing.id);
|
|
242
|
+
if (!current || current.kind !== "text") return shapes;
|
|
243
|
+
if (!value) return shapes.filter((shape) => shape.id !== existing.id);
|
|
244
|
+
if (value === current.text) return shapes;
|
|
245
|
+
return shapes.map((shape) => (shape.id === existing.id ? { ...current, text: value } : shape));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Markup mode for an image in the media pane: replaces the pane's header and stage while open. */
|
|
249
|
+
export function MarkupEditor({
|
|
250
|
+
mode,
|
|
251
|
+
handle,
|
|
252
|
+
src,
|
|
253
|
+
name,
|
|
254
|
+
shapes: initialShapes,
|
|
255
|
+
maxBytes,
|
|
256
|
+
onCancel,
|
|
257
|
+
onSave,
|
|
258
|
+
onSubmit,
|
|
259
|
+
onChange: onChangeProp,
|
|
260
|
+
ribbon,
|
|
261
|
+
}: MarkupEditorProps) {
|
|
262
|
+
const root = useRef<HTMLDivElement>(null);
|
|
263
|
+
const stage = useRef<HTMLDivElement>(null);
|
|
264
|
+
const frame = useRef<HTMLDivElement>(null);
|
|
265
|
+
const canvas = useRef<HTMLCanvasElement>(null);
|
|
266
|
+
const textarea = useRef<HTMLTextAreaElement>(null);
|
|
267
|
+
const mounted = useRef(true);
|
|
268
|
+
const submitting = useRef(false);
|
|
269
|
+
const editingRef = useRef<Editing | null>(null);
|
|
270
|
+
const editSeq = useRef(0);
|
|
271
|
+
const drag = useRef<Drag | null>(null);
|
|
272
|
+
|
|
273
|
+
const inline = mode === "inline";
|
|
274
|
+
const [stageBox, setStageBox] = useState({ w: 0, h: 0 });
|
|
275
|
+
/** 1 fits the stage; more shows the picture larger than that, with the stage scrolling. */
|
|
276
|
+
const [zoom, setZoom] = useState(1);
|
|
277
|
+
/** Space held: the pointer pans the zoomed stage instead of drawing. */
|
|
278
|
+
const spaceHeld = useRef(false);
|
|
279
|
+
const [panning, setPanning] = useState(false);
|
|
280
|
+
// The shapes the editor opened with (or, inline, last saved); Done and Escape compare against them.
|
|
281
|
+
const [initial, setInitial] = useState(() => fingerprint(initialShapes));
|
|
282
|
+
const initialRef = useRef(initial);
|
|
283
|
+
// Tagged with its source, so a stale load for another src reads as still loading.
|
|
284
|
+
const [loaded, setLoaded] = useState<
|
|
285
|
+
{ src: string; image: HTMLImageElement } | { src: string; image: null } | null
|
|
286
|
+
>(null);
|
|
287
|
+
const image = loaded?.src === src ? loaded.image : null;
|
|
288
|
+
const status: Status = loaded?.src !== src ? "loading" : loaded.image ? "ready" : "failed";
|
|
289
|
+
const [history, setHistory] = useState<MarkupHistory>(() => createHistory(initialShapes));
|
|
290
|
+
// The same list, kept current synchronously: a blur and the click that follows it land in one tick, and the
|
|
291
|
+
// click's save must already see the label the blur just committed.
|
|
292
|
+
const historyRef = useRef(history);
|
|
293
|
+
const [tool, setTool] = useState<MarkupTool>("pen");
|
|
294
|
+
const [ink, setInk] = useState(DEFAULT_INK);
|
|
295
|
+
const [highlight, setHighlight] = useState(DEFAULT_HIGHLIGHT);
|
|
296
|
+
const [size, setSize] = useState<MarkupSize>("m");
|
|
297
|
+
const [selection, setSelected] = useState<string | null>(null);
|
|
298
|
+
const [draft, setDraft] = useState<MarkupShape | null>(null);
|
|
299
|
+
const [sliding, setSliding] = useState<MarkupShape | null>(null);
|
|
300
|
+
const [editing, setEditing] = useState<Editing | null>(null);
|
|
301
|
+
const [overShape, setOverShape] = useState(false);
|
|
302
|
+
const [saving, setSaving] = useState(false);
|
|
303
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
304
|
+
|
|
305
|
+
const onChange = useRef(onChangeProp);
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
onChange.current = onChangeProp;
|
|
308
|
+
});
|
|
309
|
+
const apply = useCallback((update: (current: MarkupHistory) => MarkupHistory) => {
|
|
310
|
+
const next = update(historyRef.current);
|
|
311
|
+
if (next === historyRef.current) return;
|
|
312
|
+
const changed = next.present !== historyRef.current.present;
|
|
313
|
+
historyRef.current = next;
|
|
314
|
+
setHistory(next);
|
|
315
|
+
if (changed) onChange.current?.(next.present);
|
|
316
|
+
}, []);
|
|
317
|
+
const commit = useCallback(
|
|
318
|
+
(next: MarkupShape[]) => apply((current) => commitHistory(current, next)),
|
|
319
|
+
[apply],
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const [measure] = useState(makeMeasure);
|
|
323
|
+
const natural = useMemo(() => {
|
|
324
|
+
const w = image?.naturalWidth ?? 0;
|
|
325
|
+
const h = image?.naturalHeight ?? 0;
|
|
326
|
+
return w > 0 && h > 0 ? { w, h } : null;
|
|
327
|
+
}, [image]);
|
|
328
|
+
const metrics = useMemo(() => markupMetrics(natural?.w ?? 1, natural?.h ?? 1), [natural]);
|
|
329
|
+
const fitScale = useMemo(() => {
|
|
330
|
+
if (!natural) return 1;
|
|
331
|
+
const fit = Math.min((stageBox.w - PAD_X) / natural.w, (stageBox.h - PAD_Y) / natural.h);
|
|
332
|
+
// An unmeasured stage (jsdom, or a pane that has not laid out yet) shows the image at 1:1.
|
|
333
|
+
return Number.isFinite(fit) && fit > 0 ? fit : 1;
|
|
334
|
+
}, [natural, stageBox]);
|
|
335
|
+
const scale = fitScale * zoom;
|
|
336
|
+
const zoomTo = useCallback((next: number) => {
|
|
337
|
+
setZoom(Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, next)));
|
|
338
|
+
}, []);
|
|
339
|
+
/** The zoom that shows the picture at one image pixel per screen pixel. */
|
|
340
|
+
const actualZoom = natural ? 1 / fitScale : 1;
|
|
341
|
+
|
|
342
|
+
// ⌘/Ctrl + wheel (a trackpad pinch arrives the same way) zooms the picture, never the page behind.
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
const node = stage.current;
|
|
345
|
+
if (!node || !inline) return;
|
|
346
|
+
const onWheel = (event: WheelEvent) => {
|
|
347
|
+
if (!(event.ctrlKey || event.metaKey)) return;
|
|
348
|
+
event.preventDefault();
|
|
349
|
+
setZoom((current) =>
|
|
350
|
+
Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, current * Math.exp(-event.deltaY * 0.004))),
|
|
351
|
+
);
|
|
352
|
+
};
|
|
353
|
+
node.addEventListener("wheel", onWheel, { passive: false });
|
|
354
|
+
return () => node.removeEventListener("wheel", onWheel);
|
|
355
|
+
}, [inline]);
|
|
356
|
+
|
|
357
|
+
// Space + drag pans the zoomed stage. The press is read at the window (the toolbar's shield stops it before
|
|
358
|
+
// React), and while Space is down the canvas draws nothing.
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
const node = stage.current;
|
|
361
|
+
if (!node || !inline) return;
|
|
362
|
+
let pan: { pointerId: number; x: number; y: number; left: number; top: number } | null = null;
|
|
363
|
+
const onKey = (event: KeyboardEvent) => {
|
|
364
|
+
if (event.code !== "Space" || !root.current?.contains(document.activeElement)) return;
|
|
365
|
+
const target = event.target;
|
|
366
|
+
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return;
|
|
367
|
+
event.preventDefault();
|
|
368
|
+
const held = event.type === "keydown";
|
|
369
|
+
if (spaceHeld.current !== held) {
|
|
370
|
+
spaceHeld.current = held;
|
|
371
|
+
setPanning(held);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
const onDown = (event: PointerEvent) => {
|
|
375
|
+
const middle = event.pointerType === "mouse" && event.button === 1;
|
|
376
|
+
if (!(spaceHeld.current || middle)) return;
|
|
377
|
+
if (!(event.target instanceof Node) || !node.contains(event.target)) return;
|
|
378
|
+
event.preventDefault();
|
|
379
|
+
pan = {
|
|
380
|
+
pointerId: event.pointerId,
|
|
381
|
+
x: event.clientX,
|
|
382
|
+
y: event.clientY,
|
|
383
|
+
left: node.scrollLeft,
|
|
384
|
+
top: node.scrollTop,
|
|
385
|
+
};
|
|
386
|
+
};
|
|
387
|
+
const onMove = (event: PointerEvent) => {
|
|
388
|
+
if (!pan || event.pointerId !== pan.pointerId) return;
|
|
389
|
+
node.scrollLeft = pan.left - (event.clientX - pan.x);
|
|
390
|
+
node.scrollTop = pan.top - (event.clientY - pan.y);
|
|
391
|
+
};
|
|
392
|
+
const onUp = (event: PointerEvent) => {
|
|
393
|
+
if (pan && event.pointerId === pan.pointerId) pan = null;
|
|
394
|
+
};
|
|
395
|
+
window.addEventListener("keydown", onKey, true);
|
|
396
|
+
window.addEventListener("keyup", onKey, true);
|
|
397
|
+
window.addEventListener("pointerdown", onDown, true);
|
|
398
|
+
window.addEventListener("pointermove", onMove, true);
|
|
399
|
+
window.addEventListener("pointerup", onUp, true);
|
|
400
|
+
window.addEventListener("pointercancel", onUp, true);
|
|
401
|
+
return () => {
|
|
402
|
+
window.removeEventListener("keydown", onKey, true);
|
|
403
|
+
window.removeEventListener("keyup", onKey, true);
|
|
404
|
+
window.removeEventListener("pointerdown", onDown, true);
|
|
405
|
+
window.removeEventListener("pointermove", onMove, true);
|
|
406
|
+
window.removeEventListener("pointerup", onUp, true);
|
|
407
|
+
window.removeEventListener("pointercancel", onUp, true);
|
|
408
|
+
};
|
|
409
|
+
}, [inline]);
|
|
410
|
+
|
|
411
|
+
const present = history.present;
|
|
412
|
+
const changed = useMemo(() => fingerprint(present) !== initial, [initial, present]);
|
|
413
|
+
// A selection undone or deleted out from under the select tool simply stops being one.
|
|
414
|
+
const selectedShape = useMemo(
|
|
415
|
+
() => present.find((shape) => shape.id === selection) ?? null,
|
|
416
|
+
[present, selection],
|
|
417
|
+
);
|
|
418
|
+
const selected = selectedShape?.id ?? null;
|
|
419
|
+
// A label being typed follows its live shape, so a swatch pressed mid-edit shows on it straight away.
|
|
420
|
+
const editingShape = editing?.shape
|
|
421
|
+
? (present.find((shape) => shape.id === editing.shape?.id) ?? editing.shape)
|
|
422
|
+
: null;
|
|
423
|
+
// The style buttons show what they would change: the open label, else the selection, else the next shape.
|
|
424
|
+
const styled = editingShape ?? (tool === "select" ? selectedShape : null);
|
|
425
|
+
const activeColor = styled ? styled.color : tool === "highlight" ? highlight : ink;
|
|
426
|
+
const activeSize = styled ? styled.size : size;
|
|
427
|
+
const failed = status === "failed" || (status === "ready" && !natural);
|
|
428
|
+
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
mounted.current = true;
|
|
431
|
+
return () => {
|
|
432
|
+
mounted.current = false;
|
|
433
|
+
};
|
|
434
|
+
}, []);
|
|
435
|
+
|
|
436
|
+
// Every shortcut lands on the root, so it takes focus before one can arrive.
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
root.current?.focus({ preventScroll: true });
|
|
439
|
+
}, []);
|
|
440
|
+
|
|
441
|
+
useEffect(() => {
|
|
442
|
+
let stale = false;
|
|
443
|
+
loadImage(src)
|
|
444
|
+
.then((decoded) => {
|
|
445
|
+
if (!stale) setLoaded({ src, image: decoded });
|
|
446
|
+
})
|
|
447
|
+
.catch(() => {
|
|
448
|
+
if (!stale) setLoaded({ src, image: null });
|
|
449
|
+
});
|
|
450
|
+
return () => {
|
|
451
|
+
stale = true;
|
|
452
|
+
};
|
|
453
|
+
}, [src]);
|
|
454
|
+
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
const node = stage.current;
|
|
457
|
+
if (!node) return;
|
|
458
|
+
const read = () => {
|
|
459
|
+
const box = node.getBoundingClientRect();
|
|
460
|
+
const w = Number.isFinite(box.width) ? box.width : 0;
|
|
461
|
+
const h = Number.isFinite(box.height) ? box.height : 0;
|
|
462
|
+
setStageBox((current) => (current.w === w && current.h === h ? current : { w, h }));
|
|
463
|
+
};
|
|
464
|
+
read();
|
|
465
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
466
|
+
const observer = new ResizeObserver(read);
|
|
467
|
+
observer.observe(node);
|
|
468
|
+
return () => observer.disconnect();
|
|
469
|
+
}, []);
|
|
470
|
+
|
|
471
|
+
const drawn = useMemo(() => {
|
|
472
|
+
// A label being typed is the textarea's to paint, not the canvas's.
|
|
473
|
+
const hidden = editing?.shape?.id;
|
|
474
|
+
const base = hidden ? present.filter((shape) => shape.id !== hidden) : present;
|
|
475
|
+
const preview = sliding
|
|
476
|
+
? base.map((shape) => (shape.id === sliding.id ? sliding : shape))
|
|
477
|
+
: base;
|
|
478
|
+
return draft ? [...preview, draft] : preview;
|
|
479
|
+
}, [draft, editing, present, sliding]);
|
|
480
|
+
const outlined = sliding ?? selectedShape;
|
|
481
|
+
|
|
482
|
+
useEffect(() => {
|
|
483
|
+
// One frame per change keeps a fast stroke from repainting for every coalesced sample.
|
|
484
|
+
const pending = requestAnimationFrame(() => {
|
|
485
|
+
const node = canvas.current;
|
|
486
|
+
if (!node || !natural) return;
|
|
487
|
+
const ratio = window.devicePixelRatio || 1;
|
|
488
|
+
const width = Math.max(1, Math.round(natural.w * scale * ratio));
|
|
489
|
+
const height = Math.max(1, Math.round(natural.h * scale * ratio));
|
|
490
|
+
if (node.width !== width) node.width = width;
|
|
491
|
+
if (node.height !== height) node.height = height;
|
|
492
|
+
const ctx = node.getContext("2d");
|
|
493
|
+
if (!ctx) return;
|
|
494
|
+
// The transform carries both the pane's scale and the backing store's pixel ratio, so shapes stay in
|
|
495
|
+
// image pixels and the file matches what is on screen.
|
|
496
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
497
|
+
ctx.clearRect(0, 0, width, height);
|
|
498
|
+
ctx.setTransform(scale * ratio, 0, 0, scale * ratio, 0, 0);
|
|
499
|
+
drawShapes(ctx, drawn, metrics);
|
|
500
|
+
if (outlined) drawSelection(ctx, shapeBounds(outlined, metrics, measure), scale);
|
|
501
|
+
});
|
|
502
|
+
return () => cancelAnimationFrame(pending);
|
|
503
|
+
}, [drawn, measure, metrics, natural, outlined, scale]);
|
|
504
|
+
|
|
505
|
+
const startEditor = useCallback((shape: TextShape | null, at: Point, value: string) => {
|
|
506
|
+
editSeq.current += 1;
|
|
507
|
+
const next = { seq: editSeq.current, shape, at, value };
|
|
508
|
+
editingRef.current = next;
|
|
509
|
+
setEditing(next);
|
|
510
|
+
}, []);
|
|
511
|
+
|
|
512
|
+
const closeEditor = useCallback(
|
|
513
|
+
(edit: Editing) => {
|
|
514
|
+
editingRef.current = null;
|
|
515
|
+
setEditing(null);
|
|
516
|
+
const next = applyText(historyRef.current.present, edit, ink, size);
|
|
517
|
+
if (next !== historyRef.current.present) commit(next);
|
|
518
|
+
root.current?.focus({ preventScroll: true });
|
|
519
|
+
},
|
|
520
|
+
[commit, ink, size],
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
const typeText = useCallback((value: string) => {
|
|
524
|
+
const current = editingRef.current;
|
|
525
|
+
if (!current) return;
|
|
526
|
+
const next = { ...current, value };
|
|
527
|
+
editingRef.current = next;
|
|
528
|
+
setEditing(next);
|
|
529
|
+
}, []);
|
|
530
|
+
|
|
531
|
+
const commitText = useCallback(() => {
|
|
532
|
+
const current = editingRef.current;
|
|
533
|
+
if (current) closeEditor(current);
|
|
534
|
+
}, [closeEditor]);
|
|
535
|
+
|
|
536
|
+
useEffect(() => {
|
|
537
|
+
const frameNode = frame.current;
|
|
538
|
+
const canvasNode = canvas.current;
|
|
539
|
+
if (!natural || !frameNode || !canvasNode) return;
|
|
540
|
+
|
|
541
|
+
const toImage = (clientX: number, clientY: number): Point => {
|
|
542
|
+
const box = frameNode.getBoundingClientRect();
|
|
543
|
+
return {
|
|
544
|
+
x: clamp((clientX - box.left) / scale, 0, natural.w),
|
|
545
|
+
y: clamp((clientY - box.top) / scale, 0, natural.h),
|
|
546
|
+
};
|
|
547
|
+
};
|
|
548
|
+
const coalesced = (event: PointerEvent): PointerEvent[] => {
|
|
549
|
+
try {
|
|
550
|
+
return event.getCoalescedEvents?.() ?? [];
|
|
551
|
+
} catch {
|
|
552
|
+
return [];
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
const find = (id: string | null) =>
|
|
556
|
+
id ? (historyRef.current.present.find((shape) => shape.id === id) ?? null) : null;
|
|
557
|
+
const hitAt = (point: Point) =>
|
|
558
|
+
find(hitTest(historyRef.current.present, point, HIT_SLOP / scale, metrics, measure));
|
|
559
|
+
// Text presses only ever find labels, so a box drawn over one does not swallow the double-click.
|
|
560
|
+
const hitLabel = (point: Point) =>
|
|
561
|
+
find(
|
|
562
|
+
hitTest(
|
|
563
|
+
historyRef.current.present.filter((shape) => shape.kind === "text"),
|
|
564
|
+
point,
|
|
565
|
+
HIT_SLOP / scale,
|
|
566
|
+
metrics,
|
|
567
|
+
measure,
|
|
568
|
+
),
|
|
569
|
+
);
|
|
570
|
+
// Coalesced samples keep a fast stroke smooth; one nearer than a screen px to the last is dropped.
|
|
571
|
+
// The list grows in place: the drag owns it, and copying it per sample made long strokes quadratic.
|
|
572
|
+
const extend = (points: Point[], event: PointerEvent) => {
|
|
573
|
+
for (const sample of [...coalesced(event), event]) {
|
|
574
|
+
if (points.length >= MAX_STROKE_POINTS) break;
|
|
575
|
+
const point = toImage(sample.clientX, sample.clientY);
|
|
576
|
+
const last = points[points.length - 1]!;
|
|
577
|
+
if (Math.hypot((point.x - last.x) * scale, (point.y - last.y) * scale) < 1) continue;
|
|
578
|
+
points.push(point);
|
|
579
|
+
}
|
|
580
|
+
return points;
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const onDown = (event: PointerEvent) => {
|
|
584
|
+
if (event.target !== canvasNode) return;
|
|
585
|
+
if (event.pointerType === "mouse" && event.button !== 0) return;
|
|
586
|
+
if (spaceHeld.current) return;
|
|
587
|
+
if (status !== "ready" || saving) return;
|
|
588
|
+
// A second finger or a resting palm must not take over the stroke in flight. The same pointer pressing
|
|
589
|
+
// again means its release was lost, so that press starts over.
|
|
590
|
+
if (drag.current && drag.current.pointerId !== event.pointerId) return;
|
|
591
|
+
if (drag.current) {
|
|
592
|
+
drag.current = null;
|
|
593
|
+
setDraft(null);
|
|
594
|
+
setSliding(null);
|
|
595
|
+
}
|
|
596
|
+
// A label being typed only closes here; a press must not open a second one under it.
|
|
597
|
+
if (editingRef.current) {
|
|
598
|
+
commitText();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const point = toImage(event.clientX, event.clientY);
|
|
602
|
+
root.current?.focus({ preventScroll: true });
|
|
603
|
+
|
|
604
|
+
if (tool === "text") {
|
|
605
|
+
const label = hitLabel(point);
|
|
606
|
+
if (label?.kind === "text") startEditor(label, label.at, label.text);
|
|
607
|
+
else startEditor(null, point, "");
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (tool === "select") {
|
|
611
|
+
const hit = hitAt(point);
|
|
612
|
+
setSelected(hit?.id ?? null);
|
|
613
|
+
if (!hit) return;
|
|
614
|
+
drag.current = {
|
|
615
|
+
mode: "move",
|
|
616
|
+
pointerId: event.pointerId,
|
|
617
|
+
start: point,
|
|
618
|
+
origin: hit,
|
|
619
|
+
shape: hit,
|
|
620
|
+
moved: false,
|
|
621
|
+
};
|
|
622
|
+
try {
|
|
623
|
+
canvasNode.setPointerCapture(event.pointerId);
|
|
624
|
+
} catch {
|
|
625
|
+
// jsdom has no pointer capture, and a press can end before capture lands.
|
|
626
|
+
}
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
drag.current = {
|
|
630
|
+
mode: "draw",
|
|
631
|
+
pointerId: event.pointerId,
|
|
632
|
+
tool,
|
|
633
|
+
id: nextId(),
|
|
634
|
+
color: tool === "highlight" ? highlight : ink,
|
|
635
|
+
size,
|
|
636
|
+
start: point,
|
|
637
|
+
points: [point],
|
|
638
|
+
point,
|
|
639
|
+
shift: event.shiftKey,
|
|
640
|
+
};
|
|
641
|
+
setDraft(strokeShape(drag.current));
|
|
642
|
+
try {
|
|
643
|
+
canvasNode.setPointerCapture(event.pointerId);
|
|
644
|
+
} catch {
|
|
645
|
+
// jsdom has no pointer capture, and a press can end before capture lands.
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const onMove = (event: PointerEvent) => {
|
|
650
|
+
const current = drag.current;
|
|
651
|
+
if (!current) {
|
|
652
|
+
if (tool !== "select") return;
|
|
653
|
+
setOverShape(
|
|
654
|
+
event.target === canvasNode && Boolean(hitAt(toImage(event.clientX, event.clientY))),
|
|
655
|
+
);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (event.pointerId !== current.pointerId) return;
|
|
659
|
+
const point = toImage(event.clientX, event.clientY);
|
|
660
|
+
if (current.mode === "move") {
|
|
661
|
+
const moved = moveShape(
|
|
662
|
+
current.origin,
|
|
663
|
+
point.x - current.start.x,
|
|
664
|
+
point.y - current.start.y,
|
|
665
|
+
);
|
|
666
|
+
const far =
|
|
667
|
+
Math.hypot((point.x - current.start.x) * scale, (point.y - current.start.y) * scale) >= 1;
|
|
668
|
+
drag.current = { ...current, shape: moved, moved: current.moved || far };
|
|
669
|
+
setSliding(moved);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const points =
|
|
673
|
+
current.tool === "pen" || current.tool === "highlight"
|
|
674
|
+
? extend(current.points, event)
|
|
675
|
+
: current.points;
|
|
676
|
+
drag.current = { ...current, points, point, shift: event.shiftKey };
|
|
677
|
+
setDraft(strokeShape(drag.current));
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const onUp = (event: PointerEvent) => {
|
|
681
|
+
const current = drag.current;
|
|
682
|
+
if (!current || event.pointerId !== current.pointerId) return;
|
|
683
|
+
drag.current = null;
|
|
684
|
+
// The release point counts: a fast flick can lift before any move arrives.
|
|
685
|
+
const point = toImage(event.clientX, event.clientY);
|
|
686
|
+
if (current.mode === "move") {
|
|
687
|
+
setSliding(null);
|
|
688
|
+
const moved = moveShape(
|
|
689
|
+
current.origin,
|
|
690
|
+
point.x - current.start.x,
|
|
691
|
+
point.y - current.start.y,
|
|
692
|
+
);
|
|
693
|
+
const far =
|
|
694
|
+
Math.hypot((point.x - current.start.x) * scale, (point.y - current.start.y) * scale) >= 1;
|
|
695
|
+
if (!current.moved && !far) return;
|
|
696
|
+
commit(historyRef.current.present.map((shape) => (shape.id === moved.id ? moved : shape)));
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
setDraft(null);
|
|
700
|
+
const finished: Stroke = {
|
|
701
|
+
...current,
|
|
702
|
+
points:
|
|
703
|
+
current.tool === "pen" || current.tool === "highlight"
|
|
704
|
+
? extend([...current.points], event)
|
|
705
|
+
: current.points,
|
|
706
|
+
// Shift is read while dragging; letting go of it a moment before the button must not change the shape.
|
|
707
|
+
point,
|
|
708
|
+
};
|
|
709
|
+
const shape = strokeShape(finished);
|
|
710
|
+
// A tap is a slip of the mouse, not a mark worth keeping or an undo step worth spending.
|
|
711
|
+
if (!isMeaningful(shape, TAP_SIZE / scale)) return;
|
|
712
|
+
commit([...historyRef.current.present, shape]);
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
const onCancel = (event: PointerEvent) => {
|
|
716
|
+
const current = drag.current;
|
|
717
|
+
if (current && event.pointerId !== current.pointerId) return;
|
|
718
|
+
drag.current = null;
|
|
719
|
+
setDraft(null);
|
|
720
|
+
setSliding(null);
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
const onDouble = (event: MouseEvent) => {
|
|
724
|
+
if (event.target !== canvasNode) return;
|
|
725
|
+
if (tool !== "select" && tool !== "text") return;
|
|
726
|
+
const label = hitLabel(toImage(event.clientX, event.clientY));
|
|
727
|
+
if (label?.kind === "text") startEditor(label, label.at, label.text);
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
window.addEventListener("pointerdown", onDown, true);
|
|
731
|
+
window.addEventListener("pointermove", onMove, true);
|
|
732
|
+
window.addEventListener("pointerup", onUp, true);
|
|
733
|
+
window.addEventListener("pointercancel", onCancel, true);
|
|
734
|
+
window.addEventListener("dblclick", onDouble, true);
|
|
735
|
+
return () => {
|
|
736
|
+
window.removeEventListener("pointerdown", onDown, true);
|
|
737
|
+
window.removeEventListener("pointermove", onMove, true);
|
|
738
|
+
window.removeEventListener("pointerup", onUp, true);
|
|
739
|
+
window.removeEventListener("pointercancel", onCancel, true);
|
|
740
|
+
window.removeEventListener("dblclick", onDouble, true);
|
|
741
|
+
};
|
|
742
|
+
}, [
|
|
743
|
+
commit,
|
|
744
|
+
commitText,
|
|
745
|
+
highlight,
|
|
746
|
+
ink,
|
|
747
|
+
measure,
|
|
748
|
+
metrics,
|
|
749
|
+
natural,
|
|
750
|
+
saving,
|
|
751
|
+
scale,
|
|
752
|
+
size,
|
|
753
|
+
startEditor,
|
|
754
|
+
status,
|
|
755
|
+
tool,
|
|
756
|
+
]);
|
|
757
|
+
|
|
758
|
+
const openSeq = editing?.seq ?? 0;
|
|
759
|
+
useEffect(() => {
|
|
760
|
+
const node = textarea.current;
|
|
761
|
+
if (!openSeq || !node) return;
|
|
762
|
+
node.focus({ preventScroll: true });
|
|
763
|
+
node.setSelectionRange(node.value.length, node.value.length);
|
|
764
|
+
}, [openSeq]);
|
|
765
|
+
|
|
766
|
+
const editingColor = editingShape?.color ?? ink;
|
|
767
|
+
const editingSize = editingShape?.size ?? size;
|
|
768
|
+
const editingFont = metrics.font(editingSize);
|
|
769
|
+
const layout = editing
|
|
770
|
+
? textLayout(
|
|
771
|
+
{
|
|
772
|
+
id: "draft",
|
|
773
|
+
kind: "text",
|
|
774
|
+
at: editing.at,
|
|
775
|
+
text: editing.value,
|
|
776
|
+
color: editingColor,
|
|
777
|
+
size: editingSize,
|
|
778
|
+
},
|
|
779
|
+
metrics,
|
|
780
|
+
measure,
|
|
781
|
+
)
|
|
782
|
+
: null;
|
|
783
|
+
// The input must look exactly like the label it becomes, drawn at the pane's scale.
|
|
784
|
+
const textStyle: CSSProperties | null =
|
|
785
|
+
editing && layout
|
|
786
|
+
? {
|
|
787
|
+
left: editing.at.x * scale,
|
|
788
|
+
top: editing.at.y * scale,
|
|
789
|
+
// Room for the caret even before the first character lands.
|
|
790
|
+
width: Math.max(layout.box.w, editingFont * 1.2) * scale,
|
|
791
|
+
height: layout.box.h * scale,
|
|
792
|
+
background: editingColor,
|
|
793
|
+
color: contrastText(editingColor),
|
|
794
|
+
font: markupFont(editingFont * scale),
|
|
795
|
+
lineHeight: 1.25,
|
|
796
|
+
padding: "0.4em",
|
|
797
|
+
borderRadius: "0.3em",
|
|
798
|
+
}
|
|
799
|
+
: null;
|
|
800
|
+
|
|
801
|
+
const restyle = (target: string, patch: { color?: string; size?: MarkupSize }) =>
|
|
802
|
+
apply((current) => {
|
|
803
|
+
const shape = current.present.find((candidate) => candidate.id === target);
|
|
804
|
+
if (!shape) return current;
|
|
805
|
+
if (patch.color !== undefined && shape.color === patch.color) return current;
|
|
806
|
+
if (patch.size !== undefined && shape.size === patch.size) return current;
|
|
807
|
+
return commitHistory(current, [
|
|
808
|
+
...current.present.map((candidate) =>
|
|
809
|
+
candidate.id === target ? { ...candidate, ...patch } : candidate,
|
|
810
|
+
),
|
|
811
|
+
]);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// A selection belongs to the select tool; drawing with another must not recolour or delete it.
|
|
815
|
+
const chooseTool = (next: MarkupTool) => {
|
|
816
|
+
setTool(next);
|
|
817
|
+
if (next !== "select") setSelected(null);
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
// The shape the style buttons act on: the label being typed, else the selection.
|
|
821
|
+
const styleTarget = () => editingRef.current?.shape?.id ?? selected;
|
|
822
|
+
|
|
823
|
+
const applyColor = (color: string) => {
|
|
824
|
+
if (tool === "highlight") setHighlight(color);
|
|
825
|
+
else setInk(color);
|
|
826
|
+
// Recolouring a selected shape takes the clicked colour whatever the active tool is.
|
|
827
|
+
const target = styleTarget();
|
|
828
|
+
if (target) restyle(target, { color });
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
const applySize = (next: MarkupSize) => {
|
|
832
|
+
setSize(next);
|
|
833
|
+
const target = styleTarget();
|
|
834
|
+
if (target) restyle(target, { size: next });
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
const removeSelected = () => {
|
|
838
|
+
const target = selected;
|
|
839
|
+
if (!target) return;
|
|
840
|
+
setSelected(null);
|
|
841
|
+
commit(historyRef.current.present.filter((shape) => shape.id !== target));
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
const nudge = (dx: number, dy: number) => {
|
|
845
|
+
const target = selected;
|
|
846
|
+
if (!target) return;
|
|
847
|
+
apply((current) => {
|
|
848
|
+
const shape = current.present.find((candidate) => candidate.id === target);
|
|
849
|
+
if (!shape) return current;
|
|
850
|
+
return commitHistory(current, [
|
|
851
|
+
...current.present.map((candidate) =>
|
|
852
|
+
candidate.id === target ? moveShape(candidate, dx, dy) : candidate,
|
|
853
|
+
),
|
|
854
|
+
]);
|
|
855
|
+
});
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
/** The current shapes with any open label committed, as `done` and `save` read them. */
|
|
859
|
+
const settledShapes = useCallback(() => {
|
|
860
|
+
const open = editingRef.current;
|
|
861
|
+
const shapes = open
|
|
862
|
+
? applyText(historyRef.current.present, open, ink, size)
|
|
863
|
+
: historyRef.current.present;
|
|
864
|
+
if (open) {
|
|
865
|
+
editingRef.current = null;
|
|
866
|
+
setEditing(null);
|
|
867
|
+
if (shapes !== historyRef.current.present) commit(shapes);
|
|
868
|
+
}
|
|
869
|
+
return shapes;
|
|
870
|
+
}, [commit, ink, size]);
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Starts a save. Settled at once when nothing has to be flattened (nothing changed, or every shape is gone),
|
|
874
|
+
* so Done and Cancel paths that need no work still answer in the same tick; a flatten comes back as a promise
|
|
875
|
+
* that rejects when the image can't be flattened (the reason is shown in the editor).
|
|
876
|
+
*/
|
|
877
|
+
const startSave = useCallback((): MarkupResult | null | Promise<MarkupResult> => {
|
|
878
|
+
// A second press lands before `saving` renders, so the ref is the guard, not the state.
|
|
879
|
+
if (submitting.current) return null;
|
|
880
|
+
submitting.current = true;
|
|
881
|
+
const shapes = settledShapes();
|
|
882
|
+
const mark = fingerprint(shapes);
|
|
883
|
+
const finish = <T extends MarkupResult | null>(result: T): T => {
|
|
884
|
+
// Inline, the editor stays open: the next save compares against what was just saved.
|
|
885
|
+
if (inline) {
|
|
886
|
+
initialRef.current = mark;
|
|
887
|
+
setInitial(mark);
|
|
888
|
+
submitting.current = false;
|
|
889
|
+
}
|
|
890
|
+
return result;
|
|
891
|
+
};
|
|
892
|
+
if (mark === initialRef.current) return finish(null);
|
|
893
|
+
if (!shapes.length) return finish({ blob: null, name: name, shapes: [] });
|
|
894
|
+
if (!image || !natural) {
|
|
895
|
+
submitting.current = false;
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
setSaving(true);
|
|
899
|
+
setSaveError(null);
|
|
900
|
+
return flattenMarkup({
|
|
901
|
+
image,
|
|
902
|
+
width: natural.w,
|
|
903
|
+
height: natural.h,
|
|
904
|
+
shapes,
|
|
905
|
+
name: name,
|
|
906
|
+
maxBytes: maxBytes,
|
|
907
|
+
}).then(
|
|
908
|
+
(flat) => {
|
|
909
|
+
if (!mounted.current) throw new Error("unmounted");
|
|
910
|
+
if (inline) setSaving(false);
|
|
911
|
+
return finish({ blob: flat.blob, name: flat.name, shapes });
|
|
912
|
+
},
|
|
913
|
+
(caught: unknown) => {
|
|
914
|
+
if (!mounted.current) throw caught;
|
|
915
|
+
// Anything but a successful hand-off leaves the editor open with the reason on screen.
|
|
916
|
+
submitting.current = false;
|
|
917
|
+
setSaving(false);
|
|
918
|
+
setSaveError(caught instanceof Error ? caught.message : "Couldn’t save this image.");
|
|
919
|
+
throw caught;
|
|
920
|
+
},
|
|
921
|
+
);
|
|
922
|
+
}, [image, inline, natural, maxBytes, name, settledShapes]);
|
|
923
|
+
|
|
924
|
+
useImperativeHandle(
|
|
925
|
+
handle,
|
|
926
|
+
() => ({
|
|
927
|
+
save: () => {
|
|
928
|
+
const outcome = startSave();
|
|
929
|
+
return outcome instanceof Promise ? outcome : Promise.resolve(outcome);
|
|
930
|
+
},
|
|
931
|
+
}),
|
|
932
|
+
[startSave],
|
|
933
|
+
);
|
|
934
|
+
|
|
935
|
+
const done = () => {
|
|
936
|
+
if (submitting.current) return;
|
|
937
|
+
const deliver = (result: MarkupResult | null) => {
|
|
938
|
+
if (inline) {
|
|
939
|
+
onSubmit?.(result);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (result === null) {
|
|
943
|
+
onCancel();
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
onSave(result);
|
|
947
|
+
};
|
|
948
|
+
const outcome = startSave();
|
|
949
|
+
if (outcome instanceof Promise) {
|
|
950
|
+
outcome.then(deliver, () => {});
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
deliver(outcome);
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
957
|
+
// Nothing typed at the toolbar belongs to the page behind it.
|
|
958
|
+
event.stopPropagation();
|
|
959
|
+
// Mid-drag, the release is what commits; undo or Done landing first would lose the shape or the redo steps.
|
|
960
|
+
if (saving || drag.current) {
|
|
961
|
+
if (drag.current) event.preventDefault();
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const command = event.metaKey || event.ctrlKey;
|
|
965
|
+
const key = event.key;
|
|
966
|
+
if (command && key === "Enter") {
|
|
967
|
+
event.preventDefault();
|
|
968
|
+
done();
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (command && (key === "z" || key === "Z")) {
|
|
972
|
+
event.preventDefault();
|
|
973
|
+
apply(event.shiftKey ? redoHistory : undoHistory);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (command && (key === "y" || key === "Y")) {
|
|
977
|
+
event.preventDefault();
|
|
978
|
+
apply(redoHistory);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (command || event.altKey) return;
|
|
982
|
+
if (key === "Escape") {
|
|
983
|
+
if (selected) {
|
|
984
|
+
event.preventDefault();
|
|
985
|
+
setSelected(null);
|
|
986
|
+
} else if (!changed) {
|
|
987
|
+
// Leaving with nothing drawn is a cancel; leaving with drawings would silently lose them.
|
|
988
|
+
event.preventDefault();
|
|
989
|
+
onCancel();
|
|
990
|
+
}
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (key === "Delete" || key === "Backspace") {
|
|
994
|
+
if (!selected) return;
|
|
995
|
+
event.preventDefault();
|
|
996
|
+
removeSelected();
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (key.startsWith("Arrow")) {
|
|
1000
|
+
if (!selected) return;
|
|
1001
|
+
event.preventDefault();
|
|
1002
|
+
// Nudges are measured on screen, so they feel the same whatever the image scale.
|
|
1003
|
+
const step = (event.shiftKey ? 10 : 1) / scale;
|
|
1004
|
+
const dx = key === "ArrowLeft" ? -step : key === "ArrowRight" ? step : 0;
|
|
1005
|
+
const dy = key === "ArrowUp" ? -step : key === "ArrowDown" ? step : 0;
|
|
1006
|
+
nudge(dx, dy);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (inline && (key === "+" || key === "=")) {
|
|
1010
|
+
event.preventDefault();
|
|
1011
|
+
zoomTo(zoom * ZOOM_STEP);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (inline && (key === "-" || key === "_")) {
|
|
1015
|
+
event.preventDefault();
|
|
1016
|
+
zoomTo(zoom / ZOOM_STEP);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
if (inline && key === "0") {
|
|
1020
|
+
event.preventDefault();
|
|
1021
|
+
zoomTo(1);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (event.shiftKey || key.length !== 1) return;
|
|
1025
|
+
const next = TOOL_KEYS[key.toLowerCase()];
|
|
1026
|
+
if (!next) return;
|
|
1027
|
+
event.preventDefault();
|
|
1028
|
+
chooseTool(next);
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
// The same controls in both layouts: the dialog's strip at the bottom, inline the ribbon at the top.
|
|
1032
|
+
const toolButtons = (big: boolean) =>
|
|
1033
|
+
TOOLS.map((entry) => (
|
|
1034
|
+
<button
|
|
1035
|
+
key={entry.tool}
|
|
1036
|
+
type="button"
|
|
1037
|
+
className={big ? "cs-ribbon-big" : "cs-icon-button"}
|
|
1038
|
+
data-cs-ui="markup-tool"
|
|
1039
|
+
data-tool={entry.tool}
|
|
1040
|
+
data-tip={big ? undefined : entry.label}
|
|
1041
|
+
aria-label={entry.label}
|
|
1042
|
+
aria-pressed={tool === entry.tool}
|
|
1043
|
+
onClick={() => chooseTool(entry.tool)}
|
|
1044
|
+
>
|
|
1045
|
+
{entry.icon}
|
|
1046
|
+
{big ? <span>{entry.label}</span> : null}
|
|
1047
|
+
</button>
|
|
1048
|
+
));
|
|
1049
|
+
const colorButtons = MARKUP_COLORS.map((entry) => (
|
|
1050
|
+
<button
|
|
1051
|
+
key={entry.value}
|
|
1052
|
+
type="button"
|
|
1053
|
+
className="cs-markup-color"
|
|
1054
|
+
data-cs-ui="markup-color"
|
|
1055
|
+
data-color={entry.value}
|
|
1056
|
+
aria-label={entry.name}
|
|
1057
|
+
aria-pressed={activeColor.toLowerCase() === entry.value.toLowerCase()}
|
|
1058
|
+
onClick={() => applyColor(entry.value)}
|
|
1059
|
+
>
|
|
1060
|
+
<span
|
|
1061
|
+
className={entry.value.toLowerCase() === "#ffffff" ? "is-light" : undefined}
|
|
1062
|
+
style={{ background: entry.value }}
|
|
1063
|
+
/>
|
|
1064
|
+
</button>
|
|
1065
|
+
));
|
|
1066
|
+
const sizeButtons = SIZE_ORDER.map((entry) => (
|
|
1067
|
+
<button
|
|
1068
|
+
key={entry}
|
|
1069
|
+
type="button"
|
|
1070
|
+
className="cs-markup-size"
|
|
1071
|
+
data-cs-ui="markup-size"
|
|
1072
|
+
data-size={entry}
|
|
1073
|
+
aria-label={SIZE_LABELS[entry]}
|
|
1074
|
+
aria-pressed={activeSize === entry}
|
|
1075
|
+
onClick={() => applySize(entry)}
|
|
1076
|
+
>
|
|
1077
|
+
<span />
|
|
1078
|
+
</button>
|
|
1079
|
+
));
|
|
1080
|
+
const undo = () => {
|
|
1081
|
+
if (!drag.current) apply(undoHistory);
|
|
1082
|
+
};
|
|
1083
|
+
const redo = () => {
|
|
1084
|
+
if (!drag.current) apply(redoHistory);
|
|
1085
|
+
};
|
|
1086
|
+
const clearAll = () => commit([]);
|
|
1087
|
+
const historyButtons = (big: boolean) => {
|
|
1088
|
+
const cls = big ? "cs-ribbon-small" : "cs-icon-button";
|
|
1089
|
+
return (
|
|
1090
|
+
<>
|
|
1091
|
+
<button
|
|
1092
|
+
type="button"
|
|
1093
|
+
className={cls}
|
|
1094
|
+
data-cs-ui="markup-undo"
|
|
1095
|
+
data-tip={big ? undefined : "Undo"}
|
|
1096
|
+
aria-label="Undo"
|
|
1097
|
+
disabled={!history.past.length}
|
|
1098
|
+
onClick={undo}
|
|
1099
|
+
>
|
|
1100
|
+
<Icon.Undo />
|
|
1101
|
+
{big ? "Undo" : null}
|
|
1102
|
+
</button>
|
|
1103
|
+
<button
|
|
1104
|
+
type="button"
|
|
1105
|
+
className={cls}
|
|
1106
|
+
data-cs-ui="markup-redo"
|
|
1107
|
+
data-tip={big ? undefined : "Redo"}
|
|
1108
|
+
aria-label="Redo"
|
|
1109
|
+
disabled={!history.future.length}
|
|
1110
|
+
onClick={redo}
|
|
1111
|
+
>
|
|
1112
|
+
<Icon.Redo />
|
|
1113
|
+
{big ? "Redo" : null}
|
|
1114
|
+
</button>
|
|
1115
|
+
<button
|
|
1116
|
+
type="button"
|
|
1117
|
+
className={cls}
|
|
1118
|
+
data-cs-ui="markup-delete"
|
|
1119
|
+
data-tip={big ? undefined : "Delete shape"}
|
|
1120
|
+
aria-label="Delete shape"
|
|
1121
|
+
disabled={!selected}
|
|
1122
|
+
onClick={removeSelected}
|
|
1123
|
+
>
|
|
1124
|
+
<Icon.Trash />
|
|
1125
|
+
{big ? "Delete" : null}
|
|
1126
|
+
</button>
|
|
1127
|
+
<button
|
|
1128
|
+
type="button"
|
|
1129
|
+
className={cls}
|
|
1130
|
+
data-cs-ui="markup-clear"
|
|
1131
|
+
data-tip={big ? undefined : "Clear all"}
|
|
1132
|
+
aria-label="Clear all"
|
|
1133
|
+
disabled={!present.length}
|
|
1134
|
+
onClick={clearAll}
|
|
1135
|
+
>
|
|
1136
|
+
<Icon.Close />
|
|
1137
|
+
{big ? "Clear all" : null}
|
|
1138
|
+
</button>
|
|
1139
|
+
</>
|
|
1140
|
+
);
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
return (
|
|
1144
|
+
<div
|
|
1145
|
+
ref={root}
|
|
1146
|
+
className={`cs-markup${inline ? " is-inline" : ""}`}
|
|
1147
|
+
data-cs-ui="markup"
|
|
1148
|
+
tabIndex={-1}
|
|
1149
|
+
onKeyDown={onKeyDown}
|
|
1150
|
+
>
|
|
1151
|
+
{inline ? null : (
|
|
1152
|
+
<header className="cs-media-bar cs-markup-bar">
|
|
1153
|
+
<span className="cs-media-name" title={name}>
|
|
1154
|
+
{name}
|
|
1155
|
+
</span>
|
|
1156
|
+
<button
|
|
1157
|
+
type="button"
|
|
1158
|
+
className="cs-markup-cancel"
|
|
1159
|
+
data-cs-ui="markup-cancel"
|
|
1160
|
+
onClick={onCancel}
|
|
1161
|
+
>
|
|
1162
|
+
Cancel
|
|
1163
|
+
</button>
|
|
1164
|
+
<button
|
|
1165
|
+
type="button"
|
|
1166
|
+
className="cs-markup-done"
|
|
1167
|
+
data-cs-ui="markup-done"
|
|
1168
|
+
disabled={!natural || saving}
|
|
1169
|
+
onClick={done}
|
|
1170
|
+
>
|
|
1171
|
+
{saving ? "Saving…" : "Done"}
|
|
1172
|
+
</button>
|
|
1173
|
+
</header>
|
|
1174
|
+
)}
|
|
1175
|
+
{inline ? (
|
|
1176
|
+
<div className="cs-ribbon" data-cs-ui="markup-ribbon" role="toolbar" aria-label="Tools">
|
|
1177
|
+
<div className="cs-ribbon-body">
|
|
1178
|
+
<div className="cs-ribbon-group">
|
|
1179
|
+
<div className="cs-ribbon-row">{toolButtons(true)}</div>
|
|
1180
|
+
<div className="cs-ribbon-name">Draw</div>
|
|
1181
|
+
</div>
|
|
1182
|
+
<div className="cs-ribbon-group">
|
|
1183
|
+
<div className="cs-ribbon-row">
|
|
1184
|
+
<div className="cs-ribbon-swatches">{colorButtons}</div>
|
|
1185
|
+
<div className="cs-ribbon-sizes">{sizeButtons}</div>
|
|
1186
|
+
</div>
|
|
1187
|
+
<div className="cs-ribbon-name">Style</div>
|
|
1188
|
+
</div>
|
|
1189
|
+
<div className="cs-ribbon-group">
|
|
1190
|
+
<div className="cs-ribbon-row cs-ribbon-stack">{historyButtons(true)}</div>
|
|
1191
|
+
<div className="cs-ribbon-name">Edit</div>
|
|
1192
|
+
</div>
|
|
1193
|
+
{ribbon}
|
|
1194
|
+
</div>
|
|
1195
|
+
</div>
|
|
1196
|
+
) : null}
|
|
1197
|
+
<div
|
|
1198
|
+
ref={stage}
|
|
1199
|
+
className={`cs-markup-stage${zoom > 1 ? " is-zoomed" : ""}${panning ? " is-panning" : ""}`}
|
|
1200
|
+
>
|
|
1201
|
+
{natural ? (
|
|
1202
|
+
<div
|
|
1203
|
+
ref={frame}
|
|
1204
|
+
className="cs-markup-frame"
|
|
1205
|
+
style={{ width: natural.w * scale, height: natural.h * scale }}
|
|
1206
|
+
>
|
|
1207
|
+
<img src={src} alt="" draggable={false} />
|
|
1208
|
+
<canvas
|
|
1209
|
+
ref={canvas}
|
|
1210
|
+
className="cs-markup-canvas"
|
|
1211
|
+
data-cs-ui="markup-canvas"
|
|
1212
|
+
data-tool={tool}
|
|
1213
|
+
data-over={overShape || sliding ? "" : undefined}
|
|
1214
|
+
/>
|
|
1215
|
+
{editing && textStyle ? (
|
|
1216
|
+
<textarea
|
|
1217
|
+
ref={textarea}
|
|
1218
|
+
className="cs-markup-text"
|
|
1219
|
+
data-cs-ui="markup-text-input"
|
|
1220
|
+
aria-label="Label text"
|
|
1221
|
+
spellCheck={false}
|
|
1222
|
+
wrap="off"
|
|
1223
|
+
value={editing.value}
|
|
1224
|
+
style={textStyle}
|
|
1225
|
+
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => typeText(event.target.value)}
|
|
1226
|
+
onBlur={commitText}
|
|
1227
|
+
onKeyDown={(event) => {
|
|
1228
|
+
// The page and the toolbar's own shortcuts never see typing in a label.
|
|
1229
|
+
event.stopPropagation();
|
|
1230
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
1231
|
+
event.preventDefault();
|
|
1232
|
+
commitText();
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
if (event.key === "Escape") {
|
|
1236
|
+
event.preventDefault();
|
|
1237
|
+
// Backing out of an existing label leaves its text as it was; a new one keeps what was typed.
|
|
1238
|
+
const open = editingRef.current;
|
|
1239
|
+
if (open?.shape) {
|
|
1240
|
+
editingRef.current = null;
|
|
1241
|
+
setEditing(null);
|
|
1242
|
+
root.current?.focus({ preventScroll: true });
|
|
1243
|
+
} else commitText();
|
|
1244
|
+
}
|
|
1245
|
+
}}
|
|
1246
|
+
/>
|
|
1247
|
+
) : null}
|
|
1248
|
+
</div>
|
|
1249
|
+
) : failed ? (
|
|
1250
|
+
<p className="cs-markup-status" role="alert" data-cs-ui="markup-error">
|
|
1251
|
+
Couldn’t open this image.
|
|
1252
|
+
</p>
|
|
1253
|
+
) : (
|
|
1254
|
+
<p className="cs-markup-status">Loading image…</p>
|
|
1255
|
+
)}
|
|
1256
|
+
</div>
|
|
1257
|
+
{inline ? (
|
|
1258
|
+
<div className="cs-markup-statusbar" data-cs-ui="markup-statusbar">
|
|
1259
|
+
<span className="cs-markup-zoom" role="group" aria-label="Zoom">
|
|
1260
|
+
<button
|
|
1261
|
+
type="button"
|
|
1262
|
+
className={zoom === 1 ? "is-on" : undefined}
|
|
1263
|
+
data-cs-ui="markup-zoom-fit"
|
|
1264
|
+
onClick={() => zoomTo(1)}
|
|
1265
|
+
>
|
|
1266
|
+
Fit
|
|
1267
|
+
</button>
|
|
1268
|
+
<button
|
|
1269
|
+
type="button"
|
|
1270
|
+
className={Math.abs(zoom - actualZoom) < 0.01 ? "is-on" : undefined}
|
|
1271
|
+
data-cs-ui="markup-zoom-actual"
|
|
1272
|
+
onClick={() => zoomTo(actualZoom)}
|
|
1273
|
+
>
|
|
1274
|
+
100%
|
|
1275
|
+
</button>
|
|
1276
|
+
<button
|
|
1277
|
+
type="button"
|
|
1278
|
+
aria-label="Zoom out"
|
|
1279
|
+
data-cs-ui="markup-zoom-out"
|
|
1280
|
+
onClick={() => zoomTo(zoom / ZOOM_STEP)}
|
|
1281
|
+
>
|
|
1282
|
+
−
|
|
1283
|
+
</button>
|
|
1284
|
+
<input
|
|
1285
|
+
type="range"
|
|
1286
|
+
aria-label="Zoom"
|
|
1287
|
+
min={Math.round(ZOOM_MIN * 100)}
|
|
1288
|
+
max={Math.round(ZOOM_MAX * 100)}
|
|
1289
|
+
value={Math.round(zoom * 100)}
|
|
1290
|
+
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
1291
|
+
zoomTo(Number(event.target.value) / 100)
|
|
1292
|
+
}
|
|
1293
|
+
/>
|
|
1294
|
+
<button
|
|
1295
|
+
type="button"
|
|
1296
|
+
aria-label="Zoom in"
|
|
1297
|
+
data-cs-ui="markup-zoom-in"
|
|
1298
|
+
onClick={() => zoomTo(zoom * ZOOM_STEP)}
|
|
1299
|
+
>
|
|
1300
|
+
+
|
|
1301
|
+
</button>
|
|
1302
|
+
<span className="cs-markup-zoom-level" data-cs-ui="markup-zoom-level">
|
|
1303
|
+
{Math.round(scale * 100)}%
|
|
1304
|
+
</span>
|
|
1305
|
+
</span>
|
|
1306
|
+
</div>
|
|
1307
|
+
) : (
|
|
1308
|
+
<div className="cs-markup-tools" role="toolbar" aria-label="Markup tools">
|
|
1309
|
+
<div className="cs-markup-group" role="group" aria-label="Tools">
|
|
1310
|
+
{toolButtons(false)}
|
|
1311
|
+
</div>
|
|
1312
|
+
<span className="cs-markup-sep" aria-hidden="true" />
|
|
1313
|
+
<div className="cs-markup-group" role="group" aria-label="Colours">
|
|
1314
|
+
{colorButtons}
|
|
1315
|
+
</div>
|
|
1316
|
+
<span className="cs-markup-sep" aria-hidden="true" />
|
|
1317
|
+
<div className="cs-markup-group" role="group" aria-label="Sizes">
|
|
1318
|
+
{sizeButtons}
|
|
1319
|
+
</div>
|
|
1320
|
+
<span className="cs-markup-sep" aria-hidden="true" />
|
|
1321
|
+
<div className="cs-markup-group" role="group" aria-label="History">
|
|
1322
|
+
{historyButtons(false)}
|
|
1323
|
+
</div>
|
|
1324
|
+
</div>
|
|
1325
|
+
)}
|
|
1326
|
+
{saveError ? (
|
|
1327
|
+
<p className="cs-markup-error" role="alert" data-cs-ui="markup-error">
|
|
1328
|
+
{saveError}
|
|
1329
|
+
</p>
|
|
1330
|
+
) : null}
|
|
1331
|
+
</div>
|
|
1332
|
+
);
|
|
1333
|
+
}
|