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 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 = {
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: '',
};
@@ -73,6 +82,8 @@
editorCard.hidden = false;
resultPanel.hidden = true;
torSeed = Math.random();
params.cropOffsetX = 0.5;
params.cropOffsetY = 0.5;
rebuildSource();
render();
draftCaption();
@@ -107,10 +118,10 @@
const currentAspect = iw / ih;
if (currentAspect > targetAspect) {
cropW = Math.round(ih * targetAspect);
cropX = Math.round((iw - cropW) / 2);
cropX = Math.round((iw - cropW) * params.cropOffsetX);
} else {
cropH = Math.round(iw / targetAspect);
cropY = Math.round((ih - cropH) / 2);
cropY = Math.round((ih - cropH) * params.cropOffsetY);
}
const maxDim = 1800;
@@ -126,6 +137,9 @@
sourceCtx.clearRect(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
// physical postcard's white/cream frame.
const margin = Math.round(outW * 0.06);
@@ -133,6 +147,72 @@
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() {
if (!originalImg) return;
const w = canvas.width;