mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-25 23:32:22 +01:00
feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).
Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c74f475679
commit
5fac693c1c
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Perspective Crop tool — overlay four draggable corner handles over the
|
||||
* canvas; on Apply the quadrilateral is straightened (and resized) by the pure
|
||||
* engine in ../perspective-crop.js. The overlay is plain DOM (fixed-position
|
||||
* handles + an SVG outline) so it does not touch the canvas draw pipeline.
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
|
||||
const HANDLE = 14;
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return value < min ? min : value > max ? max : value;
|
||||
}
|
||||
|
||||
export function createPerspectiveCropTool({ getCanvas, onApply, showToast = () => {} } = {}) {
|
||||
let overlay = null;
|
||||
let corners = null; // document-space {x,y} x4 (TL,TR,BR,BL)
|
||||
let dragging = -1;
|
||||
|
||||
function canvasRect() {
|
||||
const canvas = getCanvas?.();
|
||||
const r = canvas?.getBoundingClientRect?.();
|
||||
return r && r.width > 0 ? r : null;
|
||||
}
|
||||
|
||||
function toDisplay(point, rect) {
|
||||
return { x: rect.left + (point.x / state.imgWidth) * rect.width, y: rect.top + (point.y / state.imgHeight) * rect.height };
|
||||
}
|
||||
|
||||
function toDoc(clientX, clientY, rect) {
|
||||
return {
|
||||
x: clamp(((clientX - rect.left) / rect.width) * state.imgWidth, 0, state.imgWidth),
|
||||
y: clamp(((clientY - rect.top) / rect.height) * state.imgHeight, 0, state.imgHeight),
|
||||
};
|
||||
}
|
||||
|
||||
function sizeHint() {
|
||||
const width = Math.round((Math.hypot(corners[1].x - corners[0].x, corners[1].y - corners[0].y) + Math.hypot(corners[2].x - corners[3].x, corners[2].y - corners[3].y)) / 2);
|
||||
const height = Math.round((Math.hypot(corners[3].x - corners[0].x, corners[3].y - corners[0].y) + Math.hypot(corners[2].x - corners[1].x, corners[2].y - corners[1].y)) / 2);
|
||||
return `${Math.max(1, width)} × ${Math.max(1, height)} px`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!overlay) return;
|
||||
const rect = canvasRect();
|
||||
if (!rect) return;
|
||||
overlay.style.left = rect.left + 'px';
|
||||
overlay.style.top = rect.top + 'px';
|
||||
overlay.style.width = rect.width + 'px';
|
||||
overlay.style.height = rect.height + 'px';
|
||||
const pts = corners.map(c => toDisplay(c, rect));
|
||||
overlay.querySelector('polygon')?.setAttribute('points', pts.map(p => `${p.x - rect.left},${p.y - rect.top}`).join(' '));
|
||||
overlay.querySelectorAll('.ge-pcrop-handle').forEach((h, i) => {
|
||||
const p = pts[i];
|
||||
h.style.left = (p.x - rect.left) + 'px';
|
||||
h.style.top = (p.y - rect.top) + 'px';
|
||||
});
|
||||
const hint = overlay.querySelector('.ge-pcrop-size');
|
||||
if (hint) hint.textContent = sizeHint();
|
||||
}
|
||||
|
||||
function onPointerDown(i, event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragging = i;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
document.addEventListener('pointermove', onPointerMove, true);
|
||||
document.addEventListener('pointerup', onPointerUp, true);
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (dragging < 0) return;
|
||||
const rect = canvasRect();
|
||||
if (!rect) return;
|
||||
corners[dragging] = toDoc(event.clientX, event.clientY, rect);
|
||||
render();
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging = -1;
|
||||
document.removeEventListener('pointermove', onPointerMove, true);
|
||||
document.removeEventListener('pointerup', onPointerUp, true);
|
||||
}
|
||||
|
||||
function onKey(event) {
|
||||
if (event.key === 'Escape') { event.preventDefault(); close(); }
|
||||
else if (event.key === 'Enter') { event.preventDefault(); apply(); }
|
||||
}
|
||||
|
||||
function apply() {
|
||||
const next = corners.map(c => ({ ...c }));
|
||||
close();
|
||||
onApply?.(next);
|
||||
}
|
||||
|
||||
function open() {
|
||||
close();
|
||||
const inset = 0.06;
|
||||
corners = [
|
||||
{ x: state.imgWidth * inset, y: state.imgHeight * inset },
|
||||
{ x: state.imgWidth * (1 - inset), y: state.imgHeight * inset },
|
||||
{ x: state.imgWidth * (1 - inset), y: state.imgHeight * (1 - inset) },
|
||||
{ x: state.imgWidth * inset, y: state.imgHeight * (1 - inset) },
|
||||
];
|
||||
overlay = document.createElement('div');
|
||||
overlay.className = 'ge-pcrop-overlay';
|
||||
overlay.innerHTML = `
|
||||
<svg class="ge-pcrop-svg" preserveAspectRatio="none"><polygon points=""></polygon></svg>
|
||||
<div class="ge-pcrop-bar">
|
||||
<span class="ge-pcrop-title">Perspective Crop</span>
|
||||
<span class="ge-pcrop-size"></span>
|
||||
<span class="ge-pcrop-spacer"></span>
|
||||
<button type="button" class="ge-btn ge-btn-sm" data-pcrop="cancel">Cancel</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-primary" data-pcrop="apply">Apply</button>
|
||||
</div>
|
||||
${corners.map((_, i) => `<div class="ge-pcrop-handle" data-corner="${i}"></div>`).join('')}
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelectorAll('.ge-pcrop-handle').forEach((handle, i) => {
|
||||
handle.addEventListener('pointerdown', (event) => onPointerDown(i, event));
|
||||
});
|
||||
overlay.querySelector('[data-pcrop="apply"]').addEventListener('click', apply);
|
||||
overlay.querySelector('[data-pcrop="cancel"]').addEventListener('click', close);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
window.addEventListener('resize', render);
|
||||
render();
|
||||
showToast?.('Drag the four corners, then Apply');
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!overlay) return;
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
window.removeEventListener('resize', render);
|
||||
document.removeEventListener('pointermove', onPointerMove, true);
|
||||
document.removeEventListener('pointerup', onPointerUp, true);
|
||||
overlay.remove();
|
||||
overlay = null;
|
||||
corners = null;
|
||||
dragging = -1;
|
||||
}
|
||||
|
||||
return { open, close, isOpen: () => !!overlay, render };
|
||||
}
|
||||
|
||||
export default { createPerspectiveCropTool };
|
||||
Reference in New Issue
Block a user