partforge 0.87.0 → 0.89.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/docs/AUTHORING-PARTS.md +39 -3
- package/package.json +1 -1
- package/src/framework/annotate/annotate-controls.js +15 -57
- package/src/framework/annotate/annotate-mode.js +547 -68
- package/src/framework/annotate/elements.js +464 -0
- package/src/framework/annotate/ink-canvas.js +217 -60
- package/src/framework/annotate/sketch-toolbar.js +202 -0
- package/src/framework/app.css +124 -29
- package/src/framework/chrome.css +4 -2
- package/src/framework/mount.js +30 -7
- package/src/framework/oracle/annotation-ray.js +92 -0
- package/src/framework/view-tabs.js +176 -12
- package/src/oracle.js +4 -0
- package/types/oracle.d.ts +3 -0
- package/types/testing.d.ts +15 -0
- package/src/framework/annotate/ink.js +0 -124
|
@@ -4,14 +4,182 @@
|
|
|
4
4
|
// STAGE, not document.body, so it lives in .pf-stage's positioning context and
|
|
5
5
|
// behaves under the narrow-pane layout. While visible it owns all pointer
|
|
6
6
|
// events, which is what freezes orbit controls during annotation — no viewer
|
|
7
|
-
// changes needed.
|
|
8
|
-
// both themes and any model color
|
|
7
|
+
// changes needed. Elements render dark-core-over-light-halo so ink reads on
|
|
8
|
+
// both themes and any model color; a tool-driven overlay (handles, guides,
|
|
9
|
+
// labels, ...) is drawn on top in chrome colors, and is excluded from export.
|
|
9
10
|
import { runCleanupSteps } from "../teardown.js";
|
|
11
|
+
import { visibleRuns, handlesOf, INK_COLORS } from "./elements.js";
|
|
10
12
|
|
|
11
|
-
const CORE_COLOR = "#d92d20";
|
|
12
13
|
const HALO_COLOR = "rgba(255, 255, 255, 0.85)";
|
|
13
14
|
const HALO_RATIO = 2.2; // halo pass width relative to the core width
|
|
14
15
|
|
|
16
|
+
// Stage space -> pixels. Stage y ranges over [0,1] and spans the bitmap
|
|
17
|
+
// height; stage x is pre-scaled by the viewport aspect (bitmap width =
|
|
18
|
+
// aspect x height by construction, see elements.js), so the same factor
|
|
19
|
+
// (target.height) maps both axes.
|
|
20
|
+
const mapper = (target) => {
|
|
21
|
+
const s = target.height;
|
|
22
|
+
return (p) => [p.x * s, p.y * s];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// One halo-or-core pass over every element's visible runs. Shared by the
|
|
26
|
+
// live draw and the elements-only export path.
|
|
27
|
+
function strokePass(target, ctx, elements, widthScale, colorOf) {
|
|
28
|
+
const short = Math.min(target.width, target.height);
|
|
29
|
+
ctx.lineCap = "round";
|
|
30
|
+
ctx.lineJoin = "round";
|
|
31
|
+
const toPx = mapper(target);
|
|
32
|
+
for (const el of elements) {
|
|
33
|
+
const w = el.width * short * widthScale;
|
|
34
|
+
ctx.strokeStyle = colorOf(el);
|
|
35
|
+
ctx.fillStyle = ctx.strokeStyle;
|
|
36
|
+
ctx.lineWidth = w;
|
|
37
|
+
for (const run of visibleRuns(el)) {
|
|
38
|
+
if (run.length === 1) {
|
|
39
|
+
const [x, y] = toPx(run[0]);
|
|
40
|
+
ctx.beginPath();
|
|
41
|
+
ctx.arc(x, y, w / 2, 0, Math.PI * 2);
|
|
42
|
+
ctx.fill();
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
ctx.beginPath();
|
|
46
|
+
run.forEach((p, i) => {
|
|
47
|
+
const [x, y] = toPx(p);
|
|
48
|
+
if (i === 0) ctx.moveTo(x, y);
|
|
49
|
+
else ctx.lineTo(x, y);
|
|
50
|
+
});
|
|
51
|
+
ctx.stroke();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Elements only — halo pass then core pass. This is both the export path
|
|
57
|
+
// (toDataUrl draws only this into a scratch canvas) and the base of the live
|
|
58
|
+
// draw.
|
|
59
|
+
function drawElements(ctx, target, elements) {
|
|
60
|
+
ctx.clearRect(0, 0, target.width, target.height);
|
|
61
|
+
strokePass(target, ctx, elements, HALO_RATIO, () => HALO_COLOR);
|
|
62
|
+
strokePass(target, ctx, elements, 1, (el) => INK_COLORS[el.color]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Chrome (accent/surface/text) colors for overlay adornments, resolved from
|
|
66
|
+
// CSS custom properties on the canvas so the ink layer follows the host's
|
|
67
|
+
// theme. Falls back to literal defaults — required in bare test environments
|
|
68
|
+
// where getComputedStyle may be absent or return unset custom properties.
|
|
69
|
+
const chromeColor = (canvas, name, fallback) => {
|
|
70
|
+
try {
|
|
71
|
+
const v = globalThis.getComputedStyle?.(canvas)?.getPropertyValue(name)?.trim();
|
|
72
|
+
return v || fallback;
|
|
73
|
+
} catch {
|
|
74
|
+
return fallback;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const chromeColors = (canvas) => ({
|
|
79
|
+
accent: chromeColor(canvas, "--pf-accent", "#3f7bf0"),
|
|
80
|
+
surface: chromeColor(canvas, "--pf-surface", "#ffffff"),
|
|
81
|
+
text: chromeColor(canvas, "--pf-text", "#111111"),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function drawGlow(ctx, target, glowEl, accent) {
|
|
85
|
+
if (!glowEl) return;
|
|
86
|
+
const short = Math.min(target.width, target.height);
|
|
87
|
+
const toPx = mapper(target);
|
|
88
|
+
const w = glowEl.width * short * HALO_RATIO * 1.6;
|
|
89
|
+
ctx.save();
|
|
90
|
+
ctx.globalAlpha = 0.35;
|
|
91
|
+
ctx.strokeStyle = accent;
|
|
92
|
+
ctx.fillStyle = accent;
|
|
93
|
+
ctx.lineCap = "round";
|
|
94
|
+
ctx.lineJoin = "round";
|
|
95
|
+
ctx.lineWidth = w;
|
|
96
|
+
for (const run of visibleRuns(glowEl)) {
|
|
97
|
+
if (run.length === 1) {
|
|
98
|
+
const [x, y] = toPx(run[0]);
|
|
99
|
+
ctx.beginPath();
|
|
100
|
+
ctx.arc(x, y, w / 2, 0, Math.PI * 2);
|
|
101
|
+
ctx.fill();
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
ctx.beginPath();
|
|
105
|
+
run.forEach((p, i) => {
|
|
106
|
+
const [x, y] = toPx(p);
|
|
107
|
+
if (i === 0) ctx.moveTo(x, y);
|
|
108
|
+
else ctx.lineTo(x, y);
|
|
109
|
+
});
|
|
110
|
+
ctx.stroke();
|
|
111
|
+
}
|
|
112
|
+
ctx.restore();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Handles/labels/glyph geometry and the chrome stroke widths below are
|
|
116
|
+
// specified as CSS pixels, but the bitmap they draw into is device pixels
|
|
117
|
+
// (CSS size × dpr, see resize()) — the same distinction the ink strokes
|
|
118
|
+
// already get for free via el.width being a fraction of the (dpr-scaled)
|
|
119
|
+
// short edge. Every literal constant here is multiplied by `dpr` (the
|
|
120
|
+
// current bitmap dpr, threaded in from draw()) so this chrome renders at its
|
|
121
|
+
// intended CSS size instead of shrinking to half that on a dpr-2 display.
|
|
122
|
+
function drawHandles(ctx, target, handlesEl, chrome, dpr) {
|
|
123
|
+
if (!handlesEl) return;
|
|
124
|
+
const toPx = mapper(target);
|
|
125
|
+
const HS = 7 * dpr;
|
|
126
|
+
ctx.fillStyle = chrome.surface;
|
|
127
|
+
ctx.strokeStyle = chrome.accent;
|
|
128
|
+
ctx.lineWidth = 1.5 * dpr;
|
|
129
|
+
for (const h of handlesOf(handlesEl)) {
|
|
130
|
+
const [x, y] = toPx(h);
|
|
131
|
+
ctx.fillRect(x - HS / 2, y - HS / 2, HS, HS);
|
|
132
|
+
ctx.strokeRect(x - HS / 2, y - HS / 2, HS, HS);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function drawGuide(ctx, target, guide, chrome, dpr) {
|
|
137
|
+
if (!guide) return;
|
|
138
|
+
const toPx = mapper(target);
|
|
139
|
+
ctx.strokeStyle = chrome.accent;
|
|
140
|
+
ctx.lineWidth = 1 * dpr;
|
|
141
|
+
ctx.setLineDash([4 * dpr, 3 * dpr]);
|
|
142
|
+
if (guide.kind === "rect") {
|
|
143
|
+
// w/h are stage-space extents, not a stroke width — map them with the
|
|
144
|
+
// same factor as points (target.height), not the short-edge convention
|
|
145
|
+
// strokePass/drawGlow use for line width.
|
|
146
|
+
const [x, y] = toPx({ x: guide.cx - guide.w / 2, y: guide.cy - guide.h / 2 });
|
|
147
|
+
const [x2, y2] = toPx({ x: guide.cx + guide.w / 2, y: guide.cy + guide.h / 2 });
|
|
148
|
+
ctx.strokeRect(x, y, x2 - x, y2 - y);
|
|
149
|
+
} else if (guide.kind === "cross") {
|
|
150
|
+
const [x, y] = toPx({ x: guide.cx, y: guide.cy });
|
|
151
|
+
const arm = 5 * dpr;
|
|
152
|
+
ctx.beginPath();
|
|
153
|
+
ctx.moveTo(x - arm, y);
|
|
154
|
+
ctx.lineTo(x + arm, y);
|
|
155
|
+
ctx.moveTo(x, y - arm);
|
|
156
|
+
ctx.lineTo(x, y + arm);
|
|
157
|
+
ctx.stroke();
|
|
158
|
+
}
|
|
159
|
+
ctx.setLineDash([]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function drawLabel(ctx, target, label, chrome, dpr) {
|
|
163
|
+
if (!label) return;
|
|
164
|
+
const toPx = mapper(target);
|
|
165
|
+
const [x, y] = toPx(label);
|
|
166
|
+
ctx.font = `${10 * dpr}px monospace`;
|
|
167
|
+
const metrics = ctx.measureText(label.text);
|
|
168
|
+
const padX = 4 * dpr;
|
|
169
|
+
const padY = 3 * dpr;
|
|
170
|
+
const boxW = (metrics?.width || 0) + padX * 2;
|
|
171
|
+
const boxH = 10 * dpr + padY * 2;
|
|
172
|
+
// Offset the plate clear of the pointer: the label anchors at the dragged
|
|
173
|
+
// corner/handle, i.e. under the cursor, so 16px right + 12px up keeps it
|
|
174
|
+
// outside both the arrow cursor's body and the crosshair's arms.
|
|
175
|
+
const bx = x + 16 * dpr;
|
|
176
|
+
const by = y - 12 * dpr - boxH;
|
|
177
|
+
ctx.fillStyle = chrome.surface;
|
|
178
|
+
ctx.fillRect(bx, by, boxW, boxH);
|
|
179
|
+
ctx.fillStyle = chrome.accent;
|
|
180
|
+
ctx.fillText(label.text, bx + padX, by + boxH - padY);
|
|
181
|
+
}
|
|
182
|
+
|
|
15
183
|
export function createInkCanvas(stage, {
|
|
16
184
|
getContext2d = (canvas) => canvas.getContext("2d"),
|
|
17
185
|
createCanvas = () => document.createElement("canvas"),
|
|
@@ -21,52 +189,39 @@ export function createInkCanvas(stage, {
|
|
|
21
189
|
canvas.hidden = true;
|
|
22
190
|
stage.appendChild(canvas);
|
|
23
191
|
const ctx = getContext2d(canvas);
|
|
24
|
-
let
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
targetCtx.fillStyle = color;
|
|
32
|
-
targetCtx.lineCap = "round";
|
|
33
|
-
targetCtx.lineJoin = "round";
|
|
34
|
-
const short = Math.min(target.width, target.height);
|
|
35
|
-
for (const stroke of strokes) {
|
|
36
|
-
const w = stroke.width * short * widthScale;
|
|
37
|
-
if (stroke.points.length === 1) {
|
|
38
|
-
const [nx, ny] = stroke.points[0];
|
|
39
|
-
targetCtx.beginPath();
|
|
40
|
-
targetCtx.arc(nx * target.width, ny * target.height, w / 2, 0, Math.PI * 2);
|
|
41
|
-
targetCtx.fill();
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
targetCtx.lineWidth = w;
|
|
45
|
-
targetCtx.beginPath();
|
|
46
|
-
stroke.points.forEach(([nx, ny], i) => {
|
|
47
|
-
const x = nx * target.width;
|
|
48
|
-
const y = ny * target.height;
|
|
49
|
-
if (i === 0) targetCtx.moveTo(x, y);
|
|
50
|
-
else targetCtx.lineTo(x, y);
|
|
51
|
-
});
|
|
52
|
-
targetCtx.stroke();
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function drawInto(targetCtx, target) {
|
|
57
|
-
targetCtx.clearRect(0, 0, target.width, target.height);
|
|
58
|
-
drawPass(target, targetCtx, HALO_COLOR, HALO_RATIO);
|
|
59
|
-
drawPass(target, targetCtx, CORE_COLOR, 1);
|
|
60
|
-
}
|
|
192
|
+
let scene = { elements: [], overlay: {} };
|
|
193
|
+
// The bitmap's current dpr, set by resize()/size() below and read by draw()
|
|
194
|
+
// to scale the overlay chrome's fixed-CSS-pixel constants (handles, label,
|
|
195
|
+
// chrome line widths) up to device pixels. Defaults to 1 so a
|
|
196
|
+
// draw() before the first resize() (there isn't one on this path, but
|
|
197
|
+
// belt-and-suspenders) doesn't under/over-scale.
|
|
198
|
+
let currentDpr = 1;
|
|
61
199
|
|
|
200
|
+
// Live draw order: glow -> all halos -> all cores -> handles -> guide ->
|
|
201
|
+
// label. Glow renders before the elements' own halo/core passes so it reads
|
|
202
|
+
// as a soft field behind the ink, not on top of it. Pointer-followers (the
|
|
203
|
+
// eraser ring, the rotate glyph) are deliberately NOT drawn here: anything
|
|
204
|
+
// that must track the cursor per-mousemove would force a full-canvas redraw
|
|
205
|
+
// per event — they are CSS cursors instead (app.css), rendered by the
|
|
206
|
+
// compositor at zero canvas cost.
|
|
62
207
|
function draw() {
|
|
63
208
|
if (!ctx) return;
|
|
64
|
-
|
|
209
|
+
const overlay = scene.overlay || {};
|
|
210
|
+
const chrome = chromeColors(canvas);
|
|
211
|
+
const dpr = currentDpr;
|
|
212
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
213
|
+
drawGlow(ctx, canvas, overlay.glowEl, chrome.accent);
|
|
214
|
+
strokePass(canvas, ctx, scene.elements, HALO_RATIO, () => HALO_COLOR);
|
|
215
|
+
strokePass(canvas, ctx, scene.elements, 1, (el) => INK_COLORS[el.color]);
|
|
216
|
+
drawHandles(ctx, canvas, overlay.handlesEl, chrome, dpr);
|
|
217
|
+
drawGuide(ctx, canvas, overlay.guide, chrome, dpr);
|
|
218
|
+
drawLabel(ctx, canvas, overlay.label, chrome, dpr);
|
|
65
219
|
}
|
|
66
220
|
|
|
67
221
|
function resize() {
|
|
68
222
|
const rect = stage.getBoundingClientRect();
|
|
69
223
|
const dpr = globalThis.devicePixelRatio || 1;
|
|
224
|
+
currentDpr = dpr;
|
|
70
225
|
const width = Math.max(1, Math.round(rect.width * dpr));
|
|
71
226
|
const height = Math.max(1, Math.round(rect.height * dpr));
|
|
72
227
|
if (canvas.width !== width || canvas.height !== height) {
|
|
@@ -77,11 +232,11 @@ export function createInkCanvas(stage, {
|
|
|
77
232
|
}
|
|
78
233
|
|
|
79
234
|
// The viewer's own ResizeObserver is internal (viewer.js exposes no resize
|
|
80
|
-
// hook), so the overlay runs its own —
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
235
|
+
// hook), so the overlay runs its own — a resize is just a re-rasterize at
|
|
236
|
+
// the new bitmap size. Skip it while hidden: the stage keeps resizing (rail
|
|
237
|
+
// drags, window resizes) whether or not annotate mode is on, and
|
|
238
|
+
// re-rasterizing an invisible canvas is wasted work; show() already calls
|
|
239
|
+
// resize() so nothing is missed on re-entry.
|
|
85
240
|
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => {
|
|
86
241
|
if (canvas.hidden) return;
|
|
87
242
|
resize();
|
|
@@ -93,27 +248,29 @@ export function createInkCanvas(stage, {
|
|
|
93
248
|
element: canvas,
|
|
94
249
|
show() { canvas.hidden = false; resize(); },
|
|
95
250
|
hide() { canvas.hidden = true; },
|
|
96
|
-
|
|
97
|
-
// The ink layer as a transparent PNG
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
251
|
+
setScene(next) { scene = next; draw(); },
|
|
252
|
+
// The ink layer as a transparent PNG, elements only — overlay adornments
|
|
253
|
+
// (handles, guides, labels, the eraser ring, ...) are tool chrome, not
|
|
254
|
+
// part of the drawing, and must never reach the export. Since the live
|
|
255
|
+
// canvas now carries that overlay, export always re-rasterizes elements
|
|
256
|
+
// into a scratch canvas rather than reading the live bitmap directly.
|
|
257
|
+
// `maxEdge` bounds the exported bitmap: the live canvas is stage-sized ×
|
|
258
|
+
// devicePixelRatio, so on a large hi-DPI display it runs to several
|
|
259
|
+
// thousand pixels a side, and a PNG that big is both slow to encode and
|
|
260
|
+
// large enough that a host with a payload ceiling would have to drop it —
|
|
261
|
+
// losing the drawing while keeping the picture of the model, which is the
|
|
262
|
+
// one outcome worse than failing. Above the bound the elements are
|
|
263
|
+
// re-rasterized at the scaled-down size rather than resampled, so thin
|
|
264
|
+
// ink stays crisp instead of turning to mush.
|
|
107
265
|
toDataUrl({ maxEdge } = {}) {
|
|
108
266
|
const long = Math.max(canvas.width, canvas.height);
|
|
109
|
-
|
|
110
|
-
const scale = maxEdge / long;
|
|
267
|
+
const scale = maxEdge && long > maxEdge ? maxEdge / long : 1;
|
|
111
268
|
const scratch = createCanvas();
|
|
112
269
|
scratch.width = Math.max(1, Math.round(canvas.width * scale));
|
|
113
270
|
scratch.height = Math.max(1, Math.round(canvas.height * scale));
|
|
114
271
|
const scratchCtx = getContext2d(scratch);
|
|
115
272
|
if (!scratchCtx) return canvas.toDataURL("image/png");
|
|
116
|
-
|
|
273
|
+
drawElements(scratchCtx, scratch, scene.elements);
|
|
117
274
|
return scratch.toDataURL("image/png");
|
|
118
275
|
},
|
|
119
276
|
size: () => ({ width: canvas.width, height: canvas.height, dpr: globalThis.devicePixelRatio || 1 }),
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Sketch-mode toolbar: the top-centre pill that OWNS the mode while sketch
|
|
2
|
+
// mode is on (annotate-controls.js's viewbar pencil+actions is hidden for the
|
|
3
|
+
// duration — mount toggles both). A direct sibling of annotate-controls.js:
|
|
4
|
+
// same mode-object-owns-behavior split, same tooltip/cleanup idioms, same
|
|
5
|
+
// `send: "host"` contract for a host that draws its own send affordance.
|
|
6
|
+
//
|
|
7
|
+
// Two DOM nodes are appended to the stage: the toolbar pill itself and a
|
|
8
|
+
// `.pf-sketch-hint` sibling (not a child — see the CSS, which positions it
|
|
9
|
+
// independently below the pill) carrying a one-line usage hint for the
|
|
10
|
+
// active tool.
|
|
11
|
+
import { attachButtonTooltips } from "../tooltip.js";
|
|
12
|
+
import { runCleanupSteps } from "../teardown.js";
|
|
13
|
+
import { INK_COLORS } from "./elements.js";
|
|
14
|
+
|
|
15
|
+
// pen: the same pencil glyph as annotate-controls.js's toggle button.
|
|
16
|
+
const PEN_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>`;
|
|
17
|
+
const LINE_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 19 19 5"/></svg>`;
|
|
18
|
+
const RECT_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="6" width="16" height="12" rx="1"/></svg>`;
|
|
19
|
+
const ELLIPSE_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="12" rx="9" ry="6.5"/></svg>`;
|
|
20
|
+
// hand: lucide "hand" glyph.
|
|
21
|
+
const HAND_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>`;
|
|
22
|
+
// eraser: lucide "eraser" glyph.
|
|
23
|
+
const ERASER_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>`;
|
|
24
|
+
// undo: lucide "undo-2" glyph.
|
|
25
|
+
const UNDO_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/></svg>`;
|
|
26
|
+
// clear: lucide "trash-2" glyph.
|
|
27
|
+
const CLEAR_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>`;
|
|
28
|
+
// close: lucide "x" glyph. The toolbar's own exit affordance — see its
|
|
29
|
+
// wiring below for why the mode needs one now that #viewbar (and the pencil
|
|
30
|
+
// toggle that used to sit on it) is hidden for the duration of sketch mode.
|
|
31
|
+
const CLOSE_ICON = `<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`;
|
|
32
|
+
|
|
33
|
+
const TOOLS = [
|
|
34
|
+
{ tool: "pen", icon: PEN_ICON, label: "Pen", hint: "drag to draw" },
|
|
35
|
+
{ tool: "line", icon: LINE_ICON, label: "Line", hint: "drag endpoint to endpoint · snaps to 0/45/90° · shift forces" },
|
|
36
|
+
{ tool: "rect", icon: RECT_ICON, label: "Rectangle", hint: "drag corner to corner · snaps to square · shift forces" },
|
|
37
|
+
{ tool: "ellipse", icon: ELLIPSE_ICON, label: "Ellipse", hint: "drag corner to corner · snaps to circle · shift forces" },
|
|
38
|
+
{ tool: "hand", icon: HAND_ICON, label: "Move", hint: "drag a shape to move it · handles resize · just outside rotates" },
|
|
39
|
+
{ tool: "eraser", icon: ERASER_ICON, label: "Eraser", hint: "scrub to erase" },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const COLOR_LABELS = { red: "Red ink", blue: "Blue ink", green: "Green ink" };
|
|
43
|
+
|
|
44
|
+
function makeButton({ className = "", dataset = {}, title, ariaLabel, html }) {
|
|
45
|
+
const button = document.createElement("button");
|
|
46
|
+
button.type = "button";
|
|
47
|
+
if (className) button.className = className;
|
|
48
|
+
for (const [key, value] of Object.entries(dataset)) button.dataset[key] = value;
|
|
49
|
+
if (html !== undefined) button.innerHTML = html;
|
|
50
|
+
if (title) button.title = title;
|
|
51
|
+
button.setAttribute("aria-label", ariaLabel ?? title ?? "");
|
|
52
|
+
return button;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeSeparator() {
|
|
56
|
+
const sep = document.createElement("span");
|
|
57
|
+
sep.className = "sep";
|
|
58
|
+
sep.setAttribute("aria-hidden", "true");
|
|
59
|
+
return sep;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function attachSketchToolbar(mode, { stage, tooltip, send = "viewbar" } = {}) {
|
|
63
|
+
const element = document.createElement("div");
|
|
64
|
+
element.className = "pf-sketch-toolbar";
|
|
65
|
+
element.setAttribute("role", "toolbar");
|
|
66
|
+
element.hidden = true;
|
|
67
|
+
|
|
68
|
+
const hint = document.createElement("div");
|
|
69
|
+
hint.className = "pf-sketch-hint";
|
|
70
|
+
hint.hidden = true;
|
|
71
|
+
|
|
72
|
+
const toolButtons = TOOLS.map(({ tool, icon, label }) => makeButton({
|
|
73
|
+
dataset: { tool },
|
|
74
|
+
title: label,
|
|
75
|
+
html: icon,
|
|
76
|
+
}));
|
|
77
|
+
for (const button of toolButtons) button.setAttribute("aria-pressed", "false");
|
|
78
|
+
element.append(...toolButtons, makeSeparator());
|
|
79
|
+
|
|
80
|
+
const swatchButtons = Object.keys(INK_COLORS).map((color) => {
|
|
81
|
+
const button = makeButton({
|
|
82
|
+
className: "pf-swatch",
|
|
83
|
+
dataset: { color },
|
|
84
|
+
title: COLOR_LABELS[color] ?? color,
|
|
85
|
+
});
|
|
86
|
+
button.style.setProperty("--sw", INK_COLORS[color]);
|
|
87
|
+
return button;
|
|
88
|
+
});
|
|
89
|
+
element.append(...swatchButtons, makeSeparator());
|
|
90
|
+
|
|
91
|
+
const undoButton = makeButton({ dataset: { action: "undo" }, title: "Undo", html: UNDO_ICON });
|
|
92
|
+
const clearButton = makeButton({ dataset: { action: "clear" }, title: "Clear", html: CLEAR_ICON });
|
|
93
|
+
element.append(undoButton, clearButton);
|
|
94
|
+
|
|
95
|
+
let sendButton = null;
|
|
96
|
+
if (send !== "host") {
|
|
97
|
+
sendButton = makeButton({ dataset: { action: "send" }, title: "Send" });
|
|
98
|
+
sendButton.textContent = "Send";
|
|
99
|
+
element.append(sendButton);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Close: the toolbar's own exit affordance, always the LAST button — it
|
|
103
|
+
// must outlive send's presence/absence so the mode is exitable in-UI
|
|
104
|
+
// regardless of the `send` variant. Never disabled (unlike undo/clear/
|
|
105
|
+
// send, which gate on stroke count): exiting has to work with an empty
|
|
106
|
+
// sketch. annotate-mode.js's document-capture Escape listener covers the
|
|
107
|
+
// keyboard path; this covers pointer/touch, including mobile, which has no
|
|
108
|
+
// Escape key at all.
|
|
109
|
+
const closeButton = makeButton({ dataset: { action: "close" }, title: "Exit sketch", html: CLOSE_ICON });
|
|
110
|
+
element.append(closeButton);
|
|
111
|
+
|
|
112
|
+
stage.append(element, hint);
|
|
113
|
+
|
|
114
|
+
const allButtons = [...toolButtons, ...swatchButtons, undoButton, clearButton, sendButton, closeButton].filter(Boolean);
|
|
115
|
+
const tooltipBinding = tooltip
|
|
116
|
+
? attachButtonTooltips(tooltip, allButtons.map((btn) => ({ element: btn })))
|
|
117
|
+
: null;
|
|
118
|
+
|
|
119
|
+
function syncTools() {
|
|
120
|
+
const activeTool = mode.tool();
|
|
121
|
+
for (const button of toolButtons) {
|
|
122
|
+
const on = button.dataset.tool === activeTool;
|
|
123
|
+
button.classList.toggle("on", on);
|
|
124
|
+
button.setAttribute("aria-pressed", String(on));
|
|
125
|
+
}
|
|
126
|
+
const activeColor = mode.color();
|
|
127
|
+
for (const button of swatchButtons) {
|
|
128
|
+
button.classList.toggle("on", button.dataset.color === activeColor);
|
|
129
|
+
}
|
|
130
|
+
const entry = TOOLS.find((t) => t.tool === activeTool);
|
|
131
|
+
hint.textContent = entry?.hint ?? "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function syncDisabled() {
|
|
135
|
+
const empty = mode.strokeCount() === 0;
|
|
136
|
+
undoButton.disabled = !mode.canUndo();
|
|
137
|
+
clearButton.disabled = empty;
|
|
138
|
+
if (sendButton) sendButton.disabled = empty;
|
|
139
|
+
tooltipBinding?.sync();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function syncVisibility() {
|
|
143
|
+
const on = mode.isEnabled();
|
|
144
|
+
element.hidden = !on;
|
|
145
|
+
hint.hidden = !on;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sync() {
|
|
149
|
+
syncTools();
|
|
150
|
+
syncDisabled();
|
|
151
|
+
syncVisibility();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const onUndo = () => mode.undo();
|
|
155
|
+
const onClear = () => mode.clear();
|
|
156
|
+
const onSendClick = () => mode.send();
|
|
157
|
+
const onClose = () => mode.setEnabled(false);
|
|
158
|
+
|
|
159
|
+
const toolHandlers = toolButtons.map((button) => {
|
|
160
|
+
const handler = () => mode.setTool(button.dataset.tool);
|
|
161
|
+
button.addEventListener("click", handler);
|
|
162
|
+
return { button, handler };
|
|
163
|
+
});
|
|
164
|
+
const colorHandlers = swatchButtons.map((button) => {
|
|
165
|
+
const handler = () => mode.setColor(button.dataset.color);
|
|
166
|
+
button.addEventListener("click", handler);
|
|
167
|
+
return { button, handler };
|
|
168
|
+
});
|
|
169
|
+
undoButton.addEventListener("click", onUndo);
|
|
170
|
+
clearButton.addEventListener("click", onClear);
|
|
171
|
+
sendButton?.addEventListener("click", onSendClick);
|
|
172
|
+
closeButton.addEventListener("click", onClose);
|
|
173
|
+
|
|
174
|
+
const offTool = mode.onToolChange(sync);
|
|
175
|
+
const offInk = mode.onInkChange(sync);
|
|
176
|
+
const offMode = mode.onModeChange(sync);
|
|
177
|
+
|
|
178
|
+
sync();
|
|
179
|
+
|
|
180
|
+
let detached = false;
|
|
181
|
+
return {
|
|
182
|
+
element,
|
|
183
|
+
detach() {
|
|
184
|
+
if (detached) return;
|
|
185
|
+
detached = true;
|
|
186
|
+
runCleanupSteps([
|
|
187
|
+
offTool,
|
|
188
|
+
offInk,
|
|
189
|
+
offMode,
|
|
190
|
+
...toolHandlers.map(({ button, handler }) => () => button.removeEventListener("click", handler)),
|
|
191
|
+
...colorHandlers.map(({ button, handler }) => () => button.removeEventListener("click", handler)),
|
|
192
|
+
() => undoButton.removeEventListener("click", onUndo),
|
|
193
|
+
() => clearButton.removeEventListener("click", onClear),
|
|
194
|
+
() => sendButton?.removeEventListener("click", onSendClick),
|
|
195
|
+
() => closeButton.removeEventListener("click", onClose),
|
|
196
|
+
() => tooltipBinding?.detach(),
|
|
197
|
+
() => element.remove(),
|
|
198
|
+
() => hint.remove(),
|
|
199
|
+
], "sketch toolbar cleanup failed");
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|