Non-original aspects (square, 4:5, 16:9) always centered the crop with no way to choose what got cut off. Crop position is now a normalized 0-1 offset per axis instead of a fixed /2 center, and dragging directly on the canvas -- only when the current aspect actually leaves slack to pan within -- adjusts it. Pointer movement converts to rotated-image pixels via the crop-width-to-display-width ratio, so it stays 1:1 regardless of the maxDim downscale or the canvas's own CSS scaling. Resets to centered on aspect or rotation change, since old offsets don't make sense against new dimensions. Verified with a real drag test: a marker cropped out at the centered default becomes visible after dragging, and the "original" aspect (no crop, no slack) correctly leaves panning disabled. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bee81187fb
commit
cb0e0acc11
@@ -5,6 +5,7 @@
|
|||||||
const editorCard = document.getElementById('editor-card');
|
const editorCard = document.getElementById('editor-card');
|
||||||
const canvas = document.getElementById('canvas');
|
const canvas = document.getElementById('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
const panHint = document.getElementById('pan-hint');
|
||||||
|
|
||||||
const aspectRow = document.getElementById('aspect-row');
|
const aspectRow = document.getElementById('aspect-row');
|
||||||
const rotateLeftBtn = document.getElementById('rotate-left-btn');
|
const rotateLeftBtn = document.getElementById('rotate-left-btn');
|
||||||
@@ -52,6 +53,11 @@
|
|||||||
let originalImg = null;
|
let originalImg = null;
|
||||||
let exposureImg = null;
|
let exposureImg = null;
|
||||||
|
|
||||||
|
// Slack the current rotation+aspect leaves to pan within, in rotated-
|
||||||
|
// image pixel space — recomputed every rebuildSource(), read by the
|
||||||
|
// canvas drag handler to convert pointer movement into cropOffset.
|
||||||
|
let cropBounds = { rotatedW: 0, rotatedH: 0, cropW: 0, cropH: 0 };
|
||||||
|
|
||||||
const PRESETS = {
|
const PRESETS = {
|
||||||
none: { filter: 'none' },
|
none: { filter: 'none' },
|
||||||
// "Pick a vibe" presets rather than raw white-balance sliders, per
|
// "Pick a vibe" presets rather than raw white-balance sliders, per
|
||||||
@@ -74,6 +80,12 @@
|
|||||||
const params = {
|
const params = {
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
cropAspect: 'original',
|
cropAspect: 'original',
|
||||||
|
// Normalized 0-1 position of the crop window within whatever slack the
|
||||||
|
// chosen aspect leaves — 0.5/0.5 is centered (the old fixed behavior).
|
||||||
|
// Stored as a fraction rather than raw pixels so it survives rotation/
|
||||||
|
// aspect changes without needing to be re-derived.
|
||||||
|
cropOffsetX: 0.5,
|
||||||
|
cropOffsetY: 0.5,
|
||||||
preset: 'none',
|
preset: 'none',
|
||||||
grain: 0,
|
grain: 0,
|
||||||
grainSize: 40,
|
grainSize: 40,
|
||||||
@@ -122,6 +134,8 @@
|
|||||||
function resetParams() {
|
function resetParams() {
|
||||||
params.rotation = 0;
|
params.rotation = 0;
|
||||||
params.cropAspect = 'original';
|
params.cropAspect = 'original';
|
||||||
|
params.cropOffsetX = 0.5;
|
||||||
|
params.cropOffsetY = 0.5;
|
||||||
params.preset = 'none';
|
params.preset = 'none';
|
||||||
params.grain = 0;
|
params.grain = 0;
|
||||||
params.grainSize = 40;
|
params.grainSize = 40;
|
||||||
@@ -201,12 +215,14 @@
|
|||||||
const currentAspect = rotatedW / rotatedH;
|
const currentAspect = rotatedW / rotatedH;
|
||||||
if (currentAspect > targetAspect) {
|
if (currentAspect > targetAspect) {
|
||||||
cropW = Math.round(rotatedH * targetAspect);
|
cropW = Math.round(rotatedH * targetAspect);
|
||||||
cropX = Math.round((rotatedW - cropW) / 2);
|
cropX = Math.round((rotatedW - cropW) * params.cropOffsetX);
|
||||||
} else {
|
} else {
|
||||||
cropH = Math.round(rotatedW / targetAspect);
|
cropH = Math.round(rotatedW / targetAspect);
|
||||||
cropY = Math.round((rotatedH - cropH) / 2);
|
cropY = Math.round((rotatedH - cropH) * params.cropOffsetY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cropBounds = { rotatedW, rotatedH, cropW, cropH };
|
||||||
|
updatePanAffordance();
|
||||||
|
|
||||||
// Cap so a raw phone photo doesn't choke canvas ops.
|
// Cap so a raw phone photo doesn't choke canvas ops.
|
||||||
const maxDim = 2400;
|
const maxDim = 2400;
|
||||||
@@ -233,6 +249,70 @@
|
|||||||
channelCanvas.height = outH;
|
channelCanvas.height = outH;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasPanSlack() {
|
||||||
|
return cropBounds.rotatedW > cropBounds.cropW || cropBounds.rotatedH > cropBounds.cropH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePanAffordance() {
|
||||||
|
const pannable = hasPanSlack();
|
||||||
|
canvas.classList.toggle('pannable', pannable);
|
||||||
|
panHint.hidden = !pannable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag-to-reposition: only meaningful once a non-"original" aspect has
|
||||||
|
// actually cropped something away. Converts pointer movement in CSS
|
||||||
|
// pixels to rotated-image pixels via the crop width/display width
|
||||||
|
// ratio, so it stays 1:1 regardless of the maxDim downscale or the
|
||||||
|
// canvas's own CSS max-width/max-height scaling.
|
||||||
|
let dragState = null;
|
||||||
|
let panRaf = null;
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', (e) => {
|
||||||
|
if (!hasPanSlack()) return;
|
||||||
|
const { rotatedW, rotatedH, cropW, cropH } = cropBounds;
|
||||||
|
dragState = {
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
cropX0: Math.round((rotatedW - cropW) * params.cropOffsetX),
|
||||||
|
cropY0: Math.round((rotatedH - 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 { rotatedW, rotatedH, cropW, cropH } = cropBounds;
|
||||||
|
const rotatedPxPerCssPxX = cropW / rect.width;
|
||||||
|
const rotatedPxPerCssPxY = cropH / rect.height;
|
||||||
|
const deltaX = (e.clientX - dragState.startX) * rotatedPxPerCssPxX;
|
||||||
|
const deltaY = (e.clientY - dragState.startY) * rotatedPxPerCssPxY;
|
||||||
|
|
||||||
|
const slackX = rotatedW - cropW;
|
||||||
|
const slackY = rotatedH - 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);
|
||||||
|
|
||||||
// Recomputes the full stack from `source` every time — never chains
|
// Recomputes the full stack from `source` every time — never chains
|
||||||
// operations onto already-processed pixels — so dragging any slider
|
// operations onto already-processed pixels — so dragging any slider
|
||||||
// back to 0 reproduces the source exactly.
|
// back to 0 reproduces the source exactly.
|
||||||
@@ -531,6 +611,8 @@
|
|||||||
[...aspectRow.children].forEach(btn => {
|
[...aspectRow.children].forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
params.cropAspect = btn.dataset.aspect;
|
params.cropAspect = btn.dataset.aspect;
|
||||||
|
params.cropOffsetX = 0.5;
|
||||||
|
params.cropOffsetY = 0.5;
|
||||||
[...aspectRow.children].forEach(b => b.classList.toggle('active', b === btn));
|
[...aspectRow.children].forEach(b => b.classList.toggle('active', b === btn));
|
||||||
rebuildSource();
|
rebuildSource();
|
||||||
render();
|
render();
|
||||||
@@ -539,11 +621,15 @@
|
|||||||
|
|
||||||
rotateLeftBtn.addEventListener('click', () => {
|
rotateLeftBtn.addEventListener('click', () => {
|
||||||
params.rotation = (params.rotation + 270) % 360;
|
params.rotation = (params.rotation + 270) % 360;
|
||||||
|
params.cropOffsetX = 0.5;
|
||||||
|
params.cropOffsetY = 0.5;
|
||||||
rebuildSource();
|
rebuildSource();
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
rotateRightBtn.addEventListener('click', () => {
|
rotateRightBtn.addEventListener('click', () => {
|
||||||
params.rotation = (params.rotation + 90) % 360;
|
params.rotation = (params.rotation + 90) % 360;
|
||||||
|
params.cropOffsetX = 0.5;
|
||||||
|
params.cropOffsetY = 0.5;
|
||||||
rebuildSource();
|
rebuildSource();
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,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">
|
||||||
|
|||||||
@@ -128,6 +128,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);
|
||||||
|
margin: -12px 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Controls ───────────────────────────────────────────────────────── */
|
/* ── Controls ───────────────────────────────────────────────────────── */
|
||||||
|
|||||||
Reference in New Issue
Block a user