Files
odysseus/static/js/editor/layer-geometry.js
T

250 lines
10 KiB
JavaScript

/** Exact active-layer geometry used by controls, dragging, and key nudging. */
import { state } from './state.js';
import { selectedLayers, selectionLabel } from './layer-selection.js';
import { isLayerPositionLocked } from './layer-groups.js';
import { translatePlacedData } from './placed-layer.js';
const MAX_COORDINATE = 1000000;
export function normalizeLayerCoordinate(value, fallback = 0) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return Math.round(Number(fallback) || 0);
return Math.max(-MAX_COORDINATE, Math.min(MAX_COORDINATE, Math.round(parsed)));
}
export function readLayerGeometry(editorState, layer) {
if (!layer?.canvas) return null;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
return {
x: normalizeLayerCoordinate(offset.x),
y: normalizeLayerCoordinate(offset.y),
width: Math.max(1, Math.round(layer.canvas.width || 1)),
height: Math.max(1, Math.round(layer.canvas.height || 1)),
};
}
export function setLayerPosition(editorState, layer, x, y) {
if (!layer) return null;
const current = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const next = {
x: normalizeLayerCoordinate(x, current.x),
y: normalizeLayerCoordinate(y, current.y),
};
editorState.layerOffsets.set(layer.id, next);
return next;
}
export function nudgeLayerPosition(editorState, layer, dx, dy) {
const current = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
return setLayerPosition(
editorState,
layer,
current.x + normalizeLayerCoordinate(dx),
current.y + normalizeLayerCoordinate(dy),
);
}
function moveLayerBy(editorState, layer, dx, dy) {
if (!layer || isLayerPositionLocked(editorState, layer)) return false;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const next = setLayerPosition(editorState, layer, offset.x + dx, offset.y + dy);
if (layer.kind === 'placed' && layer.placed) {
layer.placed = translatePlacedData(layer.placed, next.x - offset.x, next.y - offset.y);
}
for (const mask of layer.masks || []) {
if (mask.mode !== 'layer' || mask.linked !== false) continue;
mask.offset = {
x: (Number(mask.offset?.x) || 0) - (next.x - offset.x),
y: (Number(mask.offset?.y) || 0) - (next.y - offset.y),
};
}
return next.x !== offset.x || next.y !== offset.y;
}
/** Move selected layers to a document edge/center without rasterizing them. */
export function alignSelectedLayers(editorState, layers, alignment) {
const targets = (layers || []).filter(layer => readLayerGeometry(editorState, layer));
if (targets.length < 2) return false;
let changed = false;
for (const layer of targets) {
const geometry = readLayerGeometry(editorState, layer);
let x = geometry.x;
let y = geometry.y;
if (alignment === 'left') x = 0;
else if (alignment === 'center') x = (editorState.imgWidth - geometry.width) / 2;
else if (alignment === 'right') x = editorState.imgWidth - geometry.width;
else if (alignment === 'top') y = 0;
else if (alignment === 'middle') y = (editorState.imgHeight - geometry.height) / 2;
else if (alignment === 'bottom') y = editorState.imgHeight - geometry.height;
changed = moveLayerBy(editorState, layer, x - geometry.x, y - geometry.y) || changed;
}
return changed;
}
/** Evenly distribute selected layer centers along one document axis. */
export function distributeSelectedLayers(editorState, layers, axis) {
const entries = (layers || []).map(layer => ({ layer, geometry: readLayerGeometry(editorState, layer) }))
.filter(entry => entry.geometry)
.sort((a, b) => a.geometry[axis === 'vertical' ? 'y' : 'x'] - b.geometry[axis === 'vertical' ? 'y' : 'x']);
if (entries.length < 3) return false;
const coordinate = axis === 'vertical' ? 'y' : 'x';
const size = axis === 'vertical' ? 'height' : 'width';
const first = entries[0].geometry[coordinate] + entries[0].geometry[size] / 2;
const lastEntry = entries[entries.length - 1];
const last = lastEntry.geometry[coordinate] + lastEntry.geometry[size] / 2;
const step = (last - first) / (entries.length - 1);
let changed = false;
entries.forEach((entry, index) => {
const target = first + step * index - entry.geometry[size] / 2;
const delta = target - entry.geometry[coordinate];
changed = moveLayerBy(editorState, entry.layer, axis === 'vertical' ? 0 : delta, axis === 'vertical' ? delta : 0) || changed;
});
return changed;
}
export function createLayerGeometryController({ activeLayer, saveState, composite }) {
let root = null;
let fieldHistorySaved = false;
let nudgeHistorySaved = false;
function shiftTransformFrame(dx, dy) {
if (!state.transformActive) return;
if (state.transformCenter) {
state.transformCenter = { x: state.transformCenter.x + dx, y: state.transformCenter.y + dy };
}
if (state.transformBounds) {
state.transformBounds = { ...state.transformBounds, x: state.transformBounds.x + dx, y: state.transformBounds.y + dy };
}
if (state.transformOrigOffset) {
state.transformOrigOffset.x += dx;
state.transformOrigOffset.y += dy;
}
}
const inputs = () => ({
x: root?.querySelector('#ge-layer-x') || null,
y: root?.querySelector('#ge-layer-y') || null,
width: root?.querySelector('#ge-layer-width') || null,
height: root?.querySelector('#ge-layer-height') || null,
name: root?.querySelector('#ge-layer-geometry-name') || null,
});
function sync() {
const layer = activeLayer();
const fields = inputs();
const geometry = readLayerGeometry(state, layer);
if (fields.name) fields.name.textContent = layer?.name || 'No layer selected';
for (const field of [fields.x, fields.y]) field && (field.disabled = !layer || isLayerPositionLocked(state, layer));
for (const field of [fields.width, fields.height]) field && (field.disabled = !layer);
if (!geometry) {
for (const field of [fields.x, fields.y, fields.width, fields.height]) {
if (field) field.value = '';
}
return null;
}
if (fields.x && document.activeElement !== fields.x) fields.x.value = String(geometry.x);
if (fields.y && document.activeElement !== fields.y) fields.y.value = String(geometry.y);
if (fields.width) fields.width.value = String(geometry.width);
if (fields.height) fields.height.value = String(geometry.height);
return geometry;
}
function moveTo(x, y, label = null) {
const layer = activeLayer();
if (!layer || isLayerPositionLocked(state, layer)) return false;
const before = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const nextX = normalizeLayerCoordinate(x, before.x);
const nextY = normalizeLayerCoordinate(y, before.y);
if (before.x === nextX && before.y === nextY) {
sync();
return false;
}
if (label) saveState(label);
const dx = nextX - before.x;
const dy = nextY - before.y;
for (const selected of selectedLayers(state).filter(item => !isLayerPositionLocked(state, item))) {
const selectedOffset = state.layerOffsets.get(selected.id) || { x: 0, y: 0 };
setLayerPosition(state, selected, selectedOffset.x + dx, selectedOffset.y + dy);
if (selected.kind === 'placed' && selected.placed) {
selected.placed = translatePlacedData(selected.placed, dx, dy);
}
for (const mask of selected.masks || []) {
if (mask.mode !== 'layer' || mask.linked !== false) continue;
mask.offset = {
x: (Number(mask.offset?.x) || 0) - dx,
y: (Number(mask.offset?.y) || 0) - dy,
};
}
}
if (state.transformActive && state.transformLayer?.id === layer.id) shiftTransformFrame(dx, dy);
composite();
sync();
return true;
}
function nudge(dx, dy) {
const layer = activeLayer();
if (!layer || isLayerPositionLocked(state, layer)) return false;
const activeMask = (layer.masks || []).find(mask =>
mask.id === layer.activeMaskId && mask.mode === 'layer' && mask.linked === false);
if (activeMask) {
if (!nudgeHistorySaved) saveState(`Nudge mask "${activeMask.name || 'Layer Mask'}"`);
activeMask.offset = {
x: (Number(activeMask.offset?.x) || 0) + dx,
y: (Number(activeMask.offset?.y) || 0) + dy,
};
nudgeHistorySaved = true;
composite();
return true;
}
const current = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const label = !state.transformActive && !nudgeHistorySaved
? `Nudge ${selectionLabel(state, `"${layer.name || 'layer'}"`)}`
: null;
const moved = moveTo(current.x + dx, current.y + dy, label);
if (moved) nudgeHistorySaved = true;
return moved;
}
function endNudge() {
nudgeHistorySaved = false;
}
function trackExternalMove(layer, before, next) {
if (state.transformActive && state.transformLayer?.id === layer?.id) {
shiftTransformFrame(next.x - before.x, next.y - before.y);
}
sync();
}
function wire(nextRoot) {
root = nextRoot;
const fields = inputs();
const applyFields = () => {
const layer = activeLayer();
const geometry = readLayerGeometry(state, layer);
if (!geometry || isLayerPositionLocked(state, layer)) return sync();
const nextX = normalizeLayerCoordinate(fields.x?.value, geometry.x);
const nextY = normalizeLayerCoordinate(fields.y?.value, geometry.y);
if (nextX === geometry.x && nextY === geometry.y) return;
const label = !state.transformActive && !fieldHistorySaved
? `Position "${layer.name || 'layer'}"`
: null;
if (moveTo(nextX, nextY, label)) fieldHistorySaved = true;
};
for (const field of [fields.x, fields.y]) {
if (!field) continue;
field.addEventListener('input', applyFields);
field.addEventListener('change', () => { applyFields(); fieldHistorySaved = false; });
field.addEventListener('blur', () => { fieldHistorySaved = false; sync(); });
field.addEventListener('keydown', (event) => {
if (event.key === 'Enter') field.blur();
if (event.key === 'Escape') { event.preventDefault(); field.blur(); sync(); }
});
}
sync();
}
return { wire, sync, moveTo, nudge, endNudge, trackExternalMove };
}