diff --git a/frontend/src/app.ts b/frontend/src/app.ts index 1ee54fa..5e70532 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -69,6 +69,11 @@ type AppState = { currentStage: string; }; +type ViewportAnchor = { + clientX: number; + clientY: number; +}; + function byId(id: string): T { const element = document.getElementById(id); if (!element) { @@ -177,6 +182,12 @@ const state: AppState = { let currentEventSource: EventSource | null = null; let painting = false; +let panning = false; +let activePanPointerId: number | null = null; +let panStartClientX = 0; +let panStartClientY = 0; +let panStartScrollLeft = 0; +let panStartScrollTop = 0; let offscreenCanvas: HTMLCanvasElement | null = null; let offscreenContext: CanvasRenderingContext2D | null = null; let singlePixel = new ImageData(1, 1); @@ -185,6 +196,17 @@ function clampByte(value: number): number { return Math.max(0, Math.min(255, Math.round(value))); } +function clampZoom(value: number): number { + const minZoom = Number(zoomInput.min) || 1; + const maxZoom = Number(zoomInput.max) || 64; + return Math.max(minZoom, Math.min(maxZoom, Math.round(value))); +} + +function syncEditorCursorState(): void { + editorSurface.classList.toggle("is-panning", panning); + editorSurface.classList.toggle("is-eyedropper", state.altHeld); +} + function setPaintColor(color: [number, number, number, number]): void { state.paintColor = color; paintInputs.r.value = String(color[0]); @@ -607,6 +629,42 @@ function renderEditor(): void { `; } +function setZoom(nextZoom: number, anchor: ViewportAnchor | null = null): void { + const clampedZoom = clampZoom(nextZoom); + const previousZoom = state.zoom; + if (clampedZoom === previousZoom) { + zoomInput.value = String(clampedZoom); + zoomValue.textContent = `${clampedZoom}x`; + return; + } + + let viewportX = 0; + let viewportY = 0; + let imageX: number | null = null; + let imageY: number | null = null; + if (offscreenCanvas && anchor) { + const surfaceRect = editorSurface.getBoundingClientRect(); + viewportX = anchor.clientX - surfaceRect.left; + viewportY = anchor.clientY - surfaceRect.top; + imageX = (editorSurface.scrollLeft + viewportX - editorCanvas.offsetLeft) / previousZoom; + imageY = (editorSurface.scrollTop + viewportY - editorCanvas.offsetTop) / previousZoom; + } + + state.zoom = clampedZoom; + zoomInput.value = String(clampedZoom); + zoomValue.textContent = `${clampedZoom}x`; + renderEditor(); + + if (offscreenCanvas && imageX !== null && imageY !== null) { + const nextScrollLeft = imageX * clampedZoom + editorCanvas.offsetLeft - viewportX; + const nextScrollTop = imageY * clampedZoom + editorCanvas.offsetTop - viewportY; + const maxScrollLeft = Math.max(0, editorSurface.scrollWidth - editorSurface.clientWidth); + const maxScrollTop = Math.max(0, editorSurface.scrollHeight - editorSurface.clientHeight); + editorSurface.scrollLeft = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft)); + editorSurface.scrollTop = Math.max(0, Math.min(maxScrollTop, nextScrollTop)); + } +} + function editorPixelFromPointer(event: PointerEvent): { x: number; y: number } | null { if (!offscreenCanvas) { return null; @@ -654,6 +712,29 @@ function exportEditorPng(): void { link.click(); } +function beginPan(event: PointerEvent): void { + panning = true; + activePanPointerId = event.pointerId; + panStartClientX = event.clientX; + panStartClientY = event.clientY; + panStartScrollLeft = editorSurface.scrollLeft; + panStartScrollTop = editorSurface.scrollTop; + editorSurface.setPointerCapture(event.pointerId); + syncEditorCursorState(); +} + +function endPan(pointerId: number | null = null): void { + if (pointerId !== null && activePanPointerId !== pointerId) { + return; + } + if (activePanPointerId !== null && editorSurface.hasPointerCapture(activePanPointerId)) { + editorSurface.releasePointerCapture(activePanPointerId); + } + panning = false; + activePanPointerId = null; + syncEditorCursorState(); +} + function buildFormData(): FormData { if (!state.file) { throw new Error("No file selected"); @@ -869,9 +950,7 @@ stepSlider.addEventListener("input", () => { }); zoomInput.addEventListener("input", () => { - state.zoom = Number(zoomInput.value); - zoomValue.textContent = `${state.zoom}x`; - renderEditor(); + setZoom(Number(zoomInput.value)); }); gridToggle.addEventListener("change", () => { @@ -893,16 +972,78 @@ for (const input of Object.values(paintInputs)) { window.addEventListener("keydown", (event) => { if (event.key === "Alt") { state.altHeld = true; + syncEditorCursorState(); } }); window.addEventListener("keyup", (event) => { if (event.key === "Alt") { state.altHeld = false; + syncEditorCursorState(); } }); +window.addEventListener("blur", () => { + state.altHeld = false; + painting = false; + endPan(); + syncEditorCursorState(); +}); + +editorSurface.addEventListener("mousedown", (event) => { + if (event.button === 1) { + event.preventDefault(); + } +}); + +editorSurface.addEventListener("auxclick", (event) => { + if (event.button === 1) { + event.preventDefault(); + } +}); + +editorSurface.addEventListener( + "wheel", + (event) => { + if (!offscreenCanvas) { + return; + } + event.preventDefault(); + const delta = event.deltaY < 0 ? 1 : -1; + setZoom(state.zoom + delta, { clientX: event.clientX, clientY: event.clientY }); + }, + { passive: false }, +); + +editorSurface.addEventListener("pointerdown", (event) => { + if (event.button !== 1 || !offscreenCanvas) { + return; + } + event.preventDefault(); + beginPan(event); +}); + +editorSurface.addEventListener("pointermove", (event) => { + if (!panning || activePanPointerId !== event.pointerId) { + return; + } + event.preventDefault(); + editorSurface.scrollLeft = panStartScrollLeft - (event.clientX - panStartClientX); + editorSurface.scrollTop = panStartScrollTop - (event.clientY - panStartClientY); +}); + +editorSurface.addEventListener("pointerup", (event) => { + endPan(event.pointerId); +}); + +editorSurface.addEventListener("pointercancel", (event) => { + endPan(event.pointerId); +}); + editorCanvas.addEventListener("pointerdown", (event) => { + if (event.button !== 0) { + return; + } const pixel = editorPixelFromPointer(event); if (!pixel) { return; @@ -918,6 +1059,9 @@ editorCanvas.addEventListener("pointerdown", (event) => { }); editorCanvas.addEventListener("pointermove", (event) => { + if (panning) { + return; + } const pixel = editorPixelFromPointer(event); if (!pixel) { return; @@ -950,6 +1094,7 @@ downloadButton.addEventListener("click", () => { }); setPaintColor(state.paintColor); +syncEditorCursorState(); 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 32fe6aa..6f4f6dc 100644 --- a/src/repixelizer/gui_static/app.js +++ b/src/repixelizer/gui_static/app.js @@ -28,7 +28,6 @@ const middleVizLabel = byId("middleVizLabel"); const rightVizLabel = byId("rightVizLabel"); const metricsPanel = byId("metricsPanel"); const paintSwatch = byId("paintSwatch"); -const toolMode = byId("toolMode"); const zoomInput = byId("zoomInput"); const zoomValue = byId("zoomValue"); const gridToggle = byId("gridToggle"); @@ -102,12 +101,27 @@ const state = { }; let currentEventSource = null; let painting = false; +let panning = false; +let activePanPointerId = null; +let panStartClientX = 0; +let panStartClientY = 0; +let panStartScrollLeft = 0; +let panStartScrollTop = 0; let offscreenCanvas = null; let offscreenContext = null; let singlePixel = new ImageData(1, 1); function clampByte(value) { return Math.max(0, Math.min(255, Math.round(value))); } +function clampZoom(value) { + const minZoom = Number(zoomInput.min) || 1; + const maxZoom = Number(zoomInput.max) || 64; + return Math.max(minZoom, Math.min(maxZoom, Math.round(value))); +} +function syncEditorCursorState() { + editorSurface.classList.toggle("is-panning", panning); + editorSurface.classList.toggle("is-eyedropper", state.altHeld); +} function setPaintColor(color) { state.paintColor = color; paintInputs.r.value = String(color[0]); @@ -116,9 +130,6 @@ function setPaintColor(color) { paintInputs.a.value = String(color[3]); paintSwatch.style.background = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3] / 255})`; } -function updateToolMode() { - toolMode.textContent = state.altHeld ? "Eyedropper armed. Sample a pixel." : "Pencil. Hold Alt for eyedropper."; -} function addLog(label, detail) { state.eventLog.unshift({ label, detail }); state.eventLog = state.eventLog.slice(0, 8); @@ -512,6 +523,38 @@ function renderEditor() { `; } +function setZoom(nextZoom, anchor = null) { + const clampedZoom = clampZoom(nextZoom); + const previousZoom = state.zoom; + if (clampedZoom === previousZoom) { + zoomInput.value = String(clampedZoom); + zoomValue.textContent = `${clampedZoom}x`; + return; + } + let viewportX = 0; + let viewportY = 0; + let imageX = null; + let imageY = null; + if (offscreenCanvas && anchor) { + const surfaceRect = editorSurface.getBoundingClientRect(); + viewportX = anchor.clientX - surfaceRect.left; + viewportY = anchor.clientY - surfaceRect.top; + imageX = (editorSurface.scrollLeft + viewportX - editorCanvas.offsetLeft) / previousZoom; + imageY = (editorSurface.scrollTop + viewportY - editorCanvas.offsetTop) / previousZoom; + } + state.zoom = clampedZoom; + zoomInput.value = String(clampedZoom); + zoomValue.textContent = `${clampedZoom}x`; + renderEditor(); + if (offscreenCanvas && imageX !== null && imageY !== null) { + const nextScrollLeft = imageX * clampedZoom + editorCanvas.offsetLeft - viewportX; + const nextScrollTop = imageY * clampedZoom + editorCanvas.offsetTop - viewportY; + const maxScrollLeft = Math.max(0, editorSurface.scrollWidth - editorSurface.clientWidth); + const maxScrollTop = Math.max(0, editorSurface.scrollHeight - editorSurface.clientHeight); + editorSurface.scrollLeft = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft)); + editorSurface.scrollTop = Math.max(0, Math.min(maxScrollTop, nextScrollTop)); + } +} function editorPixelFromPointer(event) { if (!offscreenCanvas) { return null; @@ -555,6 +598,27 @@ function exportEditorPng() { link.download = (state.file?.name?.replace(/\.[^.]+$/, "") || "repixelized") + "-edited.png"; link.click(); } +function beginPan(event) { + panning = true; + activePanPointerId = event.pointerId; + panStartClientX = event.clientX; + panStartClientY = event.clientY; + panStartScrollLeft = editorSurface.scrollLeft; + panStartScrollTop = editorSurface.scrollTop; + editorSurface.setPointerCapture(event.pointerId); + syncEditorCursorState(); +} +function endPan(pointerId = null) { + if (pointerId !== null && activePanPointerId !== pointerId) { + return; + } + if (activePanPointerId !== null && editorSurface.hasPointerCapture(activePanPointerId)) { + editorSurface.releasePointerCapture(activePanPointerId); + } + panning = false; + activePanPointerId = null; + syncEditorCursorState(); +} function buildFormData() { if (!state.file) { throw new Error("No file selected"); @@ -759,9 +823,7 @@ stepSlider.addEventListener("input", () => { void renderEverything(); }); zoomInput.addEventListener("input", () => { - state.zoom = Number(zoomInput.value); - zoomValue.textContent = `${state.zoom}x`; - renderEditor(); + setZoom(Number(zoomInput.value)); }); gridToggle.addEventListener("change", () => { state.showGrid = gridToggle.checked; @@ -780,16 +842,64 @@ for (const input of Object.values(paintInputs)) { window.addEventListener("keydown", (event) => { if (event.key === "Alt") { state.altHeld = true; - updateToolMode(); + syncEditorCursorState(); } }); window.addEventListener("keyup", (event) => { if (event.key === "Alt") { state.altHeld = false; - updateToolMode(); + syncEditorCursorState(); } }); +window.addEventListener("blur", () => { + state.altHeld = false; + painting = false; + endPan(); + syncEditorCursorState(); +}); +editorSurface.addEventListener("mousedown", (event) => { + if (event.button === 1) { + event.preventDefault(); + } +}); +editorSurface.addEventListener("auxclick", (event) => { + if (event.button === 1) { + event.preventDefault(); + } +}); +editorSurface.addEventListener("wheel", (event) => { + if (!offscreenCanvas) { + return; + } + event.preventDefault(); + const delta = event.deltaY < 0 ? 1 : -1; + setZoom(state.zoom + delta, { clientX: event.clientX, clientY: event.clientY }); +}, { passive: false }); +editorSurface.addEventListener("pointerdown", (event) => { + if (event.button !== 1 || !offscreenCanvas) { + return; + } + event.preventDefault(); + beginPan(event); +}); +editorSurface.addEventListener("pointermove", (event) => { + if (!panning || activePanPointerId !== event.pointerId) { + return; + } + event.preventDefault(); + editorSurface.scrollLeft = panStartScrollLeft - (event.clientX - panStartClientX); + editorSurface.scrollTop = panStartScrollTop - (event.clientY - panStartClientY); +}); +editorSurface.addEventListener("pointerup", (event) => { + endPan(event.pointerId); +}); +editorSurface.addEventListener("pointercancel", (event) => { + endPan(event.pointerId); +}); editorCanvas.addEventListener("pointerdown", (event) => { + if (event.button !== 0) { + return; + } const pixel = editorPixelFromPointer(event); if (!pixel) { return; @@ -798,19 +908,20 @@ editorCanvas.addEventListener("pointerdown", (event) => { editorCanvas.setPointerCapture(event.pointerId); if (event.altKey || state.altHeld) { samplePaintColor(pixel.x, pixel.y); - updateToolMode(); return; } painting = true; paintPixel(pixel.x, pixel.y); }); editorCanvas.addEventListener("pointermove", (event) => { + if (panning) { + return; + } const pixel = editorPixelFromPointer(event); if (!pixel) { return; } if (event.altKey || state.altHeld) { - updateToolMode(); if (event.buttons === 1) { samplePaintColor(pixel.x, pixel.y); } @@ -833,7 +944,7 @@ downloadButton.addEventListener("click", () => { exportEditorPng(); }); setPaintColor(state.paintColor); -updateToolMode(); +syncEditorCursorState(); zoomValue.textContent = `${state.zoom}x`; renderEventLog(); void renderEverything(); diff --git a/src/repixelizer/gui_static/index.html b/src/repixelizer/gui_static/index.html index 4e33b37..e494575 100644 --- a/src/repixelizer/gui_static/index.html +++ b/src/repixelizer/gui_static/index.html @@ -171,7 +171,7 @@

Pixel Fixes

Output Editor

-

Pencil the stragglers. Hold Alt to steal color straight from the panel.

+

Pencil the stragglers. Wheel zooms to cursor, middle mouse pans, Alt steals color.

diff --git a/src/repixelizer/gui_static/styles.css b/src/repixelizer/gui_static/styles.css index fc31ffc..181f6b3 100644 --- a/src/repixelizer/gui_static/styles.css +++ b/src/repixelizer/gui_static/styles.css @@ -713,12 +713,23 @@ p { min-height: 420px; overflow: auto; padding: 16px; + overscroll-behavior: contain; } .editor-surface canvas { position: absolute; top: 16px; left: 16px; + cursor: crosshair; +} + +.editor-surface.is-eyedropper canvas { + cursor: copy; +} + +.editor-surface.is-panning, +.editor-surface.is-panning canvas { + cursor: grabbing; } #editorGridCanvas {