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:
parent
bb19b4bc89
commit
0a6dcef4b9
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue