Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/CRICUT-WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
3 changes: 3 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 12 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -2840,7 +2847,11 @@ export default function App() {
: 'Your canvas is ready'}
<span className="view-label">PREVIEW ONLY</span>
</div>
<div className="paper-viewport" ref={paperViewport}>
<div
className="paper-viewport"
ref={paperViewport}
title="Scroll to zoom · Drag empty space or right-drag anywhere to pan"
>
<SheetPreview
sheet={sheet}
settings={project.settings}
Expand Down
92 changes: 92 additions & 0 deletions src/lib/artboard-pan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/** Move the preview by dragging empty space or holding the right mouse button. */
export function enableArtboardPanning(viewport: HTMLElement): () => 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);
};
}
6 changes: 6 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
142 changes: 142 additions & 0 deletions tests/artboard-pan.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
const captures = new Set<number>();
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<string, unknown> = {}, 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);
}
});
Loading