Add drag-to-reposition for the fixed 3:2 crop
Docker / build-and-push (push) Successful in 4m29s

Same fix as latent: the photo area always cover-crops to 3:2 and always
centered, with no way to choose what got cut off. Crop position is now a
normalized 0-1 offset per axis, adjustable by dragging directly on the
canvas whenever the photo's aspect actually leaves slack to pan within.
Pointer movement converts to original-photo pixels via canvas-width/
display-width and outW/cropW (postcard downscales through outW before
compositing, unlike latent's more direct pipeline), so it stays 1:1
regardless of the maxDim downscale or CSS scaling. Resets to centered on
a new photo upload. Verified with a real drag test using far-edge
markers: dragging left/right correctly reveals the opposite edge's
marker in both directions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-08-07 17:33:58 +02:00
co-authored by Claude Sonnet 5
parent 8452f320ea
commit 1eb0467b76
3 changed files with 94 additions and 2 deletions
+82 -2
View File
@@ -26,8 +26,17 @@
let photoDate = new Date(); let photoDate = new Date();
let locationLabel = null; // "City, Country" from EXIF GPS + server-side reverse geocode, or null let locationLabel = null; // "City, Country" from EXIF GPS + server-side reverse geocode, or null
// Slack the fixed 3:2 crop leaves to pan within, in original-photo pixel
// space — recomputed every rebuildSource(), read by the drag handler.
let cropBounds = { iw: 0, ih: 0, cropW: 0, cropH: 0, outW: 0, outH: 0 };
const panHint = document.getElementById('pan-hint');
const params = { const params = {
template: 'cream', template: 'cream',
// Normalized 0-1 position of the crop window within whatever slack the
// fixed 3:2 aspect leaves — 0.5/0.5 is centered.
cropOffsetX: 0.5,
cropOffsetY: 0.5,
caption: '', caption: '',
}; };
@@ -73,6 +82,8 @@
editorCard.hidden = false; editorCard.hidden = false;
resultPanel.hidden = true; resultPanel.hidden = true;
torSeed = Math.random(); torSeed = Math.random();
params.cropOffsetX = 0.5;
params.cropOffsetY = 0.5;
rebuildSource(); rebuildSource();
render(); render();
draftCaption(); draftCaption();
@@ -107,10 +118,10 @@
const currentAspect = iw / ih; const currentAspect = iw / ih;
if (currentAspect > targetAspect) { if (currentAspect > targetAspect) {
cropW = Math.round(ih * targetAspect); cropW = Math.round(ih * targetAspect);
cropX = Math.round((iw - cropW) / 2); cropX = Math.round((iw - cropW) * params.cropOffsetX);
} else { } else {
cropH = Math.round(iw / targetAspect); cropH = Math.round(iw / targetAspect);
cropY = Math.round((ih - cropH) / 2); cropY = Math.round((ih - cropH) * params.cropOffsetY);
} }
const maxDim = 1800; const maxDim = 1800;
@@ -126,6 +137,9 @@
sourceCtx.clearRect(0, 0, outW, outH); sourceCtx.clearRect(0, 0, outW, outH);
sourceCtx.drawImage(originalImg, cropX, cropY, cropW, cropH, 0, 0, outW, outH); sourceCtx.drawImage(originalImg, cropX, cropY, cropW, cropH, 0, 0, outW, outH);
cropBounds = { iw, ih, cropW, cropH, outW, outH };
updatePanAffordance();
// Full card = photo area + a border margin all around, matching a // Full card = photo area + a border margin all around, matching a
// physical postcard's white/cream frame. // physical postcard's white/cream frame.
const margin = Math.round(outW * 0.06); const margin = Math.round(outW * 0.06);
@@ -133,6 +147,72 @@
canvas.height = outH + margin * 2; canvas.height = outH + margin * 2;
} }
function hasPanSlack() {
return cropBounds.iw > cropBounds.cropW || cropBounds.ih > cropBounds.cropH;
}
function updatePanAffordance() {
const pannable = hasPanSlack();
canvas.classList.toggle('pannable', pannable);
if (panHint) panHint.hidden = !pannable;
}
// Drag-to-reposition, same trick as latent: convert pointer movement in
// CSS pixels to original-photo pixels via crop-width/display-width,
// through the outW downscale (canvas's own pixel space === outW/outH,
// not cropW/cropH) and the canvas's CSS scaling, so it stays 1:1
// regardless of either. Dragging from anywhere on the canvas is fine
// even though the border margin isn't photo -- the gesture still reads
// as "drag the photo," and the margin is only ~6% per side.
let dragState = null;
let panRaf = null;
canvas.addEventListener('pointerdown', (e) => {
if (!hasPanSlack()) return;
const { iw, ih, cropW, cropH } = cropBounds;
dragState = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
cropX0: Math.round((iw - cropW) * params.cropOffsetX),
cropY0: Math.round((ih - cropH) * params.cropOffsetY),
};
canvas.setPointerCapture(e.pointerId);
canvas.classList.add('panning');
});
canvas.addEventListener('pointermove', (e) => {
if (!dragState || e.pointerId !== dragState.pointerId) return;
const rect = canvas.getBoundingClientRect();
const { iw, ih, cropW, cropH, outW, outH } = cropBounds;
const cropPxPerCssPxX = (canvas.width / rect.width) * (cropW / outW);
const cropPxPerCssPxY = (canvas.height / rect.height) * (cropH / outH);
const deltaX = (e.clientX - dragState.startX) * cropPxPerCssPxX;
const deltaY = (e.clientY - dragState.startY) * cropPxPerCssPxY;
const slackX = iw - cropW;
const slackY = ih - cropH;
const newCropX = Math.min(Math.max(dragState.cropX0 - deltaX, 0), slackX);
const newCropY = Math.min(Math.max(dragState.cropY0 - deltaY, 0), slackY);
params.cropOffsetX = slackX > 0 ? newCropX / slackX : 0.5;
params.cropOffsetY = slackY > 0 ? newCropY / slackY : 0.5;
if (panRaf) return;
panRaf = requestAnimationFrame(() => {
panRaf = null;
rebuildSource();
render();
});
});
function endPan(e) {
if (!dragState || (e && e.pointerId !== dragState.pointerId)) return;
canvas.classList.remove('panning');
dragState = null;
}
canvas.addEventListener('pointerup', endPan);
canvas.addEventListener('pointercancel', endPan);
function render() { function render() {
if (!originalImg) return; if (!originalImg) return;
const w = canvas.width; const w = canvas.width;
+1
View File
@@ -32,6 +32,7 @@
<div class="canvas-wrap"> <div class="canvas-wrap">
<canvas id="canvas"></canvas> <canvas id="canvas"></canvas>
</div> </div>
<p class="pan-hint" id="pan-hint" hidden>Drag the photo to reposition the crop</p>
<div class="controls"> <div class="controls">
<div class="control-group"> <div class="control-group">
+11
View File
@@ -144,6 +144,17 @@ p { margin: 0; }
max-width: 100%; max-width: 100%;
max-height: 60vh; max-height: 60vh;
display: block; display: block;
touch-action: none;
}
#canvas.pannable { cursor: grab; }
#canvas.pannable.panning { cursor: grabbing; }
.pan-hint {
text-align: center;
font-size: 12px;
color: var(--text-dim, #888);
margin: -12px 0 20px;
} }
/* ── Controls ───────────────────────────────────────────────────────── */ /* ── Controls ───────────────────────────────────────────────────────── */