Odysseus v1.0
This commit is contained in:
381
static/js/editor/tools/transform-session.js
Normal file
381
static/js/editor/tools/transform-session.js
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Transform-tool session lifecycle + floating popup wiring.
|
||||
*
|
||||
* _startTransform snapshot the active layer + open popup
|
||||
* _openTransformPopup build the W/H/rotation popup, wire inputs
|
||||
* _wireTransformDrag header drag, mobile + desktop position handling
|
||||
* _reapplyTransform live preview re-render from the snapshot
|
||||
* _confirmTransform commit + clear session state
|
||||
* _cancelTransform restore via undo() + clear session state
|
||||
*
|
||||
* Handle-drag interactions on the CANVAS (corner / rotation grip) live
|
||||
* in `editor/tools/transform-drag.js` — those mutate the same staged
|
||||
* `state.transformPending*` fields that the popup inputs do, so both
|
||||
* surfaces stay in sync via `_reapplyTransform()`.
|
||||
*
|
||||
* @param {{
|
||||
* activeLayer: () => object | null,
|
||||
* saveState: (label?: string) => void,
|
||||
* composite: () => void,
|
||||
* fitZoom: () => void,
|
||||
* drawTransformHandles: () => void,
|
||||
* showCanvasLoading: (label: string) => void,
|
||||
* hideCanvasLoading: () => void,
|
||||
* undo: () => void,
|
||||
* uiModule: object | null,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {{
|
||||
* startTransform, openTransformPopup, closeTransformPopup,
|
||||
* reapplyTransform, confirmTransform, cancelTransform,
|
||||
* }}
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import {
|
||||
transformPopupHTML,
|
||||
attachSpinRepeat,
|
||||
} from '../build/transform-popup.js';
|
||||
|
||||
export function createTransformSession({
|
||||
activeLayer, saveState, composite, fitZoom, drawTransformHandles,
|
||||
showCanvasLoading, hideCanvasLoading, undo, uiModule,
|
||||
}) {
|
||||
function startTransform() {
|
||||
const layer = activeLayer();
|
||||
if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; }
|
||||
if (state.transformActive) { cancelTransform(); return; } // toggle off
|
||||
state.transformActive = true;
|
||||
state.transformLayer = layer;
|
||||
state.transformOrigW = layer.canvas.width;
|
||||
state.transformOrigH = layer.canvas.height;
|
||||
state.transformPendingW = state.transformOrigW;
|
||||
state.transformPendingH = state.transformOrigH;
|
||||
state.transformPendingRot = 0;
|
||||
state.transformPendingFlipH = false;
|
||||
state.transformPendingFlipV = false;
|
||||
// Snapshot the layer so live preview can re-derive from the
|
||||
// original pixels on every keystroke instead of stacking
|
||||
// destructive edits.
|
||||
state.transformOrigCanvas = document.createElement('canvas');
|
||||
state.transformOrigCanvas.width = state.transformOrigW;
|
||||
state.transformOrigCanvas.height = state.transformOrigH;
|
||||
state.transformOrigCanvas.getContext('2d').drawImage(layer.canvas, 0, 0);
|
||||
state.transformOrigOffset = { ...(state.layerOffsets.get(layer.id) || { x: 0, y: 0 }) };
|
||||
saveState();
|
||||
// Fit canvas to viewport so the corner handles are visible —
|
||||
// without this, a layer larger than the viewport leaves the grab
|
||||
// markers off-screen.
|
||||
try { fitZoom(); } catch {}
|
||||
composite();
|
||||
drawTransformHandles();
|
||||
openTransformPopup();
|
||||
}
|
||||
|
||||
function closeTransformPopup() {
|
||||
if (state.transformPopup) {
|
||||
try { state.transformPopup.remove(); } catch {}
|
||||
state.transformPopup = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Floating Transform popup — horizontal layout, draggable via its
|
||||
// header, anchored over the right panel (layers area) by default
|
||||
// so it doesn't cover the canvas. Lets the user type exact W/H/Rot
|
||||
// and flip via negative values.
|
||||
function openTransformPopup() {
|
||||
closeTransformPopup();
|
||||
if (!state.container) return;
|
||||
const pop = document.createElement('div');
|
||||
pop.className = 'ge-transform-popup';
|
||||
pop.innerHTML = transformPopupHTML();
|
||||
state.container.appendChild(pop);
|
||||
state.transformPopup = pop;
|
||||
wireTransformDrag(pop);
|
||||
const wInput = pop.querySelector('#ge-transform-w');
|
||||
const hInput = pop.querySelector('#ge-transform-h');
|
||||
const rotInput = pop.querySelector('#ge-transform-rot');
|
||||
const aspectBtn = pop.querySelector('#ge-transform-aspect');
|
||||
wInput.value = String(state.transformOrigW);
|
||||
hInput.value = String(state.transformOrigH);
|
||||
rotInput.value = '0';
|
||||
aspectBtn.classList.toggle('active', state.transformAspectLock);
|
||||
aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false');
|
||||
|
||||
// Aspect-lock follower model: while the lock is engaged, ONE
|
||||
// field is the "driver" and the other is read-only + dimmed.
|
||||
// Driver = whichever field the user last typed in. Toggling the
|
||||
// chain releases the follower.
|
||||
let driver = null;
|
||||
const applyAspectVisuals = () => {
|
||||
if (!state.transformAspectLock || !driver) {
|
||||
wInput.readOnly = false;
|
||||
hInput.readOnly = false;
|
||||
wInput.classList.remove('ge-transform-input-locked');
|
||||
hInput.classList.remove('ge-transform-input-locked');
|
||||
return;
|
||||
}
|
||||
const followerW = driver === 'h';
|
||||
const followerH = driver === 'w';
|
||||
wInput.readOnly = followerW;
|
||||
hInput.readOnly = followerH;
|
||||
wInput.classList.toggle('ge-transform-input-locked', followerW);
|
||||
hInput.classList.toggle('ge-transform-input-locked', followerH);
|
||||
};
|
||||
const refresh = () => {
|
||||
let w = parseInt(wInput.value, 10);
|
||||
let h = parseInt(hInput.value, 10);
|
||||
const rot = parseInt(rotInput.value, 10) || 0;
|
||||
state.transformPendingFlipH = w < 0;
|
||||
state.transformPendingFlipV = h < 0;
|
||||
w = Math.abs(w || state.transformOrigW);
|
||||
h = Math.abs(h || state.transformOrigH);
|
||||
state.transformPendingW = Math.max(1, w);
|
||||
state.transformPendingH = Math.max(1, h);
|
||||
state.transformPendingRot = rot;
|
||||
reapplyTransform();
|
||||
};
|
||||
wInput.addEventListener('input', () => {
|
||||
if (state.transformAspectLock) {
|
||||
driver = 'w';
|
||||
const w = parseInt(wInput.value, 10);
|
||||
if (!Number.isNaN(w) && state.transformOrigW > 0) {
|
||||
const sign = (parseInt(hInput.value, 10) || 1) < 0 ? -1 : 1;
|
||||
const newH = Math.round((Math.abs(w) / state.transformOrigW) * state.transformOrigH) * sign;
|
||||
hInput.value = String(newH);
|
||||
}
|
||||
applyAspectVisuals();
|
||||
}
|
||||
refresh();
|
||||
});
|
||||
hInput.addEventListener('input', () => {
|
||||
if (state.transformAspectLock) {
|
||||
driver = 'h';
|
||||
const h = parseInt(hInput.value, 10);
|
||||
if (!Number.isNaN(h) && state.transformOrigH > 0) {
|
||||
const sign = (parseInt(wInput.value, 10) || 1) < 0 ? -1 : 1;
|
||||
const newW = Math.round((Math.abs(h) / state.transformOrigH) * state.transformOrigW) * sign;
|
||||
wInput.value = String(newW);
|
||||
}
|
||||
applyAspectVisuals();
|
||||
}
|
||||
refresh();
|
||||
});
|
||||
rotInput.addEventListener('input', refresh);
|
||||
aspectBtn.addEventListener('click', () => {
|
||||
state.transformAspectLock = !state.transformAspectLock;
|
||||
aspectBtn.classList.toggle('active', state.transformAspectLock);
|
||||
aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false');
|
||||
// Reset follower the moment the user breaks the lock so both
|
||||
// fields go editable; re-engaging means "next type sets the driver".
|
||||
driver = null;
|
||||
applyAspectVisuals();
|
||||
});
|
||||
pop.querySelector('#ge-transform-apply').addEventListener('click', () => confirmTransform());
|
||||
pop.querySelector('#ge-transform-cancel').addEventListener('click', () => cancelTransform());
|
||||
pop.querySelector('#ge-transform-cancel-btn')?.addEventListener('click', () => cancelTransform());
|
||||
// Minimise — collapses the body so only the header is visible.
|
||||
pop.querySelector('#ge-transform-min')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
pop.classList.toggle('ge-transform-popup-minimised');
|
||||
});
|
||||
// Quick actions: flip W/H via sign so the reapply pipeline picks
|
||||
// up the new orientation. Rotate-90 nudges rotation ±90°.
|
||||
pop.querySelector('#ge-transform-flip-h')?.addEventListener('click', () => {
|
||||
const wIn = pop.querySelector('#ge-transform-w');
|
||||
const cur = parseInt(wIn.value, 10) || state.transformOrigW;
|
||||
wIn.value = String(-cur);
|
||||
wIn.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
pop.querySelector('#ge-transform-flip-v')?.addEventListener('click', () => {
|
||||
const hIn = pop.querySelector('#ge-transform-h');
|
||||
const cur = parseInt(hIn.value, 10) || state.transformOrigH;
|
||||
hIn.value = String(-cur);
|
||||
hIn.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
pop.querySelector('#ge-transform-rot-90')?.addEventListener('click', (e) => {
|
||||
const rIn = pop.querySelector('#ge-transform-rot');
|
||||
const cur = parseInt(rIn.value, 10) || 0;
|
||||
const delta = e.shiftKey ? -90 : 90;
|
||||
let next = cur + delta;
|
||||
while (next > 180) next -= 360;
|
||||
while (next <= -180) next += 360;
|
||||
rIn.value = String(next);
|
||||
// Big images: rotation pass blocks UI ~0.5–2 s. Show a spinner
|
||||
// so the user sees something happen. rAF defers the heavy work
|
||||
// past the current frame so the overlay paints first.
|
||||
showCanvasLoading('Rotating…');
|
||||
requestAnimationFrame(() => {
|
||||
try { rIn.dispatchEvent(new Event('input', { bubbles: true })); }
|
||||
finally { hideCanvasLoading(); }
|
||||
});
|
||||
});
|
||||
attachSpinRepeat(pop);
|
||||
}
|
||||
|
||||
// Header-drag for the Transform popup. Default position: over the
|
||||
// right panel (layers area). Mobile pins via stylesheet so we use
|
||||
// setProperty 'important' to override during drag.
|
||||
function wireTransformDrag(pop) {
|
||||
const isMobile = window.matchMedia('(max-width: 820px)').matches;
|
||||
const defaultRight = 20;
|
||||
const defaultTop = 60;
|
||||
if (isMobile) {
|
||||
pop.style.setProperty('position', 'fixed', 'important');
|
||||
} else {
|
||||
pop.style.position = 'absolute';
|
||||
pop.style.right = defaultRight + 'px';
|
||||
pop.style.top = defaultTop + 'px';
|
||||
pop.style.left = 'auto';
|
||||
}
|
||||
const dragSource = pop.querySelector('[data-transform-drag]') || pop;
|
||||
let dragging = false;
|
||||
let startX = 0, startY = 0, originLeft = 0, originTop = 0;
|
||||
const NON_DRAG = 'input,button,select,textarea,a,[contenteditable]';
|
||||
|
||||
const setPos = (x, y) => {
|
||||
if (isMobile) {
|
||||
pop.style.setProperty('left', x + 'px', 'important');
|
||||
pop.style.setProperty('top', y + 'px', 'important');
|
||||
pop.style.setProperty('right', 'auto', 'important');
|
||||
pop.style.setProperty('bottom', 'auto', 'important');
|
||||
pop.style.setProperty('width', 'auto', 'important');
|
||||
pop.style.setProperty('max-width', 'calc(100vw - 16px)', 'important');
|
||||
} else {
|
||||
pop.style.left = x + 'px';
|
||||
pop.style.top = y + 'px';
|
||||
pop.style.right = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const beginDrag = (clientX, clientY) => {
|
||||
dragging = true;
|
||||
const rect = pop.getBoundingClientRect();
|
||||
if (isMobile) {
|
||||
originLeft = rect.left;
|
||||
originTop = rect.top;
|
||||
} else {
|
||||
const parentRect = state.container.getBoundingClientRect();
|
||||
originLeft = rect.left - parentRect.left;
|
||||
originTop = rect.top - parentRect.top;
|
||||
}
|
||||
startX = clientX;
|
||||
startY = clientY;
|
||||
setPos(originLeft, originTop);
|
||||
pop.classList.add('ge-transform-popup-dragging');
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const moveDrag = (clientX, clientY) => {
|
||||
if (!dragging) return;
|
||||
const dx = clientX - startX;
|
||||
const dy = clientY - startY;
|
||||
let nx = originLeft + dx;
|
||||
let ny = originTop + dy;
|
||||
if (isMobile) {
|
||||
const rect = pop.getBoundingClientRect();
|
||||
nx = Math.max(0, Math.min(window.innerWidth - rect.width, nx));
|
||||
ny = Math.max(0, Math.min(window.innerHeight - rect.height, ny));
|
||||
}
|
||||
setPos(nx, ny);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
document.body.style.userSelect = '';
|
||||
pop.classList.remove('ge-transform-popup-dragging');
|
||||
};
|
||||
|
||||
dragSource.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest(NON_DRAG)) return;
|
||||
e.preventDefault();
|
||||
beginDrag(e.clientX, e.clientY);
|
||||
});
|
||||
document.addEventListener('mousemove', (e) => moveDrag(e.clientX, e.clientY));
|
||||
document.addEventListener('mouseup', endDrag);
|
||||
|
||||
dragSource.addEventListener('touchstart', (e) => {
|
||||
if (e.target.closest(NON_DRAG)) return;
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
e.preventDefault();
|
||||
beginDrag(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
if (!dragging) return;
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
e.preventDefault();
|
||||
moveDrag(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchend', endDrag);
|
||||
document.addEventListener('touchcancel', endDrag);
|
||||
}
|
||||
|
||||
// Re-derive the active layer's pixels from the original snapshot
|
||||
// with the popup's current W/H/flip/rotation applied. Cheap —
|
||||
// paints into an off-screen canvas of the final size.
|
||||
function reapplyTransform() {
|
||||
const layer = state.transformLayer;
|
||||
if (!layer || !state.transformOrigCanvas) return;
|
||||
const w = state.transformPendingW;
|
||||
const h = state.transformPendingH;
|
||||
const rotDeg = state.transformPendingRot;
|
||||
const rotRad = (rotDeg * Math.PI) / 180;
|
||||
const cos = Math.abs(Math.cos(rotRad));
|
||||
const sin = Math.abs(Math.sin(rotRad));
|
||||
// Bounding box of the rotated W×H — canvas grows so corners
|
||||
// don't clip.
|
||||
const finalW = Math.max(1, Math.round(w * cos + h * sin));
|
||||
const finalH = Math.max(1, Math.round(w * sin + h * cos));
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = finalW; tmp.height = finalH;
|
||||
const tCtx = tmp.getContext('2d');
|
||||
tCtx.imageSmoothingEnabled = true;
|
||||
tCtx.imageSmoothingQuality = 'high';
|
||||
tCtx.save();
|
||||
tCtx.translate(finalW / 2, finalH / 2);
|
||||
if (rotDeg) tCtx.rotate(rotRad);
|
||||
tCtx.scale(state.transformPendingFlipH ? -1 : 1, state.transformPendingFlipV ? -1 : 1);
|
||||
tCtx.drawImage(state.transformOrigCanvas, -w / 2, -h / 2, w, h);
|
||||
tCtx.restore();
|
||||
layer.canvas.width = finalW;
|
||||
layer.canvas.height = finalH;
|
||||
layer.ctx.clearRect(0, 0, finalW, finalH);
|
||||
layer.ctx.drawImage(tmp, 0, 0);
|
||||
// Recenter the layer so the rotation pivot stays put visually.
|
||||
const origCenterX = state.transformOrigOffset.x + state.transformOrigW / 2;
|
||||
const origCenterY = state.transformOrigOffset.y + state.transformOrigH / 2;
|
||||
state.layerOffsets.set(layer.id, {
|
||||
x: Math.round(origCenterX - finalW / 2),
|
||||
y: Math.round(origCenterY - finalH / 2),
|
||||
});
|
||||
composite();
|
||||
drawTransformHandles();
|
||||
}
|
||||
|
||||
function confirmTransform() {
|
||||
closeTransformPopup();
|
||||
state.transformOrigCanvas = null;
|
||||
state.transformOrigOffset = null;
|
||||
state.transformActive = false;
|
||||
state.transformLayer = null;
|
||||
state.transformHandle = null;
|
||||
composite();
|
||||
uiModule.showToast('Transform applied');
|
||||
}
|
||||
|
||||
function cancelTransform() {
|
||||
closeTransformPopup();
|
||||
state.transformOrigCanvas = null;
|
||||
state.transformOrigOffset = null;
|
||||
if (state.transformLayer) undo(); // restore saved state
|
||||
state.transformActive = false;
|
||||
state.transformLayer = null;
|
||||
state.transformHandle = null;
|
||||
composite();
|
||||
}
|
||||
|
||||
return {
|
||||
startTransform, openTransformPopup, closeTransformPopup,
|
||||
reapplyTransform, confirmTransform, cancelTransform,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user