From 137eead97b2562d36279fa7c740421da021c8b43 Mon Sep 17 00:00:00 2001 From: Chris Engle Date: Fri, 9 Oct 2026 20:53:04 -0400 Subject: [PATCH] feat(preview): move around the sheet with mouse dragging --- README.md | 2 +- docs/CRICUT-WORKFLOW.md | 5 ++ docs/FEATURES.md | 3 + src/App.tsx | 13 +++- src/lib/artboard-pan.ts | 92 ++++++++++++++++++++++++ src/style.css | 6 ++ tests/artboard-pan.test.ts | 142 +++++++++++++++++++++++++++++++++++++ 7 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 src/lib/artboard-pan.ts create mode 100644 tests/artboard-pan.test.ts diff --git a/README.md b/README.md index 4ebe1f5..07f9b54 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ CriProx brings the fiddly parts of a playtest-card workflow into one focused des ## From card list to cut 1. **Build the sheet.** Import a Moxfield or Archidekt link, paste a deck list, search cards, browse sets, commander precons, or tokens, or add local artwork. -2. **Dial in the output.** Choose the machine, paper, layout, resolution, bleed, and optional card backs. Edit your preferred setup directly in Settings and save it for future projects, or also apply it to the current deck. +2. **Dial in the output.** Choose the machine, paper, layout, resolution, bleed, and optional card backs. Scroll over the preview to zoom; drag empty space or right-drag anywhere to pan. Edit your preferred setup directly in Settings and save it for future projects, or also apply it to the current deck. 3. **Set up the cut template once.** In Create print PDF, download the setup PNG. Upload it to Design Space as a flat Print Then Cut image, preserve the supplied dimensions, and save the project and mat arrangement. 4. **Capture the template once.** Save that Design Space job's print output as a one-page PDF and import it into CriProx to verify and save its registration marks. The setup sections collapse with green checks; choose Done for reviewed settings, and reopen any section from its header. 5. **Print the PDF.** Use the visible prepare controls, review the pages, and save the finished card-sheet PDF from CriProx. Open it in a dedicated PDF application and print at 100% / Actual size, with fit or shrink scaling disabled. diff --git a/docs/CRICUT-WORKFLOW.md b/docs/CRICUT-WORKFLOW.md index 80c77bf..d8a4427 100644 --- a/docs/CRICUT-WORKFLOW.md +++ b/docs/CRICUT-WORKFLOW.md @@ -9,6 +9,11 @@ focus automatically when the dialog opens. Selecting text or dragging from inside the dialog and releasing outside keeps it open. Click outside the dialog or choose **Done** to close it. +While reviewing the sheet, scroll the mouse wheel to zoom in or out. To move around the preview, +left-click and drag empty space on or around the paper, or hold the right mouse button and drag +anywhere, including over a selected card. Release the button to stop panning. This changes only +your view; card positions and print output stay the same. + ## Build a sheet from a set 1. Open **Find a card** and choose **Browse sets**. Filter by a set name or code, such as `MH2`. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 5424a5e..85be302 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -52,6 +52,9 @@ newly announced products may not have decklists or artwork yet. - Millimeter-based placement and vector geometry shared by preview and export. - Print-sheet preview with captured Cricut registration marks when available, sheet pagination, and zoom controls. +- Scroll the mouse wheel over the sheet to zoom. Drag empty space on or around the sheet to pan, + or hold the right mouse button and drag anywhere, including over a selected card. Panning moves + only the preview; normal left-clicks on cards still select them or open their artwork preview. - 300, 600, 900, and 1200 DPI output. - Optional playtest label along the bottom edge. - Size-check card with a 5 mm measurement grid. diff --git a/src/App.tsx b/src/App.tsx index 32c65cd..a12ad7e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { templateFilenameStem } from './lib/filenames'; +import { enableArtboardPanning } from './lib/artboard-pan'; import RegisteredPrint from './components/RegisteredPrint'; import FrontBleedControl from './components/FrontBleedControl'; import MpcArtworkSearch from './components/MpcArtworkSearch'; @@ -1951,6 +1952,12 @@ export default function App() { const viewport = paperViewport.current; if (!viewport) return; + return enableArtboardPanning(viewport); + }, []); + useEffect(() => { + const viewport = paperViewport.current; + if (!viewport) return; + const handleWheel = (event: WheelEvent) => { if (!event.deltaY) return; event.preventDefault(); @@ -2840,7 +2847,11 @@ export default function App() { : 'Your canvas is ready'} PREVIEW ONLY -
+
void { + let drag: { + pointerId: number; + buttons: number; + x: number; + y: number; + scrollLeft: number; + scrollTop: number; + } | null = null; + let suppressClick = false; + + const finish = () => { + if (!drag) return; + const { pointerId } = drag; + drag = null; + viewport.classList.remove('panning'); + if (viewport.hasPointerCapture(pointerId)) viewport.releasePointerCapture(pointerId); + }; + const start = (event: PointerEvent) => { + if (event.pointerType !== 'mouse' || drag) return; + suppressClick = false; + if (event.button !== 0 && event.button !== 2) return; + const target = event.target as Element; + if (event.button === 0 && target.closest('.placed-card')) return; + + // Leave the native scrollbars available for ordinary scrollbar dragging. + const rect = viewport.getBoundingClientRect(); + if ( + event.clientX >= rect.left + viewport.clientWidth || + event.clientY >= rect.top + viewport.clientHeight + ) + return; + + event.preventDefault(); + drag = { + pointerId: event.pointerId, + buttons: event.button === 2 ? 2 : 1, + x: event.clientX, + y: event.clientY, + scrollLeft: viewport.scrollLeft, + scrollTop: viewport.scrollTop, + }; + viewport.setPointerCapture(event.pointerId); + viewport.classList.add('panning'); + }; + const move = (event: PointerEvent) => { + if (!drag || event.pointerId !== drag.pointerId) return; + if (!(event.buttons & drag.buttons)) { + finish(); + return; + } + event.preventDefault(); + const dx = drag.x - event.clientX; + const dy = drag.y - event.clientY; + if (dx || dy) suppressClick = true; + // Measure from the start so fractional mouse steps cannot accumulate rounding drift. + viewport.scrollLeft = drag.scrollLeft + dx; + viewport.scrollTop = drag.scrollTop + dy; + }; + const end = (event: PointerEvent) => { + if (event.pointerId === drag?.pointerId) finish(); + }; + const click = (event: MouseEvent) => { + if (!suppressClick || event.detail === 0) return; + suppressClick = false; + event.preventDefault(); + event.stopPropagation(); + }; + const contextMenu = (event: MouseEvent) => event.preventDefault(); + const window = viewport.ownerDocument.defaultView; + + viewport.addEventListener('pointerdown', start); + viewport.addEventListener('pointermove', move); + viewport.addEventListener('pointerup', end); + viewport.addEventListener('pointercancel', end); + viewport.addEventListener('lostpointercapture', end); + viewport.addEventListener('click', click, true); + viewport.addEventListener('contextmenu', contextMenu); + window?.addEventListener('blur', finish); + return () => { + finish(); + viewport.removeEventListener('pointerdown', start); + viewport.removeEventListener('pointermove', move); + viewport.removeEventListener('pointerup', end); + viewport.removeEventListener('pointercancel', end); + viewport.removeEventListener('lostpointercapture', end); + viewport.removeEventListener('click', click, true); + viewport.removeEventListener('contextmenu', contextMenu); + window?.removeEventListener('blur', finish); + }; +} diff --git a/src/style.css b/src/style.css index f2f42ee..f513c2e 100644 --- a/src/style.css +++ b/src/style.css @@ -830,6 +830,12 @@ h1 > span { overflow: auto; container-type: size; scrollbar-width: thin; + cursor: grab; +} +.paper-viewport.panning, +.paper-viewport.panning * { + cursor: grabbing !important; + user-select: none; } .sheet-label { font-size: 9px; diff --git a/tests/artboard-pan.test.ts b/tests/artboard-pan.test.ts new file mode 100644 index 0000000..8e9606a --- /dev/null +++ b/tests/artboard-pan.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { enableArtboardPanning } from '../src/lib/artboard-pan'; + +function preview() { + const classes = new Set(); + const captures = new Set(); + const window = new EventTarget(); + const viewport = Object.assign(new EventTarget(), { + scrollLeft: 200, + scrollTop: 300, + clientWidth: 300, + clientHeight: 200, + ownerDocument: { defaultView: window }, + classList: { + add: (name: string) => classes.add(name), + remove: (name: string) => classes.delete(name), + }, + getBoundingClientRect: () => ({ left: 10, top: 20 }), + setPointerCapture: (id: number) => captures.add(id), + hasPointerCapture: (id: number) => captures.has(id), + releasePointerCapture: (id: number) => captures.delete(id), + }); + const dispose = enableArtboardPanning(viewport as unknown as HTMLElement); + function send(type: string, options: Record = {}, card = false) { + const event = Object.assign(new Event(type, { cancelable: true }), { + pointerType: 'mouse', + pointerId: 1, + button: 0, + buttons: 1, + clientX: 100, + clientY: 100, + detail: 1, + ...options, + }); + Object.defineProperty(event, 'target', { value: { closest: () => (card ? {} : null) } }); + viewport.dispatchEvent(event); + return event; + } + return { viewport, window, classes, captures, dispose, send }; +} + +test('dragging empty space moves the view with the mouse and suppresses the release click', () => { + const p = preview(); + assert.equal(p.send('pointerdown').defaultPrevented, true); + assert.equal(p.classes.has('panning'), true); + assert.equal(p.captures.has(1), true); + p.send('pointermove', { clientX: 140, clientY: 125 }); + assert.equal(p.viewport.scrollLeft, 160); + assert.equal(p.viewport.scrollTop, 275); + p.send('pointermove', { clientX: 80, clientY: 90 }); + assert.equal(p.viewport.scrollLeft, 220); + assert.equal(p.viewport.scrollTop, 310); + p.send('pointerup', { buttons: 0 }); + assert.equal(p.classes.has('panning'), false); + assert.equal(p.captures.size, 0); + assert.equal(p.send('click', {}, true).defaultPrevented, true); + p.dispose(); +}); + +test('right dragging over card artwork pans without opening the context menu', () => { + const p = preview(); + assert.equal(p.send('pointerdown', { button: 2, buttons: 2 }, true).defaultPrevented, true); + assert.equal(p.send('contextmenu', {}, true).defaultPrevented, true); + p.send('pointermove', { buttons: 2, clientX: 65, clientY: 50 }, true); + assert.equal(p.viewport.scrollLeft, 235); + assert.equal(p.viewport.scrollTop, 350); + p.send('pointerup', { button: 2, buttons: 0 }); + assert.equal(p.classes.has('panning'), false); + p.dispose(); +}); + +test('fractional mouse steps do not accumulate rounding drift in browser scroll positions', () => { + const p = preview(); + let scrollLeft = p.viewport.scrollLeft; + Object.defineProperty(p.viewport, 'scrollLeft', { + get: () => scrollLeft, + set: (value: number) => { + scrollLeft = Math.round(value); + }, + }); + p.send('pointerdown'); + for (let step = 1; step <= 6; step++) { + p.send('pointermove', { clientX: 100 + (70 * step) / 6 }); + } + assert.equal(p.viewport.scrollLeft, 130); + p.dispose(); +}); + +test('ordinary card clicks, keyboard activation, touch, middle button, and scrollbar input stay available', () => { + const p = preview(); + for (const [options, card] of [ + [{}, true], + [{ pointerType: 'touch' }, false], + [{ button: 1, buttons: 4 }, false], + [{ clientX: 311 }, false], + [{ clientY: 221 }, false], + ] as const) { + assert.equal(p.send('pointerdown', options, card).defaultPrevented, false); + assert.equal(p.classes.has('panning'), false); + } + assert.equal(p.send('click', {}, true).defaultPrevented, false); + p.send('pointerdown'); + p.send('pointermove', { clientX: 110 }); + p.send('pointerup', { buttons: 0 }); + assert.equal(p.send('click', { detail: 0 }, true).defaultPrevented, false); + p.send('pointerdown', {}, true); + assert.equal(p.send('click', {}, true).defaultPrevented, false); + p.dispose(); +}); + +test('capture keeps a drag active outside the viewport and ignores other pointers', () => { + const p = preview(); + p.send('pointerdown'); + p.send('pointermove', { pointerId: 2, clientX: 130 }); + p.send('pointerup', { pointerId: 2, buttons: 0 }); + assert.equal(p.viewport.scrollLeft, 200); + assert.equal(p.classes.has('panning'), true); + p.send('pointermove', { clientX: -50, clientY: -20 }); + assert.equal(p.viewport.scrollLeft, 350); + assert.equal(p.viewport.scrollTop, 420); + p.dispose(); +}); + +test('cancellation, capture loss, released buttons, blur, and disposal stop panning', () => { + for (const reason of ['pointercancel', 'lostpointercapture', 'buttons', 'blur', 'dispose']) { + const p = preview(); + p.send('pointerdown'); + if (reason === 'buttons') p.send('pointermove', { buttons: 2 }); + else if (reason === 'blur') p.window.dispatchEvent(new Event('blur')); + else if (reason === 'dispose') p.dispose(); + else p.send(reason); + assert.equal(p.classes.has('panning'), false, reason); + assert.equal(p.captures.size, 0, reason); + p.send('pointermove', { clientX: 150, clientY: 140 }); + assert.equal(p.viewport.scrollLeft, 200, reason); + assert.equal(p.viewport.scrollTop, 300, reason); + p.dispose(); + assert.equal(p.send('contextmenu').defaultPrevented, false); + assert.equal(p.send('pointerdown').defaultPrevented, false); + } +});