From 0a6dcef4b9ec0ec2a7b635edc7576f80e854e9d2 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 31 Mar 2026 12:22:20 -0700 Subject: [PATCH] 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 --- lib/folk-blender.ts | 199 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 194 insertions(+), 5 deletions(-) diff --git a/lib/folk-blender.ts b/lib/folk-blender.ts index f9b9772..70a0cc5 100644 --- a/lib/folk-blender.ts +++ b/lib/folk-blender.ts @@ -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(); + #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 = `3D Render`; + this.#previewArea.innerHTML = `3D RenderScroll to zoom · drag to pan`; + 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 = '
Script generated (see Script tab)
'; } @@ -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;