From 6108f001beca3a4b95b0cedef51dadf40b13dc2e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 25 Apr 2026 03:50:39 +0100 Subject: [PATCH] Add hold-to-inspect viewer panels --- frontend/src/app.ts | 170 +++++++++++++++++++++++++- src/repixelizer/gui_static/app.js | 139 ++++++++++++++++++++- src/repixelizer/gui_static/styles.css | 6 + 3 files changed, 307 insertions(+), 8 deletions(-) diff --git a/frontend/src/app.ts b/frontend/src/app.ts index c04b2a3..32bae7f 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -73,6 +73,13 @@ type PhaseFieldPrepInfo = { cellY: number; }; +type ViewerPanelKey = "left" | "right"; + +type ViewerPanelState = { + asset: ImageAsset | null; + label: string; +}; + type AppState = { file: File | null; jobId: string | null; @@ -234,6 +241,12 @@ let panStartScrollTop = 0; let offscreenCanvas: HTMLCanvasElement | null = null; let offscreenContext: CanvasRenderingContext2D | null = null; let singlePixel = new ImageData(1, 1); +let viewerInspect: { panel: ViewerPanelKey; pointerId: number; clientX: number; clientY: number } | null = null; +let viewerRenderQueued = false; +let currentViewerPanels: Record = { + left: { asset: null, label: "Input" }, + right: { asset: null, label: "Output" }, +}; function clampByte(value: number): number { return Math.max(0, Math.min(255, Math.round(value))); @@ -378,6 +391,62 @@ async function drawAsset(canvas: HTMLCanvasElement, asset: ImageAsset | null): P context.drawImage(image, 0, 0, canvas.width, canvas.height); } +async function drawAssetInspection( + canvas: HTMLCanvasElement, + asset: ImageAsset | null, + pointer: { clientX: number; clientY: number }, +): Promise { + if (!asset) { + await drawAsset(canvas, asset); + return; + } + const context = canvas.getContext("2d"); + if (!context) { + return; + } + const image = await loadImage(asset); + const rect = canvas.getBoundingClientRect(); + const displayWidth = Math.max(1, Math.round(rect.width)); + const displayHeight = Math.max(1, Math.round(rect.height)); + if (displayWidth <= 0 || displayHeight <= 0) { + await drawAsset(canvas, asset); + return; + } + const scale = Math.max(1, Math.ceil(window.devicePixelRatio || 1)); + canvas.width = displayWidth * scale; + canvas.height = displayHeight * scale; + context.setTransform(scale, 0, 0, scale, 0, 0); + context.imageSmoothingEnabled = false; + context.clearRect(0, 0, displayWidth, displayHeight); + context.fillStyle = "#081012"; + context.fillRect(0, 0, displayWidth, displayHeight); + + const imageWidth = image.naturalWidth || asset.width; + const imageHeight = image.naturalHeight || asset.height; + const viewportWidth = Math.min(displayWidth, imageWidth); + const viewportHeight = Math.min(displayHeight, imageHeight); + const pointerX = Math.max(0, Math.min(displayWidth, pointer.clientX - rect.left)); + const pointerY = Math.max(0, Math.min(displayHeight, pointer.clientY - rect.top)); + const centerX = imageWidth <= displayWidth ? imageWidth * 0.5 : (pointerX / displayWidth) * imageWidth; + const centerY = imageHeight <= displayHeight ? imageHeight * 0.5 : (pointerY / displayHeight) * imageHeight; + const sourceX = Math.max(0, Math.min(imageWidth - viewportWidth, Math.round(centerX - viewportWidth * 0.5))); + const sourceY = Math.max(0, Math.min(imageHeight - viewportHeight, Math.round(centerY - viewportHeight * 0.5))); + const destX = Math.floor((displayWidth - viewportWidth) * 0.5); + const destY = Math.floor((displayHeight - viewportHeight) * 0.5); + + context.drawImage( + image, + sourceX, + sourceY, + viewportWidth, + viewportHeight, + destX, + destY, + viewportWidth, + viewportHeight, + ); +} + async function fileToImageAsset(file: File): Promise { const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); @@ -593,7 +662,7 @@ function renderSummary(): void { } } -async function renderViewer(): Promise { +function resolveViewerPanels(): Record { const frame = getSelectedFrame(); const debugViewerStage = new Set(["inference", "analysis", "selection", "rerank", "solver", "cleanup"]); const showDebugViewer = debugViewerStage.has(state.stageKey); @@ -627,9 +696,40 @@ async function renderViewer(): Promise { } } - leftVizLabel.textContent = leftLabel; - rightVizLabel.textContent = rightLabel; - await Promise.all([drawAsset(leftCanvas, leftAsset), drawAsset(rightCanvas, rightAsset)]); + return { + left: { asset: leftAsset, label: leftLabel }, + right: { asset: rightAsset, label: rightLabel }, + }; +} + +function syncViewerInspectCursor(): void { + leftCanvas.classList.toggle("is-inspecting", viewerInspect?.panel === "left"); + rightCanvas.classList.toggle("is-inspecting", viewerInspect?.panel === "right"); +} + +function scheduleViewerRender(): void { + if (viewerRenderQueued) { + return; + } + viewerRenderQueued = true; + requestAnimationFrame(() => { + viewerRenderQueued = false; + void renderViewer(); + }); +} + +async function renderViewer(): Promise { + currentViewerPanels = resolveViewerPanels(); + leftVizLabel.textContent = currentViewerPanels.left.label; + rightVizLabel.textContent = currentViewerPanels.right.label; + await Promise.all([ + viewerInspect?.panel === "left" + ? drawAssetInspection(leftCanvas, currentViewerPanels.left.asset, viewerInspect) + : drawAsset(leftCanvas, currentViewerPanels.left.asset), + viewerInspect?.panel === "right" + ? drawAssetInspection(rightCanvas, currentViewerPanels.right.asset, viewerInspect) + : drawAsset(rightCanvas, currentViewerPanels.right.asset), + ]); } function drawWrappedCanvasText( @@ -1011,6 +1111,48 @@ function exportEditorPng(): void { link.click(); } +function beginViewerInspect(panel: ViewerPanelKey, event: PointerEvent): void { + if (event.button !== 0 || !currentViewerPanels[panel].asset) { + return; + } + event.preventDefault(); + viewerInspect = { + panel, + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + }; + const canvas = panel === "left" ? leftCanvas : rightCanvas; + canvas.setPointerCapture(event.pointerId); + syncViewerInspectCursor(); + scheduleViewerRender(); +} + +function updateViewerInspect(panel: ViewerPanelKey, event: PointerEvent): void { + if (!viewerInspect || viewerInspect.panel !== panel || viewerInspect.pointerId !== event.pointerId) { + return; + } + viewerInspect.clientX = event.clientX; + viewerInspect.clientY = event.clientY; + scheduleViewerRender(); +} + +function endViewerInspect(pointerId: number | null = null): void { + if (!viewerInspect) { + return; + } + if (pointerId !== null && viewerInspect.pointerId !== pointerId) { + return; + } + const canvas = viewerInspect.panel === "left" ? leftCanvas : rightCanvas; + if (canvas.hasPointerCapture(viewerInspect.pointerId)) { + canvas.releasePointerCapture(viewerInspect.pointerId); + } + viewerInspect = null; + syncViewerInspectCursor(); + scheduleViewerRender(); +} + function beginPan(event: PointerEvent): void { panning = true; activePanPointerId = event.pointerId; @@ -1438,10 +1580,29 @@ window.addEventListener("keyup", (event) => { window.addEventListener("blur", () => { state.altHeld = false; painting = false; + endViewerInspect(); endPan(); syncEditorCursorState(); }); +for (const [panel, canvas] of [ + ["left", leftCanvas], + ["right", rightCanvas], +] as const) { + canvas.addEventListener("pointerdown", (event) => { + beginViewerInspect(panel, event); + }); + canvas.addEventListener("pointermove", (event) => { + updateViewerInspect(panel, event); + }); + canvas.addEventListener("pointerup", (event) => { + endViewerInspect(event.pointerId); + }); + canvas.addEventListener("pointercancel", (event) => { + endViewerInspect(event.pointerId); + }); +} + editorSurface.addEventListener("mousedown", (event) => { if (event.button === 1) { event.preventDefault(); @@ -1560,6 +1721,7 @@ window.addEventListener("resize", () => { setPaintColor(state.paintColor); syncEditorCursorState(); +syncViewerInspectCursor(); zoomValue.textContent = `${state.zoom}x`; renderEventLog(); void renderEverything(); diff --git a/src/repixelizer/gui_static/app.js b/src/repixelizer/gui_static/app.js index ea74f69..7d430ab 100644 --- a/src/repixelizer/gui_static/app.js +++ b/src/repixelizer/gui_static/app.js @@ -117,6 +117,12 @@ let panStartScrollTop = 0; let offscreenCanvas = null; let offscreenContext = null; let singlePixel = new ImageData(1, 1); +let viewerInspect = null; +let viewerRenderQueued = false; +let currentViewerPanels = { + left: { asset: null, label: "Input" }, + right: { asset: null, label: "Output" }, +}; function clampByte(value) { return Math.max(0, Math.min(255, Math.round(value))); } @@ -246,6 +252,45 @@ async function drawAsset(canvas, asset) { context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height); } +async function drawAssetInspection(canvas, asset, pointer) { + if (!asset) { + await drawAsset(canvas, asset); + return; + } + const context = canvas.getContext("2d"); + if (!context) { + return; + } + const image = await loadImage(asset); + const rect = canvas.getBoundingClientRect(); + const displayWidth = Math.max(1, Math.round(rect.width)); + const displayHeight = Math.max(1, Math.round(rect.height)); + if (displayWidth <= 0 || displayHeight <= 0) { + await drawAsset(canvas, asset); + return; + } + const scale = Math.max(1, Math.ceil(window.devicePixelRatio || 1)); + canvas.width = displayWidth * scale; + canvas.height = displayHeight * scale; + context.setTransform(scale, 0, 0, scale, 0, 0); + context.imageSmoothingEnabled = false; + context.clearRect(0, 0, displayWidth, displayHeight); + context.fillStyle = "#081012"; + context.fillRect(0, 0, displayWidth, displayHeight); + const imageWidth = image.naturalWidth || asset.width; + const imageHeight = image.naturalHeight || asset.height; + const viewportWidth = Math.min(displayWidth, imageWidth); + const viewportHeight = Math.min(displayHeight, imageHeight); + const pointerX = Math.max(0, Math.min(displayWidth, pointer.clientX - rect.left)); + const pointerY = Math.max(0, Math.min(displayHeight, pointer.clientY - rect.top)); + const centerX = imageWidth <= displayWidth ? imageWidth * 0.5 : (pointerX / displayWidth) * imageWidth; + const centerY = imageHeight <= displayHeight ? imageHeight * 0.5 : (pointerY / displayHeight) * imageHeight; + const sourceX = Math.max(0, Math.min(imageWidth - viewportWidth, Math.round(centerX - viewportWidth * 0.5))); + const sourceY = Math.max(0, Math.min(imageHeight - viewportHeight, Math.round(centerY - viewportHeight * 0.5))); + const destX = Math.floor((displayWidth - viewportWidth) * 0.5); + const destY = Math.floor((displayHeight - viewportHeight) * 0.5); + context.drawImage(image, sourceX, sourceY, viewportWidth, viewportHeight, destX, destY, viewportWidth, viewportHeight); +} async function fileToImageAsset(file) { const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); @@ -447,7 +492,7 @@ function renderSummary() { summaryPanel.appendChild(card); } } -async function renderViewer() { +function resolveViewerPanels() { const frame = getSelectedFrame(); const debugViewerStage = new Set(["inference", "analysis", "selection", "rerank", "solver", "cleanup"]); const showDebugViewer = debugViewerStage.has(state.stageKey); @@ -480,9 +525,37 @@ async function renderViewer() { rightLabel = "Cleaned Output"; } } - leftVizLabel.textContent = leftLabel; - rightVizLabel.textContent = rightLabel; - await Promise.all([drawAsset(leftCanvas, leftAsset), drawAsset(rightCanvas, rightAsset)]); + return { + left: { asset: leftAsset, label: leftLabel }, + right: { asset: rightAsset, label: rightLabel }, + }; +} +function syncViewerInspectCursor() { + leftCanvas.classList.toggle("is-inspecting", viewerInspect?.panel === "left"); + rightCanvas.classList.toggle("is-inspecting", viewerInspect?.panel === "right"); +} +function scheduleViewerRender() { + if (viewerRenderQueued) { + return; + } + viewerRenderQueued = true; + requestAnimationFrame(() => { + viewerRenderQueued = false; + void renderViewer(); + }); +} +async function renderViewer() { + currentViewerPanels = resolveViewerPanels(); + leftVizLabel.textContent = currentViewerPanels.left.label; + rightVizLabel.textContent = currentViewerPanels.right.label; + await Promise.all([ + viewerInspect?.panel === "left" + ? drawAssetInspection(leftCanvas, currentViewerPanels.left.asset, viewerInspect) + : drawAsset(leftCanvas, currentViewerPanels.left.asset), + viewerInspect?.panel === "right" + ? drawAssetInspection(rightCanvas, currentViewerPanels.right.asset, viewerInspect) + : drawAsset(rightCanvas, currentViewerPanels.right.asset), + ]); } function drawWrappedCanvasText(context, text, options) { const { x, y, maxWidth, lineHeight } = options; @@ -831,6 +904,45 @@ function exportEditorPng() { link.download = (state.file?.name?.replace(/\.[^.]+$/, "") || "repixelized") + "-edited.png"; link.click(); } +function beginViewerInspect(panel, event) { + if (event.button !== 0 || !currentViewerPanels[panel].asset) { + return; + } + event.preventDefault(); + viewerInspect = { + panel, + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + }; + const canvas = panel === "left" ? leftCanvas : rightCanvas; + canvas.setPointerCapture(event.pointerId); + syncViewerInspectCursor(); + scheduleViewerRender(); +} +function updateViewerInspect(panel, event) { + if (!viewerInspect || viewerInspect.panel !== panel || viewerInspect.pointerId !== event.pointerId) { + return; + } + viewerInspect.clientX = event.clientX; + viewerInspect.clientY = event.clientY; + scheduleViewerRender(); +} +function endViewerInspect(pointerId = null) { + if (!viewerInspect) { + return; + } + if (pointerId !== null && viewerInspect.pointerId !== pointerId) { + return; + } + const canvas = viewerInspect.panel === "left" ? leftCanvas : rightCanvas; + if (canvas.hasPointerCapture(viewerInspect.pointerId)) { + canvas.releasePointerCapture(viewerInspect.pointerId); + } + viewerInspect = null; + syncViewerInspectCursor(); + scheduleViewerRender(); +} function beginPan(event) { panning = true; activePanPointerId = event.pointerId; @@ -1206,9 +1318,27 @@ window.addEventListener("keyup", (event) => { window.addEventListener("blur", () => { state.altHeld = false; painting = false; + endViewerInspect(); endPan(); syncEditorCursorState(); }); +for (const [panel, canvas] of [ + ["left", leftCanvas], + ["right", rightCanvas], +]) { + canvas.addEventListener("pointerdown", (event) => { + beginViewerInspect(panel, event); + }); + canvas.addEventListener("pointermove", (event) => { + updateViewerInspect(panel, event); + }); + canvas.addEventListener("pointerup", (event) => { + endViewerInspect(event.pointerId); + }); + canvas.addEventListener("pointercancel", (event) => { + endViewerInspect(event.pointerId); + }); +} editorSurface.addEventListener("mousedown", (event) => { if (event.button === 1) { event.preventDefault(); @@ -1307,6 +1437,7 @@ window.addEventListener("resize", () => { }); setPaintColor(state.paintColor); syncEditorCursorState(); +syncViewerInspectCursor(); zoomValue.textContent = `${state.zoom}x`; renderEventLog(); void renderEverything(); diff --git a/src/repixelizer/gui_static/styles.css b/src/repixelizer/gui_static/styles.css index 62d5770..77bc568 100644 --- a/src/repixelizer/gui_static/styles.css +++ b/src/repixelizer/gui_static/styles.css @@ -672,6 +672,8 @@ p { width: 100%; height: auto; display: block; + cursor: zoom-in; + touch-action: none; background: repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.03) 0 1px, transparent 1px 4px), linear-gradient(180deg, rgba(5, 11, 13, 0.8), rgba(6, 14, 16, 0.92)); @@ -681,6 +683,10 @@ p { 0 0 14px rgba(255, 216, 74, 0.06); } +.viz-card canvas.is-inspecting { + cursor: grabbing; +} + .metrics-panel { display: grid; gap: 10px;