Make the focused-view crop live in the editor canvas
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m40s

The annotation canvas now renders as a viewport into the focused-view
crop region, matching what core/raster.js produces on export. Sliders
update the canvas live; annotation data stays in full-image-normalized
coordinates and remains correctly positioned, sized, and editable.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-06-13 18:09:17 -05:00
co-authored by Claude Sonnet 4.6
parent 91495a3614
commit f23b49c0d1
2 changed files with 71 additions and 17 deletions
+68 -17
View File
@@ -28,6 +28,7 @@ class AnnotationCanvas {
this.selectedId = null; this.selectedId = null;
this.drag = null; this.drag = null;
this.cropRect = null; this.cropRect = null;
this.focusedView = null;
canvasEl.addEventListener('pointerdown', (e) => this.onDown(e)); canvasEl.addEventListener('pointerdown', (e) => this.onDown(e));
canvasEl.addEventListener('pointermove', (e) => this.onMove(e)); canvasEl.addEventListener('pointermove', (e) => this.onMove(e));
@@ -55,6 +56,14 @@ class AnnotationCanvas {
this.render(); this.render();
} }
// The focused view crops and zooms the canvas itself to match what
// exports produce. Annotation data stays in full-image-normalized
// coordinates; only the on-screen viewport changes.
setFocusedView(focusedView) {
this.focusedView = focusedView || null;
this.render();
}
setTool(tool) { setTool(tool) {
this.tool = tool; this.tool = tool;
this.cropRect = null; this.cropRect = null;
@@ -98,30 +107,62 @@ class AnnotationCanvas {
} }
// ---- coordinate helpers ---- // ---- coordinate helpers ----
// The focused-view crop region, in coordinates normalized to the full
// image (0..1). Identity rect when focused view is off, matching
// core/raster.js applyFocusedView.
viewRect() {
const fv = this.focusedView;
if (!fv || !fv.enabled || !(fv.zoom > 1)) return { x: 0, y: 0, w: 1, h: 1 };
const w = 1 / fv.zoom, h = 1 / fv.zoom;
const cx = Math.min(Math.max(fv.panX ?? 0.5, w / 2), 1 - w / 2);
const cy = Math.min(Math.max(fv.panY ?? 0.5, h / 2), 1 - h / 2);
return { x: cx - w / 2, y: cy - h / 2, w, h };
}
toNorm(e) { toNorm(e) {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
const r = this.viewRect();
return { return {
x: (e.clientX - rect.left) / rect.width, x: r.x + ((e.clientX - rect.left) / rect.width) * r.w,
y: (e.clientY - rect.top) / rect.height, y: r.y + ((e.clientY - rect.top) / rect.height) * r.h,
}; };
} }
px(ann) { px(ann) {
const r = this.viewRect();
return { return {
x: ann.x * this.canvas.width, x: (ann.x - r.x) / r.w * this.canvas.width,
y: ann.y * this.canvas.height, y: (ann.y - r.y) / r.h * this.canvas.height,
w: ann.w * this.canvas.width, w: ann.w / r.w * this.canvas.width,
h: ann.h * this.canvas.height, h: ann.h / r.h * this.canvas.height,
}; };
} }
// Converts a canvas-pixel point to image-pixel coordinates in the
// full (uncropped) source image, for sampling `this.image` directly.
toImagePx(x, y) {
const r = this.viewRect();
return {
x: (x / this.canvas.width * r.w + r.x) * this.imgW,
y: (y / this.canvas.height * r.h + r.y) * this.imgH,
};
}
toImageLen(len, axis) {
const r = this.viewRect();
return axis === 'y' ? len / this.canvas.height * r.h * this.imgH : len / this.canvas.width * r.w * this.imgW;
}
// ---- rendering ---- // ---- rendering ----
render() { render() {
const { ctx, canvas } = this; const { ctx, canvas } = this;
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!this.image) return; if (!this.image) return;
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.drawImage(this.image, 0, 0, canvas.width, canvas.height); const r = this.viewRect();
ctx.drawImage(this.image,
r.x * this.imgW, r.y * this.imgH, r.w * this.imgW, r.h * this.imgH,
0, 0, canvas.width, canvas.height);
const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3)); const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3));
for (const ann of ordered) this.drawAnnotation(ann); for (const ann of ordered) this.drawAnnotation(ann);
@@ -131,12 +172,17 @@ class AnnotationCanvas {
if (this.cropRect) this.drawCropOverlay(); if (this.cropRect) this.drawCropOverlay();
} }
// Annotation strokes/fonts are sized relative to the full image, then
// magnified by the focused-view crop on export (core/raster.js) — divide
// by the view rect so the canvas preview matches that magnification.
strokePx(ann) { strokePx(ann) {
return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000); const r = this.viewRect();
return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000 / r.w);
} }
fontPx(ann) { fontPx(ann) {
return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height); const r = this.viewRect();
return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height / r.h);
} }
drawAnnotation(ann) { drawAnnotation(ann) {
@@ -202,10 +248,13 @@ class AnnotationCanvas {
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2); ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
ctx.clip(); ctx.clip();
const sw = w / zoom, sh = h / zoom; const sw = w / zoom, sh = h / zoom;
const center = this.toImagePx(x + w / 2, y + h / 2);
const srcW = this.toImageLen(sw, 'x');
const srcH = this.toImageLen(sh, 'y');
ctx.drawImage( ctx.drawImage(
this.image, this.image,
(x + w / 2 - sw / 2) / this.scale, (y + h / 2 - sh / 2) / this.scale, center.x - srcW / 2, center.y - srcH / 2,
sw / this.scale, sh / this.scale, srcW, srcH,
x, y, w, h x, y, w, h
); );
ctx.restore(); ctx.restore();
@@ -310,10 +359,11 @@ class AnnotationCanvas {
drawCropOverlay() { drawCropOverlay() {
const { ctx, canvas } = this; const { ctx, canvas } = this;
const r = this.cropRect; const r = this.cropRect;
const x = Math.min(r.x0, r.x1) * canvas.width; const view = this.viewRect();
const y = Math.min(r.y0, r.y1) * canvas.height; const x = (Math.min(r.x0, r.x1) - view.x) / view.w * canvas.width;
const w = Math.abs(r.x1 - r.x0) * canvas.width; const y = (Math.min(r.y0, r.y1) - view.y) / view.h * canvas.height;
const h = Math.abs(r.y1 - r.y0) * canvas.height; const w = Math.abs(r.x1 - r.x0) / view.w * canvas.width;
const h = Math.abs(r.y1 - r.y0) / view.h * canvas.height;
ctx.save(); ctx.save();
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.beginPath(); ctx.beginPath();
@@ -486,8 +536,9 @@ class AnnotationCanvas {
nudgeSelected(dx, dy) { nudgeSelected(dx, dy) {
const sel = this.selected(); const sel = this.selected();
if (!sel) return false; if (!sel) return false;
sel.x += dx / this.canvas.width; const r = this.viewRect();
sel.y += dy / this.canvas.height; sel.x += dx / this.canvas.width * r.w;
sel.y += dy / this.canvas.height * r.h;
this.changed(); this.changed();
return true; return true;
} }
+3
View File
@@ -397,6 +397,7 @@ class GuideEditor {
step.focusedView[field] = Number(node.value); step.focusedView[field] = Number(node.value);
this.pendingSave = true; this.pendingSave = true;
this.saveStepDebounced(); this.saveStepDebounced();
this.canvas.setFocusedView(step.focusedView);
}); });
bindFocusedSlider(this.dom.fvZoom, 'zoom'); bindFocusedSlider(this.dom.fvZoom, 'zoom');
bindFocusedSlider(this.dom.fvPanX, 'panX'); bindFocusedSlider(this.dom.fvPanX, 'panX');
@@ -451,6 +452,7 @@ class GuideEditor {
this.dom.fvPanX.value = fv.panX ?? 0.5; this.dom.fvPanX.value = fv.panX ?? 0.5;
this.dom.fvPanY.value = fv.panY ?? 0.5; this.dom.fvPanY.value = fv.panY ?? 0.5;
} }
this.canvas.setFocusedView(fv);
} }
// ---- text / code / table blocks ---------------------------------------- // ---- text / code / table blocks ----------------------------------------
@@ -852,6 +854,7 @@ class GuideEditor {
if (token !== this.imageLoadToken) return; if (token !== this.imageLoadToken) return;
this.canvas.setImage(img, img.naturalWidth || img.width, img.naturalHeight || img.height); this.canvas.setImage(img, img.naturalWidth || img.width, img.naturalHeight || img.height);
this.canvas.setAnnotations(step.annotations || []); this.canvas.setAnnotations(step.annotations || []);
this.canvas.setFocusedView(step.focusedView);
this.canvas.setTool(this.currentTool); this.canvas.setTool(this.currentTool);
this.canvas.setZoom(this.currentZoom); this.canvas.setZoom(this.currentZoom);
}; };