mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-14 12:12:20 +02:00
250 lines
10 KiB
JavaScript
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 };
|
|
}
|