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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 12px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.render-preview.dragging { cursor: grabbing; }
|
||||||
|
|
||||||
.render-preview img {
|
.render-preview img {
|
||||||
max-width: 100%;
|
transform-origin: 0 0;
|
||||||
max-height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
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 {
|
.code-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
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() {
|
#renderResult() {
|
||||||
if (this.#previewArea) {
|
if (this.#previewArea) {
|
||||||
|
// Clean up previous listeners
|
||||||
|
this.#viewCleanup?.();
|
||||||
|
this.#viewCleanup = null;
|
||||||
|
|
||||||
if (this.#renderUrl) {
|
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 {
|
} else {
|
||||||
this.#previewArea.innerHTML = '<div class="placeholder"><span class="placeholder-icon">✅</span><span>Script generated (see Script tab)</span></div>';
|
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 {
|
#escapeHtml(text: string): string {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue