Files
Fredrik JohanssonandClaude Sonnet 5 1eb0467b76
Docker / build-and-push (push) Successful in 4m29s
Add drag-to-reposition for the fixed 3:2 crop
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>
2026-08-07 17:33:58 +02:00

457 lines
16 KiB
JavaScript

(() => {
const fileInput = document.getElementById('file-input');
const cameraInput = document.getElementById('camera-input');
const cameraBtn = document.getElementById('camera-btn');
const libraryBtn = document.getElementById('library-btn');
const dropZone = document.getElementById('drop-zone');
const dropCard = document.getElementById('drop-card');
const editorCard = document.getElementById('editor-card');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const templateRow = document.getElementById('template-row');
const captionInput = document.getElementById('caption-input');
const captionStatus = document.getElementById('caption-status');
const redraftBtn = document.getElementById('redraft-btn');
const newPhotoBtn = document.getElementById('new-photo-btn');
const downloadBtn = document.getElementById('download-btn');
const shareBtn = document.getElementById('share-btn');
const resultPanel = document.getElementById('result-panel');
const source = document.createElement('canvas'); // original photo, cover-cropped into the photo area
const sourceCtx = source.getContext('2d');
let originalImg = null;
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: '',
};
// Registered via CSS @font-face but canvas fillText won't wait for a
// lazily-loaded webfont on its own — load it explicitly once up front.
let fontReady = false;
document.fonts.load('700 40px Caveat').catch(() => {}).finally(() => {
fontReady = true;
if (originalImg) render();
});
// Small seeded PRNG so a template's jagged/faded look stays fixed per
// photo instead of jittering every redraw.
function mulberry32(seed) {
let a = Math.floor(seed * 0xffffffff);
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
let torSeed = Math.random();
async function loadFile(file) {
if (!file || !file.type.startsWith('image/')) return;
// Read EXIF before the image element even loads — DateTimeOriginal
// backdates the caption/postmark to when the photo was actually taken,
// not when it happened to be uploaded; GPS (if present) drives the
// location line. Both are optional: parse failures/missing tags just
// mean "fall back to upload date, no location," per PROPOSAL.md.
const exif = window.PostcardExif ? await window.PostcardExif.read(file) : null;
photoDate = exif?.date || new Date();
locationLabel = null;
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
originalImg = img;
URL.revokeObjectURL(url);
dropCard.hidden = true;
editorCard.hidden = false;
resultPanel.hidden = true;
torSeed = Math.random();
params.cropOffsetX = 0.5;
params.cropOffsetY = 0.5;
rebuildSource();
render();
draftCaption();
if (exif && exif.lat != null && exif.lon != null) lookupLocation(exif.lat, exif.lon);
};
img.src = url;
}
async function lookupLocation(lat, lon) {
try {
const res = await fetch(`/api/geocode?lat=${lat}&lon=${lon}`);
const data = await res.json();
if (data.label) {
locationLabel = data.label;
render();
}
} catch {
// No location line — never invents one, per PROPOSAL.md.
}
}
// Builds `source`: the photo cover-cropped to a fixed postcard photo-area
// aspect (3:2, classic postcard proportions), capped so a raw phone photo
// doesn't choke canvas ops.
function rebuildSource() {
if (!originalImg) return;
const iw = originalImg.naturalWidth;
const ih = originalImg.naturalHeight;
const targetAspect = 3 / 2;
let cropW = iw, cropH = ih, cropX = 0, cropY = 0;
const currentAspect = iw / ih;
if (currentAspect > targetAspect) {
cropW = Math.round(ih * targetAspect);
cropX = Math.round((iw - cropW) * params.cropOffsetX);
} else {
cropH = Math.round(iw / targetAspect);
cropY = Math.round((ih - cropH) * params.cropOffsetY);
}
const maxDim = 1800;
let outW = cropW, outH = cropH;
if (Math.max(outW, outH) > maxDim) {
const scale = maxDim / Math.max(outW, outH);
outW = Math.round(outW * scale);
outH = Math.round(outH * scale);
}
source.width = outW;
source.height = outH;
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);
canvas.width = outW + 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() {
if (!originalImg) return;
const w = canvas.width;
const h = canvas.height;
const margin = Math.round(source.width * 0.06);
ctx.clearRect(0, 0, w, h);
// Border background per template.
if (params.template === 'faded') {
ctx.fillStyle = '#e9dfc8';
} else {
ctx.fillStyle = '#f7f2e6';
}
ctx.fillRect(0, 0, w, h);
// Photo, with a per-template filter applied at draw time.
ctx.save();
ctx.filter = params.template === 'faded'
? 'sepia(0.25) saturate(0.55) contrast(0.85) brightness(1.1)'
: 'none';
if (params.template === 'torn') {
clipTornRect(margin, margin, source.width, source.height, torSeed);
}
ctx.drawImage(source, margin, margin);
ctx.restore();
if (params.template !== 'torn') {
ctx.strokeStyle = 'rgba(0,0,0,0.15)';
ctx.lineWidth = 1;
ctx.strokeRect(margin + 0.5, margin + 0.5, source.width - 1, source.height - 1);
}
drawStampCorner(w, h, margin);
drawCaption(w, h, margin);
}
// Clips the current path to a rectangle with a hand-torn edge — jagged
// teeth along each side, seeded so it's stable per photo.
function clipTornRect(x, y, w, h, seed) {
const rng = mulberry32(seed);
const tooth = Math.max(2, Math.round(Math.min(w, h) * 0.006));
const step = tooth * 3;
ctx.beginPath();
let px = x, py = y;
ctx.moveTo(px, py);
for (; px < x + w; px += step) ctx.lineTo(px, y + (rng() - 0.5) * tooth);
ctx.lineTo(x + w, y + (rng() - 0.5) * tooth);
for (; py < y + h; py += step) ctx.lineTo(x + w + (rng() - 0.5) * tooth, py);
ctx.lineTo(x + w + (rng() - 0.5) * tooth, y + h);
for (px = x + w; px > x; px -= step) ctx.lineTo(px, y + h + (rng() - 0.5) * tooth);
ctx.lineTo(x, y + h + (rng() - 0.5) * tooth);
for (py = y + h; py > y; py -= step) ctx.lineTo(x + (rng() - 0.5) * tooth, py);
ctx.closePath();
ctx.clip();
}
// Postmark stamp — date always, plus a location line only when the photo
// had EXIF GPS and reverse geocoding succeeded. Never an invented
// location, per PROPOSAL.md's fallback for "no EXIF GPS."
function drawStampCorner(w, h, margin) {
const size = margin * 1.6;
// Right edge of the (rotated) box must stay inside the card, not just
// inside the photo area — the border margin is the only safe zone.
const x = w - margin - size;
const y = margin * 0.15;
ctx.save();
ctx.translate(x, y);
ctx.rotate((6 * Math.PI) / 180);
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
ctx.lineWidth = Math.max(1, size * 0.02);
ctx.setLineDash([size * 0.04, size * 0.03]);
ctx.strokeRect(0, 0, size, size * 0.8);
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.textAlign = 'center';
const dateStr = photoDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
if (locationLabel) {
ctx.font = `${Math.round(size * 0.1)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`;
ctx.fillText(truncate(locationLabel, 18), size / 2, size * 0.3);
ctx.font = `${Math.round(size * 0.11)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`;
ctx.fillText(dateStr, size / 2, size * 0.52);
ctx.font = `${Math.round(size * 0.08)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`;
ctx.fillText('postcard', size / 2, size * 0.7);
} else {
ctx.font = `${Math.round(size * 0.13)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`;
ctx.fillText(dateStr, size / 2, size * 0.45);
ctx.font = `${Math.round(size * 0.09)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`;
ctx.fillText('postcard', size / 2, size * 0.65);
}
ctx.restore();
}
function truncate(str, maxLen) {
return str.length > maxLen ? `${str.slice(0, maxLen - 1)}…` : str;
}
function drawCaption(w, h, margin) {
if (!params.caption) return;
ctx.save();
ctx.fillStyle = 'rgba(30,25,15,0.85)';
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
// Caveat reads smaller than a sans face at the same px size (thin
// connected strokes), so it gets a bigger multiplier and bolder weight
// than a plain cursive fallback would need.
const fontSize = Math.max(16, Math.round(margin * 0.55));
ctx.font = fontReady
? `700 ${fontSize}px "Caveat", cursive`
: `${Math.round(fontSize * 0.75)}px "Segoe Script", "Bradley Hand", cursive`;
wrapText(params.caption, margin, h - margin * 0.35, w - margin * 2, fontSize * 1.15);
ctx.restore();
}
function wrapText(text, x, bottomY, maxWidth, lineHeight) {
const words = text.split(/\s+/);
const lines = [];
let line = '';
for (const word of words) {
const test = line ? `${line} ${word}` : word;
if (ctx.measureText(test).width > maxWidth && line) {
lines.push(line);
line = word;
} else {
line = test;
}
}
if (line) lines.push(line);
const clipped = lines.slice(-3); // at most 3 lines, bottom-anchored
const startY = bottomY - (clipped.length - 1) * lineHeight;
clipped.forEach((l, i) => ctx.fillText(l, x, startY + i * lineHeight));
}
async function draftCaption() {
captionStatus.textContent = 'drafting…';
try {
const dateStr = photoDate.toISOString().slice(0, 10);
const res = await fetch(`/api/caption?date=${dateStr}`);
const data = await res.json();
captionInput.value = data.caption || '';
params.caption = captionInput.value;
captionStatus.textContent = '';
render();
} catch {
captionStatus.textContent = '';
}
}
function showResult(html, isError) {
resultPanel.innerHTML = html;
resultPanel.classList.toggle('error', !!isError);
resultPanel.hidden = false;
}
function canvasToBlob() {
return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.92));
}
async function upload() {
shareBtn.disabled = true;
showResult('uploading&hellip;');
try {
const blob = await canvasToBlob();
const res = await fetch('/api/upload', {
method: 'POST',
headers: { 'Content-Type': 'image/jpeg' },
body: blob,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `upload failed (${res.status})`);
}
const data = await res.json();
const link = `${location.origin}${data.url}`;
showResult(`share link: <a href="${link}">${link}</a>`);
} catch (err) {
showResult(err.message || 'something went wrong', true);
} finally {
shareBtn.disabled = false;
}
}
fileInput.addEventListener('change', () => loadFile(fileInput.files[0]));
cameraInput.addEventListener('change', () => loadFile(cameraInput.files[0]));
cameraBtn.addEventListener('click', () => cameraInput.click());
libraryBtn.addEventListener('click', () => fileInput.click());
['dragover', 'dragenter'].forEach(evt =>
dropZone.addEventListener(evt, e => {
e.preventDefault();
dropZone.classList.add('drag-over');
})
);
['dragleave', 'dragend', 'drop'].forEach(evt =>
dropZone.addEventListener(evt, () => dropZone.classList.remove('drag-over'))
);
dropZone.addEventListener('drop', e => {
e.preventDefault();
loadFile(e.dataTransfer.files[0]);
});
[...templateRow.children].forEach(btn => {
btn.addEventListener('click', () => {
params.template = btn.dataset.template;
[...templateRow.children].forEach(b => b.classList.toggle('active', b === btn));
render();
});
});
captionInput.addEventListener('input', () => {
params.caption = captionInput.value;
render();
});
redraftBtn.addEventListener('click', draftCaption);
newPhotoBtn.addEventListener('click', () => {
originalImg = null;
locationLabel = null;
fileInput.value = '';
cameraInput.value = '';
editorCard.hidden = true;
dropCard.hidden = false;
resultPanel.hidden = true;
});
downloadBtn.addEventListener('click', async () => {
const blob = await canvasToBlob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `postcard-${Date.now()}.jpg`;
a.click();
URL.revokeObjectURL(url);
});
shareBtn.addEventListener('click', upload);
})();