Improve editor pan and zoom controls
This commit is contained in:
parent
9964c67334
commit
5a50a9068f
4 changed files with 283 additions and 16 deletions
|
|
@ -69,6 +69,11 @@ type AppState = {
|
|||
currentStage: string;
|
||||
};
|
||||
|
||||
type ViewportAnchor = {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
};
|
||||
|
||||
function byId<T extends HTMLElement>(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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
`;
|
||||
}
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@
|
|||
<div class="copy-stack card-copy">
|
||||
<p class="eyebrow">Pixel Fixes</p>
|
||||
<h2>Output Editor</h2>
|
||||
<p class="copy-tag">Pencil the stragglers. Hold Alt to steal color straight from the panel.</p>
|
||||
<p class="copy-tag">Pencil the stragglers. Wheel zooms to cursor, middle mouse pans, Alt steals color.</p>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button id="resetEditorButton" class="button button-small button-secondary" type="button">Reset Output</button>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue