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:
co-authored by
Claude Sonnet 5
parent
8452f320ea
commit
1eb0467b76
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user