Files
2025-12-29 17:32:31 +00:00

4502 lines
136 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.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { fabric } from "./fabric.js";
// Constants
const COMPOSITOR_4 = "Compositor4-beyond_nodes";
const CANVAS_BORDER_COLOR = "#00b300b0";
const COMPOSITION_BORDER_COLOR = "#00b300b0";
const COMPOSITION_BORDER_SIZE = 2;
const COMPOSITION_BACKGROUND_COLOR = "rgba(0,0,0,0.2)";
const PADDING = 10;
const HEIGHT = 512;
const WIDTH = 512;
const LITEGRAPH_NODE_PADDING = 10;
const QUALITY = 1;
const UPLOAD_ENDPOINT = "/upload/image";
const STORE_FOLDER = "compositor";
const INDICATOR_RADIUS = 8;
const GRID_SIZE = 1; // pixels for snap to grid
const SNAP_ENABLED = false; // toggle snap to grid on/off
// wether to overwrite existing images on upload
const OVERWRITE = true;
// UI Color Constants
const COLOR_TOOLBAR_BG = "rgba(50, 50, 50, 0.9)";
const COLOR_BUTTON_BG = "rgba(70, 70, 70, 0.9)";
const COLOR_BUTTON_HOVER = "rgba(200, 162, 255, 0.7)"; // Pastel purple for hover state
const COLOR_BUTTON_DISABLED = "rgba(100, 100, 100, 0.7)";
const COLOR_BUTTON_BORDER = "rgba(100, 100, 100, 0.5)";
const COLOR_BUTTON_TEXT = "white";
const COLOR_SEPARATOR = "rgba(100, 100, 100, 0.5)";
const COLOR_CONTAINER_BG = "rgba(172, 95, 224, 0)";
const COLOR_INDICATOR_SAVING = "red";
const COLOR_BUTTON_ACTIVE = "rgba(152, 251, 152, 0.7)"; // Pastel green for active state
const COLOR_CANVAS_BG = "transparent";
const COLOR_CANVAS_SELECTION = "transparent";
// UI Size Constants
const BUTTON_HEIGHT = "24px";
const ICON_BUTTON_SIZE = "24px";
const BUTTON_FONT_SIZE = "12px";
const ICON_FONT_SIZE = "14px";
// Define the default locked layer index at the top of the file
const DEFAULT_LOCKED_LAYER_INDEX = 0; // Set to the first layer by default
app.registerExtension({
name: "Comfy.Compositor4-beyond_nodes",
async setup(app) {
api.addEventListener("compositor4_init", executedMessageHandler);
},
async nodeCreated(node) {
// Initialize the basic editor UI structure when node is created
// At this point, widget values are NOT yet available (they're populated later)
if (isCorrectType(node)) {
initializeCustomCanvasWidget(node);
}
},
loadedGraphNode(node) {
// This is called AFTER widget values have been populated from the workflow file
// This is the correct place to restore saved state
if (isCorrectType(node)) {
// Call the restoreCanvasState function which accesses widget values
// and calls the editor's restoreState method
restoreCanvasState(node);
}
},
async afterConfigureGraph(args) {
// All nodes have been created and loaded at this point
},
});
function isCorrectType(node) {
return node.constructor.comfyClass == COMPOSITOR_4;
}
function getWidget(node, widgetName) {
return node.widgets.find((w) => w.name === widgetName);
}
function getImageNameWidget(node) {
return getWidget(node, "imageName");
}
function getFabricDataWidget(node) {
return getWidget(node, "fabricData");
}
function getSeedWidget(node) {
return getWidget(node, "seed");
}
const initializeCustomCanvasWidget = (node) => {
if (isCorrectType(node)) {
// Note: Widget hiding functionality is commented out as it doesn't work as expected
// hideWidgets(node, ["imageName", "fabricData"]);
const editor = Editor(node, fabric);
editor.initialize(); // Initialize UI structure only, don't restore state yet
const editorWidget = node.addDOMWidget(
"compositorGui",
"compositorGui",
editor.getContainer(),
{
hideOnZoom: false,
}
);
node.editorWidget = editorWidget;
node.editor = editor;
// Add cleanup when node is removed
const originalOnRemoved = node.onRemoved;
node.onRemoved = function () {
if (node.editor && node.editor.cleanup) {
node.editor.cleanup();
}
if (originalOnRemoved) {
originalOnRemoved.call(this);
}
};
// Set initial size - this will be updated in loadedGraphNode or compositor4_init
node.setSize(editor.calculateNodeSize());
node.resizable = false;
node.setDirtyCanvas(true, true);
}
};
// Restore canvas state when widget values are available (loadedGraphNode)
const restoreCanvasState = (node) => {
if (!isCorrectType(node) || !node.editor) {
return;
}
try {
// Get the fabricData widget which contains serialized canvas state
const fabricDataWidget = node.widgets?.find((w) => w.name === "fabricData");
if (
fabricDataWidget &&
fabricDataWidget.value &&
fabricDataWidget.value !== "{}"
) {
// Call the editor's restoreState method which will:
// 1. Deserialize the compositor data
// 2. Restore canvas dimensions, imagePositions, snap settings, grid size
// 3. Store pending transforms
// 4. Load images via appendImage (which applies the transforms)
// 5. Update UI elements
const restored = node.editor.restoreState(fabricDataWidget.value);
}
// Update node size after restoring state
const newSize = node.editor.calculateNodeSize();
node.setSize(newSize);
node.setDirtyCanvas(true, true);
} catch (error) {
console.error("Compositor4: Error restoring canvas state:", error);
}
};
// const hideWidget = (widget) => {
// if (widget) {
// // TODO
// }
// };
// const hideWidgets = (node, widgetNames) => {
// widgetNames.forEach((name) => {
// const widget = getWidget(node, name);
// hideWidget(widget);
// });
// };
function executedMessageHandler(event, a, b) {
const nodeId = event.detail.node;
const node = getNodeById(nodeId);
console.log("[Compositor4] executedMessageHandler: nodeId=", nodeId);
// Check if node exists before checking type
if (!node) {
console.error("[Compositor4] Node not found");
return;
}
const nodeFound = isCorrectType(node);
if (nodeFound) {
console.log("[Compositor4] 1 Node found");
// This event is triggered when the Python backend executes the node
// At this point, widget values are populated with actual config data
// This is when we update the editor with canvas dimensions, images, etc.
const e = event.detail.output;
const editor = node.editor;
// console.log("[Compositor4] Event data:", {
// // width: e.width,
// // height: e.height,
// // namesCount: e.names?.length,
// configSignature: e.configSignature,
// configChanged: e.configChanged,
// onConfigChangedContinue: e.onConfigChangedContinue,
// });
// Check if editor exists (it might not if node was just created)
if (!editor) {
console.warn("[Compositor4] 2 Editor not initialized yet");
return;
}
// Update canvas dimensions from config if available
if (
e.width !== undefined &&
e.height !== undefined &&
e.padding !== undefined
) {
editor.updateCanvasDimensions(e.width, e.height, e.padding);
}
// Store saveFolder in editor if provided
if (e.saveFolder !== undefined) {
editor.setSaveFolder(e.saveFolder);
}
// Load images (this will clear old images and load new ones)
if (e.names && Array.isArray(e.names)) {
// console.log("[Compositor4] Loading images:", e.names);
e.names.forEach((name, index) => editor.appendImage(name, index));
}
// Store applyMaskInConfig mode
if (e.applyMaskInConfig !== undefined) {
editor.setApplyMaskInConfig(Boolean(e.applyMaskInConfig?.[0]));
}
// Load mask filenames if available
if (e.maskNames && Array.isArray(e.maskNames)) {
editor.loadMasks(e.maskNames);
}
// Handle auto-save for "grab and continue" mode
const onConfigChangedContinue = Boolean(e.onConfigChangedContinue?.[0]);
const configChanged = Boolean(e.configChanged?.[0]);
console.log("[Compositor4] 3 Auto-save check:", {
configChanged,
onConfigChangedContinue,
rawConfigChanged: e.configChanged,
rawOnConfigChangedContinue: e.onConfigChangedContinue,
});
// Generic wait utility function
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// If config changed, IMMEDIATELY update the seed to invalidate cache
// This must happen synchronously before any user action
if (configChanged) {
console.log(
"[Compositor4] Config changed - updating seed with configSignature:",
e.configSignature
);
editor.updateSeedValue(e.configSignature);
}
// If in "grab and continue" mode, auto-save and re-queue
if (configChanged && onConfigChangedContinue) {
console.log("[Compositor4] Auto-save mode triggered");
// In "grab and continue" mode: auto-save snapshot and re-queue
// Sequence: wait for images to load -> save -> wait -> enqueue
wait(100)
.then(() => {
console.log("[Compositor4] Starting auto-save");
return editor.queuedSave(false);
})
.then(() => wait(100))
.then(() => {
console.log("[Compositor4] Re-queueing workflow");
app.queuePrompt(0, 1);
})
.catch((error) => {
console.error("[Compositor4] Auto-save sequence failed:", error);
});
}
}
}
const getNodeById = (nodeId) => {
return app.graph.getNodeById(nodeId);
};
// Legacy wrappers for backward compatibility (use createButton instead)
const createToolbarButton = (text, onClick, parent) =>
createButton({ type: "toolbar", content: text, onClick, parent });
const createIconButton = (icon, onClick, parent) =>
createButton({ type: "icon", content: icon, onClick, parent });
// Utility function to create toolbar separator
const createSeparator = (parent) => {
const separator = document.createElement("div");
applyStyles(separator, {
width: "1px",
height: "20px",
backgroundColor: COLOR_SEPARATOR,
margin: "0 5px",
});
if (parent) {
parent.appendChild(separator);
}
return separator;
};
// Utility function to create vertical button group
const createVerticalButtonGroup = (parent) => {
const group = document.createElement("div");
applyStyles(group, {
display: "flex",
flexDirection: "column",
gap: "2px",
});
if (parent) {
parent.appendChild(group);
}
return group;
};
const createHorizontalButtonGroup = (parent) => {
const group = document.createElement("div");
applyStyles(group, {
display: "flex",
flexDirection: "row",
gap: "2px",
alignItems: "center",
});
if (parent) {
parent.appendChild(group);
}
return group;
};
// Utility function to apply multiple styles at once
const applyStyles = (element, styles) => {
Object.entries(styles).forEach(([key, value]) => {
element.style[key] = value;
});
};
// Utility function to create a horizontal button row
const createButtonRow = (parent) => {
const row = document.createElement("div");
applyStyles(row, {
display: "flex",
gap: "2px",
});
if (parent) {
parent.appendChild(row);
}
return row;
};
// Proposal 9: Array/Collection Utilities
const ArrayUtils = {
// Create array filled with null values
createNullArray: (length) => Array(length).fill(null),
// Create sorted pairs of [index, position] for layer ordering
createSortedIndexPositionPairs: (positions, descending = false) => {
const pairs = positions.map((position, index) => ({ index, position }));
return descending
? pairs.sort((a, b) => b.position - a.position)
: pairs.sort((a, b) => a.position - b.position);
},
// Filter objects by type
filterByType: (objects, type) => objects.filter((obj) => obj.type === type),
// Filter image objects from a collection
filterImageObjects: (objects, imageArray) =>
objects.filter((obj) => imageArray.includes(obj)),
// Apply function to each item with optional condition
forEachIf: (array, condition, callback) => {
array.forEach((item, index) => {
if (!condition || condition(item, index)) {
callback(item, index);
}
});
},
};
// Legacy wrappers for backward compatibility
const createNullArray = ArrayUtils.createNullArray;
const createSortedIndexPositionPairs =
ArrayUtils.createSortedIndexPositionPairs;
// ============================================================================
// REFACTORED UTILITIES (Proposals 1, 2, 5)
// ============================================================================
// Proposal 2: Style Presets
const STYLE_PRESETS = {
button: {
height: BUTTON_HEIGHT,
padding: "0 12px",
backgroundColor: COLOR_BUTTON_BG,
color: COLOR_BUTTON_TEXT,
border: `1px solid ${COLOR_BUTTON_BORDER}`,
borderRadius: "4px",
cursor: "pointer",
fontSize: BUTTON_FONT_SIZE,
display: "flex",
alignItems: "center",
justifyContent: "center",
whiteSpace: "nowrap",
},
iconButton: {
width: ICON_BUTTON_SIZE,
height: ICON_BUTTON_SIZE,
minWidth: ICON_BUTTON_SIZE,
minHeight: ICON_BUTTON_SIZE,
padding: "0",
backgroundColor: COLOR_BUTTON_BG,
color: COLOR_BUTTON_TEXT,
border: `1px solid ${COLOR_BUTTON_BORDER}`,
borderRadius: "4px",
cursor: "pointer",
fontSize: ICON_FONT_SIZE,
display: "flex",
alignItems: "center",
justifyContent: "center",
lineHeight: "1",
},
dragHandle: {
width: "20px",
height: "20px",
padding: "0",
backgroundColor: COLOR_BUTTON_BG,
color: COLOR_BUTTON_TEXT,
border: `1px solid ${COLOR_BUTTON_BORDER}`,
borderRadius: "3px",
cursor: "grab",
fontSize: "14px",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
visibilityButton: {
width: "20px",
height: "20px",
padding: "0",
backgroundColor: COLOR_BUTTON_BG,
color: COLOR_BUTTON_TEXT,
border: `1px solid ${COLOR_BUTTON_BORDER}`,
borderRadius: "3px",
cursor: "pointer",
fontSize: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
};
// Proposal 2: Add hover behavior to button
const addHoverBehavior = (button, getActiveState = null) => {
button.onmouseover = () => {
button.style.backgroundColor = COLOR_BUTTON_HOVER;
};
button.onmouseout = () => {
if (getActiveState) {
const isActive = getActiveState();
button.style.backgroundColor = isActive
? COLOR_BUTTON_ACTIVE
: COLOR_BUTTON_BG;
} else {
button.style.backgroundColor = COLOR_BUTTON_BG;
}
};
};
// Proposal 1: Unified Button Factory
const createButton = (config) => {
const {
type = "toolbar", // 'toolbar', 'icon', 'toggle', 'visibility', 'drag'
content,
onClick,
parent,
initialState = false,
onLabel = "ON",
offLabel = "OFF",
customStyles = {},
title = "",
} = config;
const button = document.createElement("button");
button.textContent = content;
if (title) button.title = title;
// Apply preset styles based on type
let baseStyles = {};
switch (type) {
case "icon":
baseStyles = STYLE_PRESETS.iconButton;
break;
case "drag":
baseStyles = STYLE_PRESETS.dragHandle;
break;
case "visibility":
baseStyles = STYLE_PRESETS.visibilityButton;
break;
case "toggle":
baseStyles = {
...STYLE_PRESETS.button,
backgroundColor: initialState
? COLOR_BUTTON_ACTIVE
: COLOR_BUTTON_DISABLED,
};
break;
default:
baseStyles = STYLE_PRESETS.button;
}
applyStyles(button, { ...baseStyles, ...customStyles });
// Toggle button special behavior
if (type === "toggle") {
let state = initialState;
const toggleHandler = () => {
state = !state;
button.textContent = state ? onLabel : offLabel;
button.style.backgroundColor = state
? COLOR_BUTTON_ACTIVE
: COLOR_BUTTON_DISABLED;
if (onClick) onClick(state);
};
button.onclick = toggleHandler;
addHoverBehavior(button, () => state);
} else {
button.onclick = onClick;
addHoverBehavior(button);
}
if (parent) {
parent.appendChild(button);
}
return button;
};
// Proposal 5: Transformation Engine
const TransformationEngine = {
// Helper: Apply transformation and save
applyAndSave: (obj, transformation, fabricInstance, saveCallback) => {
obj.set(transformation);
obj.setCoords();
fabricInstance.renderAll();
saveCallback();
},
// Stretch operations
stretch: (fabricInstance, axis, canvasDimension, saveCallback) => {
const activeObject = fabricInstance.getActiveObject();
if (!activeObject || activeObject.type === "activeSelection") return;
const currentDimension =
axis === "horizontal"
? activeObject.getScaledWidth()
: activeObject.getScaledHeight();
const scaleFactor = canvasDimension / currentDimension;
TransformationEngine.applyAndSave(
activeObject,
{
scaleX: activeObject.scaleX * scaleFactor,
scaleY: activeObject.scaleY * scaleFactor,
},
fabricInstance,
saveCallback
);
},
// Equalize operations
equalize: (fabricInstance, axis, imageArray, saveCallback) => {
const activeObject = fabricInstance.getActiveObject();
if (!activeObject || activeObject.type !== "activeSelection") return;
const objects = ArrayUtils.filterImageObjects(
activeObject._objects,
imageArray
);
if (objects.length < 2) return;
const referenceDimension =
axis === "horizontal"
? objects[0].getScaledWidth()
: objects[0].getScaledHeight();
objects.forEach((obj, index) => {
if (index === 0) return;
const scale =
referenceDimension / (axis === "horizontal" ? obj.width : obj.height);
obj.set({ scaleX: scale, scaleY: scale });
obj.setCoords();
});
fabricInstance.renderAll();
saveCallback();
},
// Distribute operations
distribute: (
fabricInstance,
axis,
isImageObject,
snapToGrid,
saveCallback
) => {
const activeObject = fabricInstance.getActiveObject();
if (!activeObject || activeObject.type !== "activeSelection") return;
const objects = activeObject._objects.filter((obj) => isImageObject(obj));
if (objects.length < 2) return;
const getCenterCoord = (obj) =>
axis === "horizontal" ? obj.getCenterPoint().x : obj.getCenterPoint().y;
objects.sort((a, b) => getCenterCoord(a) - getCenterCoord(b));
const firstCoord = getCenterCoord(objects[0]);
const lastCoord = getCenterCoord(objects[objects.length - 1]);
const spacing = (lastCoord - firstCoord) / (objects.length - 1);
objects.forEach((obj, index) => {
const newCoord = firstCoord + spacing * index;
const currentCoord = getCenterCoord(obj);
const delta = newCoord - currentCoord;
if (axis === "horizontal") {
obj.set({ left: snapToGrid(obj.left + delta) });
} else {
obj.set({ top: snapToGrid(obj.top + delta) });
}
obj.setCoords();
});
fabricInstance.renderAll();
saveCallback();
},
// Flip operations
flip: (fabricInstance, axis, saveCallback) => {
const activeObject = fabricInstance.getActiveObject();
if (!activeObject) return;
const originalOriginX = activeObject.originX;
const originalOriginY = activeObject.originY;
activeObject.set({ originX: "center", originY: "center" });
const scaleProperty = axis === "horizontal" ? "scaleX" : "scaleY";
activeObject.set({ [scaleProperty]: -activeObject[scaleProperty] });
activeObject.set({ originX: originalOriginX, originY: originalOriginY });
activeObject.setCoords();
fabricInstance.renderAll();
saveCallback();
},
};
// Proposal 3: Layer UI Component Factory
const createLayerUI = (config) => {
const {
index = null,
type = "image", // 'image', 'foreground', 'background'
label,
isDraggable = true,
hasColorPicker = false,
icon = null,
onVisibilityToggle,
onSelect,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
colorPickerValue = "#ffffff",
} = config;
const layerItem = document.createElement("div");
applyStyles(layerItem, {
width: "100%",
height: "40px",
backgroundColor: COLOR_BUTTON_BG,
border: `1px solid ${COLOR_BUTTON_BORDER}`,
borderRadius: "4px",
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: "5px",
padding: "5px",
boxSizing: "border-box",
position: "relative",
cursor: type === "image" ? "default" : "pointer",
});
// Drag handle or icon placeholder
let dragHandle = null;
if (isDraggable && type === "image") {
dragHandle = createButton({
type: "drag",
content: "☰",
});
dragHandle.draggable = true;
if (onDragStart) dragHandle.ondragstart = onDragStart;
if (onDragEnd) dragHandle.ondragend = onDragEnd;
layerItem.appendChild(dragHandle);
// Add drop handlers to layer item
if (onDragOver) layerItem.ondragover = onDragOver;
if (onDragLeave) layerItem.ondragleave = onDragLeave;
if (onDrop) layerItem.ondrop = onDrop;
} else {
const iconPlaceholder = document.createElement("div");
iconPlaceholder.textContent = icon || (type === "foreground" ? "✏" : "🖼");
applyStyles(iconPlaceholder, {
width: "20px",
height: "20px",
flexShrink: "0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
color: COLOR_BUTTON_TEXT,
});
layerItem.appendChild(iconPlaceholder);
}
// Thumbnail or color picker
let thumbnail = null;
let colorInput = null;
if (hasColorPicker) {
thumbnail = document.createElement("div");
applyStyles(thumbnail, {
width: "30px",
height: "30px",
backgroundColor: colorPickerValue,
borderRadius: "2px",
border: "1px solid rgba(255, 255, 255, 0.3)",
cursor: "pointer",
flexShrink: "0",
});
colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.value = colorPickerValue.startsWith("#")
? colorPickerValue
: "#ffffff";
colorInput.style.display = "none";
thumbnail.onclick = () => colorInput.click();
layerItem.appendChild(colorInput);
layerItem.appendChild(thumbnail);
} else {
thumbnail = document.createElement("div");
applyStyles(thumbnail, {
width: "30px",
height: "30px",
backgroundColor: "rgba(0, 0, 0, 0.3)",
borderRadius: "2px",
backgroundSize: "contain",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: COLOR_BUTTON_TEXT,
fontSize: "9px",
cursor: type === "image" || type === "foreground" ? "pointer" : "default",
flexShrink: "0",
});
if (type === "background") {
thumbnail.style.border = hasColorPicker
? "1px solid rgba(255, 255, 255, 0.3)"
: `1px solid ${COLOR_BUTTON_BORDER}`;
}
if (onSelect && type === "image") {
thumbnail.onclick = onSelect;
}
layerItem.appendChild(thumbnail);
}
// Mask preview (only for image layers)
let maskThumbnail = null;
let onMaskToggle = config.onMaskToggle; // Callback for mask toggle
if (type === "image") {
maskThumbnail = document.createElement("div");
applyStyles(maskThumbnail, {
width: "30px",
height: "30px",
backgroundColor: "rgba(0, 0, 0, 0.3)",
borderRadius: "2px",
backgroundSize: "contain",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: COLOR_BUTTON_TEXT,
fontSize: "9px", // Match image thumbnail font size
cursor: "pointer", // Make clickable
flexShrink: "0",
// No border by default - matches image thumbnail
});
maskThumbnail.textContent = "";
// Add click handler for mask toggle
if (onMaskToggle) {
maskThumbnail.onclick = (e) => {
e.stopPropagation();
onMaskToggle(index);
};
}
layerItem.appendChild(maskThumbnail);
}
// Info container with label and visibility button
const infoContainer = document.createElement("div");
applyStyles(infoContainer, {
display: "flex",
flexDirection: "row",
alignItems: "center",
flex: "1",
gap: "3px",
minWidth: "64px",
});
const labelElement = document.createElement("div");
labelElement.textContent = label;
applyStyles(labelElement, {
color: COLOR_BUTTON_TEXT,
fontSize: "10px",
fontWeight: "bold",
width: type === "image" ? "auto" : "37px",
});
const visibilityButton = createButton({
type: "visibility",
content: "👁",
onClick: (e) => {
e.stopPropagation();
if (onVisibilityToggle) onVisibilityToggle();
},
});
// Lock button Beyond
const lockButton = createButton({
type: "visibility",
content: "🔓",
onClick: (e) => {
e.stopPropagation();
if (config.onLockToggle) config.onLockToggle(index);
},
title: "Lock/unlock layer (prevents moving/selecting)",
});
infoContainer.appendChild(labelElement);
infoContainer.appendChild(visibilityButton);
infoContainer.appendChild(lockButton);
layerItem.appendChild(infoContainer);
// Add click handler for foreground/background layers
if ((type === "foreground" || type === "background") && onSelect) {
layerItem.onclick = onSelect;
}
return {
layerItem,
thumbnail,
maskThumbnail,
visibilityButton,
colorInput,
dragHandle,
lockButton,
};
};
// Proposal 7: Input Control Factory
const createControl = (config) => {
const {
type = "slider", // 'slider', 'number'
label = "",
min = 0,
max = 100,
value = 0,
disabled = false,
onChange,
parent,
unit = "",
} = config;
if (type === "slider") {
// Create slider with label
const container = document.createElement("div");
applyStyles(container, {
display: "flex",
flexDirection: "column",
gap: "2px",
//minWidth: "80px",
});
// Label element
const labelElement = document.createElement("label");
labelElement.textContent = `${label}: ${value}${unit}`;
applyStyles(labelElement, {
color: COLOR_BUTTON_TEXT,
fontSize: "10px",
textAlign: "center",
height: "24px",
lineHeight: "24px",
display: "flex",
alignItems: "center",
justifyContent: "center",
});
container.appendChild(labelElement);
// Slider container
const sliderContainer = document.createElement("div");
applyStyles(sliderContainer, {
height: "24px",
display: "flex",
alignItems: "center",
});
container.appendChild(sliderContainer);
// Slider input
const slider = document.createElement("input");
slider.type = "range";
slider.min = min.toString();
slider.max = max.toString();
slider.value = value.toString();
slider.disabled = disabled;
applyStyles(slider, {
width: "100%",
cursor: disabled ? "not-allowed" : "pointer",
});
slider.oninput = (e) => {
const newValue =
type === "number"
? parseInt(e.target.value)
: parseFloat(e.target.value);
labelElement.textContent = `${label}: ${newValue}${unit}`;
if (onChange) onChange(newValue, e);
};
sliderContainer.appendChild(slider);
if (parent) {
parent.appendChild(container);
}
return { container, slider, label: labelElement };
} else if (type === "number") {
// Create number input with label
const container = document.createElement("div");
applyStyles(container, {
display: "flex",
gap: "3px",
alignItems: "center",
height: "24px",
});
const labelElement = document.createElement("label");
labelElement.textContent = label;
applyStyles(labelElement, {
color: COLOR_BUTTON_TEXT,
fontSize: "10px",
minWidth: "15px",
});
container.appendChild(labelElement);
const input = document.createElement("input");
input.type = "number";
input.value = value.toString();
input.disabled = disabled;
applyStyles(input, {
width: "60px",
height: "20px",
fontSize: "10px",
padding: "2px",
backgroundColor: COLOR_BUTTON_BG,
color: COLOR_BUTTON_TEXT,
border: `1px solid ${COLOR_BUTTON_BORDER}`,
borderRadius: "3px",
boxSizing: "border-box",
MozAppearance: "textfield",
appearance: "textfield",
});
if (onChange) {
input.onchange = (e) => onChange(parseFloat(e.target.value), e);
}
container.appendChild(input);
if (parent) {
parent.appendChild(container);
}
return { container, input, label: labelElement };
}
};
// ============================================================================
// END REFACTORED UTILITIES
// ============================================================================
// Editor Component
const Editor = (node, fabric) => {
// the widget with the gui to maniuplate images
// containerEl -> canvasEl -> fabricInstance -> compositionArea, compositionBorder, clickableRect
// setCanvasSize
let containerEl = null;
let canvasEl = null;
let fabricInstance = null;
let compositionBorder = null;
let compositionArea = null;
let toolbarEl = null;
let saveBtn = null;
let rotationSlider = null;
let rotationLabel = null;
let layersPanelEl = null;
let snapBtn = null; // Reference to snap button for UI updates
let gridSizeLabel = null; // Reference to grid size label
let gridSizeSlider = null; // Reference to grid size slider
let isUpdatingRotationSlider = false; // Flag to prevent circular updates
let snapEnabled = SNAP_ENABLED; // Editor property for snap to grid
let gridSize = GRID_SIZE; // Editor property for grid size
let backgroundColor = COMPOSITION_BACKGROUND_COLOR; // Background color for composition area
const IMAGE_COUNT = 9;
let images = createNullArray(IMAGE_COUNT);
let maskImages = createNullArray(IMAGE_COUNT); // Store Fabric mask image objects for clipPath
let maskNames = createNullArray(IMAGE_COUNT); // Store mask filenames for each layer
let maskStates = Array.from({ length: IMAGE_COUNT }, () => true); // Track if mask is enabled per layer (default: true)
let applyMaskInConfig = true; // Global setting: true = masks applied in config (RGBA), false = frontend clipPath
let imagePositions = Array.from({ length: IMAGE_COUNT }, (_, i) => i); // Z-index stacking order (0=bottom, 8=top)
let draggedLayerIndex = null; // Track which layer is being dragged
let pendingTransforms = createNullArray(IMAGE_COUNT); // Store transforms to apply during restoration
// Store direct references to layer UI elements (avoids getElementById issues with multiple nodes)
let layerItems = createNullArray(IMAGE_COUNT);
let layerThumbnails = createNullArray(IMAGE_COUNT);
let layerMaskThumbnails = createNullArray(IMAGE_COUNT); // Store mask preview elements
let layerVisibilityButtons = createNullArray(IMAGE_COUNT);
// Beyond: Store lock button references Beyond
let layerLockButtons = createNullArray(IMAGE_COUNT);
// Store references to background layer UI elements
let backgroundColorInput = null;
let backgroundColorThumbnail = null;
let backgroundVisibilityButton = null;
let backgroundColorOpaque = COMPOSITION_BACKGROUND_COLOR; // Store the opaque color when toggling to transparent
let backgroundIsVisible = true; // Track if background is visible (not transparent)
// Lock image layer as background
let lockedLayerIndex = DEFAULT_LOCKED_LAYER_INDEX; // Track which layer is locked as background starting with the Default locked
// Store references to foreground drawing layer
let foregroundLayer = null; // Fabric image object for drawing layer
let foregroundLayerItem = null; // UI element
let foregroundThumbnail = null; // Thumbnail preview element
let foregroundVisibilityButton = null;
let isDrawingMode = false; // Track if drawing mode is active
let foregroundIsVisible = true; // Track if FG layer is visible
let toolMode = "select"; // 'select', 'draw', or 'erase'
let brushColor = "#ff0000"; // Red by default
let brushWidth = 3; // 3px by default
let brushShape = "circle"; // 'circle' or 'square'
let isCanvasDrawing = false; // Track if using canvas primitives for eraser
let canvasDrawingPath = []; // Store path points for canvas drawing
let storedSelectedLayerIndex = null; // Store layer selection when switching to draw/erase
let isCtrlPressed = false; // Track Ctrl key for temporary mode switching
let tempToolMode = null; // Temporary tool mode when Ctrl is held
// Canvas dimensions - can be updated from config
let canvasWidth = WIDTH;
let canvasHeight = HEIGHT;
let canvasPadding = PADDING;
let saveFolder = "output"; // Default folder for saving images
let preciseSelection = false; // perPixelTargetFind for precise selection
// Store keyboard handler reference for cleanup
let keyboardHandler = null;
// Save queue management for throttling/batching saves with debounce
let isSaving = false;
let pendingSaveRequest = null;
let saveDebounceTimeout = null;
const SAVE_DEBOUNCE_DELAY = 15; // milliseconds
let colorChangeDebounceTimeout = null; // Debounce timer for background color changes
const COLOR_CHANGE_DEBOUNCE_DELAY = 250; // milliseconds
const imageNameWidget = getImageNameWidget(node);
const fabricDataWidget = getFabricDataWidget(node);
const seedWidget = getSeedWidget(node);
// Helper function to save and update seed
const saveAndUpdateSeed = () => {
return queuedSave(false).then(() => {
updateSeedValue();
});
};
// Helper function to create a toggle button with active/disabled states
const createToggleButton = (
initialState,
onLabel,
offLabel,
onChange,
parent
) =>
createButton({
type: "toggle",
content: initialState ? onLabel : offLabel,
onClick: onChange,
parent,
initialState,
onLabel,
offLabel,
});
// Helper function to create number input
// Legacy wrapper for backward compatibility
const createNumberInput = (labelText, parent) =>
createControl({ type: "number", label: labelText, parent, disabled: true });
const createContainer = () => {
containerEl = document.createElement("div");
applyStyles(containerEl, {
backgroundColor: COLOR_CONTAINER_BG,
display: "flex",
flexDirection: "column",
width:
canvasWidth +
canvasPadding * 2 +
COMPOSITION_BORDER_SIZE * 2 +
150 +
"px", // Added 150px for layers panel
height:
canvasHeight + canvasPadding * 2 + COMPOSITION_BORDER_SIZE * 2 + "px",
margin: "0px",
overflow: "visible",
});
return containerEl;
};
const createToolbar = () => {
toolbarEl = document.createElement("div");
applyStyles(toolbarEl, {
width: "100%",
minWidth: "400px",
minHeight: "80px",
height: "80px",
backgroundColor: COLOR_TOOLBAR_BG,
display: "flex",
alignItems: "center",
borderRadius: "8px",
padding: "5px 10px",
boxSizing: "border-box",
gap: "5px",
position: "relative",
boxShadow: "inset 0 0 5px rgba(0, 0, 0, 0.2)",
overflowX: "auto",
overflowY: "hidden",
scrollbarWidth: "none", // Firefox
msOverflowStyle: "none", // IE/Edge
});
// Hide scrollbar for Chrome/Safari/Opera
const style = document.createElement("style");
style.textContent = `
#${toolbarEl.id || "toolbar"}::-webkit-scrollbar {
display: none;
}
`;
document.head.appendChild(style);
containerEl.appendChild(toolbarEl);
// Create vertical group for Save and Reset buttons
const mainButtonGroup = createVerticalButtonGroup(toolbarEl);
// Create and append Save button (non-blocking, queued save)
saveBtn = createToolbarButton(
"Save",
(event) => {
updateWidgetValues(event, node);
},
mainButtonGroup
);
// Create and append Reset button
createToolbarButton(
"Reset",
(event) => resetImagePositions(event, node),
mainButtonGroup
);
// Add spacing separator
createSeparator(toolbarEl);
// Create alignment buttons grid (3x3)
const alignments = [
{ label: "↖", align: "top-left", title: "Align Top-Left" },
{ label: "↑", align: "top", title: "Align Top" },
{ label: "↗", align: "top-right", title: "Align Top-Right" },
{ label: "←", align: "left", title: "Align Left" },
{ label: "●", align: "center", title: "Align Center" },
{ label: "→", align: "right", title: "Align Right" },
{ label: "↙", align: "bottom-left", title: "Align Bottom-Left" },
{ label: "↓", align: "bottom", title: "Align Bottom" },
{ label: "↘", align: "bottom-right", title: "Align Bottom-Right" },
];
const alignmentGrid = document.createElement("div");
applyStyles(alignmentGrid, {
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "2px",
width: "78px", // 3 × 24px + 2 × 2px gaps
});
alignments.forEach(({ label, align, title }) => {
const btn = createIconButton(label, () => alignSelected(align));
btn.title = title;
alignmentGrid.appendChild(btn);
});
toolbarEl.appendChild(alignmentGrid);
// Add separator before flip controls
createSeparator(toolbarEl);
// Create vertical group for flip buttons
const flipButtonGroup = createVerticalButtonGroup(toolbarEl);
// Create horizontal flip button
const flipHRow = createButtonRow(flipButtonGroup);
const flipHBtn = createIconButton("⇄", () => flipHorizontally(), flipHRow);
flipHBtn.title = "Flip selected object horizontally";
const flipVBtn = createIconButton("⇵", () => flipVertically(), flipHRow);
flipVBtn.title = "Flip selected object vertically";
// Add separator before transformation buttons
createSeparator(toolbarEl);
// Create vertical group for transformation buttons (3 rows: stretch, equalize, distribute)
const transformButtonGroup = createVerticalButtonGroup(toolbarEl);
// Row 1: Stretch buttons (↔ ↕ symbols)
const stretchRow = createButtonRow(transformButtonGroup);
const stretchHBtn = createIconButton(
"↔",
() => stretchHorizontally(),
stretchRow
);
stretchHBtn.title =
"Stretch selected image horizontally (keeping proportions)";
const stretchVBtn = createIconButton(
"↕",
() => stretchVertically(),
stretchRow
);
stretchVBtn.title =
"Stretch selected image vertically (keeping proportions)";
// Row 2: Equalize buttons (= symbol for equalize)
// COMMENTED OUT: Not reliable, but functionality is kept
// const equalizeRow = createButtonRow(transformButtonGroup);
// const equalizeWidthBtn = createIconButton(
// "=W",
// () => equalizeWidth(),
// equalizeRow
// );
// equalizeWidthBtn.title =
// "Equalize width of all selected images (keeping proportions)";
// const equalizeHeightBtn = createIconButton(
// "=H",
// () => equalizeHeight(),
// equalizeRow
// );
// equalizeHeightBtn.title =
// "Equalize height of all selected images (keeping proportions)";
// Row 3: Distribute buttons (⋮ ⋯ symbols for distribute)
// COMMENTED OUT: Not reliable, but functionality is kept
// const distributeRow = createButtonRow(transformButtonGroup);
// const distributeHBtn = createIconButton(
// "⋯",
// () => distributeHorizontally(),
// distributeRow
// );
// distributeHBtn.title = "Distribute selected images horizontally";
// const distributeVBtn = createIconButton(
// "⋮",
// () => distributeVertically(),
// distributeRow
// );
// distributeVBtn.title = "Distribute selected images vertically";
// Add separator before grid and precision controls
createSeparator(toolbarEl);
// Create vertical container for Snap button and Grid slider (3 rows: Snap 24px, Label 24px, Slider 24px)
const snapGridContainer = document.createElement("div");
applyStyles(snapGridContainer, {
display: "flex",
flexDirection: "column",
gap: "2px",
minWidth: "80px",
});
toolbarEl.appendChild(snapGridContainer);
// Create and append Snap button with special toggle behavior (24px height)
snapBtn = createToggleButton(
snapEnabled,
"Snap: ON",
"Snap: OFF",
(newState) => {
snapEnabled = newState;
},
snapGridContainer
);
snapBtn.title = "Toggle snap-to-grid for precise alignment";
// Create grid size slider
const gridControl = createControl({
type: "slider",
label: "Grid",
min: 1,
max: 50,
value: gridSize,
unit: "px",
onChange: (newValue) => {
gridSize = newValue;
},
});
snapGridContainer.appendChild(gridControl.container);
gridSizeSlider = gridControl.slider;
gridSizeLabel = gridControl.label;
// Add separator before rotation and precision controls
createSeparator(toolbarEl);
// Create vertical container for Rotation and Precise Selection (3 rows: Precise button 24px, Label 24px, Slider 24px)
const rotationPreciseContainer = document.createElement("div");
applyStyles(rotationPreciseContainer, {
display: "flex",
flexDirection: "column",
gap: "2px",
minWidth: "80px",
});
toolbarEl.appendChild(rotationPreciseContainer);
// Create Precise Selection toggle button (24px height) - now at the top
const preciseBtn = createToggleButton(
preciseSelection,
"Precise: ON",
"Precise: OFF",
(newState) => {
preciseSelection = newState;
// Update all images with perPixelTargetFind
ArrayUtils.forEachIf(
images,
(img) => img !== null,
(img) => img.set("perPixelTargetFind", preciseSelection)
);
fabricInstance.renderAll();
},
rotationPreciseContainer
);
preciseBtn.title =
"Toggle precise pixel-level selection (ignores transparent areas)";
// Create rotation slider
const rotationControl = createControl({
type: "slider",
label: "Rotate",
min: 0,
max: 360,
value: 0,
unit: "°",
disabled: true,
onChange: (angle, e) => {
if (isUpdatingRotationSlider) return; // Prevent circular updates
// Constrain to 5-degree steps if Shift is pressed
if (e.shiftKey) {
angle = Math.round(angle / 5) * 5;
rotationSlider.value = angle; // Update slider to snapped value
rotationLabel.textContent = `Rotate: ${angle}°`;
}
const activeObject = fabricInstance.getActiveObject();
if (activeObject) {
const center = activeObject.getCenterPoint();
activeObject.set({
angle: angle,
originX: "center",
originY: "center",
left: center.x,
top: center.y,
});
activeObject.setCoords();
fabricInstance.renderAll();
saveAndUpdateSeed().then(() => {
api.enqueuePrompt(0, 1);
});
}
},
});
rotationPreciseContainer.appendChild(rotationControl.container);
rotationSlider = rotationControl.slider;
rotationLabel = rotationControl.label;
// Add separator before brush controls
createSeparator(toolbarEl);
// Create tool mode container (always visible)
const toolModeContainer = document.createElement("div");
applyStyles(toolModeContainer, {
display: "flex",
flexDirection: "column",
gap: "2px",
minWidth: "80px",
});
toolbarEl.appendChild(toolModeContainer);
// Tool mode buttons stacked vertically
const toolModeGroup = createVerticalButtonGroup(toolModeContainer);
// Create three mutually exclusive tool mode buttons
let selectModeBtn, drawModeBtn, eraseModeBtn;
const updateToolModeButtons = () => {
const activeMode = tempToolMode || toolMode;
// Update button styles and text based on active mode
selectModeBtn.textContent = "Select";
drawModeBtn.textContent = "Draw";
eraseModeBtn.textContent = "Erase";
selectModeBtn.style.backgroundColor =
activeMode === "select" ? COLOR_BUTTON_ACTIVE : COLOR_BUTTON_BG;
drawModeBtn.style.backgroundColor =
activeMode === "draw" ? COLOR_BUTTON_ACTIVE : COLOR_BUTTON_BG;
eraseModeBtn.style.backgroundColor =
activeMode === "erase" ? COLOR_BUTTON_ACTIVE : COLOR_BUTTON_BG;
};
const setToolMode = (newMode) => {
if (toolMode === newMode) return;
const previousMode = toolMode;
toolMode = newMode;
tempToolMode = null;
if (fabricInstance) {
if (toolMode === "select") {
// Select mode: enable layer selection, disable drawing
isDrawingMode = false;
fabricInstance.isDrawingMode = false;
cleanupCanvasEraser();
// Make image layers selectable
fabricInstance.getObjects().forEach((obj) => {
if (obj !== foregroundLayer && obj.type === "image") {
obj.set({ selectable: true, evented: true });
}
});
// Restore previously selected layer if stored
if (storedSelectedLayerIndex !== null) {
const layerToSelect = images[storedSelectedLayerIndex];
if (layerToSelect) {
fabricInstance.setActiveObject(layerToSelect);
}
} else {
fabricInstance.discardActiveObject();
}
// Hide all tool controls and separators
tools1Container.style.display = "none";
tools1Separator.style.display = "none";
tools2Container.style.display = "none";
tools2Separator.style.display = "none";
// Update layer highlights
updateLayerSelectionHighlight();
} else {
// Draw or Erase mode
// Store current selection if coming from select mode
if (previousMode === "select") {
const activeObj = fabricInstance.getActiveObject();
if (
activeObj &&
activeObj.type === "image" &&
activeObj !== foregroundLayer
) {
storedSelectedLayerIndex = images.indexOf(activeObj);
}
fabricInstance.discardActiveObject();
}
// Make image layers non-selectable
fabricInstance.getObjects().forEach((obj) => {
if (obj !== foregroundLayer && obj.type === "image") {
obj.set({ selectable: false, evented: false });
}
});
// Select foreground layer automatically (but don't show transform controls)
if (foregroundLayer) {
fabricInstance.setActiveObject(foregroundLayer);
fabricInstance.discardActiveObject(); // Don't show transform controls
}
// Enable drawing mode
isDrawingMode = true;
// Update layer highlights to show FG is active
updateLayerSelectionHighlight();
if (toolMode === "draw") {
// Pencil mode: use Fabric drawing
cleanupCanvasEraser();
fabricInstance.isDrawingMode = true;
if (fabricInstance.freeDrawingBrush) {
fabricInstance.freeDrawingBrush.color = brushColor;
fabricInstance.freeDrawingBrush.width = brushWidth;
fabricInstance.freeDrawingBrush.globalCompositeOperation =
"source-over";
// Apply brush shape
if (brushShape === "square") {
fabricInstance.freeDrawingBrush.strokeLineCap = "square";
fabricInstance.freeDrawingBrush.strokeLineJoin = "miter";
} else {
fabricInstance.freeDrawingBrush.strokeLineCap = "round";
fabricInstance.freeDrawingBrush.strokeLineJoin = "round";
}
}
} else {
// Erase mode: use canvas primitives
fabricInstance.isDrawingMode = false;
setupCanvasEraser();
}
// Show tools1 and tools2 containers
tools1Container.style.display = "flex";
tools1Separator.style.display = "block";
tools2Container.style.display = "flex";
tools2Separator.style.display = "block";
// Show/hide specific controls based on mode
if (toolMode === "draw") {
colorPickerGroup.style.display = "flex";
eraseControlsContainer.style.display = "none";
} else {
// Erase mode
colorPickerGroup.style.display = "none";
eraseControlsContainer.style.display = "flex";
}
}
updateToolModeButtons();
fabricInstance.renderAll();
}
};
selectModeBtn = createToolbarButton(
"Select",
() => setToolMode("select"),
toolModeGroup
);
selectModeBtn.title =
"Select and transform images (hold Ctrl to temporarily switch modes)";
drawModeBtn = createToolbarButton(
"Draw",
() => setToolMode("draw"),
toolModeGroup
);
drawModeBtn.title =
"Draw on foreground layer with brush (hold Ctrl to temporarily erase)";
eraseModeBtn = createToolbarButton(
"Erase",
() => setToolMode("erase"),
toolModeGroup
);
eraseModeBtn.title =
"Erase from foreground layer (hold Ctrl to temporarily draw)";
// Make buttons more compact
applyStyles(selectModeBtn, { minWidth: "50px", padding: "0 6px" });
applyStyles(drawModeBtn, { minWidth: "50px", padding: "0 6px" });
applyStyles(eraseModeBtn, { minWidth: "50px", padding: "0 6px" });
// Override hover behavior to maintain active state
const setupModeButtonHover = (btn) => {
btn.onmouseover = () => {
btn.style.backgroundColor = COLOR_BUTTON_HOVER;
};
btn.onmouseout = () => {
const activeMode = tempToolMode || toolMode;
const isActive =
(btn === selectModeBtn && activeMode === "select") ||
(btn === drawModeBtn && activeMode === "draw") ||
(btn === eraseModeBtn && activeMode === "erase");
btn.style.backgroundColor = isActive
? COLOR_BUTTON_ACTIVE
: COLOR_BUTTON_BG;
};
};
setupModeButtonHover(selectModeBtn);
setupModeButtonHover(drawModeBtn);
setupModeButtonHover(eraseModeBtn);
// Initialize button states
updateToolModeButtons();
// Add separator before tools1 (hidden in select mode)
const tools1Separator = createSeparator(toolbarEl);
tools1Separator.style.display = "none";
// Create tools1 container (color picker, clear button, width slider)
const tools1Container = document.createElement("div");
applyStyles(tools1Container, {
display: "none",
flexDirection: "column",
gap: "2px",
minWidth: "80px",
});
toolbarEl.appendChild(tools1Container);
// Row 1: Color picker (for draw mode only)
const colorPickerGroup = createHorizontalButtonGroup(tools1Container);
// Brush color picker
const brushColorContainer = document.createElement("div");
applyStyles(brushColorContainer, {
display: "flex",
gap: "3px",
alignItems: "center",
height: "24px",
});
colorPickerGroup.appendChild(brushColorContainer);
const brushColorLabel = document.createElement("label");
brushColorLabel.textContent = "Draw Color:";
applyStyles(brushColorLabel, {
color: COLOR_BUTTON_TEXT,
fontSize: "9px",
minWidth: "55px",
});
brushColorContainer.appendChild(brushColorLabel);
const brushColorInput = document.createElement("input");
brushColorInput.type = "color";
brushColorInput.value = brushColor;
applyStyles(brushColorInput, {
width: "30px",
height: "20px",
border: "none",
cursor: "pointer",
});
brushColorInput.oninput = (e) => {
brushColor = e.target.value;
if (
toolMode === "draw" &&
fabricInstance &&
fabricInstance.freeDrawingBrush
) {
fabricInstance.freeDrawingBrush.color = brushColor;
}
};
brushColorContainer.appendChild(brushColorInput);
// Row 2: Clear FG button (for erase mode only) - shown above slider
const eraseControlsContainer = document.createElement("div");
applyStyles(eraseControlsContainer, {
display: "none",
flexDirection: "column",
gap: "2px",
});
tools1Container.appendChild(eraseControlsContainer);
const clearFgBtn = createToolbarButton(
"Clear FG",
async () => {
if (!fabricInstance) return;
// Remove all drawing paths
const pathsToRemove = ArrayUtils.filterByType(
fabricInstance.getObjects(),
"path"
);
pathsToRemove.forEach((path) => {
fabricInstance.remove(path);
});
// Create a completely empty transparent canvas
const emptyCanvas = document.createElement("canvas");
emptyCanvas.width = canvasWidth;
emptyCanvas.height = canvasHeight;
// CRITICAL: Update the eraser canvas if it exists (keeps them synchronized)
if (fabricInstance._eraserCanvas) {
const eraserCtx = fabricInstance._eraserCanvas.getContext("2d");
eraserCtx.clearRect(0, 0, canvasWidth, canvasHeight);
}
// Update foreground layer with empty canvas element (no need for dataUrl/Image)
if (foregroundLayer) {
foregroundLayer.setElement(emptyCanvas);
fabricInstance.renderAll();
}
// Update thumbnail to show empty state
if (foregroundThumbnail) {
foregroundThumbnail.style.backgroundImage = "none";
}
// Save the cleared state (this will persist the empty canvas)
await saveForegroundLayer();
saveAndUpdateSeed();
},
eraseControlsContainer
);
clearFgBtn.title = "Clear all drawings from the foreground layer";
// Row 3: Brush width slider (for both draw and erase)
const brushWidthControl = createControl({
type: "slider",
label: "Width",
min: 1,
max: 50,
value: brushWidth,
unit: "px",
onChange: (newValue) => {
brushWidth = newValue;
if (fabricInstance && fabricInstance.freeDrawingBrush) {
fabricInstance.freeDrawingBrush.width = brushWidth;
}
},
parent: tools1Container,
});
// Add separator before tools2
const tools2Separator = createSeparator(toolbarEl);
tools2Separator.style.display = "none";
// Create tools2 container (brush shape selector - for both draw and erase)
const tools2Container = document.createElement("div");
applyStyles(tools2Container, {
display: "none",
flexDirection: "column",
gap: "2px",
maxWidth: "fit-content",
});
toolbarEl.appendChild(tools2Container);
// Brush shape selector (circle/square) - applies to both draw and erase
const brushShapeGroup = createVerticalButtonGroup(tools2Container);
brushShapeGroup.style.maxWidth = "fit-content";
let circleShapeBtn, squareShapeBtn;
const updateBrushShapeButtons = () => {
circleShapeBtn.style.backgroundColor =
brushShape === "circle" ? COLOR_BUTTON_ACTIVE : COLOR_BUTTON_BG;
squareShapeBtn.style.backgroundColor =
brushShape === "square" ? COLOR_BUTTON_ACTIVE : COLOR_BUTTON_BG;
};
const setBrushShape = (shape) => {
brushShape = shape;
updateBrushShapeButtons();
// Update Fabric brush if in draw mode
if (
toolMode === "draw" &&
fabricInstance &&
fabricInstance.freeDrawingBrush
) {
// Fabric.js doesn't have built-in square brush, but we can simulate it
// by setting the brush to have sharp corners (strokeLineCap)
if (brushShape === "square") {
fabricInstance.freeDrawingBrush.strokeLineCap = "square";
fabricInstance.freeDrawingBrush.strokeLineJoin = "miter";
} else {
fabricInstance.freeDrawingBrush.strokeLineCap = "round";
fabricInstance.freeDrawingBrush.strokeLineJoin = "round";
}
}
};
circleShapeBtn = createIconButton(
"●",
() => setBrushShape("circle"),
brushShapeGroup
);
circleShapeBtn.title = "Round brush shape";
squareShapeBtn = createIconButton(
"■",
() => setBrushShape("square"),
brushShapeGroup
);
squareShapeBtn.title = "Square brush shape";
// Setup hover behavior for shape buttons
const setupShapeButtonHover = (btn) => {
btn.onmouseover = () => {
btn.style.backgroundColor = COLOR_BUTTON_HOVER;
};
btn.onmouseout = () => {
const isActive =
(btn === circleShapeBtn && brushShape === "circle") ||
(btn === squareShapeBtn && brushShape === "square");
btn.style.backgroundColor = isActive
? COLOR_BUTTON_ACTIVE
: COLOR_BUTTON_BG;
};
};
setupShapeButtonHover(circleShapeBtn);
setupShapeButtonHover(squareShapeBtn);
updateBrushShapeButtons();
// Add separator before size controls
createSeparator(toolbarEl);
// Create size controls container (width and height inputs, 2 rows × 24px)
const sizeControlsContainer = document.createElement("div");
applyStyles(sizeControlsContainer, {
display: "flex",
flexDirection: "column",
gap: "2px",
minWidth: "80px",
});
toolbarEl.appendChild(sizeControlsContainer);
// Width control (24px height)
const { input: widthInput } = createNumberInput(
"W:",
sizeControlsContainer
);
// Height control (24px height)
const { input: heightInput } = createNumberInput(
"H:",
sizeControlsContainer
);
// Add CSS to hide number input spinners (webkit browsers)
const styleId = `compositor-spinner-hide-${node.id}`;
if (!document.getElementById(styleId)) {
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
`;
document.head.appendChild(style);
}
// Store references for later use
node.widthInput = widthInput;
node.heightInput = heightInput;
// Width input change handler
widthInput.onchange = (e) => {
const activeObject = fabricInstance.getActiveObject();
if (!activeObject || activeObject.type === "activeSelection") return;
const targetWidth = parseFloat(e.target.value);
if (isNaN(targetWidth) || targetWidth <= 0) {
// Reset to current value if invalid
widthInput.value = Math.round(activeObject.getScaledWidth());
return;
}
// Calculate new scale to achieve target width
const currentWidth = activeObject.width;
const newScaleX = targetWidth / currentWidth;
activeObject.set({
scaleX: newScaleX,
scaleY: newScaleX, // Maintain aspect ratio
});
activeObject.setCoords();
fabricInstance.renderAll();
// Update height input to reflect new size
heightInput.value = Math.round(activeObject.getScaledHeight());
// Trigger save
saveAndUpdateSeed();
};
// Height input change handler
heightInput.onchange = (e) => {
const activeObject = fabricInstance.getActiveObject();
if (!activeObject || activeObject.type === "activeSelection") return;
const targetHeight = parseFloat(e.target.value);
if (isNaN(targetHeight) || targetHeight <= 0) {
// Reset to current value if invalid
heightInput.value = Math.round(activeObject.getScaledHeight());
return;
}
// Calculate new scale to achieve target height
const currentHeight = activeObject.height;
const newScaleY = targetHeight / currentHeight;
activeObject.set({
scaleX: newScaleY, // Maintain aspect ratio
scaleY: newScaleY,
});
activeObject.setCoords();
fabricInstance.renderAll();
// Update width input to reflect new size
widthInput.value = Math.round(activeObject.getScaledWidth());
// Trigger save
saveAndUpdateSeed();
};
};
const createLayerItem = (index) => {
const {
layerItem,
thumbnail,
maskThumbnail,
visibilityButton,
dragHandle,
lockButton, // Beyond
} = createLayerUI({
index,
type: "image",
label: `${index + 1}`,
isDraggable: !isLockedLayer(index),
onLockToggle: (idx) => toggleLayerLock(idx),
onVisibilityToggle: () => toggleImageVisibility(index),
onMaskToggle: (idx) => toggleMaskEnabled(idx),
onSelect: () => selectImageByIndex(index),
onDragStart: (e) => {
if (isLockedLayer(index)) return;
draggedLayerIndex = index;
dragHandle.style.cursor = "grabbing";
if (layerItems[index]) {
layerItems[index].style.opacity = "0.5";
}
e.dataTransfer.effectAllowed = "move";
},
onDragEnd: (e) => {
dragHandle.style.cursor = "grab";
if (layerItems[index]) {
layerItems[index].style.opacity = "1";
}
draggedLayerIndex = null;
},
onDragOver: (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (draggedLayerIndex !== null && draggedLayerIndex !== index) {
layerItem.style.borderColor = COLOR_BUTTON_ACTIVE;
layerItem.style.borderWidth = "2px";
}
},
onDragLeave: (e) => {
layerItem.style.borderColor = COLOR_BUTTON_BORDER;
layerItem.style.borderWidth = "1px";
},
onDrop: (e) => {
e.preventDefault();
layerItem.style.borderColor = COLOR_BUTTON_BORDER;
layerItem.style.borderWidth = "1px";
if (draggedLayerIndex !== null && draggedLayerIndex !== index) {
swapLayerPositions(draggedLayerIndex, index);
}
},
});
// Store references in arrays
layerItems[index] = layerItem;
layerThumbnails[index] = thumbnail;
layerMaskThumbnails[index] = maskThumbnail;
layerVisibilityButtons[index] = visibilityButton;
layerLockButtons[index] = lockButton; // Beyond
// Set initial lock button state Beyond
if (lockButton) {
const locked = isLockedLayer(index);
lockButton.textContent = locked ? "🔒" : "🔓";
lockButton.style.backgroundColor = locked ? COLOR_BUTTON_ACTIVE : COLOR_BUTTON_BG;
}
return layerItem;
};
const createLayersPanelTitle = () => {
const title = document.createElement("div");
title.textContent = "Layers";
applyStyles(title, {
color: COLOR_BUTTON_TEXT,
fontSize: "14px",
fontWeight: "bold",
marginBottom: "5px",
textAlign: "center",
});
return title;
};
const createForegroundLayer = () => {
const { layerItem, thumbnail, visibilityButton } = createLayerUI({
type: "foreground",
label: "FG",
isDraggable: false,
icon: "✏",
onVisibilityToggle: () => {
foregroundIsVisible = !foregroundIsVisible;
if (foregroundIsVisible) {
visibilityButton.textContent = "👁";
visibilityButton.style.backgroundColor = COLOR_BUTTON_BG;
if (foregroundLayer) {
foregroundLayer.set({
visible: true,
opacity: 1,
selectable: false,
evented: false,
});
}
} else {
visibilityButton.textContent = "👁‍🗨";
visibilityButton.style.backgroundColor = COLOR_BUTTON_DISABLED;
if (foregroundLayer) {
foregroundLayer.set({
visible: false,
opacity: 0,
selectable: false,
evented: false,
});
}
if (isDrawingMode) {
isDrawingMode = false;
fabricInstance.isDrawingMode = false;
images.forEach((img) => {
if (img && img.visible !== false) {
img.set({ selectable: true, evented: true });
}
});
if (foregroundLayerItem) {
foregroundLayerItem.style.backgroundColor = COLOR_BUTTON_BG;
}
thumbnail.style.backgroundColor = COLOR_BUTTON_BG;
}
}
fabricInstance.renderAll();
if (colorChangeDebounceTimeout) {
clearTimeout(colorChangeDebounceTimeout);
}
colorChangeDebounceTimeout = setTimeout(() => {
colorChangeDebounceTimeout = null;
saveAndUpdateSeed();
}, COLOR_CHANGE_DEBOUNCE_DELAY);
},
onSelect: () => {
if (typeof setToolMode === "function") {
setToolMode("select");
}
},
});
foregroundLayerItem = layerItem;
foregroundThumbnail = thumbnail;
foregroundVisibilityButton = visibilityButton;
return layerItem;
};
const createBackgroundLayer = () => {
const { layerItem, thumbnail, visibilityButton, colorInput } =
createLayerUI({
type: "background",
label: "BG",
isDraggable: false,
hasColorPicker: true,
icon: "🖼",
colorPickerValue: backgroundColor,
onVisibilityToggle: () => {
if (backgroundIsVisible) {
backgroundColorOpaque = backgroundColor;
backgroundColor = "transparent";
visibilityButton.textContent = "👁‍🗨";
visibilityButton.style.backgroundColor = COLOR_BUTTON_DISABLED;
} else {
backgroundColor = backgroundColorOpaque;
visibilityButton.textContent = "👁";
visibilityButton.style.backgroundColor = COLOR_BUTTON_BG;
}
backgroundIsVisible = !backgroundIsVisible;
thumbnail.style.backgroundColor = backgroundColor;
if (compositionArea) {
compositionArea.set({ fill: backgroundColor });
fabricInstance.renderAll();
}
if (colorChangeDebounceTimeout) {
clearTimeout(colorChangeDebounceTimeout);
}
colorChangeDebounceTimeout = setTimeout(() => {
colorChangeDebounceTimeout = null;
saveAndUpdateSeed();
}, COLOR_CHANGE_DEBOUNCE_DELAY);
},
});
// Set up color picker input handler
colorInput.oninput = (e) => {
const newColor = e.target.value;
backgroundColor = newColor;
thumbnail.style.backgroundColor = newColor;
if (compositionArea) {
compositionArea.set({ fill: newColor });
fabricInstance.renderAll();
}
if (colorChangeDebounceTimeout) {
clearTimeout(colorChangeDebounceTimeout);
}
colorChangeDebounceTimeout = setTimeout(() => {
colorChangeDebounceTimeout = null;
saveAndUpdateSeed();
}, COLOR_CHANGE_DEBOUNCE_DELAY);
};
backgroundColorInput = colorInput;
backgroundColorThumbnail = thumbnail;
backgroundVisibilityButton = visibilityButton;
return layerItem;
};
// Lock Image Background Image Helpers - Beyond
const isLockedLayer = (index) => lockedLayerIndex === index;
const applyLockStateToImage = (index) => {
const img = images[index];
if (!img) return;
const locked = isLockedLayer(index);
img.set({
selectable: !locked,
evented: !locked,
hasControls: !locked,
hasBorders: !locked,
lockMovementX: locked,
lockMovementY: locked,
lockScalingX: locked,
lockScalingY: locked,
lockRotation: locked,
hoverCursor: locked ? "default" : "move",
});
// Optional extra hardening (prevents hit-testing in some cases)
// img.set({ skipTargetFind: locked });
img.setCoords();
};
const setLockedLayer = (indexOrNull) => {
lockedLayerIndex = indexOrNull;
// Apply to all (only one will be locked)
for (let i = 0; i < images.length; i++) applyLockStateToImage(i);
// If currently selected object became locked, deselect it
const active = fabricInstance?.getActiveObject();
if (active && lockedLayerIndex !== null && active === images[lockedLayerIndex]) {
fabricInstance.discardActiveObject();
}
updateLayerPanelOrder();
updateCanvasZOrder();
fabricInstance?.renderAll();
saveAndUpdateSeed();
};
const toggleLayerLock = (index) => {
// Toggle: lock this layer, or unlock if it’s already locked
setLockedLayer(isLockedLayer(index) ? null : index);
};
// Helper function to convert rgba/hex to hex format for color input
const rgbaToHex = (color) => {
// If already hex, return as-is
if (color.startsWith("#")) {
return color.length === 7 ? color : color.substring(0, 7);
}
// If rgba format, extract rgb values
const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgbaMatch) {
const r = parseInt(rgbaMatch[1]);
const g = parseInt(rgbaMatch[2]);
const b = parseInt(rgbaMatch[3]);
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
// Default to transparent (white)
return "#ffffff";
};
const createLayersPanel = () => {
// Create main content wrapper (canvas + layers side by side)
const contentWrapper = document.createElement("div");
applyStyles(contentWrapper, {
display: "flex",
flexDirection: "row",
gap: "10px",
width: "100%",
});
containerEl.appendChild(contentWrapper);
// Create layers panel
layersPanelEl = document.createElement("div");
applyStyles(layersPanelEl, {
width: "175px",
height: HEIGHT - 8 + PADDING * 2 + COMPOSITION_BORDER_SIZE * 2 + "px",
backgroundColor: COLOR_TOOLBAR_BG,
borderRadius: "8px",
padding: "4px",
boxSizing: "border-box",
overflowY: "auto",
display: "flex",
flexDirection: "column",
marginTop: "8px",
gap: "4px",
boxShadow: "inset 0 0 5px rgba(0, 0, 0, 0.2)",
});
// Add title
const title = createLayersPanelTitle();
layersPanelEl.appendChild(title);
// Add foreground drawing layer at the top (fixed)
const foregroundLayer = createForegroundLayer();
layersPanelEl.appendChild(foregroundLayer);
// Create layer items in order based on imagePositions (highest position first)
const indexPositionPairs = createSortedIndexPositionPairs(
imagePositions,
true
);
indexPositionPairs.forEach(({ index }) => {
const layerItem = createLayerItem(index);
layersPanelEl.appendChild(layerItem);
});
// Add background layer at the bottom (fixed)
const backgroundLayer = createBackgroundLayer();
layersPanelEl.appendChild(backgroundLayer);
contentWrapper.appendChild(layersPanelEl);
return contentWrapper;
};
const updateLayerThumbnail = (index) => {
// Use stored reference instead of getElementById
const thumbnail = layerThumbnails[index];
if (!thumbnail) return;
if (images[index]) {
// Get the image data URL
const imgElement = images[index].getElement();
if (imgElement && imgElement.src) {
thumbnail.style.backgroundImage = `url(${imgElement.src})`;
thumbnail.textContent = "";
}
} else {
thumbnail.style.backgroundImage = "none";
thumbnail.textContent = "";
}
};
const selectImageByIndex = (index) => {
if (isLockedLayer(index)) return; // prevent selecting locked bg
if (images[index] && images[index].visible !== false) {
fabricInstance.setActiveObject(images[index]);
fabricInstance.renderAll();
}
};
const updateLayerSelectionHighlight = () => {
// Clear all highlights first
ArrayUtils.forEachIf(
layerItems,
(item) => item !== null,
(item) => (item.style.backgroundColor = COLOR_BUTTON_BG)
);
// Clear foreground layer highlight
if (foregroundLayerItem) {
foregroundLayerItem.style.backgroundColor = COLOR_BUTTON_BG;
}
// In draw/erase mode, highlight foreground layer
if ((toolMode === "draw" || toolMode === "erase") && foregroundLayerItem) {
foregroundLayerItem.style.backgroundColor = COLOR_BUTTON_ACTIVE;
return;
}
// In select mode, highlight the selected object
const activeObject = fabricInstance.getActiveObject();
if (!activeObject) return;
// If it's the foreground layer, highlight it
if (activeObject === foregroundLayer && foregroundLayerItem) {
foregroundLayerItem.style.backgroundColor = COLOR_BUTTON_ACTIVE;
return;
}
// If it's a single object, find its index and highlight it
if (activeObject.type !== "activeSelection") {
const selectedIndex = images.indexOf(activeObject);
if (selectedIndex !== -1 && layerItems[selectedIndex]) {
layerItems[selectedIndex].style.backgroundColor = COLOR_BUTTON_ACTIVE;
}
}
// For multi-selection, we could highlight all selected layers
// but for now we'll just clear highlights for multi-select
};
const toggleImageVisibility = (index) => {
if (!images[index]) {
return;
}
const img = images[index];
const isCurrentlyVisible = img.visible !== false;
// Toggle visibility
img.set({
opacity: isCurrentlyVisible ? 0 : 1,
selectable: !isCurrentlyVisible,
evented: !isCurrentlyVisible,
visible: !isCurrentlyVisible,
});
// Update visibility button appearance using stored reference
const visibilityBtn = layerVisibilityButtons[index];
if (visibilityBtn) {
visibilityBtn.textContent = isCurrentlyVisible ? "👁‍🗨" : "👁";
visibilityBtn.style.backgroundColor = isCurrentlyVisible
? COLOR_BUTTON_DISABLED
: COLOR_BUTTON_BG;
}
// Deselect if hiding the currently selected object
if (isCurrentlyVisible) {
const activeObject = fabricInstance.getActiveObject();
if (activeObject === img) {
fabricInstance.discardActiveObject();
}
}
fabricInstance.renderAll();
// Save the changes (same as object:modified event)
saveAndUpdateSeed();
};
const swapLayerPositions = (fromIndex, toIndex) => {
// Swap the positions in the imagePositions array
const fromPosition = imagePositions[fromIndex];
const toPosition = imagePositions[toIndex];
imagePositions[fromIndex] = toPosition;
imagePositions[toIndex] = fromPosition;
// Update the layer panel UI to reflect new order
updateLayerPanelOrder();
// Update the canvas z-order based on new positions
updateCanvasZOrder();
// Save the changes
saveAndUpdateSeed();
};
const updateLayerPanelOrder = () => {
// Create array of [index, position] pairs and sort by position (highest first for UI)
const indexPositionPairs = createSortedIndexPositionPairs(
imagePositions,
true
);
// Store references to fixed layers (title, FG, BG)
const title = layersPanelEl.children[0]; // Title
const fgLayer = layersPanelEl.children[1]; // Foreground layer (always second)
// Remove only the image layer items (not title, FG, or BG)
// Keep removing the 3rd child until we hit the BG layer at the end
while (layersPanelEl.children.length > 3) {
// Remove the element after FG layer (index 2)
layersPanelEl.removeChild(layersPanelEl.children[2]);
}
// Store BG layer reference (now it should be the last child)
const bgLayer = layersPanelEl.lastChild;
// Re-insert image layer items in the new order (between FG and BG)
indexPositionPairs.forEach(({ index }) => {
const layerItem = createLayerItem(index);
// Insert before BG layer
layersPanelEl.insertBefore(layerItem, bgLayer);
// Update thumbnail in case it was already loaded
updateLayerThumbnail(index);
// Update visibility button state using stored reference
if (images[index]) {
const visibilityBtn = layerVisibilityButtons[index];
if (visibilityBtn && images[index].visible === false) {
visibilityBtn.textContent = "👁‍🗨";
visibilityBtn.style.backgroundColor = COLOR_BUTTON_DISABLED;
}
}
});
// update these icons whenever selection/order refreshes Beyond
for (let i = 0; i < IMAGE_COUNT; i++) {
const item = layerItems[i];
if (!item) continue;
const btn = layerLockButtons[i];
if (!btn) continue;
const locked = isLockedLayer(i);
btn.textContent = locked ? "🔒" : "🔓";
btn.style.backgroundColor = locked
? COLOR_BUTTON_ACTIVE
: COLOR_BUTTON_BG;
}
// Update selection highlight after reordering
updateLayerSelectionHighlight();
};
const updateCanvasZOrder = () => {
// Create array of [index, position] pairs
const indexPositionPairs = createSortedIndexPositionPairs(
imagePositions,
false
);
// Reorder objects on canvas
// First, move composition area and border to back
if (compositionArea) {
fabricInstance.sendToBack(compositionArea);
}
if (lockedLayerIndex !== null && images[lockedLayerIndex]) {
const bg = images[lockedLayerIndex];
// Force bg to the bottom of the stack...
fabricInstance.sendToBack(bg);
// ...then force compositionArea below it...
if (compositionArea) fabricInstance.sendToBack(compositionArea);
// ...then bring bg back above compositionArea.
fabricInstance.bringToFront(bg);
}
// 1) Put locked background image directly above compositionArea
if (lockedLayerIndex !== null && images[lockedLayerIndex]) {
fabricInstance.bringToFront(images[lockedLayerIndex]);
}
// Then arrange images according to their positions
indexPositionPairs.forEach(({ index }) => {
if (!images[index]) return;
if (index === lockedLayerIndex) return;
fabricInstance.bringToFront(images[index]);
});
// Bring FG layer above images but keep it below border
if (foregroundLayer) {
fabricInstance.bringToFront(foregroundLayer);
}
// Finally bring border to front (above everything)
if (compositionBorder) {
fabricInstance.bringToFront(compositionBorder);
}
fabricInstance.renderAll();
};
const createCanvasElement = () => {
canvasEl = document.createElement("canvas");
canvasEl.id = getRandomCompositorUniqueId();
return canvasEl;
};
const getRandomCompositorUniqueId = () => {
const randomUniqueIds = new Uint32Array(10);
const compositorId =
"c_" +
crypto.getRandomValues(randomUniqueIds)[0] +
"_" +
crypto.getRandomValues(randomUniqueIds)[1];
return compositorId;
};
const initializeFabricCanvas = () => {
fabricInstance = new fabric.Canvas(canvasEl, {
backgroundColor: COLOR_CANVAS_BG,
selectionColor: COLOR_CANVAS_SELECTION,
selectionLineWidth: 1,
preserveObjectStacking: true,
altSelectionKey: "ctrlKey",
altActionKey: "ctrlKey",
centeredKey: "altKey",
});
};
const createCompositionArea = () => {
// a rectangle representing the composition area
//p, w, h, node
compositionArea = new fabric.Rect({
left: canvasPadding + COMPOSITION_BORDER_SIZE / 2,
top: canvasPadding + COMPOSITION_BORDER_SIZE / 2,
fill: backgroundColor,
width: canvasWidth,
height: canvasHeight,
selectable: false,
});
};
const createCompositionBorder = () => {
// a border around (and external to) the composition area
// The stroke is centered on the rectangle edge, so we need to account for half the stroke width
// to ensure the inner edge aligns perfectly with the export area
compositionBorder = new fabric.Rect({
left:
canvasPadding + COMPOSITION_BORDER_SIZE - COMPOSITION_BORDER_SIZE / 2,
top:
canvasPadding + COMPOSITION_BORDER_SIZE - COMPOSITION_BORDER_SIZE / 2,
fill: "transparent",
width: canvasWidth,
height: canvasHeight,
selectable: false,
evented: false,
});
compositionBorder.set("strokeWidth", COMPOSITION_BORDER_SIZE);
compositionBorder.set("stroke", COMPOSITION_BORDER_COLOR);
compositionBorder.set("selectable", false);
compositionBorder.set("evented", false);
};
const setCanvasSize = (width, height, padding, borderSize) => {
fabricInstance.setWidth(width + padding * 2 + borderSize * 2);
fabricInstance.setHeight(height + padding * 2 + borderSize * 2);
fabricInstance.renderAll();
};
const appendCanvasToContainer = (contentWrapper) => {
// Append canvas to the content wrapper (left side)
contentWrapper.insertBefore(canvasEl, layersPanelEl);
};
const buildImageName = (graphId, nodeId, format, isTemp) => {
return `${graphId}_${nodeId}.${format}${isTemp ? " [temp]" : ""}`;
};
const executeSave = async (queue = false) => {
// Actual save execution
const imageName = buildImageName(app.graph.id, node.id, "png", false);
imageNameWidget.value = imageName;
// Store custom compositor data instead of full fabric JSON
const compositorData = serializeCompositorData();
fabricDataWidget.value = JSON.stringify(compositorData);
const dataUrl = grabSnapshot();
await uploadSnapshot(dataUrl, imageNameWidget.value, queue);
node.setDirtyCanvas(true, true); // Force UI update
};
const queuedSave = (queue = false) => {
// Cancel any pending debounced save
if (saveDebounceTimeout) {
clearTimeout(saveDebounceTimeout);
saveDebounceTimeout = null;
}
// If a save is in progress, store this request as pending (only keep the latest)
if (isSaving) {
pendingSaveRequest = { queue };
return Promise.resolve(); // Return resolved promise for .then() compatibility
}
// Return a promise that resolves when the save completes
return new Promise((resolve) => {
// Schedule the save after debounce delay
saveDebounceTimeout = setTimeout(async () => {
saveDebounceTimeout = null;
// Mark that we're saving
isSaving = true;
showSavingIndicator();
try {
await executeSave(queue);
} catch (error) {
console.error("Compositor4: save failed", error);
} finally {
hideSavingIndicator();
isSaving = false;
resolve(); // Resolve the promise after save completes
// If there's a pending save request, execute it now
if (pendingSaveRequest) {
const request = pendingSaveRequest;
pendingSaveRequest = null;
// Execute immediately (will go through debounce again)
queuedSave(request.queue);
}
}
}, SAVE_DEBOUNCE_DELAY);
});
};
const updateWidgetValues = (event, node) => {
queuedSave(true);
};
const resetImagePositions = (event, node) => {
images.forEach((img, index) => {
if (img) {
resetTransforms(index);
}
});
fabricInstance.discardActiveObject().renderAll();
// node.setDirtyCanvas(true, true); // Force UI update
};
const initialize = () => {
// Initialize imageName widget with default value if not set
if (!imageNameWidget.value || imageNameWidget.value === "default") {
const imageName = buildImageName(app.graph.id, node.id, "png", false);
imageNameWidget.value = imageName;
}
// DON'T restore compositor data here - widget values aren't available yet in nodeCreated
// Restoration will happen in loadedGraphNode hook where widget values are populated
// restoreImagePositions();
createContainer();
createToolbar();
// Update UI elements to reflect restored state (will be properly restored in loadedGraphNode)
updateUIAfterRestore();
createCanvasElement();
const contentWrapper = createLayersPanel();
appendCanvasToContainer(contentWrapper);
initializeFabricCanvas();
setCanvasSize(
canvasWidth,
canvasHeight,
canvasPadding,
COMPOSITION_BORDER_SIZE
);
createCompositionArea();
fabricInstance.add(compositionArea);
fabricInstance.sendToBack(compositionArea);
createCompositionBorder();
fabricInstance.add(compositionBorder);
fabricInstance.bringToFront(compositionBorder);
// Initialize foreground drawing layer
initializeForegroundLayer();
addCanvasEventListeners();
// Ensure select mode is active by default
// This must happen after fabricInstance and all layers are created
setTimeout(() => {
if (typeof setToolMode === "function") {
setToolMode("select");
}
}, 100);
fabricInstance.renderAll();
node.setDirtyCanvas(true, true);
};
const updateUIAfterRestore = () => {
// Update snap button to reflect restored state
if (snapBtn) {
snapBtn.textContent = snapEnabled ? "Snap: ON" : "Snap: OFF";
snapBtn.style.backgroundColor = snapEnabled
? COLOR_BUTTON_ACTIVE
: COLOR_BUTTON_DISABLED;
}
// Update grid size slider and label
if (gridSizeSlider) {
gridSizeSlider.value = gridSize;
}
if (gridSizeLabel) {
gridSizeLabel.textContent = `Grid: ${gridSize}px`;
}
// Update background color picker and thumbnail
if (backgroundColorInput) {
backgroundColorInput.value = rgbaToHex(backgroundColor);
}
if (backgroundColorThumbnail) {
backgroundColorThumbnail.style.backgroundColor = backgroundColor;
}
// Update background visibility button state
if (backgroundVisibilityButton) {
backgroundIsVisible = backgroundColor !== "transparent";
backgroundVisibilityButton.textContent = backgroundIsVisible
? "👁"
: "👁‍🗨";
backgroundVisibilityButton.style.backgroundColor = backgroundIsVisible
? COLOR_BUTTON_BG
: COLOR_BUTTON_DISABLED;
// If transparent, store a default opaque color for when user toggles back
if (
!backgroundIsVisible &&
backgroundColorOpaque === COMPOSITION_BACKGROUND_COLOR
) {
backgroundColorOpaque = "#ffffff"; // Default to white if no color was stored
}
}
// Update foreground visibility button state if it exists
if (foregroundVisibilityButton && foregroundLayer) {
foregroundVisibilityButton.textContent = foregroundIsVisible
? "👁"
: "👁‍🗨";
foregroundVisibilityButton.style.backgroundColor = foregroundIsVisible
? COLOR_BUTTON_BG
: COLOR_BUTTON_DISABLED;
}
};
// const restoreImagePositions = () => {
// try {
// const widgetValue = fabricDataWidget.value;
// if (widgetValue && typeof widgetValue === "string") {
// const data = deserializeCompositorData(widgetValue);
// }
// } catch (e) {
// console.error("Compositor4: could not restore compositor data", e);
// }
// };
const getContainer = () => {
return containerEl;
};
const updateCanvasDimensions = (width, height, padding, borderSize) => {
// Ensure numeric values to avoid string concatenation issues
const w = Number(width);
const h = Number(height);
const p = Number(padding);
canvasWidth = w;
canvasHeight = h;
canvasPadding = p;
// Update fabric canvas size
if (fabricInstance) {
fabricInstance.setWidth(w + p * 2 + COMPOSITION_BORDER_SIZE * 2);
fabricInstance.setHeight(h + p * 2 + COMPOSITION_BORDER_SIZE * 2);
fabricInstance.renderAll();
}
// Update composition area
if (compositionArea) {
compositionArea.set({
left: p + COMPOSITION_BORDER_SIZE,
top: p + COMPOSITION_BORDER_SIZE,
width: w,
height: h,
});
}
// Update composition border
if (compositionBorder) {
compositionBorder.set({
left: p + COMPOSITION_BORDER_SIZE / 2,
top: p + COMPOSITION_BORDER_SIZE / 2,
width: w + COMPOSITION_BORDER_SIZE,
height: h + COMPOSITION_BORDER_SIZE,
});
}
// Update container size
if (containerEl) {
containerEl.style.width =
w + p * 2 + COMPOSITION_BORDER_SIZE * 2 + 150 + "px";
containerEl.style.height = h + p * 2 + COMPOSITION_BORDER_SIZE * 2 + "px";
}
// Update node size
const nodeSize = calculateNodeSize();
node.setSize(nodeSize);
};
const grabSnapshot = () => {
const data = fabricInstance.toDataURL({
format: "png",
quality: QUALITY,
left: canvasPadding + COMPOSITION_BORDER_SIZE / 2,
top: canvasPadding + COMPOSITION_BORDER_SIZE / 2,
width: canvasWidth,
height: canvasHeight,
});
return data;
};
const uploadSnapshot = async (dataURL, imageName, queue = false) => {
const b = dataURLToBlob(dataURL);
const result = await uploadImage(b, imageName);
if (queue) {
app.queuePrompt(0, 1);
}
};
const dataURLToBlob = (dataURL) => {
const parts = dataURL.split(",");
const mime = parts[0].match(/:(.*?);/)[1];
const binary = atob(parts[1]);
const array = [];
for (let i = 0; i < binary.length; i++) {
array.push(binary.charCodeAt(i));
}
return new Blob([new Uint8Array(array)], { type: mime });
};
const uploadImage = async (blob, imageName) => {
const file = new File([blob], imageName);
const body = new FormData();
body.append("image", file);
body.append("subfolder", STORE_FOLDER);
body.append("type", saveFolder); // Use saveFolder variable instead of hardcoded "temp"
body.append("overwrite", OVERWRITE);
const result = await api.fetchApi(UPLOAD_ENDPOINT, {
method: "POST",
body,
});
};
const calculateNodeSize = () => {
const ch = fabricInstance.getHeight();
const cw = fabricInstance.getWidth();
// Added 150px for layers panel + 10px gap
return [cw + 21 + 160, ch + 111 + 138];
};
const fromUrlCallback = (img, index) => {
const locked = isLockedLayer(index); // Beyond
// callback when loading image from url, appends to fabric canvas
img.set({
left: canvasPadding + COMPOSITION_BORDER_SIZE,
top: canvasPadding + COMPOSITION_BORDER_SIZE,
selectable: !locked, // Beyond
evented: !locked, // Beyond
hasControls: !locked, // Beyond
hasBorders: !locked, // Beyond
lockMovementX: locked, // Beyond
lockMovementY: locked, // Beyond
lockScalingX: locked, // Beyond
lockScalingY: locked, // Beyond
lockRotation: locked, // Beyond
hoverCursor: locked ? "default" : "move", // Beyond
perPixelTargetFind: preciseSelection,
});
let currentTransform = null;
// First, check if there's a pending transform (from deserialization)
if (pendingTransforms[index]) {
currentTransform = pendingTransforms[index];
pendingTransforms[index] = null; // Clear after use
}
// Otherwise, check if there's an existing image to preserve its transform
else if (hasImageAtIndex(index)) {
currentTransform = getCurrentTransforms(index);
}
fabricInstance.remove(getImageAtIndex(index));
if (currentTransform) {
img.set(currentTransform);
}
setImageAtIndex(index, img);
fabricInstance.add(img);
// Update canvas z-order based on imagePositions
updateCanvasZOrder();
// Update layer thumbnail
updateLayerThumbnail(index);
// Apply lock state if needed Beyond
applyLockStateToImage(index);
updateCanvasZOrder();
// Update visibility button state if image is hidden using stored reference
const visibilityBtn = layerVisibilityButtons[index];
if (visibilityBtn && img.visible === false) {
visibilityBtn.textContent = "👁‍🗨";
visibilityBtn.style.backgroundColor = COLOR_BUTTON_DISABLED;
}
fabricInstance.renderAll();
};
const getImageAtIndex = (index) => {
if (index >= 0 && index < images.length) {
return images[index];
}
return null;
};
// base64 or imageName
const setImageAtIndex = (index, img) => {
if (index >= 0 && index < images.length) {
images[index] = img;
}
};
const hasImageAtIndex = (index) => {
return images[index] != null;
};
const createPlaceholderImage = (index, callback) => {
// Create a simple placeholder image using a canvas
const canvas = document.createElement("canvas");
canvas.width = 200;
canvas.height = 200;
const ctx = canvas.getContext("2d");
// Draw a gray rectangle with "Missing" text
ctx.fillStyle = "#444444";
ctx.fillRect(0, 0, 200, 200);
ctx.strokeStyle = "#888888";
ctx.lineWidth = 2;
ctx.strokeRect(5, 5, 190, 190);
ctx.fillStyle = "#CCCCCC";
ctx.font = "bold 24px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Missing", 100, 85);
ctx.font = "16px Arial";
ctx.fillText(`Image ${index + 1}`, 100, 115);
// Convert canvas to data URL and load as Fabric image
const dataUrl = canvas.toDataURL();
fabric.Image.fromURL(dataUrl, callback);
};
const appendImage = (imageSource, index) => {
// imageSource can be either:
// 1. A base64 data URL (starts with "data:image/")
// 2. A filename from {saveFolder}/compositor folder
// 3. null/undefined
if (!imageSource) {
return;
}
let imageUrl;
if (imageSource.startsWith("data:image/")) {
// It's a base64 data URL, use directly
imageUrl = imageSource;
} else {
// It's a filename, construct the URL using the saveFolder setting
imageUrl = `/view?filename=${encodeURIComponent(
imageSource
)}&type=${saveFolder}&subfolder=compositor`;
}
// Add a timestamp to force cache busting for file-based URLs
// This helps when the workflow is loaded from localStorage and files might be stale
const cacheBustUrl = imageSource.startsWith("data:image/")
? imageUrl
: `${imageUrl}&t=${Date.now()}`;
fabric.Image.fromURL(
cacheBustUrl,
(img) => {
// Check if image loaded successfully
if (!img || !img.getElement() || img.getElement().naturalWidth === 0) {
createPlaceholderImage(index, (placeholderImg) =>
fromUrlCallback(placeholderImg, index)
);
} else {
fromUrlCallback(img, index);
}
},
{ crossOrigin: "anonymous" }
);
};
const getCurrentTransforms = (index) => {
const ref = images[index];
return {
left: ref.left,
top: ref.top,
scaleX: ref.scaleX,
scaleY: ref.scaleY,
angle: ref.angle,
flipX: ref.flipX,
flipY: ref.flipY,
originX: ref.originX,
originY: ref.originY,
xwidth: ref.width,
xheight: ref.height,
skewY: ref.skewY,
skewX: ref.skewX,
opacity: ref.opacity,
visible: ref.visible,
selectable: ref.selectable,
evented: ref.evented,
};
};
const getBoundingBox = (index) => {
const ref = images[index].getBoundingRect();
return {
left: ref.left,
top: ref.top,
scaleX: ref.scaleX,
scaleY: ref.scaleY,
angle: ref.angle,
flipX: ref.flipX,
flipY: ref.flipY,
originX: ref.originX,
originY: ref.originY,
xwidth: ref.height,
xheight: ref.width,
skewY: ref.skewY,
skewX: ref.skewX,
};
};
const serializeCompositorData = () => {
// Serialize all necessary data to restore the compositor state
const transforms = [];
const bboxes = [];
const imageNames = [];
for (let i = 0; i < images.length; i++) {
if (images[i]) {
try {
transforms.push(getCurrentTransforms(i));
bboxes.push(getBoundingBox(i));
// Store image name/source if available
const imgElement = images[i].getElement();
if (imgElement && imgElement.src) {
const src = imgElement.src;
// Extract filename from URL or keep base64 as-is
if (src.startsWith("data:image/")) {
// Keep base64 data URLs as-is for backward compatibility
imageNames.push(src);
} else {
// Extract filename from URL like /view?filename=config_123_image1.png&type=temp&subfolder=compositor
try {
const url = new URL(src, window.location.origin);
const filename = url.searchParams.get("filename");
imageNames.push(filename || src);
} catch (e) {
// If URL parsing fails, keep original
imageNames.push(src);
}
}
} else {
imageNames.push(null);
}
} catch (e) {
transforms.push(null);
bboxes.push(null);
imageNames.push(null);
}
} else {
transforms.push(null);
bboxes.push(null);
imageNames.push(null);
}
}
// Get foreground image filename if it exists
const foregroundImageName = foregroundLayer ? `fg_${node.id}.png` : null;
return {
transforms: transforms,
bboxes: bboxes,
imageNames: imageNames,
imagePositions: imagePositions,
maskStates: maskStates, // V4: Save mask enabled/disabled states
applyMaskInConfig: applyMaskInConfig, // V4: Save mask application mode
snapEnabled: snapEnabled,
gridSize: gridSize,
width: canvasWidth,
height: canvasHeight,
padding: canvasPadding,
backgroundColor: backgroundColor,
foregroundImageName: foregroundImageName,
lockedLayerIndex: lockedLayerIndex, // Beyond: Save locked layer index
};
};
const deserializeCompositorData = (dataString) => {
try {
const data = JSON.parse(dataString);
// Restore canvas dimensions if available
if (
data.width !== undefined &&
data.height !== undefined &&
data.padding !== undefined
) {
// Just set the variables during restoration, don't call updateCanvasDimensions yet
// because fabric instance and elements don't exist yet during initialization
canvasWidth = Number(data.width);
canvasHeight = Number(data.height);
canvasPadding = Number(data.padding);
}
// Restore imagePositions if available
if (data.imagePositions && Array.isArray(data.imagePositions)) {
imagePositions = data.imagePositions;
}
// Restore snap settings if available
if (data.snapEnabled !== undefined) {
snapEnabled = data.snapEnabled;
}
if (data.gridSize !== undefined) {
gridSize = data.gridSize;
}
// V4: Restore mask states if available
if (data.maskStates && Array.isArray(data.maskStates)) {
maskStates = data.maskStates.slice(); // Copy the array
}
// V4: Restore mask application mode if available
if (data.applyMaskInConfig !== undefined) {
applyMaskInConfig = data.applyMaskInConfig;
}
// Restore background color if available
if (data.backgroundColor !== undefined) {
backgroundColor = data.backgroundColor;
}
// Restore foreground layer if available
if (data.foregroundImageName) {
loadForegroundLayer(data.foregroundImageName);
}
// Store transforms for pending restoration
if (data.transforms && Array.isArray(data.transforms)) {
pendingTransforms = data.transforms.slice(); // Copy the array
}
// Restore images from imageNames if available
if (data.imageNames && Array.isArray(data.imageNames)) {
data.imageNames.forEach((imageName, index) => {
if (imageName) {
// Use appendImage which already handles filename vs base64 and placeholders
// NOTE: When workflow is loaded from localStorage before backend runs,
// image files may not exist yet, so placeholders will be shown initially.
// They will be replaced with actual images once the backend runs.
// The transforms will be applied from pendingTransforms array in fromUrlCallback
appendImage(imageName, index);
}
});
}
// Restore locked layer index if available - Beyond
if (data.lockedLayerIndex !== undefined) {
lockedLayerIndex = data.lockedLayerIndex;
}
return data;
} catch (e) {
console.error("Compositor4: could not deserialize compositor data", e);
return null;
}
};
const resetTransforms = (index) => {
images[index].left = canvasPadding + COMPOSITION_BORDER_SIZE;
images[index].top = canvasPadding + COMPOSITION_BORDER_SIZE;
images[index].scaleX = 1;
images[index].scaleY = 1;
images[index].angle = 0;
images[index].flipX = false;
images[index].flipY = false;
//images[index].originX = "top";
//images[index].originY = "left";
images[index].skewY = 0;
images[index].skewX = 0;
// images[index].perPixelTargetFind = false;
// canvasInstance.preciseSelection;
};
const loadForegroundLayer = (fgImageName) => {
if (!fgImageName || !fabricInstance) return;
// Add timestamp and random param to break browser cache completely
const cacheBuster = `t=${Date.now()}&r=${Math.random()}`;
const fgImageUrl = `/view?filename=${encodeURIComponent(
fgImageName
)}&type=${saveFolder}&subfolder=compositor&${cacheBuster}`;
// Load the image with cache-busting
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
// Check if image loaded successfully
if (img.naturalWidth === 0) {
return;
}
if (foregroundLayer) {
// Update existing layer (no flash)
foregroundLayer.setElement(img);
fabricInstance.renderAll();
} else {
// Create new layer
fabric.Image.fromURL(
fgImageUrl,
(fabricImg) => {
foregroundLayer = fabricImg;
fabricImg.set({
left: canvasPadding + COMPOSITION_BORDER_SIZE,
top: canvasPadding + COMPOSITION_BORDER_SIZE,
selectable: false,
evented: false,
visible: foregroundIsVisible,
opacity: foregroundIsVisible ? 1 : 0,
scaleX: 1,
scaleY: 1,
});
fabricInstance.add(fabricImg);
updateCanvasZOrder();
fabricInstance.renderAll();
},
{ crossOrigin: "anonymous" }
);
}
};
img.src = fgImageUrl;
};
const setupCanvasEraser = () => {
// Use canvas primitives for true pixel erasing with destination-out
if (!fabricInstance) return;
// First, flatten the foreground layer if it exists into a temporary offscreen canvas
let fgCanvas = null;
if (foregroundLayer) {
fgCanvas = document.createElement("canvas");
fgCanvas.width = foregroundLayer.width;
fgCanvas.height = foregroundLayer.height;
const fgCtx = fgCanvas.getContext("2d");
fgCtx.drawImage(foregroundLayer.getElement(), 0, 0);
} else {
// Create empty canvas for FG layer
fgCanvas = document.createElement("canvas");
fgCanvas.width = canvasWidth;
fgCanvas.height = canvasHeight;
}
const fgCtx = fgCanvas.getContext("2d");
const previewCtx = fabricInstance.contextTop;
// Mouse down - start erasing
const onMouseDown = (opt) => {
isCanvasDrawing = true;
const p = fabricInstance.getPointer(opt.e);
canvasDrawingPath = [p];
fgCtx.beginPath();
fgCtx.moveTo(
p.x - (canvasPadding + COMPOSITION_BORDER_SIZE),
p.y - (canvasPadding + COMPOSITION_BORDER_SIZE)
);
previewCtx.clearRect(0, 0, fabricInstance.width, fabricInstance.height);
};
// Mouse move - erase and show preview
const onMouseMove = (opt) => {
if (!isCanvasDrawing) return;
const p = fabricInstance.getPointer(opt.e);
canvasDrawingPath.push(p);
// Draw preview
previewCtx.clearRect(0, 0, fabricInstance.width, fabricInstance.height);
previewCtx.strokeStyle = "rgba(255, 0, 0, 0.5)";
previewCtx.lineWidth = brushWidth;
previewCtx.lineCap = "round";
previewCtx.lineJoin = "round";
previewCtx.globalCompositeOperation = "source-over";
previewCtx.beginPath();
previewCtx.moveTo(canvasDrawingPath[0].x, canvasDrawingPath[0].y);
for (let i = 1; i < canvasDrawingPath.length; i++) {
previewCtx.lineTo(canvasDrawingPath[i].x, canvasDrawingPath[i].y);
}
previewCtx.stroke();
// Actually erase on FG canvas with destination-out
fgCtx.globalCompositeOperation = "destination-out";
fgCtx.lineWidth = brushWidth;
fgCtx.lineCap = "round";
fgCtx.lineJoin = "round";
fgCtx.lineTo(
p.x - (canvasPadding + COMPOSITION_BORDER_SIZE),
p.y - (canvasPadding + COMPOSITION_BORDER_SIZE)
);
fgCtx.stroke();
// Update foreground layer image with erased version
if (foregroundLayer) {
foregroundLayer.setElement(fgCanvas);
fabricInstance.renderAll();
}
};
// Mouse up - finalize erasing
const onMouseUp = async () => {
if (!isCanvasDrawing) return;
isCanvasDrawing = false;
// Clear preview
previewCtx.clearRect(0, 0, fabricInstance.width, fabricInstance.height);
// Reset composite operation
fgCtx.globalCompositeOperation = "source-over";
// Update foreground layer with final erased image
if (foregroundLayer) {
foregroundLayer.setElement(fgCanvas);
} else {
// Create new foreground layer from erased canvas
const dataUrl = fgCanvas.toDataURL("image/png");
fabric.Image.fromURL(dataUrl, (img) => {
foregroundLayer = img;
img.set({
left: canvasPadding + COMPOSITION_BORDER_SIZE,
top: canvasPadding + COMPOSITION_BORDER_SIZE,
selectable: false,
evented: false,
scaleX: 1,
scaleY: 1,
});
fabricInstance.add(img);
updateCanvasZOrder();
fabricInstance.renderAll();
});
}
canvasDrawingPath = [];
fabricInstance.renderAll();
// Trigger save
if (colorChangeDebounceTimeout) {
clearTimeout(colorChangeDebounceTimeout);
}
colorChangeDebounceTimeout = setTimeout(async () => {
colorChangeDebounceTimeout = null;
await saveForegroundLayer();
saveAndUpdateSeed();
}, COLOR_CHANGE_DEBOUNCE_DELAY);
};
// Attach event handlers
fabricInstance.on("mouse:down", onMouseDown);
fabricInstance.on("mouse:move", onMouseMove);
fabricInstance.on("mouse:up", onMouseUp);
// Store handlers and canvas for cleanup
fabricInstance._eraserHandlers = { onMouseDown, onMouseMove, onMouseUp };
fabricInstance._eraserCanvas = fgCanvas;
};
const cleanupCanvasEraser = () => {
if (!fabricInstance || !fabricInstance._eraserHandlers) return;
const { onMouseDown, onMouseMove, onMouseUp } =
fabricInstance._eraserHandlers;
fabricInstance.off("mouse:down", onMouseDown);
fabricInstance.off("mouse:move", onMouseMove);
fabricInstance.off("mouse:up", onMouseUp);
// Clear preview context
fabricInstance.contextTop.clearRect(
0,
0,
fabricInstance.width,
fabricInstance.height
);
delete fabricInstance._eraserHandlers;
delete fabricInstance._eraserCanvas;
isCanvasDrawing = false;
canvasDrawingPath = [];
};
const initializeForegroundLayer = async () => {
// Try to load existing foreground drawing using simplified filename
const fgImageName = `fg_${node.id}.png`;
loadForegroundLayer(fgImageName);
// Tool mode is initialized to "select" by default in setToolMode function
// which automatically hides brush controls (tools1Container and tools2Container)
};
const saveForegroundLayer = async () => {
if (!fabricInstance) return;
// Export only the drawing layer (pencil paths)
const drawingObjects = ArrayUtils.filterByType(
fabricInstance.getObjects(),
"path"
).filter((obj) => !obj.isEraserStroke);
// Check if we have any content (foreground layer or paths)
if (!foregroundLayer && drawingObjects.length === 0) {
return;
}
// Create a temporary canvas for the drawing layer
const tempCanvas = new fabric.StaticCanvas(null, {
width: canvasWidth,
height: canvasHeight,
});
// Load existing foreground layer (already has erasing applied)
if (foregroundLayer) {
await new Promise((resolve) => {
foregroundLayer.clone((clonedBg) => {
// Position at 0,0 in temp canvas (no padding offset needed)
clonedBg.set({
left: 0,
top: 0,
scaleX: 1,
scaleY: 1,
});
tempCanvas.add(clonedBg);
resolve();
});
});
}
// Clone and add pencil paths to temp canvas
if (drawingObjects.length > 0) {
const clonePromises = drawingObjects.map((path) => {
return new Promise((resolve) => {
path.clone((cloned) => {
// Adjust position: subtract padding and border offset
cloned.set({
left: cloned.left - (canvasPadding + COMPOSITION_BORDER_SIZE),
top: cloned.top - (canvasPadding + COMPOSITION_BORDER_SIZE),
});
tempCanvas.add(cloned);
resolve();
});
});
});
await Promise.all(clonePromises);
}
tempCanvas.renderAll();
// Export as data URL
const dataUrl = tempCanvas.toDataURL({
format: "png",
quality: 1,
});
// Upload the foreground layer with simplified filename
const fgImageName = `fg_${node.id}.png`;
const blob = dataURLToBlob(dataUrl);
const file = new File([blob], fgImageName);
const body = new FormData();
body.append("image", file);
body.append("subfolder", STORE_FOLDER);
body.append("type", saveFolder);
body.append("overwrite", "true");
await api.fetchApi(UPLOAD_ENDPOINT, {
method: "POST",
body,
});
// Remove all drawing paths from canvas
drawingObjects.forEach((path) => {
fabricInstance.remove(path);
});
// CRITICAL: Immediately update foreground layer with the new dataUrl
// This ensures the in-memory state matches what was saved to disk
// and breaks browser cache on the blob URL
if (foregroundLayer) {
// Create new image from dataUrl and update existing layer
const img = new Image();
img.onload = () => {
foregroundLayer.setElement(img);
fabricInstance.renderAll();
// Update thumbnail after layer is updated
if (foregroundThumbnail) {
foregroundThumbnail.style.backgroundImage = `url(${dataUrl})`;
}
};
img.src = dataUrl;
} else {
// Create new foreground layer if it doesn't exist
fabric.Image.fromURL(dataUrl, (img) => {
foregroundLayer = img;
img.set({
left: canvasPadding + COMPOSITION_BORDER_SIZE / 2,
top: canvasPadding + COMPOSITION_BORDER_SIZE / 2,
selectable: false,
evented: false,
visible: foregroundIsVisible,
opacity: foregroundIsVisible ? 1 : 0,
scaleX: 1,
scaleY: 1,
});
fabricInstance.add(img);
updateCanvasZOrder();
fabricInstance.renderAll();
// Update thumbnail after layer is created
if (foregroundThumbnail) {
foregroundThumbnail.style.backgroundImage = `url(${dataUrl})`;
}
});
}
};
const addCanvasEventListeners = () => {
// Snap to grid on object movement
fabricInstance.on("object:moving", function (opt) {
if (snapEnabled) {
const target = opt.target;
target.set({
left: snapToGrid(target.left),
top: snapToGrid(target.top),
});
}
});
// Snap to grid on object scaling
fabricInstance.on("object:scaling", function (opt) {
if (snapEnabled) {
const target = opt.target;
// Snap position to grid
target.set({
left: snapToGrid(target.left),
top: snapToGrid(target.top),
});
// Snap scaled dimensions to grid multiples
const scaledWidth = target.getScaledWidth();
const scaledHeight = target.getScaledHeight();
// Calculate target dimensions as multiples of grid
const snappedWidth = Math.round(scaledWidth / gridSize) * gridSize;
const snappedHeight = Math.round(scaledHeight / gridSize) * gridSize;
// Calculate new scale factors to achieve snapped dimensions
// Avoid division by zero
if (target.width > 0 && target.height > 0) {
const newScaleX = snappedWidth / target.width;
const newScaleY = snappedHeight / target.height;
target.set({
scaleX: newScaleX,
scaleY: newScaleY,
});
}
}
});
// Update rotation slider when object is rotated
fabricInstance.on("object:rotating", function (opt) {
updateRotationSlider();
// Constrain rotation to 5-degree steps if Shift is pressed (only values divisible by 5)
if (opt.e && opt.e.shiftKey) {
const target = opt.target;
let snappedAngle = Math.round(target.angle / 5) * 5;
// Ensure angle is exactly divisible by 5
if (snappedAngle % 5 !== 0) {
snappedAngle = Math.round(snappedAngle / 5) * 5;
}
target.set({ angle: snappedAngle });
}
});
// Update rotation slider when selection changes
fabricInstance.on("selection:created", function (opt) {
updateRotationSlider();
updateLayerSelectionHighlight();
updateSizeInputs();
if (rotationSlider) rotationSlider.disabled = false;
if (node.widthInput) node.widthInput.disabled = false;
if (node.heightInput) node.heightInput.disabled = false;
});
fabricInstance.on("selection:updated", function (opt) {
updateRotationSlider();
updateLayerSelectionHighlight();
updateSizeInputs();
if (rotationSlider) rotationSlider.disabled = false;
if (node.widthInput) node.widthInput.disabled = false;
if (node.heightInput) node.heightInput.disabled = false;
});
fabricInstance.on("selection:cleared", function (opt) {
updateLayerSelectionHighlight();
if (rotationSlider) {
rotationSlider.disabled = true;
rotationSlider.value = "0";
}
if (rotationLabel) {
rotationLabel.textContent = "Rotate: 0°";
}
if (node.widthInput) {
node.widthInput.disabled = true;
node.widthInput.value = "0";
}
if (node.heightInput) {
node.heightInput.disabled = true;
node.heightInput.value = "0";
}
});
// Update size inputs when object is scaled or modified
fabricInstance.on("object:scaling", function (opt) {
updateSizeInputs();
});
// Sync mask during transform (moving, scaling, rotating)
fabricInstance.on("object:moving", function (opt) {
if (!applyMaskInConfig && opt.target) {
const imgIndex = images.indexOf(opt.target);
if (imgIndex !== -1 && maskImages[imgIndex] && maskStates[imgIndex]) {
syncMaskWithImage(imgIndex);
}
}
});
fabricInstance.on("object:scaling", function (opt) {
if (!applyMaskInConfig && opt.target) {
const imgIndex = images.indexOf(opt.target);
if (imgIndex !== -1 && maskImages[imgIndex] && maskStates[imgIndex]) {
syncMaskWithImage(imgIndex);
}
}
});
fabricInstance.on("object:rotating", function (opt) {
if (!applyMaskInConfig && opt.target) {
const imgIndex = images.indexOf(opt.target);
if (imgIndex !== -1 && maskImages[imgIndex] && maskStates[imgIndex]) {
syncMaskWithImage(imgIndex);
}
}
});
// Save after object is modified
fabricInstance.on("object:modified", function (opt) {
updateSizeInputs();
// Sync mask transforms in frontend clipPath mode
if (!applyMaskInConfig && opt.target) {
const imgIndex = images.indexOf(opt.target);
if (imgIndex !== -1 && maskImages[imgIndex] && maskStates[imgIndex]) {
syncMaskWithImage(imgIndex);
}
}
saveAndUpdateSeed();
});
// Save when user draws on foreground layer
fabricInstance.on("path:created", function (opt) {
// Tag the path with current brush mode for processing during save
if (opt.path) {
const activeMode = tempToolMode || toolMode;
opt.path.set({
selectable: false,
evented: false,
isEraserStroke: activeMode === "erase",
});
}
// Debounce the save
if (colorChangeDebounceTimeout) {
clearTimeout(colorChangeDebounceTimeout);
}
colorChangeDebounceTimeout = setTimeout(async () => {
colorChangeDebounceTimeout = null;
await saveForegroundLayer();
saveAndUpdateSeed();
}, COLOR_CHANGE_DEBOUNCE_DELAY);
});
// Add keyboard navigation for selected objects
keyboardHandler = handleKeyboardNavigation;
document.addEventListener("keydown", keyboardHandler);
// Add Ctrl key handler for temporary mode switching
const handleCtrlKeyDown = (e) => {
if (e.key === "Control" && !isCtrlPressed) {
isCtrlPressed = true;
// Only switch if we're in draw or erase mode
if (toolMode === "draw" || toolMode === "erase") {
tempToolMode = toolMode === "draw" ? "erase" : "draw";
if (fabricInstance) {
if (tempToolMode === "draw") {
// Temporarily switch to pencil
cleanupCanvasEraser();
fabricInstance.isDrawingMode = true;
if (fabricInstance.freeDrawingBrush) {
fabricInstance.freeDrawingBrush.color = brushColor;
fabricInstance.freeDrawingBrush.width = brushWidth;
fabricInstance.freeDrawingBrush.globalCompositeOperation =
"source-over";
// Apply brush shape
if (brushShape === "square") {
fabricInstance.freeDrawingBrush.strokeLineCap = "square";
fabricInstance.freeDrawingBrush.strokeLineJoin = "miter";
} else {
fabricInstance.freeDrawingBrush.strokeLineCap = "round";
fabricInstance.freeDrawingBrush.strokeLineJoin = "round";
}
}
} else {
// Temporarily switch to eraser
fabricInstance.isDrawingMode = false;
setupCanvasEraser();
}
}
}
}
};
const handleCtrlKeyUp = (e) => {
if (e.key === "Control" && isCtrlPressed) {
isCtrlPressed = false;
if (tempToolMode) {
// Restore original mode
tempToolMode = null;
if (fabricInstance) {
if (toolMode === "draw") {
// Restore pencil mode
cleanupCanvasEraser();
fabricInstance.isDrawingMode = true;
if (fabricInstance.freeDrawingBrush) {
fabricInstance.freeDrawingBrush.color = brushColor;
fabricInstance.freeDrawingBrush.width = brushWidth;
fabricInstance.freeDrawingBrush.globalCompositeOperation =
"source-over";
// Apply brush shape
if (brushShape === "square") {
fabricInstance.freeDrawingBrush.strokeLineCap = "square";
fabricInstance.freeDrawingBrush.strokeLineJoin = "miter";
} else {
fabricInstance.freeDrawingBrush.strokeLineCap = "round";
fabricInstance.freeDrawingBrush.strokeLineJoin = "round";
}
}
} else {
// Restore eraser mode
fabricInstance.isDrawingMode = false;
setupCanvasEraser();
}
}
}
}
};
document.addEventListener("keydown", handleCtrlKeyDown);
document.addEventListener("keyup", handleCtrlKeyUp);
};
const handleKeyboardNavigation = (e) => {
// Only handle arrow keys when an object is selected in this canvas
const activeObject = fabricInstance?.getActiveObject();
if (!activeObject) return;
// Check if we should handle this event (don't interfere with text input)
if (
e.target.tagName === "INPUT" ||
e.target.tagName === "TEXTAREA" ||
e.target.isContentEditable
) {
return;
}
// Arrow key codes
const isArrowKey = [
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
].includes(e.key);
if (!isArrowKey) return;
// Prevent default behavior (scrolling)
e.preventDefault();
// Determine movement distance (1px normal, 10px with Shift)
const distance = e.shiftKey ? 10 : 1;
// Calculate new position
let deltaX = 0;
let deltaY = 0;
switch (e.key) {
case "ArrowLeft":
deltaX = -distance;
break;
case "ArrowRight":
deltaX = distance;
break;
case "ArrowUp":
deltaY = -distance;
break;
case "ArrowDown":
deltaY = distance;
break;
}
// Move the object(s)
if (activeObject.type === "activeSelection") {
// Multi-selection: move all selected objects
activeObject.forEachObject((obj) => {
obj.set({
left: obj.left + deltaX,
top: obj.top + deltaY,
});
obj.setCoords();
});
// Update the selection group position
activeObject.set({
left: activeObject.left + deltaX,
top: activeObject.top + deltaY,
});
activeObject.setCoords();
} else {
// Single selection: move the object
activeObject.set({
left: activeObject.left + deltaX,
top: activeObject.top + deltaY,
});
activeObject.setCoords();
}
// Render the changes
fabricInstance.renderAll();
// Save the changes
saveAndUpdateSeed();
};
const cleanup = () => {
// Remove keyboard event listener when editor is destroyed
if (keyboardHandler) {
document.removeEventListener("keydown", keyboardHandler);
keyboardHandler = null;
}
// Cancel any pending debounced saves
if (saveDebounceTimeout) {
clearTimeout(saveDebounceTimeout);
saveDebounceTimeout = null;
}
// Cancel any pending color change debounced saves
if (colorChangeDebounceTimeout) {
clearTimeout(colorChangeDebounceTimeout);
colorChangeDebounceTimeout = null;
}
};
const updateSeedValue = (signature = false) => {
// Store custom compositor data with a random seed to trigger update
const compositorData = serializeCompositorData();
const seedValue = signature != false ? signature : Math.random();
compositorData.seed = seedValue; // Add seed to trigger change detection
fabricDataWidget.value = JSON.stringify(compositorData);
// Update the seed widget to trigger node re-execution
if (seedWidget) {
seedWidget.value = seedValue;
}
};
const updateRotationSlider = () => {
if (!rotationSlider || !rotationLabel) return;
const activeObject = fabricInstance.getActiveObject();
if (activeObject) {
isUpdatingRotationSlider = true;
// Normalize angle to 0-360 range
let angle = activeObject.angle % 360;
if (angle < 0) angle += 360;
rotationSlider.value = Math.round(angle);
rotationLabel.textContent = `Rotate: ${Math.round(angle)}°`;
isUpdatingRotationSlider = false;
}
};
const updateSizeInputs = () => {
if (!node.widthInput || !node.heightInput) return;
const activeObject = fabricInstance.getActiveObject();
if (activeObject && activeObject.type !== "activeSelection") {
// Update inputs with current scaled dimensions
const scaledWidth = Math.round(activeObject.getScaledWidth());
const scaledHeight = Math.round(activeObject.getScaledHeight());
node.widthInput.value = scaledWidth;
node.heightInput.value = scaledHeight;
}
};
const showSavingIndicator = () => {
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.style.backgroundColor = COLOR_INDICATOR_SAVING;
saveBtn.style.cursor = "not-allowed";
}
};
const hideSavingIndicator = () => {
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.style.backgroundColor = COLOR_BUTTON_BG;
saveBtn.style.cursor = "pointer";
}
};
const snapToGrid = (value) => {
// Calculate the offset of the composition area
const offset = PADDING + COMPOSITION_BORDER_SIZE / 2;
// Snap relative to the composition area's top-left corner
return Math.round((value - offset) / gridSize) * gridSize + offset;
};
const alignSelected = (alignment) => {
const activeObject = fabricInstance.getActiveObject();
if (!activeObject) {
return;
}
const objBounds = activeObject.getBoundingRect();
const compLeft = canvasPadding + COMPOSITION_BORDER_SIZE / 2;
const compTop = canvasPadding + COMPOSITION_BORDER_SIZE / 2;
const compRight = compLeft + canvasWidth;
const compBottom = compTop + canvasHeight;
const compCenterX = compLeft + canvasWidth / 2;
const compCenterY = compTop + canvasHeight / 2;
// Calculate offset from object's origin to its bounds
const offsetX = objBounds.left - activeObject.left;
const offsetY = objBounds.top - activeObject.top;
switch (alignment) {
case "top-left":
activeObject.set({
left: compLeft - offsetX,
top: compTop - offsetY,
});
break;
case "top":
activeObject.set({
left: compCenterX - objBounds.width / 2 - offsetX,
top: compTop - offsetY,
});
break;
case "top-right":
activeObject.set({
left: compRight - objBounds.width - offsetX,
top: compTop - offsetY,
});
break;
case "left":
activeObject.set({
left: compLeft - offsetX,
top: compCenterY - objBounds.height / 2 - offsetY,
});
break;
case "center":
activeObject.set({
left: compCenterX - objBounds.width / 2 - offsetX,
top: compCenterY - objBounds.height / 2 - offsetY,
});
break;
case "right":
activeObject.set({
left: compRight - objBounds.width - offsetX,
top: compCenterY - objBounds.height / 2 - offsetY,
});
break;
case "bottom-left":
activeObject.set({
left: compLeft - offsetX,
top: compBottom - objBounds.height - offsetY,
});
break;
case "bottom":
activeObject.set({
left: compCenterX - objBounds.width / 2 - offsetX,
top: compBottom - objBounds.height - offsetY,
});
break;
case "bottom-right":
// prettier-ignore
activeObject.set({ left: compRight - objBounds.width - offsetX, top: compBottom - objBounds.height - offsetY });
break;
}
activeObject.setCoords();
fabricInstance.renderAll();
// Save the changes
saveAndUpdateSeed();
};
const isImageObject = (obj) => {
// Check if object is one of our managed images
return images.includes(obj);
};
const stretchHorizontally = () => {
TransformationEngine.stretch(
fabricInstance,
"horizontal",
canvasWidth,
saveAndUpdateSeed
);
};
const stretchVertically = () => {
TransformationEngine.stretch(
fabricInstance,
"vertical",
canvasHeight,
saveAndUpdateSeed
);
};
const equalizeHeight = () => {
TransformationEngine.equalize(
fabricInstance,
"vertical",
isImageObject,
saveAndUpdateSeed
);
};
const equalizeWidth = () => {
TransformationEngine.equalize(
fabricInstance,
"horizontal",
isImageObject,
saveAndUpdateSeed
);
};
const distributeVertically = () => {
TransformationEngine.distribute(
fabricInstance,
"vertical",
isImageObject,
snapToGrid,
saveAndUpdateSeed
);
};
const distributeHorizontally = () => {
TransformationEngine.distribute(
fabricInstance,
"horizontal",
isImageObject,
snapToGrid,
saveAndUpdateSeed
);
};
const flipHorizontally = () => {
TransformationEngine.flip(fabricInstance, "horizontal", saveAndUpdateSeed);
};
const flipVertically = () => {
TransformationEngine.flip(fabricInstance, "vertical", saveAndUpdateSeed);
};
const setSaveFolder = (folder) => {
saveFolder = folder;
};
const loadMasks = async (maskFilenames) => {
// Load mask filenames and update layer panel previews
for (
let index = 0;
index < maskFilenames.length && index < IMAGE_COUNT;
index++
) {
const maskName = maskFilenames[index];
maskNames[index] = maskName;
// If in frontend clipPath mode and mask exists, load it as Fabric image
if (!applyMaskInConfig && maskName && images[index]) {
try {
await loadMaskAsClipPath(index);
// Apply clipPath if mask is enabled
if (maskStates[index]) {
images[index].set({ clipPath: maskImages[index] });
syncMaskWithImage(index);
}
} catch (error) {
console.error(`[Compositor4] Failed to load mask ${index}:`, error);
}
}
updateMaskThumbnail(index);
}
if (fabricInstance) {
fabricInstance.renderAll();
}
};
const updateMaskThumbnail = (index) => {
const maskThumbnail = layerMaskThumbnails[index];
if (!maskThumbnail) return;
if (maskNames[index]) {
// Load mask image preview
const maskUrl = `/view?filename=${encodeURIComponent(
maskNames[index]
)}&subfolder=${STORE_FOLDER}&type=${saveFolder}`;
maskThumbnail.style.backgroundImage = `url(${maskUrl})`;
maskThumbnail.textContent = ""; // Clear the "M" placeholder
// Update visual state based on mask enabled/disabled and mode
if (!applyMaskInConfig) {
// Frontend clipPath mode - show enabled/disabled state via opacity only
maskThumbnail.style.cursor = "pointer";
maskThumbnail.style.opacity = maskStates[index] ? "1" : "0.5";
} else {
// Config mode - masks already applied, non-interactive
maskThumbnail.style.cursor = "default";
maskThumbnail.style.opacity = "1";
}
} else {
// No mask - show same background color as preview (no M letter)
maskThumbnail.style.backgroundImage = "none";
maskThumbnail.textContent = "";
maskThumbnail.style.cursor = "default";
maskThumbnail.style.opacity = "1";
}
};
const setApplyMaskInConfig = (value) => {
applyMaskInConfig = value;
console.log(`[Compositor4] applyMaskInConfig set to: ${applyMaskInConfig}`);
// Update all mask thumbnails to reflect the mode
for (let i = 0; i < IMAGE_COUNT; i++) {
updateMaskThumbnail(i);
}
};
const toggleMaskEnabled = async (index) => {
// Only allow toggling when in frontend clipPath mode
if (applyMaskInConfig) {
console.log(
"[Compositor4] Mask toggling only available in frontend clipPath mode"
);
return;
}
if (!maskNames[index]) {
console.log(`[Compositor4] No mask for layer ${index + 1}`);
return;
}
// Toggle the mask state
maskStates[index] = !maskStates[index];
console.log(
`[Compositor4] Toggled mask for layer ${index + 1}: ${maskStates[index]}`
);
// Apply or remove clipPath from the image
const img = images[index];
if (img && maskImages[index]) {
if (maskStates[index]) {
// Enable mask - apply clipPath
img.set({ clipPath: maskImages[index] });
} else {
// Disable mask - remove clipPath
img.set({ clipPath: null });
}
img.setCoords();
fabricInstance.renderAll();
}
// Update thumbnail visual state
updateMaskThumbnail(index);
// Save the state
await saveAndUpdateSeed();
};
const loadMaskAsClipPath = async (index) => {
// Load mask image from disk and create Fabric image for clipPath
if (!maskNames[index]) return;
const maskUrl = `/view?filename=${encodeURIComponent(
maskNames[index]
)}&subfolder=${STORE_FOLDER}&type=${saveFolder}`;
return new Promise((resolve, reject) => {
fabric.Image.fromURL(
maskUrl,
(maskImg) => {
if (!maskImg) {
console.error(
`[Compositor4] Failed to load mask for layer ${index + 1}`
);
reject(new Error("Mask load failed"));
return;
}
// Configure mask image
maskImg.set({
originX: "left",
originY: "top",
absolutePositioned: true, // Keep mask in absolute coordinates
inverted: true, // White = visible, black = hidden
});
// Store mask image reference
maskImages[index] = maskImg;
console.log(`[Compositor4] Loaded mask for layer ${index + 1}`);
resolve(maskImg);
},
{ crossOrigin: "anonymous" }
);
});
};
const syncMaskWithImage = (index) => {
// Sync mask transform with image transform for clipPath
const img = images[index];
const mask = maskImages[index];
if (!img || !mask) return;
// Copy transforms from image to mask
mask.set({
left: img.left,
top: img.top,
scaleX: img.scaleX,
scaleY: img.scaleY,
angle: img.angle,
flipX: img.flipX,
flipY: img.flipY,
});
mask.setCoords();
};
const restoreState = (dataString) => {
// Deserialize and restore the entire compositor state
// This should be called from loadedGraphNode when widget values are available
try {
const data = deserializeCompositorData(dataString);
if (data) {
// Update UI elements to reflect restored state
updateUIAfterRestore();
// Update canvas dimensions if they were restored
if (fabricInstance && compositionArea && compositionBorder) {
setCanvasSize(
canvasWidth,
canvasHeight,
canvasPadding,
COMPOSITION_BORDER_SIZE
);
// Update composition area (including restored background color)
compositionArea.set({
left: canvasPadding + COMPOSITION_BORDER_SIZE / 2,
top: canvasPadding + COMPOSITION_BORDER_SIZE / 2,
width: canvasWidth,
height: canvasHeight,
fill: backgroundColor,
});
// Update composition border
compositionBorder.set({
left: canvasPadding + COMPOSITION_BORDER_SIZE - COMPOSITION_BORDER_SIZE / 2,
top: canvasPadding + COMPOSITION_BORDER_SIZE - COMPOSITION_BORDER_SIZE / 2,
width: canvasWidth + COMPOSITION_BORDER_SIZE,
height: canvasHeight + COMPOSITION_BORDER_SIZE,
});
// Update container size
if (containerEl) {
containerEl.style.width =
canvasWidth +
canvasPadding * 2 +
COMPOSITION_BORDER_SIZE * 2 +
150 +
"px";
containerEl.style.height =
canvasHeight +
canvasPadding * 2 +
COMPOSITION_BORDER_SIZE * 2 +
"px";
}
fabricInstance.renderAll();
}
// Ensure all images are selectable after restoration (select mode is default)
setTimeout(() => {
if (fabricInstance) {
fabricInstance.getObjects().forEach((obj) => {
if (obj !== foregroundLayer && obj.type === "image") {
obj.set({ selectable: true, evented: true });
}
});
// Ensure tool mode buttons reflect correct state
if (typeof updateToolModeButtons === "function") {
updateToolModeButtons();
}
// Update layer highlights
if (typeof updateLayerSelectionHighlight === "function") {
updateLayerSelectionHighlight();
}
fabricInstance.renderAll();
}
}, 150);
return true;
}
} catch (e) {
console.error("Compositor4 Editor: could not restore state", e);
}
return false;
};
// public interface of the Editor
return {
initialize,
getContainer,
calculateNodeSize,
appendImage,
selectImageByIndex,
updateCanvasDimensions,
setSaveFolder,
setApplyMaskInConfig,
loadMasks,
restoreState,
cleanup,
queuedSave, // Expose for configuration change handling
saveAndUpdateSeed,
updateSeedValue, // Expose for auto-save with configSignature
saveBtn,
};
};
// Utility function to interrupt the current workflow execution
async function interrupt() {
try {
const response = await fetch("/interrupt", {
method: "POST",
cache: "no-cache",
headers: {
"Content-Type": "text/html",
},
});
return response;
} catch (error) {
console.error("Compositor4: Failed to interrupt workflow", error);
throw error;
}
}