Files
2026-07-10 19:49:35 +02:00

1077 lines
49 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const MAX_LAYERS = 20;
const DEFAULT_DOCUMENT = {
canvas_width: 1024,
canvas_height: 1024,
bg_hex: "#FFFFFF",
flatten_output: true,
invert_input_masks: false,
};
const ANCHORS = ["top_left", "top_right", "bottom_left", "bottom_right", "center"];
const BLENDS = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "add", "subtract", "difference", "soft_light", "hard_light"];
const HANDLE_RADIUS = 7;
const HANDLE_HIT = 13;
const ROTATE_OFFSET = 34;
const HISTORY_LIMIT = 36;
const MIN_VIEW_ZOOM = 0.25;
const MAX_VIEW_ZOOM = 8;
function defaultLayer(index) {
return {
name: `Layer ${index}`,
x: 0,
y: 0,
scale: 100,
scale_x: 100,
scale_y: 100,
constrain_homothety: true,
collapsed: false,
rot: 0,
opacity: 1,
visible: true,
edit: false,
anchor: "top_left",
blend: "normal",
locked: false,
};
}
const nodeId = Number(new URLSearchParams(location.search).get("nodeId"));
const bridge = window.opener?.__orion4dLayerManagerBridge;
const canvas = document.querySelector("#previewCanvas");
const ctx = canvas.getContext("2d");
const stage = document.querySelector("#previewStage");
const statusEl = document.querySelector("#status");
const canvasInfo = document.querySelector("#canvasInfo");
const emptyMessage = document.querySelector("#emptyMessage");
const layersList = document.querySelector("#layersList");
const layersEmpty = document.querySelector("#layersEmpty");
const layerCount = document.querySelector("#layerCount");
const layerProperties = document.querySelector("#layerProperties");
const selectedIndex = document.querySelector("#selectedIndex");
const alignmentSection = document.querySelector("#alignmentSection");
const undoButton = document.querySelector("#undoAction");
const redoButton = document.querySelector("#redoAction");
const zoomValue = document.querySelector("#zoomReset");
const handButton = document.querySelector("#handTool");
const lockButton = document.querySelector("#toggleLayerLock");
const muteButton = document.querySelector("#toggleLayerMute");
const soloButton = document.querySelector("#toggleLayerSolo");
const resetLayerButton = document.querySelector("#resetLayer");
const fields = {
canvas_width: document.querySelector("#canvas_width"),
canvas_height: document.querySelector("#canvas_height"),
bg_color: document.querySelector("#bg_color"),
bg_hex: document.querySelector("#bg_hex"),
flatten_output: document.querySelector("#flatten_output"),
invert_input_masks: document.querySelector("#invert_input_masks"),
layer_name: document.querySelector("#layer_name"),
layer_x: document.querySelector("#layer_x"),
layer_y: document.querySelector("#layer_y"),
layer_scale: document.querySelector("#layer_scale"),
constrain_scale: document.querySelector("#constrain_scale"),
scale_x: document.querySelector("#scale_x"),
scale_y: document.querySelector("#scale_y"),
layer_rotation: document.querySelector("#layer_rotation"),
layer_opacity: document.querySelector("#layer_opacity"),
layer_anchor: document.querySelector("#layer_anchor"),
layer_blend: document.querySelector("#layer_blend"),
layer_visible: document.querySelector("#layer_visible"),
};
const scaleOutput = document.querySelector("#scaleOutput");
const rotationOutput = document.querySelector("#rotationOutput");
const opacityOutput = document.querySelector("#opacityOutput");
let documentSettings = { ...DEFAULT_DOCUMENT };
let config = normalizeConfig({});
let metadata = [];
let activeLayer = null;
let dirty = false;
let lastRevision = -1;
let pollTimer = null;
let rafId = 0;
let viewportWidth = 1;
let viewportHeight = 1;
let documentRect = { x: 0, y: 0, w: 1, h: 1, scale: 1 };
let interaction = null;
let viewZoom = 1;
let viewOffset = { x: 0, y: 0 };
let handToolActive = false;
let spacePanActive = false;
let history = [];
let historyIndex = -1;
let historyTimer = null;
let historyReady = false;
let restoringHistory = false;
const imageEntries = new Map();
function number(value, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clamp(value, min, max) { return Math.max(min, Math.min(max, number(value, min))); }
function round3(value) { return Math.round(number(value) * 1000) / 1000; }
function normalizeHex(value, fallback = "#FFFFFF") {
let text = String(value || "").trim();
if (!text.startsWith("#")) text = `#${text}`;
if (/^#[0-9a-f]{3}$/i.test(text)) text = `#${text[1]}${text[1]}${text[2]}${text[2]}${text[3]}${text[3]}`;
if (!/^#[0-9a-f]{6}$/i.test(text)) return fallback;
return text.toUpperCase();
}
function normalizeSourceUrl(value) {
if (!value) return "";
try {
const url = new URL(value, location.origin);
for (const key of [...url.searchParams.keys()]) {
if (key === "t" || key.startsWith("_orion")) url.searchParams.delete(key);
}
return url.href;
} catch { return String(value); }
}
function withCacheBust(value) {
if (!value) return "";
const url = new URL(value, location.origin);
url.searchParams.set("t", String(Date.now()));
return url.href;
}
function clone(value) { return JSON.parse(JSON.stringify(value)); }
function normalizeLayer(layer, index) {
const next = { ...defaultLayer(index), ...(layer || {}) };
next.name = String(next.name || `Layer ${index}`);
next.x = clamp(next.x, -8192, 8192);
next.y = clamp(next.y, -8192, 8192);
next.scale = clamp(next.scale, 1, 2000);
next.scale_x = clamp(next.scale_x ?? next.scale, 1, 2000);
next.scale_y = clamp(next.scale_y ?? next.scale, 1, 2000);
next.constrain_homothety = Boolean(next.constrain_homothety);
if (next.constrain_homothety) next.scale_x = next.scale_y = next.scale;
next.rot = clamp(next.rot, -180, 180);
next.opacity = clamp(next.opacity, 0, 1);
next.visible = Boolean(next.visible);
next.locked = Boolean(next.locked);
next.anchor = ANCHORS.includes(next.anchor) ? next.anchor : "top_left";
next.blend = BLENDS.includes(next.blend) ? next.blend : "normal";
return next;
}
function normalizeConfig(raw, connected = []) {
const input = raw && typeof raw === "object" ? raw : {};
const maxIndex = Math.min(MAX_LAYERS, Math.max(1, number(input.layer_count, 1), ...connected));
const layers = Array.isArray(input.layers) ? input.layers.map(item => item ? { ...item } : null) : [null];
if (!layers.length) layers.push(null);
layers[0] = null;
while (layers.length <= maxIndex) layers.push(null);
for (let index = 1; index < layers.length; index++) layers[index] = normalizeLayer(layers[index], index);
for (const index of connected) {
while (layers.length <= index) layers.push(null);
layers[index] = normalizeLayer(layers[index], index);
}
const order = [];
if (Array.isArray(input.order)) {
for (const item of input.order) {
const index = Number(item);
if (Number.isInteger(index) && index >= 1 && index <= MAX_LAYERS && !order.includes(index)) order.push(index);
}
}
for (let index = 1; index < layers.length; index++) if (!order.includes(index)) order.push(index);
for (const index of connected) if (!order.includes(index)) order.push(index);
const rawSolo = Number(input.solo_layer);
const solo_layer = Number.isInteger(rawSolo) && rawSolo >= 1 && rawSolo <= MAX_LAYERS ? rawSolo : null;
return { version: 31, layer_count: maxIndex, order, solo_layer, layers };
}
function connectedIndices() { return metadata.map(item => item.index).sort((a, b) => a - b); }
function ensureConnectedConfig() {
config = normalizeConfig(config, connectedIndices());
if (activeLayer == null || !connectedIndices().includes(activeLayer)) activeLayer = connectedIndices()[0] ?? null;
}
function active() { return activeLayer == null ? null : config.layers?.[activeLayer] || null; }
function metaFor(index) { return metadata.find(item => item.index === index) || null; }
function imageFor(index) { return imageEntries.get(index)?.image || null; }
function isSoloActive(index) { return Number(config.solo_layer) === Number(index); }
function isEffectivelyVisible(index) {
const layer = config.layers?.[index];
if (!layer?.visible) return false;
return config.solo_layer == null || Number(config.solo_layer) === Number(index);
}
function captureHistoryState() {
return {
document: clone(documentSettings),
config: clone(config),
activeLayer,
};
}
function historyKey(state) { return JSON.stringify(state); }
function updateHistoryButtons() {
if (undoButton) undoButton.disabled = historyIndex <= 0;
if (redoButton) redoButton.disabled = historyIndex < 0 || historyIndex >= history.length - 1;
}
function resetHistory() {
if (historyTimer) clearTimeout(historyTimer);
historyTimer = null;
history = [captureHistoryState()];
historyIndex = 0;
historyReady = true;
updateHistoryButtons();
}
function commitHistory() {
if (!historyReady || restoringHistory) return;
if (historyTimer) clearTimeout(historyTimer);
historyTimer = null;
const next = captureHistoryState();
const current = history[historyIndex];
if (current && historyKey(current) === historyKey(next)) return;
history = history.slice(0, historyIndex + 1);
history.push(next);
if (history.length > HISTORY_LIMIT + 1) history.shift();
historyIndex = history.length - 1;
updateHistoryButtons();
}
function queueHistoryCommit() {
if (!historyReady || restoringHistory) return;
if (historyTimer) clearTimeout(historyTimer);
historyTimer = window.setTimeout(commitHistory, 180);
}
function restoreHistoryState(state, label) {
if (!state) return;
restoringHistory = true;
documentSettings = { ...DEFAULT_DOCUMENT, ...(clone(state.document) || {}) };
config = normalizeConfig(clone(state.config) || {}, connectedIndices());
activeLayer = connectedIndices().includes(Number(state.activeLayer)) ? Number(state.activeLayer) : (connectedIndices()[0] ?? null);
dirty = true;
writeDocumentForm();
ensureConnectedConfig();
renderLayerList();
syncActiveControls();
scheduleDraw();
statusEl.textContent = label;
restoringHistory = false;
updateHistoryButtons();
}
function undo() {
if (!historyReady) return;
if (historyTimer) commitHistory();
if (historyIndex <= 0) return;
historyIndex -= 1;
restoreHistoryState(history[historyIndex], `Undo · ${historyIndex}/${Math.max(0, history.length - 1)}`);
}
function redo() {
if (!historyReady) return;
if (historyTimer) commitHistory();
if (historyIndex >= history.length - 1) return;
historyIndex += 1;
restoreHistoryState(history[historyIndex], `Redo · ${historyIndex}/${Math.max(0, history.length - 1)}`);
}
function writeDocumentForm() {
fields.canvas_width.value = documentSettings.canvas_width;
fields.canvas_height.value = documentSettings.canvas_height;
fields.bg_hex.value = normalizeHex(documentSettings.bg_hex);
fields.bg_color.value = normalizeHex(documentSettings.bg_hex);
fields.flatten_output.checked = Boolean(documentSettings.flatten_output);
fields.invert_input_masks.checked = Boolean(documentSettings.invert_input_masks);
canvasInfo.textContent = `${documentSettings.canvas_width} × ${documentSettings.canvas_height}`;
}
function readDocumentForm() {
documentSettings.canvas_width = Math.round(clamp(fields.canvas_width.value, 16, 8192));
documentSettings.canvas_height = Math.round(clamp(fields.canvas_height.value, 16, 8192));
documentSettings.bg_hex = normalizeHex(fields.bg_hex.value, "#FFFFFF");
documentSettings.flatten_output = fields.flatten_output.checked;
documentSettings.invert_input_masks = fields.invert_input_masks.checked;
writeDocumentForm();
}
function syncActiveControls() {
const layer = active();
const disabled = !layer;
const locked = Boolean(layer?.locked);
layerProperties.classList.toggle("disabled", disabled);
layerProperties.classList.toggle("layer-locked", !disabled && locked);
alignmentSection?.classList.toggle("disabled-section", disabled || locked);
selectedIndex.textContent = layer ? `L${activeLayer}` : "—";
for (const element of Object.values(fields)) {
if (!element || !element.closest("#layerProperties")) continue;
const allowedWhenLocked = element === fields.layer_visible;
element.disabled = disabled || (locked && !allowedWhenLocked);
}
for (const button of [lockButton, muteButton, soloButton, resetLayerButton]) if (button) button.disabled = disabled;
if (resetLayerButton) resetLayerButton.disabled = disabled || locked;
document.querySelector("#fitLayer").disabled = disabled || locked;
document.querySelector("#centerLayer").disabled = disabled || locked;
document.querySelectorAll("[data-align]").forEach(button => button.disabled = disabled || locked);
if (!layer) {
lockButton?.classList.remove("active-state");
muteButton?.classList.remove("active-state");
soloButton?.classList.remove("active-state");
return;
}
fields.layer_name.value = layer.name;
fields.layer_x.value = round3(layer.x);
fields.layer_y.value = round3(layer.y);
fields.layer_scale.value = layer.scale;
fields.constrain_scale.checked = layer.constrain_homothety;
fields.scale_x.value = layer.scale_x;
fields.scale_y.value = layer.scale_y;
fields.scale_x.disabled = locked || layer.constrain_homothety;
fields.scale_y.disabled = locked || layer.constrain_homothety;
fields.layer_rotation.value = layer.rot;
fields.layer_opacity.value = layer.opacity;
fields.layer_anchor.value = layer.anchor;
fields.layer_blend.value = layer.blend;
fields.layer_visible.checked = layer.visible;
scaleOutput.textContent = `${number(layer.scale, 100).toFixed(1)}%`;
rotationOutput.textContent = `${number(layer.rot, 0).toFixed(1)}°`;
opacityOutput.textContent = `${Math.round(number(layer.opacity, 1) * 100)}%`;
lockButton.classList.toggle("active-state", locked);
lockButton.innerHTML = locked ? "🔒 <span>Locked</span>" : "🔓 <span>Lock</span>";
muteButton.classList.toggle("active-state", !layer.visible);
muteButton.innerHTML = `<strong>M</strong> <span>${layer.visible ? "Mute" : "Muted"}</span>`;
soloButton.classList.toggle("active-state", isSoloActive(activeLayer));
soloButton.innerHTML = `<strong>S</strong> <span>${isSoloActive(activeLayer) ? "Soloed" : "Solo"}</span>`;
}
function markDirty(message = "Changes not applied") {
dirty = true;
statusEl.textContent = message;
scheduleDraw();
queueHistoryCommit();
}
function syncLayerSources(nextMetadata, force = false) {
metadata = Array.isArray(nextMetadata) ? nextMetadata.map(item => ({ ...item })) : [];
const live = new Set(metadata.map(item => item.index));
for (const index of [...imageEntries.keys()]) if (!live.has(index)) imageEntries.delete(index);
for (const item of metadata) {
const source = item.imageSrc || item.rawSrc || item.cacheSrc;
const clean = normalizeSourceUrl(source);
const previous = imageEntries.get(item.index);
if (!source) {
imageEntries.set(item.index, { source: "", clean: "", image: null, loading: false });
continue;
}
if (!force && previous?.clean === clean && previous.image) continue;
const entry = { source, clean, image: previous?.image || null, loading: true };
imageEntries.set(item.index, entry);
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => {
const current = imageEntries.get(item.index);
if (!current || current.clean !== clean) return;
current.image = image;
current.loading = false;
scheduleDraw();
renderLayerList();
};
image.onerror = () => {
const current = imageEntries.get(item.index);
if (current?.clean === clean) current.loading = false;
scheduleDraw();
};
image.src = withCacheBust(source);
}
ensureConnectedConfig();
renderLayerList();
syncActiveControls();
scheduleDraw();
}
function orderedConnectedBottomToTop() {
const connected = new Set(connectedIndices());
return config.order.filter(index => connected.has(index));
}
function moveOrder(index, direction) {
const order = orderedConnectedBottomToTop();
const position = order.indexOf(index);
if (position < 0) return;
const target = direction === "up" ? position + 1 : position - 1;
if (target < 0 || target >= order.length) return;
[order[position], order[target]] = [order[target], order[position]];
const disconnected = config.order.filter(item => !order.includes(item));
config.order = [...order, ...disconnected];
markDirty("Layer order changed");
renderLayerList();
}
function renderLayerList() {
const orderTopToBottom = [...orderedConnectedBottomToTop()].reverse();
layersList.replaceChildren();
layerCount.textContent = String(orderTopToBottom.length);
layersEmpty.hidden = orderTopToBottom.length > 0;
emptyMessage.hidden = orderTopToBottom.length > 0;
for (const index of orderTopToBottom) {
const layer = config.layers[index];
const meta = metaFor(index);
const effectiveVisible = isEffectivelyVisible(index);
const row = document.createElement("div");
row.className = `layer-row${index === activeLayer ? " active" : ""}${layer.visible ? "" : " hidden-layer"}${config.solo_layer != null && !isSoloActive(index) ? " solo-muted" : ""}${layer.locked ? " locked-layer" : ""}`;
row.dataset.index = String(index);
const mute = document.createElement("button");
mute.className = `layer-eye${layer.visible ? "" : " active-state"}`;
mute.title = layer.visible ? "Mute layer" : "Unmute layer";
mute.textContent = "M";
mute.addEventListener("click", event => {
event.stopPropagation();
layer.visible = !layer.visible;
markDirty(layer.visible ? "Layer unmuted" : "Layer muted");
renderLayerList();
syncActiveControls();
});
const solo = document.createElement("button");
solo.className = `layer-solo${isSoloActive(index) ? " active-state" : ""}`;
solo.title = isSoloActive(index) ? "Disable solo" : "Solo layer";
solo.textContent = "S";
solo.addEventListener("click", event => {
event.stopPropagation();
const enablingSolo = !isSoloActive(index);
config.solo_layer = enablingSolo ? index : null;
if (enablingSolo) layer.visible = true;
markDirty(config.solo_layer == null ? "Solo disabled" : `Layer ${index} solo`);
renderLayerList();
syncActiveControls();
});
const lock = document.createElement("button");
lock.className = `layer-lock${layer.locked ? " active-state" : ""}`;
lock.title = layer.locked ? "Unlock layer" : "Lock layer";
lock.textContent = layer.locked ? "🔒" : "🔓";
lock.addEventListener("click", event => {
event.stopPropagation();
layer.locked = !layer.locked;
markDirty(layer.locked ? "Layer locked" : "Layer unlocked");
renderLayerList();
syncActiveControls();
});
const thumb = document.createElement("img");
thumb.className = "layer-thumb";
const entry = imageEntries.get(index);
if (entry?.source) thumb.src = entry.source;
thumb.style.opacity = effectiveVisible ? "1" : ".55";
const title = document.createElement("div");
title.className = "layer-title";
const name = document.createElement("div");
name.className = "layer-name";
name.textContent = layer.name || `Layer ${index}`;
const metaLine = document.createElement("div");
metaLine.className = "layer-meta";
const idText = document.createElement("span");
idText.textContent = `L${index}`;
metaLine.appendChild(idText);
if (meta?.hasMask) {
const badge = document.createElement("span");
badge.className = meta.maskReady ? "mask-badge" : "mask-pending";
badge.textContent = meta.maskReady ? "MASK" : "MASK · RUN";
metaLine.appendChild(badge);
}
title.append(name, metaLine);
const up = document.createElement("button");
up.className = "layer-order";
up.title = "Move up";
up.textContent = "▲";
up.addEventListener("click", event => { event.stopPropagation(); moveOrder(index, "up"); });
const down = document.createElement("button");
down.className = "layer-order";
down.title = "Move down";
down.textContent = "▼";
down.addEventListener("click", event => { event.stopPropagation(); moveOrder(index, "down"); });
row.append(mute, solo, lock, thumb, title, up, down);
row.addEventListener("click", () => {
activeLayer = index;
syncActiveControls();
renderLayerList();
scheduleDraw();
});
layersList.appendChild(row);
}
}
function ensureCanvasSize() {
const width = Math.max(1, Math.floor(stage.clientWidth));
const height = Math.max(1, Math.floor(stage.clientHeight));
const dpr = Math.max(1, window.devicePixelRatio || 1);
if (width !== viewportWidth || height !== viewportHeight || canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
viewportWidth = width;
viewportHeight = height;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function calculateDocumentRect() {
const cw = Math.max(1, documentSettings.canvas_width);
const ch = Math.max(1, documentSettings.canvas_height);
const padding = 28;
const fitScale = Math.max(.0001, Math.min((viewportWidth - padding * 2) / cw, (viewportHeight - padding * 2) / ch));
const scale = fitScale * viewZoom;
const w = cw * scale;
const h = ch * scale;
documentRect = { x: (viewportWidth - w) / 2 + viewOffset.x, y: (viewportHeight - h) / 2 + viewOffset.y, w, h, scale };
return documentRect;
}
function updateZoomDisplay() { if (zoomValue) zoomValue.textContent = `${Math.round(viewZoom * 100)}%`; }
function setViewZoom(value, focal = null) {
ensureCanvasSize();
calculateDocumentRect();
const focus = focal || { x: viewportWidth / 2, y: viewportHeight / 2 };
const documentPoint = canvasToDoc(focus);
const next = clamp(value, MIN_VIEW_ZOOM, MAX_VIEW_ZOOM);
if (Math.abs(next - viewZoom) < 1e-6) return;
viewZoom = next;
calculateDocumentRect();
const after = docToCanvas(documentPoint);
viewOffset.x += focus.x - after.x;
viewOffset.y += focus.y - after.y;
updateZoomDisplay();
scheduleDraw();
}
function resetViewZoom() {
viewZoom = 1;
viewOffset = { x: 0, y: 0 };
updateZoomDisplay();
scheduleDraw();
}
function isHandMode() { return handToolActive || spacePanActive; }
function isTextEntryTarget(target) {
if (!(target instanceof Element)) return false;
return Boolean(target.closest("input, textarea, select, [contenteditable='true']"));
}
function updateHandToolState() {
if (handButton) {
handButton.classList.toggle("active-tool", isHandMode());
handButton.setAttribute("aria-pressed", handToolActive ? "true" : "false");
handButton.title = handToolActive ? "Hand tool active · Hold Space for temporary use" : "Hand tool · Hold Space";
}
if (interaction?.mode === "pan") canvas.style.cursor = "grabbing";
else if (isHandMode()) canvas.style.cursor = "grab";
else canvas.style.cursor = "default";
}
function docToCanvas(point) { return { x: documentRect.x + point.x * documentRect.scale, y: documentRect.y + point.y * documentRect.scale }; }
function canvasToDoc(point) { return { x: (point.x - documentRect.x) / documentRect.scale, y: (point.y - documentRect.y) / documentRect.scale }; }
function rotateVector(x, y, degrees) {
const radians = degrees * Math.PI / 180;
const c = Math.cos(radians), s = Math.sin(radians);
return { x: x * c - y * s, y: x * s + y * c };
}
function anchorPoint(layer, image) {
switch (layer.anchor) {
case "top_right": return { x: image.naturalWidth, y: 0 };
case "bottom_left": return { x: 0, y: image.naturalHeight };
case "bottom_right": return { x: image.naturalWidth, y: image.naturalHeight };
case "center": return { x: image.naturalWidth / 2, y: image.naturalHeight / 2 };
default: return { x: 0, y: 0 };
}
}
function layerScales(layer) {
const sx = (layer.constrain_homothety ? layer.scale : layer.scale_x) / 100;
const sy = (layer.constrain_homothety ? layer.scale : layer.scale_y) / 100;
return { sx: Math.max(.001, sx), sy: Math.max(.001, sy) };
}
function sourcePointToWorld(layer, image, sourcePoint) {
const anchor = anchorPoint(layer, image);
const { sx, sy } = layerScales(layer);
const rotated = rotateVector((sourcePoint.x - anchor.x) * sx, (sourcePoint.y - anchor.y) * sy, layer.rot);
return { x: layer.x + rotated.x, y: layer.y + rotated.y };
}
function geometryFor(index) {
const layer = config.layers?.[index];
const image = imageFor(index);
if (!layer || !image?.naturalWidth || !image?.naturalHeight) return null;
const w = image.naturalWidth, h = image.naturalHeight;
const cornersDoc = [
sourcePointToWorld(layer, image, { x: 0, y: 0 }),
sourcePointToWorld(layer, image, { x: w, y: 0 }),
sourcePointToWorld(layer, image, { x: w, y: h }),
sourcePointToWorld(layer, image, { x: 0, y: h }),
];
const centerDoc = sourcePointToWorld(layer, image, { x: w / 2, y: h / 2 });
const corners = cornersDoc.map(docToCanvas);
const center = docToCanvas(centerDoc);
const topMid = { x: (corners[0].x + corners[1].x) / 2, y: (corners[0].y + corners[1].y) / 2 };
let nx = topMid.x - center.x, ny = topMid.y - center.y;
const length = Math.hypot(nx, ny) || 1;
nx /= length; ny /= length;
const rotateHandle = { x: topMid.x + nx * ROTATE_OFFSET, y: topMid.y + ny * ROTATE_OFFSET };
return { layer, image, cornersDoc, corners, centerDoc, center, topMid, rotateHandle };
}
function pointInPolygon(point, polygon) {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y;
const xj = polygon[j].x, yj = polygon[j].y;
const intersect = ((yi > point.y) !== (yj > point.y)) && (point.x < (xj - xi) * (point.y - yi) / ((yj - yi) || 1e-9) + xi);
if (intersect) inside = !inside;
}
return inside;
}
function distance(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
function hitHandle(point, geometry) {
if (!geometry) return null;
for (let index = 0; index < geometry.corners.length; index++) if (distance(point, geometry.corners[index]) <= HANDLE_HIT) return { type: "scale", corner: index };
if (distance(point, geometry.rotateHandle) <= HANDLE_HIT + 2) return { type: "rotate" };
return null;
}
function localPointer(event) {
const rect = canvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
}
function drawChecker(x, y, w, h) {
const size = 12;
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
for (let yy = y; yy < y + h; yy += size) for (let xx = x; xx < x + w; xx += size) {
ctx.fillStyle = ((Math.floor((xx - x) / size) + Math.floor((yy - y) / size)) & 1) ? "#dfe4ea" : "#f7f8fa";
ctx.fillRect(xx, yy, size, size);
}
ctx.restore();
}
function canvasBlend(mode) {
return ({ normal: "source-over", multiply: "multiply", screen: "screen", overlay: "overlay", darken: "darken", lighten: "lighten", add: "lighter", difference: "difference", soft_light: "soft-light", hard_light: "hard-light" })[mode] || "source-over";
}
function drawLayer(index) {
const layer = config.layers[index];
const image = imageFor(index);
if (!isEffectivelyVisible(index) || !image?.naturalWidth) return;
const anchor = anchorPoint(layer, image);
const { sx, sy } = layerScales(layer);
const anchorCanvas = docToCanvas({ x: layer.x, y: layer.y });
ctx.save();
ctx.beginPath(); ctx.rect(documentRect.x, documentRect.y, documentRect.w, documentRect.h); ctx.clip();
ctx.globalAlpha = layer.opacity;
ctx.globalCompositeOperation = canvasBlend(layer.blend);
ctx.translate(anchorCanvas.x, anchorCanvas.y);
ctx.rotate(layer.rot * Math.PI / 180);
ctx.scale(sx * documentRect.scale, sy * documentRect.scale);
ctx.drawImage(image, -anchor.x, -anchor.y);
ctx.restore();
}
function drawSelection() {
if (activeLayer == null) return;
const geometry = geometryFor(activeLayer);
if (!geometry) return;
ctx.save();
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
ctx.strokeStyle = geometry.layer.locked ? "#6f8dac" : "#69b0ff";
ctx.fillStyle = "#ffffff";
ctx.lineWidth = 1.5;
ctx.beginPath();
geometry.corners.forEach((point, index) => index ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y));
ctx.closePath(); ctx.stroke();
if (!geometry.layer.locked) {
ctx.beginPath(); ctx.moveTo(geometry.topMid.x, geometry.topMid.y); ctx.lineTo(geometry.rotateHandle.x, geometry.rotateHandle.y); ctx.stroke();
for (const point of geometry.corners) {
ctx.fillRect(point.x - HANDLE_RADIUS, point.y - HANDLE_RADIUS, HANDLE_RADIUS * 2, HANDLE_RADIUS * 2);
ctx.strokeRect(point.x - HANDLE_RADIUS, point.y - HANDLE_RADIUS, HANDLE_RADIUS * 2, HANDLE_RADIUS * 2);
}
ctx.fillStyle = "#3f83f8";
ctx.beginPath(); ctx.arc(geometry.rotateHandle.x, geometry.rotateHandle.y, HANDLE_RADIUS, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = "#69b0ff";
ctx.beginPath(); ctx.arc(docToCanvas({ x: geometry.layer.x, y: geometry.layer.y }).x, docToCanvas({ x: geometry.layer.x, y: geometry.layer.y }).y, 4, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function draw() {
rafId = 0;
ensureCanvasSize();
calculateDocumentRect();
ctx.clearRect(0, 0, viewportWidth, viewportHeight);
ctx.fillStyle = "#090d12"; ctx.fillRect(0, 0, viewportWidth, viewportHeight);
drawChecker(documentRect.x, documentRect.y, documentRect.w, documentRect.h);
if (documentSettings.flatten_output) {
ctx.fillStyle = normalizeHex(documentSettings.bg_hex);
ctx.fillRect(documentRect.x, documentRect.y, documentRect.w, documentRect.h);
}
for (const index of orderedConnectedBottomToTop()) drawLayer(index);
drawSelection();
ctx.strokeStyle = "#536274"; ctx.lineWidth = 1;
ctx.strokeRect(documentRect.x + .5, documentRect.y + .5, documentRect.w - 1, documentRect.h - 1);
}
function scheduleDraw() { if (!rafId) rafId = requestAnimationFrame(draw); }
function preserveCenterForTransform(layer, image, centerDoc) {
const anchor = anchorPoint(layer, image);
const { sx, sy } = layerScales(layer);
const offset = rotateVector((image.naturalWidth / 2 - anchor.x) * sx, (image.naturalHeight / 2 - anchor.y) * sy, layer.rot);
layer.x = round3(centerDoc.x - offset.x);
layer.y = round3(centerDoc.y - offset.y);
}
canvas.addEventListener("pointerdown", event => {
if (event.button !== 0) return;
const point = localPointer(event);
if (isHandMode()) {
interaction = {
mode: "pan",
startPoint: point,
startOffset: { ...viewOffset },
};
canvas.setPointerCapture?.(event.pointerId);
canvas.style.cursor = "grabbing";
event.preventDefault();
return;
}
const selectedGeometry = activeLayer == null ? null : geometryFor(activeLayer);
const handle = selectedGeometry?.layer.locked ? null : hitHandle(point, selectedGeometry);
if (handle && selectedGeometry) {
interaction = {
mode: handle.type,
index: activeLayer,
startPoint: point,
startLayer: clone(selectedGeometry.layer),
centerDoc: { ...selectedGeometry.centerDoc },
startAngle: Math.atan2(point.y - selectedGeometry.center.y, point.x - selectedGeometry.center.x),
};
} else {
let hitIndex = null;
const topToBottom = [...orderedConnectedBottomToTop()].reverse();
for (const index of topToBottom) {
const layer = config.layers?.[index];
if (!layer || layer.locked || !isEffectivelyVisible(index)) continue;
const geometry = geometryFor(index);
if (geometry && pointInPolygon(point, geometry.corners)) { hitIndex = index; break; }
}
if (hitIndex == null) { activeLayer = null; syncActiveControls(); renderLayerList(); scheduleDraw(); return; }
activeLayer = hitIndex;
const layer = config.layers[hitIndex];
interaction = { mode: "move", index: hitIndex, startDoc: canvasToDoc(point), startX: layer.x, startY: layer.y };
syncActiveControls(); renderLayerList(); scheduleDraw();
}
if (interaction) canvas.setPointerCapture?.(event.pointerId);
event.preventDefault();
});
canvas.addEventListener("pointermove", event => {
const point = localPointer(event);
if (!interaction) {
if (isHandMode()) { canvas.style.cursor = "grab"; return; }
const geometry = activeLayer == null ? null : geometryFor(activeLayer);
const handle = geometry?.layer.locked ? null : hitHandle(point, geometry);
if (handle?.type === "scale") canvas.style.cursor = "nwse-resize";
else if (handle?.type === "rotate") canvas.style.cursor = "crosshair";
else {
let over = false;
for (const index of [...orderedConnectedBottomToTop()].reverse()) {
const layer = config.layers?.[index];
if (!layer || layer.locked || !isEffectivelyVisible(index)) continue;
const g = geometryFor(index);
if (g && pointInPolygon(point, g.corners)) { over = true; break; }
}
canvas.style.cursor = over ? "move" : "default";
}
return;
}
if (interaction.mode === "pan") {
viewOffset.x = interaction.startOffset.x + point.x - interaction.startPoint.x;
viewOffset.y = interaction.startOffset.y + point.y - interaction.startPoint.y;
canvas.style.cursor = "grabbing";
scheduleDraw();
return;
}
const layer = config.layers[interaction.index];
const image = imageFor(interaction.index);
if (!layer || !image) return;
if (interaction.mode === "move") {
const current = canvasToDoc(point);
layer.x = round3(interaction.startX + current.x - interaction.startDoc.x);
layer.y = round3(interaction.startY + current.y - interaction.startDoc.y);
} else if (interaction.mode === "scale") {
const currentDoc = canvasToDoc(point);
const delta = { x: currentDoc.x - interaction.centerDoc.x, y: currentDoc.y - interaction.centerDoc.y };
const local = rotateVector(delta.x, delta.y, -interaction.startLayer.rot);
let sx = clamp(Math.abs(local.x) / Math.max(1, image.naturalWidth / 2), .01, 20);
let sy = clamp(Math.abs(local.y) / Math.max(1, image.naturalHeight / 2), .01, 20);
if (interaction.startLayer.constrain_homothety) {
const uniform = Math.max(sx, sy);
sx = sy = uniform;
layer.scale = round3(uniform * 100);
}
layer.scale_x = round3(sx * 100);
layer.scale_y = round3(sy * 100);
if (!layer.constrain_homothety) layer.scale = round3((layer.scale_x + layer.scale_y) / 2);
preserveCenterForTransform(layer, image, interaction.centerDoc);
} else if (interaction.mode === "rotate") {
const centerCanvas = docToCanvas(interaction.centerDoc);
const angle = Math.atan2(point.y - centerCanvas.y, point.x - centerCanvas.x);
let degrees = interaction.startLayer.rot + (angle - interaction.startAngle) * 180 / Math.PI;
degrees = ((degrees + 180) % 360 + 360) % 360 - 180;
layer.rot = round3(degrees);
preserveCenterForTransform(layer, image, interaction.centerDoc);
}
markDirty();
syncActiveControls();
});
function endInteraction(event) {
if (!interaction) return;
const completedMode = interaction.mode;
interaction = null;
try { canvas.releasePointerCapture?.(event.pointerId); } catch {}
updateHandToolState();
if (completedMode === "pan") { scheduleDraw(); return; }
renderLayerList();
syncActiveControls();
commitHistory();
}
canvas.addEventListener("pointerup", endInteraction);
canvas.addEventListener("pointercancel", endInteraction);
function currentBounds(index) {
const geometry = geometryFor(index);
if (!geometry) return null;
const xs = geometry.cornersDoc.map(point => point.x), ys = geometry.cornersDoc.map(point => point.y);
return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
}
function alignActive(mode) {
if (activeLayer == null) return;
const layer = active();
const bounds = currentBounds(activeLayer);
if (!layer || layer.locked || !bounds) return;
const width = bounds.maxX - bounds.minX, height = bounds.maxY - bounds.minY;
let targetX = bounds.minX, targetY = bounds.minY;
const centerX = (documentSettings.canvas_width - width) / 2;
const centerY = (documentSettings.canvas_height - height) / 2;
const right = documentSettings.canvas_width - width;
const bottom = documentSettings.canvas_height - height;
if (["top_left", "left", "bottom_left"].includes(mode)) targetX = 0;
if (["top", "center", "bottom"].includes(mode)) targetX = centerX;
if (["top_right", "right", "bottom_right"].includes(mode)) targetX = right;
if (["top_left", "top", "top_right"].includes(mode)) targetY = 0;
if (["left", "center", "right"].includes(mode)) targetY = centerY;
if (["bottom_left", "bottom", "bottom_right"].includes(mode)) targetY = bottom;
layer.x = round3(layer.x + targetX - bounds.minX);
layer.y = round3(layer.y + targetY - bounds.minY);
markDirty("Layer aligned"); syncActiveControls();
}
function centerActive() { alignActive("center"); }
function fitActive() {
if (activeLayer == null) return;
const layer = active();
const image = imageFor(activeLayer);
if (!layer || layer.locked || !image?.naturalWidth) return;
const factor = Math.min(documentSettings.canvas_width / image.naturalWidth, documentSettings.canvas_height / image.naturalHeight);
layer.anchor = "center";
layer.rot = 0;
layer.constrain_homothety = true;
layer.scale = layer.scale_x = layer.scale_y = round3(factor * 100);
layer.x = documentSettings.canvas_width / 2;
layer.y = documentSettings.canvas_height / 2;
markDirty("Layer fitted to canvas"); syncActiveControls();
}
function resetActive() {
if (activeLayer == null) return;
const old = active();
if (!old || old.locked) return;
config.layers[activeLayer] = {
...defaultLayer(activeLayer),
name: old.name || `Layer ${activeLayer}`,
anchor: "center",
x: documentSettings.canvas_width / 2,
y: documentSettings.canvas_height / 2,
scale: 100,
scale_x: 100,
scale_y: 100,
rot: 0,
opacity: 1,
blend: "normal",
visible: true,
locked: false,
};
markDirty("Layer reset · centered at 100%"); syncActiveControls(); renderLayerList();
}
function bindDocumentFields() {
for (const field of [fields.canvas_width, fields.canvas_height]) field.addEventListener("change", () => { readDocumentForm(); markDirty("Document changed"); });
fields.bg_color.addEventListener("input", () => { fields.bg_hex.value = fields.bg_color.value.toUpperCase(); readDocumentForm(); markDirty("Background changed"); });
fields.bg_hex.addEventListener("change", () => { fields.bg_hex.value = normalizeHex(fields.bg_hex.value); fields.bg_color.value = fields.bg_hex.value; readDocumentForm(); markDirty("Background changed"); });
fields.flatten_output.addEventListener("change", () => { readDocumentForm(); markDirty("Document changed"); });
fields.invert_input_masks.addEventListener("change", () => { readDocumentForm(); markDirty("Mask setting changed"); });
}
function canEditActive() { const layer = active(); return Boolean(layer && !layer.locked); }
function bindLayerFields() {
fields.layer_name.addEventListener("input", () => { const layer = active(); if (!layer || layer.locked) return; layer.name = fields.layer_name.value; markDirty(); renderLayerList(); });
fields.layer_x.addEventListener("change", () => { const layer = active(); if (!layer || layer.locked) return; layer.x = clamp(fields.layer_x.value, -8192, 8192); markDirty(); syncActiveControls(); });
fields.layer_y.addEventListener("change", () => { const layer = active(); if (!layer || layer.locked) return; layer.y = clamp(fields.layer_y.value, -8192, 8192); markDirty(); syncActiveControls(); });
fields.layer_scale.addEventListener("input", () => {
const layer = active(); if (!layer || layer.locked) return;
layer.scale = clamp(fields.layer_scale.value, 1, 2000);
if (layer.constrain_homothety) layer.scale_x = layer.scale_y = layer.scale;
markDirty(); syncActiveControls();
});
fields.constrain_scale.addEventListener("change", () => {
const layer = active(); if (!layer || layer.locked) return;
layer.constrain_homothety = fields.constrain_scale.checked;
if (layer.constrain_homothety) layer.scale_x = layer.scale_y = layer.scale;
markDirty(); syncActiveControls();
});
fields.scale_x.addEventListener("change", () => { const layer = active(); if (!layer || layer.locked) return; layer.scale_x = clamp(fields.scale_x.value, 1, 2000); layer.scale = (layer.scale_x + layer.scale_y) / 2; markDirty(); syncActiveControls(); });
fields.scale_y.addEventListener("change", () => { const layer = active(); if (!layer || layer.locked) return; layer.scale_y = clamp(fields.scale_y.value, 1, 2000); layer.scale = (layer.scale_x + layer.scale_y) / 2; markDirty(); syncActiveControls(); });
fields.layer_rotation.addEventListener("input", () => { const layer = active(); if (!layer || layer.locked) return; layer.rot = clamp(fields.layer_rotation.value, -180, 180); markDirty(); syncActiveControls(); });
fields.layer_opacity.addEventListener("input", () => { const layer = active(); if (!layer || layer.locked) return; layer.opacity = clamp(fields.layer_opacity.value, 0, 1); markDirty(); syncActiveControls(); });
fields.layer_anchor.addEventListener("change", () => {
const layer = active(), image = imageFor(activeLayer); if (!layer || layer.locked || !image) return;
const geometry = geometryFor(activeLayer); layer.anchor = fields.layer_anchor.value;
if (geometry) preserveCenterForTransform(layer, image, geometry.centerDoc);
markDirty(); syncActiveControls();
});
fields.layer_blend.addEventListener("change", () => { const layer = active(); if (!layer || layer.locked) return; layer.blend = fields.layer_blend.value; markDirty(); });
fields.layer_visible.addEventListener("change", () => { const layer = active(); if (!layer) return; layer.visible = fields.layer_visible.checked; markDirty(); renderLayerList(); });
}
function apply() {
readDocumentForm();
config = normalizeConfig(config, connectedIndices());
const result = bridge?.applyState?.(nodeId, { document: documentSettings, config });
if (result) {
documentSettings = { ...DEFAULT_DOCUMENT, ...(result.document || {}) };
config = normalizeConfig(result.config || config, connectedIndices());
dirty = false;
statusEl.textContent = "Composition applied to ComfyUI";
writeDocumentForm(); syncActiveControls(); renderLayerList(); scheduleDraw();
return true;
}
statusEl.textContent = "Unable to reach the ComfyUI node";
return false;
}
function consumeState(state, { full = false, forceImages = false } = {}) {
if (!state) return;
lastRevision = Number(state.revision ?? lastRevision);
if (full || !dirty) {
documentSettings = { ...DEFAULT_DOCUMENT, ...(state.document || {}) };
config = normalizeConfig(state.config || {}, (state.layers || []).map(item => item.index));
writeDocumentForm();
}
syncLayerSources(state.layers || [], forceImages);
ensureConnectedConfig();
statusEl.textContent = metadata.length ? `Connected — ${metadata.length} layer${metadata.length > 1 ? "s" : ""}` : "Connected — no image layer";
}
function initialLoad() {
if (!bridge || !Number.isFinite(nodeId)) {
statusEl.textContent = "Open this application from the Layer Manager node";
return;
}
const state = bridge.getState?.(nodeId);
if (!state) { statusEl.textContent = "Layer Manager node not found"; return; }
consumeState(state, { full: true, forceImages: true });
resetHistory();
pollTimer = window.setInterval(() => {
const next = bridge.getState?.(nodeId);
if (!next) return;
const revisionChanged = Number(next.revision ?? -1) !== lastRevision;
const signature = JSON.stringify((next.layers || []).map(item => [item.index, normalizeSourceUrl(item.imageSrc), item.hasMask, item.maskReady]));
const currentSignature = JSON.stringify(metadata.map(item => [item.index, normalizeSourceUrl(item.imageSrc), item.hasMask, item.maskReady]));
if (revisionChanged || signature !== currentSignature) consumeState(next, { full: !dirty, forceImages: signature !== currentSignature });
}, 800);
}
bindDocumentFields();
bindLayerFields();
document.querySelectorAll("[data-align]").forEach(button => button.addEventListener("click", () => alignActive(button.dataset.align)));
document.querySelector("#fitLayer").addEventListener("click", fitActive);
document.querySelector("#centerLayer").addEventListener("click", centerActive);
document.querySelector("#refreshLayers").addEventListener("click", () => {
const state = bridge?.refreshLayers?.(nodeId);
if (state) consumeState(state, { full: false, forceImages: true });
});
document.querySelector("#applySettings").addEventListener("click", apply);
document.querySelector("#applyClose").addEventListener("click", () => { if (apply()) window.close(); });
document.querySelector("#closeApp").addEventListener("click", () => window.close());
undoButton.addEventListener("click", undo);
redoButton.addEventListener("click", redo);
document.querySelector("#zoomOut").addEventListener("click", () => setViewZoom(viewZoom / 1.2));
document.querySelector("#zoomIn").addEventListener("click", () => setViewZoom(viewZoom * 1.2));
zoomValue.addEventListener("click", resetViewZoom);
handButton?.addEventListener("click", () => {
handToolActive = !handToolActive;
updateHandToolState();
});
canvas.addEventListener("wheel", event => {
event.preventDefault();
const focal = localPointer(event);
setViewZoom(viewZoom * (event.deltaY < 0 ? 1.12 : 1 / 1.12), focal);
}, { passive: false });
lockButton.addEventListener("click", () => {
const layer = active(); if (!layer) return;
layer.locked = !layer.locked;
markDirty(layer.locked ? "Layer locked" : "Layer unlocked");
syncActiveControls(); renderLayerList();
});
muteButton.addEventListener("click", () => {
const layer = active(); if (!layer) return;
layer.visible = !layer.visible;
markDirty(layer.visible ? "Layer unmuted" : "Layer muted");
syncActiveControls(); renderLayerList();
});
soloButton.addEventListener("click", () => {
if (activeLayer == null) return;
const enablingSolo = !isSoloActive(activeLayer);
config.solo_layer = enablingSolo ? activeLayer : null;
if (enablingSolo) active().visible = true;
markDirty(config.solo_layer == null ? "Solo disabled" : `Layer ${activeLayer} solo`);
syncActiveControls(); renderLayerList();
});
resetLayerButton.addEventListener("click", resetActive);
new ResizeObserver(scheduleDraw).observe(stage);
window.addEventListener("beforeunload", () => { if (pollTimer) clearInterval(pollTimer); });
window.addEventListener("keydown", event => {
const key = event.key.toLowerCase();
if (event.code === "Space" && !isTextEntryTarget(event.target)) {
event.preventDefault();
if (!spacePanActive) {
spacePanActive = true;
updateHandToolState();
}
return;
}
if ((event.ctrlKey || event.metaKey) && key === "z") {
event.preventDefault();
if (event.shiftKey) redo(); else undo();
return;
}
if ((event.ctrlKey || event.metaKey) && key === "y") { event.preventDefault(); redo(); return; }
if ((event.ctrlKey || event.metaKey) && key === "s") { event.preventDefault(); apply(); }
if (event.key === "Escape") { interaction = null; updateHandToolState(); scheduleDraw(); }
});
window.addEventListener("keyup", event => {
if (event.code !== "Space") return;
if (spacePanActive) {
spacePanActive = false;
updateHandToolState();
}
});
window.addEventListener("blur", () => {
spacePanActive = false;
if (interaction?.mode === "pan") interaction = null;
updateHandToolState();
scheduleDraw();
});
writeDocumentForm();
syncActiveControls();
updateZoomDisplay();
updateHandToolState();
updateHistoryButtons();
initialLoad();
scheduleDraw();