partforge 0.87.0 → 0.88.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 +64 -21
- 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/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
|
+
}
|
package/src/framework/app.css
CHANGED
|
@@ -278,7 +278,7 @@ button.action:disabled { opacity: .5; cursor: default; }
|
|
|
278
278
|
/* keyboard focus ring shared across the panel's interactive controls */
|
|
279
279
|
.seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
|
|
280
280
|
button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible, #viewbar button:focus-visible,
|
|
281
|
-
.pf-viewcube-toggle:focus-visible, .pf-float-rail-toggle:focus-visible {
|
|
281
|
+
.pf-viewcube-toggle:focus-visible, .pf-float-rail-toggle:focus-visible, .pf-sketch-toolbar button:focus-visible {
|
|
282
282
|
outline: none;
|
|
283
283
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
|
|
284
284
|
}
|
|
@@ -318,6 +318,10 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
318
318
|
border-radius: var(--pf-radius-pill);
|
|
319
319
|
box-shadow: var(--pf-shadow-float);
|
|
320
320
|
}
|
|
321
|
+
/* Sketch mode hides the whole bar while it owns the stage (mount.js) — the
|
|
322
|
+
display: flex above is author-origin and beats the UA's [hidden] rule, the
|
|
323
|
+
same trap the buttons' own [hidden] rule below already guards against. */
|
|
324
|
+
#viewbar[hidden] { display: none; }
|
|
321
325
|
#viewbar:not(.pf-float-viewbar) { position: fixed; top: 12px; right: 12px; z-index: 15; }
|
|
322
326
|
#viewbar button {
|
|
323
327
|
width: 34px; height: 34px; border: 0; border-radius: var(--pf-radius-control);
|
|
@@ -337,25 +341,68 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
337
341
|
#viewbar .pf-measure-actions { display: flex; gap: 4px; }
|
|
338
342
|
#viewbar .pf-measure-actions[hidden] { display: none; }
|
|
339
343
|
#viewbar .pf-measure-actions button { width: auto; min-width: 56px; padding: 0 8px; }
|
|
340
|
-
#viewbar .pf-annotate-actions { display: flex; gap: 4px; }
|
|
341
|
-
#viewbar .pf-annotate-actions[hidden] { display: none; }
|
|
342
|
-
#viewbar .pf-annotate-actions button { width: auto; min-width: 56px; padding: 0 8px; }
|
|
343
|
-
/* Annotate's actions row has three buttons (Undo/Clear/Send) against
|
|
344
|
-
cutaway's/measure's two, so it is the first to overflow the stage's left
|
|
345
|
-
edge as the viewport narrows — the full-size pill (5 icon buttons + this
|
|
346
|
-
row, ~374px) already exceeds a 375-390px phone's usable width (viewport
|
|
347
|
-
minus the stage's 12px margins on both sides) before the shared 360px
|
|
348
|
-
rule below ever engages. Shrink only this row here; the icon buttons and
|
|
349
|
-
the other two action rows still have room down to 360px. */
|
|
350
|
-
@media (max-width: 430px) {
|
|
351
|
-
#viewbar .pf-annotate-actions { gap: 3px; }
|
|
352
|
-
#viewbar .pf-annotate-actions button { min-width: 40px; padding: 0 5px; font-size: 11px; }
|
|
353
|
-
}
|
|
354
344
|
#viewbar button:disabled { opacity: .38; cursor: not-allowed; }
|
|
355
345
|
#viewbar button:disabled:hover { color: var(--pf-muted-2); background: transparent; }
|
|
356
346
|
#viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
|
|
357
347
|
#viewbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
|
|
358
348
|
|
|
349
|
+
/* Sketch-mode toolbar: the #viewbar pill idiom, top-centre, owning the mode
|
|
350
|
+
while the viewbar itself is hidden (mount toggles both).
|
|
351
|
+
|
|
352
|
+
`left: 50%` is a shrink-to-fit trap for an absolutely positioned element:
|
|
353
|
+
its auto width shrink-wraps against the space between `left` and the
|
|
354
|
+
containing block's right edge, which here is only HALF the stage, not the
|
|
355
|
+
whole stage — so the row wraps (stranding the close button on its own
|
|
356
|
+
centred second line, on top of .pf-sketch-hint) well before it actually
|
|
357
|
+
needs to. `width: max-content` opts back into sizing to the row's
|
|
358
|
+
preferred (unwrapped) width; `max-width` below still caps that on stages
|
|
359
|
+
too narrow for the full row, so genuine wrapping on narrow stages still
|
|
360
|
+
works. */
|
|
361
|
+
.pf-sketch-toolbar {
|
|
362
|
+
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
|
|
363
|
+
display: flex; align-items: center; gap: 4px; padding: 4px;
|
|
364
|
+
background: var(--pf-surface); border: 1px solid var(--pf-border);
|
|
365
|
+
border-radius: var(--pf-radius-pill); box-shadow: var(--pf-shadow-float);
|
|
366
|
+
z-index: 20; width: max-content; max-width: calc(100% - 16px); flex-wrap: wrap; justify-content: center;
|
|
367
|
+
}
|
|
368
|
+
.pf-sketch-toolbar[hidden] { display: none; }
|
|
369
|
+
.pf-sketch-toolbar .sep { width: 1px; align-self: stretch; margin: 4px 2px; background: var(--pf-border); }
|
|
370
|
+
.pf-sketch-toolbar button {
|
|
371
|
+
width: 34px; height: 34px; border: 0; border-radius: var(--pf-radius-control);
|
|
372
|
+
background: transparent; color: var(--pf-muted-2); cursor: pointer;
|
|
373
|
+
display: flex; align-items: center; justify-content: center;
|
|
374
|
+
}
|
|
375
|
+
.pf-sketch-toolbar button[data-action="send"] { width: auto; min-width: 56px; padding: 0 10px; }
|
|
376
|
+
.pf-sketch-toolbar button:hover { color: var(--pf-text-2); background: var(--pf-surface-2); }
|
|
377
|
+
.pf-sketch-toolbar button:disabled { opacity: .35; cursor: default; background: transparent; color: var(--pf-muted); }
|
|
378
|
+
.pf-sketch-toolbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
|
|
379
|
+
.pf-sketch-toolbar .pf-swatch { width: 26px; height: 26px; margin: 4px 1px; border-radius: 50%; }
|
|
380
|
+
.pf-sketch-toolbar .pf-swatch::before {
|
|
381
|
+
content: ""; width: 14px; height: 14px; border-radius: 50%;
|
|
382
|
+
background: var(--sw); box-shadow: 0 0 0 2px color-mix(in oklab, var(--sw) 25%, transparent);
|
|
383
|
+
}
|
|
384
|
+
.pf-sketch-toolbar .pf-swatch.on { background: var(--pf-surface-2); }
|
|
385
|
+
.pf-sketch-toolbar .pf-swatch.on::before { box-shadow: 0 0 0 2.5px var(--pf-bg), 0 0 0 4.5px var(--sw); }
|
|
386
|
+
.pf-sketch-hint {
|
|
387
|
+
position: absolute; top: 58px; left: 50%; transform: translateX(-50%);
|
|
388
|
+
font-family: var(--pf-mono); font-size: 10px; letter-spacing: .04em;
|
|
389
|
+
color: var(--pf-hint); z-index: 19; pointer-events: none; white-space: nowrap;
|
|
390
|
+
}
|
|
391
|
+
.pf-sketch-hint[hidden] { display: none; }
|
|
392
|
+
/* hand-tool cursors on the ink canvas */
|
|
393
|
+
.pf-ink-canvas.hand { cursor: default; }
|
|
394
|
+
.pf-ink-canvas.hand.over { cursor: grab; }
|
|
395
|
+
.pf-ink-canvas.hand.handle { cursor: crosshair; }
|
|
396
|
+
/* Pointer-followers are CSS cursors, not canvas drawings: anything that must
|
|
397
|
+
track the pointer per-mousemove would force a full-canvas redraw per event
|
|
398
|
+
(annotate-mode used to do exactly that for the rotate glyph and eraser
|
|
399
|
+
ring, and it read as lag). The compositor renders cursors for free. */
|
|
400
|
+
.pf-ink-canvas.hand.rotate { cursor: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2224%22 height=%2224%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cg stroke=%22%23000%22 stroke-width=%224.5%22 opacity=%22.55%22%3E%3Cpath d=%22M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8%22/%3E%3Cpath d=%22M21 3v5h-5%22/%3E%3C/g%3E%3Cg stroke=%22%23fff%22 stroke-width=%222%22%3E%3Cpath d=%22M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8%22/%3E%3Cpath d=%22M21 3v5h-5%22/%3E%3C/g%3E%3C/svg%3E") 12 12, alias; }
|
|
401
|
+
.pf-ink-canvas.hand.dragging { cursor: grabbing; }
|
|
402
|
+
/* The ring's 16px radius mirrors annotate-mode.js's ERASER_PX — keep them
|
|
403
|
+
in step so the cursor shows the true brush footprint. */
|
|
404
|
+
.pf-ink-canvas.erasing { cursor: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2236%22 height=%2236%22 viewBox=%220 0 36 36%22 fill=%22none%22%3E%3Ccircle cx=%2218%22 cy=%2218%22 r=%2216%22 stroke=%22%23000%22 stroke-width=%223.5%22 opacity=%22.55%22/%3E%3Ccircle cx=%2218%22 cy=%2218%22 r=%2216%22 stroke=%22%23fff%22 stroke-width=%221.5%22/%3E%3C/svg%3E") 18 18, crosshair; }
|
|
405
|
+
|
|
359
406
|
/* ---- the rail toggle: a bare floating icon at the stage's top right --------
|
|
360
407
|
APPEARANCE only (placement lives in chrome.css, per the split at the top of
|
|
361
408
|
this file). Through 2026-08-20 this was the last button in #viewbar's pill;
|
|
@@ -482,12 +529,8 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
482
529
|
@media (max-width: 360px) {
|
|
483
530
|
#viewbar { gap: 3px; }
|
|
484
531
|
#viewbar button { width: 30px; height: 30px; font-size: 13px; }
|
|
485
|
-
#viewbar .pf-cutaway-actions, #viewbar .pf-measure-actions
|
|
486
|
-
#viewbar .pf-cutaway-actions button, #viewbar .pf-measure-actions button
|
|
487
|
-
/* Annotate's three-button row (Undo/Clear/Send) is wider than cutaway's or
|
|
488
|
-
measure's two-button rows at the shared size above, so it still clips the
|
|
489
|
-
bar's left edge at 320px — shrink it further than the shared rule. */
|
|
490
|
-
#viewbar .pf-annotate-actions button { min-width: 38px; padding: 0 4px; font-size: 10px; }
|
|
532
|
+
#viewbar .pf-cutaway-actions, #viewbar .pf-measure-actions { gap: 3px; }
|
|
533
|
+
#viewbar .pf-cutaway-actions button, #viewbar .pf-measure-actions button { min-width: 44px; padding: 0 6px; }
|
|
491
534
|
}
|
|
492
535
|
|
|
493
536
|
/* ---- measurement mode -----------------------------------------------------
|
package/src/framework/chrome.css
CHANGED
|
@@ -469,8 +469,10 @@
|
|
|
469
469
|
|
|
470
470
|
/* ---- annotation ink layer: a transparent 2D canvas over the viewer --------
|
|
471
471
|
Shown only while annotation mode is on. It deliberately owns pointer events
|
|
472
|
-
while visible — that is what freezes orbit/pan/zoom during drawing.
|
|
473
|
-
the
|
|
472
|
+
while visible — that is what freezes orbit/pan/zoom during drawing. #viewbar
|
|
473
|
+
itself is hidden for the duration (mount.js toggles both), replaced by
|
|
474
|
+
.pf-sketch-toolbar (app.css, z 20) which now holds Undo/Clear/Send; this
|
|
475
|
+
layer sits below that at z 10 so the toolbar stays clickable over it. */
|
|
474
476
|
.pf-ink-canvas {
|
|
475
477
|
position: absolute;
|
|
476
478
|
inset: 0;
|