feat(blender): pan/zoom viewer for rendered images

Rendered 3D images now center-fit on load and support:
- Mouse wheel zoom (toward cursor)
- Click-drag pan (mouse, pen, touch)
- Pinch-to-zoom (multi-touch)
- Double-click to reset view
- Reset button on hover

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-31 12:22:20 -07:00
parent bb19b4bc89
commit 0a6dcef4b9
1 changed files with 194 additions and 5 deletions

View File

@ -155,19 +155,53 @@ const styles = css`
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
overflow: hidden;
min-height: 0;
position: relative;
touch-action: none;
cursor: grab;
user-select: none;
}
.render-preview.dragging { cursor: grabbing; }
.render-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transform-origin: 0 0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
pointer-events: none;
max-width: none;
max-height: none;
}
.render-preview .zoom-hint {
position: absolute;
bottom: 8px;
right: 8px;
font-size: 10px;
color: var(--rs-text-tertiary, #94a3b8);
opacity: 0.7;
pointer-events: none;
}
.render-preview .zoom-reset {
position: absolute;
top: 8px;
right: 8px;
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
border: 1px solid var(--rs-border, #e2e8f0);
background: var(--rs-bg-surface, #fff);
color: var(--rs-text-secondary, #64748b);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.render-preview:hover .zoom-reset { opacity: 1; }
.render-preview .zoom-reset:hover { background: var(--rs-surface-hover, #f1f5f9); }
.code-area {
flex: 1;
overflow: auto;
@ -449,10 +483,51 @@ export class FolkBlender extends FolkShape {
}
}
// Pan/zoom state
#viewX = 0;
#viewY = 0;
#viewScale = 1;
#pointers = new Map<number, { x: number; y: number }>();
#lastPinchDist = 0;
#viewCleanup: (() => void) | null = null;
#renderResult() {
if (this.#previewArea) {
// Clean up previous listeners
this.#viewCleanup?.();
this.#viewCleanup = null;
if (this.#renderUrl) {
this.#previewArea.innerHTML = `<img src="${this.#escapeHtml(this.#renderUrl)}" alt="3D Render" />`;
this.#previewArea.innerHTML = `<img src="${this.#escapeHtml(this.#renderUrl)}" alt="3D Render" /><button class="zoom-reset" title="Reset zoom">Reset</button><span class="zoom-hint">Scroll to zoom · drag to pan</span>`;
const img = this.#previewArea.querySelector("img") as HTMLImageElement;
const resetBtn = this.#previewArea.querySelector(".zoom-reset") as HTMLButtonElement;
// Reset view state and fit image once loaded
this.#viewScale = 1;
this.#viewX = 0;
this.#viewY = 0;
const fitImage = () => {
if (!this.#previewArea) return;
const cw = this.#previewArea.clientWidth;
const ch = this.#previewArea.clientHeight;
const iw = img.naturalWidth || img.width;
const ih = img.naturalHeight || img.height;
if (!iw || !ih) return;
this.#viewScale = Math.min(cw / iw, ch / ih, 1);
this.#viewX = (cw - iw * this.#viewScale) / 2;
this.#viewY = (ch - ih * this.#viewScale) / 2;
this.#applyTransform(img);
};
if (img.complete) fitImage();
else img.addEventListener("load", fitImage);
this.#wireViewerEvents(this.#previewArea, img);
resetBtn?.addEventListener("click", (e) => {
e.stopPropagation();
fitImage();
});
} else {
this.#previewArea.innerHTML = '<div class="placeholder"><span class="placeholder-icon">✅</span><span>Script generated (see Script tab)</span></div>';
}
@ -469,6 +544,120 @@ export class FolkBlender extends FolkShape {
}
}
#applyTransform(img: HTMLImageElement) {
img.style.transform = `translate(${this.#viewX}px, ${this.#viewY}px) scale(${this.#viewScale})`;
}
#wireViewerEvents(container: HTMLElement, img: HTMLImageElement) {
let dragging = false;
let dragStartX = 0;
let dragStartY = 0;
let startViewX = 0;
let startViewY = 0;
const onPointerDown = (e: PointerEvent) => {
e.stopPropagation();
this.#pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
container.setPointerCapture(e.pointerId);
if (this.#pointers.size === 1) {
dragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
startViewX = this.#viewX;
startViewY = this.#viewY;
container.classList.add("dragging");
} else if (this.#pointers.size === 2) {
// Start pinch
const pts = [...this.#pointers.values()];
this.#lastPinchDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
}
};
const onPointerMove = (e: PointerEvent) => {
if (!this.#pointers.has(e.pointerId)) return;
this.#pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (this.#pointers.size === 2) {
// Pinch zoom
const pts = [...this.#pointers.values()];
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
if (this.#lastPinchDist > 0) {
const ratio = dist / this.#lastPinchDist;
const cx = (pts[0].x + pts[1].x) / 2;
const cy = (pts[0].y + pts[1].y) / 2;
const rect = container.getBoundingClientRect();
const px = cx - rect.left;
const py = cy - rect.top;
this.#zoomAt(px, py, ratio, img);
}
this.#lastPinchDist = dist;
} else if (dragging && this.#pointers.size === 1) {
this.#viewX = startViewX + (e.clientX - dragStartX);
this.#viewY = startViewY + (e.clientY - dragStartY);
this.#applyTransform(img);
}
};
const onPointerUp = (e: PointerEvent) => {
this.#pointers.delete(e.pointerId);
if (this.#pointers.size < 2) this.#lastPinchDist = 0;
if (this.#pointers.size === 0) {
dragging = false;
container.classList.remove("dragging");
}
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
const rect = container.getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
this.#zoomAt(px, py, factor, img);
};
const onDblClick = (e: MouseEvent) => {
e.stopPropagation();
// Double-click to reset
const cw = container.clientWidth;
const ch = container.clientHeight;
const iw = img.naturalWidth || img.width;
const ih = img.naturalHeight || img.height;
if (!iw || !ih) return;
this.#viewScale = Math.min(cw / iw, ch / ih, 1);
this.#viewX = (cw - iw * this.#viewScale) / 2;
this.#viewY = (ch - ih * this.#viewScale) / 2;
this.#applyTransform(img);
};
container.addEventListener("pointerdown", onPointerDown);
container.addEventListener("pointermove", onPointerMove);
container.addEventListener("pointerup", onPointerUp);
container.addEventListener("pointercancel", onPointerUp);
container.addEventListener("wheel", onWheel, { passive: false });
container.addEventListener("dblclick", onDblClick);
this.#viewCleanup = () => {
container.removeEventListener("pointerdown", onPointerDown);
container.removeEventListener("pointermove", onPointerMove);
container.removeEventListener("pointerup", onPointerUp);
container.removeEventListener("pointercancel", onPointerUp);
container.removeEventListener("wheel", onWheel);
container.removeEventListener("dblclick", onDblClick);
};
}
#zoomAt(px: number, py: number, factor: number, img: HTMLImageElement) {
const newScale = Math.min(Math.max(this.#viewScale * factor, 0.1), 10);
const ratio = newScale / this.#viewScale;
this.#viewX = px - ratio * (px - this.#viewX);
this.#viewY = py - ratio * (py - this.#viewY);
this.#viewScale = newScale;
this.#applyTransform(img);
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;