Files
2025-11-27 00:55:16 +00:00

1249 lines
49 KiB
JavaScript

import { app } from '../../../scripts/app.js';
import { PowerSplineWidget } from './spline_utils.js';
import { HandDrawLayerWidget } from './layer_type_draw.js';
import { BoxLayerWidget } from './layer_type_box.js';
import { updateDrivenConfigValue, updateEasingConfigValue } from './persistence.js';
// Helper to create interactive number input with arrows and drag support
// For driven config (syncs to both driven and _drivenConfig)
function createNumberInput(widget, configObj, field, min, max, step, precision = 0) {
const container = document.createElement('div');
container.style.cssText = `
display: flex !important;
align-items: center !important;
justify-content: center !important;
gap: 2px !important;
padding: 2px 2px !important;
background-color: #1a1a1a !important;
color: #aaa !important;
`;
// Left arrow
const leftArrow = document.createElement('div');
leftArrow.textContent = '◀';
leftArrow.style.cssText = `
cursor: pointer !important;
user-select: none !important;
padding: 0 1px !important;
color: #aaa !important;
font-size: 10px !important;
`;
leftArrow.onmouseover = () => leftArrow.style.color = '#fff';
leftArrow.onmouseout = () => leftArrow.style.color = '#aaa';
leftArrow.onclick = (e) => {
e.stopPropagation();
const newValue = Math.max(min, configObj[field] - step);
updateDrivenConfigValue(widget, field, newValue);
numberDisplay.textContent = newValue.toFixed(precision);
};
// Number display (draggable and clickable)
const numberDisplay = document.createElement('div');
numberDisplay.textContent = configObj[field].toFixed(precision);
numberDisplay.style.cssText = `
min-width: 24px !important;
text-align: center !important;
cursor: ew-resize !important;
user-select: none !important;
outline: none !important;
color: #ddd !important;
font-size: 11px !important;
`;
let isDragging = false;
let hasDragged = false;
let startX = 0;
let startValue = 0;
const onMouseMove = (e) => {
if (isDragging) {
e.stopPropagation();
e.preventDefault();
const deltaX = e.clientX - startX;
// Only mark as dragged if movement exceeds threshold
if (Math.abs(deltaX) > 3) {
hasDragged = true;
}
const delta = Math.round(deltaX / 5) * step;
const newValue = Math.max(min, Math.min(max, startValue + delta));
updateDrivenConfigValue(widget, field, newValue);
numberDisplay.textContent = newValue.toFixed(precision);
}
};
const onMouseUp = () => {
if (isDragging) {
isDragging = false;
numberDisplay.style.cursor = 'ew-resize';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
};
numberDisplay.onmousedown = (e) => {
e.stopPropagation();
e.preventDefault();
if (e.button === 0) {
isDragging = true;
hasDragged = false;
startX = e.clientX;
startValue = configObj[field];
numberDisplay.style.cursor = 'ew-resize';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
};
numberDisplay.onclick = (e) => {
e.stopPropagation();
// Only show prompt if user didn't drag
if (!hasDragged) {
const fieldLabel = field === 'd_scale' ? 'D Scale' : field.charAt(0).toUpperCase() + field.slice(1);
app.canvas.prompt(fieldLabel, configObj[field], (v) => {
const newValue = Math.max(min, Math.min(max, Number(v)));
updateDrivenConfigValue(widget, field, newValue);
numberDisplay.textContent = newValue.toFixed(precision);
});
}
hasDragged = false;
};
// Right arrow
const rightArrow = document.createElement('div');
rightArrow.textContent = '▶';
rightArrow.style.cssText = `
cursor: pointer !important;
user-select: none !important;
padding: 0 1px !important;
color: #aaa !important;
font-size: 10px !important;
`;
rightArrow.onmouseover = () => rightArrow.style.color = '#fff';
rightArrow.onmouseout = () => rightArrow.style.color = '#aaa';
rightArrow.onclick = (e) => {
e.stopPropagation();
const newValue = Math.min(max, configObj[field] + step);
updateDrivenConfigValue(widget, field, newValue);
numberDisplay.textContent = newValue.toFixed(precision);
};
container.appendChild(leftArrow);
container.appendChild(numberDisplay);
container.appendChild(rightArrow);
return container;
}
// Helper to create interactive number input for easing config (syncs to easingConfig)
function createEasingNumberInput(widget, configObj, field, min, max, step, precision = 0) {
const container = document.createElement('div');
container.style.cssText = `
display: flex !important;
align-items: center !important;
justify-content: center !important;
gap: 2px !important;
padding: 2px 2px !important;
background-color: #1a1a1a !important;
color: #aaa !important;
`;
// Left arrow
const leftArrow = document.createElement('div');
leftArrow.textContent = '◀';
leftArrow.style.cssText = `
cursor: pointer !important;
user-select: none !important;
padding: 0 1px !important;
color: #aaa !important;
font-size: 10px !important;
`;
leftArrow.onmouseover = () => leftArrow.style.color = '#fff';
leftArrow.onmouseout = () => leftArrow.style.color = '#aaa';
leftArrow.onclick = (e) => {
e.stopPropagation();
const newValue = Math.max(min, configObj[field] - step);
updateEasingConfigValue(widget, field, newValue);
numberDisplay.textContent = newValue.toFixed(precision);
};
// Number display (draggable and clickable)
const numberDisplay = document.createElement('div');
numberDisplay.textContent = configObj[field].toFixed(precision);
numberDisplay.style.cssText = `
min-width: 24px !important;
text-align: center !important;
cursor: ew-resize !important;
user-select: none !important;
outline: none !important;
color: #ddd !important;
font-size: 11px !important;
`;
let isDragging = false;
let hasDragged = false;
let startX = 0;
let startValue = 0;
const onMouseMove = (e) => {
if (isDragging) {
e.stopPropagation();
e.preventDefault();
const deltaX = e.clientX - startX;
// Only mark as dragged if movement exceeds threshold
if (Math.abs(deltaX) > 3) {
hasDragged = true;
}
const delta = Math.round(deltaX / 5) * step;
const newValue = Math.max(min, Math.min(max, startValue + delta));
updateEasingConfigValue(widget, field, newValue);
numberDisplay.textContent = newValue.toFixed(precision);
}
};
const onMouseUp = () => {
if (isDragging) {
isDragging = false;
numberDisplay.style.cursor = 'ew-resize';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
};
numberDisplay.onmousedown = (e) => {
e.stopPropagation();
e.preventDefault();
if (e.button === 0) {
isDragging = true;
hasDragged = false;
startX = e.clientX;
startValue = configObj[field];
numberDisplay.style.cursor = 'ew-resize';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
};
numberDisplay.onclick = (e) => {
e.stopPropagation();
// Only show prompt if user didn't drag
if (!hasDragged) {
const fieldLabel = field.charAt(0).toUpperCase() + field.slice(1);
app.canvas.prompt(fieldLabel, configObj[field], (v) => {
const newValue = Math.max(min, Math.min(max, Number(v)));
updateEasingConfigValue(widget, field, newValue);
numberDisplay.textContent = newValue.toFixed(precision);
});
}
hasDragged = false;
};
// Right arrow
const rightArrow = document.createElement('div');
rightArrow.textContent = '▶';
rightArrow.style.cssText = `
cursor: pointer !important;
user-select: none !important;
padding: 0 1px !important;
color: #aaa !important;
font-size: 10px !important;
`;
rightArrow.onmouseover = () => rightArrow.style.color = '#fff';
rightArrow.onmouseout = () => rightArrow.style.color = '#aaa';
rightArrow.onclick = (e) => {
e.stopPropagation();
const newValue = Math.min(max, configObj[field] + step);
updateEasingConfigValue(widget, field, newValue);
numberDisplay.textContent = newValue.toFixed(precision);
};
container.appendChild(leftArrow);
container.appendChild(numberDisplay);
container.appendChild(rightArrow);
return container;
}
// Helper to create text input field
function createTextInput(widget, configObj, field, placeholder = '') {
const input = document.createElement('input');
input.type = 'text';
input.value = configObj[field] || '';
input.placeholder = placeholder;
input.style.cssText = `
width: 100% !important;
padding: 2px 3px !important;
background-color: #1a1a1a !important;
border: 1px solid #333 !important;
color: #ddd !important;
font-family: Arial, sans-serif !important;
font-size: 11px !important;
outline: none !important;
`;
input.onfocus = () => {
input.style.borderColor = '#555';
input.style.backgroundColor = '#222';
};
input.onblur = () => {
input.style.borderColor = '#333';
input.style.backgroundColor = '#1a1a1a';
};
input.oninput = (e) => {
e.stopPropagation();
// Use the centralized update function to ensure data is saved correctly
updateDrivenConfigValue(widget, field, input.value);
};
input.onclick = (e) => e.stopPropagation();
input.onmousedown = (e) => e.stopPropagation();
return input;
}
// Function to display a custom HTML context menu with interactive inputs
export function showCustomDrivenToggleMenu(event, widget, position) {
console.log("showCustomDrivenToggleMenu called.");
// Stop native context menu if invoked from a right-click
try { event?.preventDefault?.(); event?.stopPropagation?.(); } catch {}
// Remove any existing custom menu to prevent duplicates
const existingMenu = document.getElementById('custom-driven-toggle-menu');
if (existingMenu) {
existingMenu.remove();
}
// Work with the config object - either from driven (if on) or _drivenConfig (if off)
// This ensures we preserve settings even when the toggle is off
let configObj;
if (widget.value.driven && typeof widget.value.driven === 'object') {
// Driven is on - use the active config
configObj = widget.value.driven;
} else {
// Driven is off - use the preserved config
if (!widget.value._drivenConfig || typeof widget.value._drivenConfig !== 'object') {
widget.value._drivenConfig = { driver: "", rotate: 0, d_scale: 1.0 };
}
configObj = widget.value._drivenConfig;
}
// Ensure all fields exist
if (!configObj.driver) configObj.driver = "";
if (typeof configObj.rotate !== 'number') configObj.rotate = 0;
if (typeof configObj.d_scale !== 'number') configObj.d_scale = 1.0;
const menu = document.createElement('div');
menu.id = 'custom-driven-toggle-menu';
menu.className = 'litegraph litecontextmenu litemenubar-panel';
menu.style.cssText = `
position: absolute !important;
left: ${position.x}px !important;
top: ${position.y + 40}px !important;
background-color: #1a1a1a !important;
border: 1px solid #000 !important;
box-shadow: 0 2px 6px rgba(0,0,0,0.8) !important;
z-index: 10000 !important;
padding: 4px !important;
border-radius: 0px !important;
min-width: 50 !important;
display: block !important;
font-family: Arial, sans-serif !important;
font-size: 11px !important;
`;
// Create grid container
const container = document.createElement('div');
container.style.cssText = `
display: grid !important;
grid-template-columns: 1fr 0.5fr 0.5fr !important;
grid-template-rows: auto auto !important;
gap: 2px !important;
width: 100% !important;
`;
// Helper to create header label
const createHeader = (text) => {
const header = document.createElement('div');
header.textContent = text;
header.style.cssText = `
padding: 2px 4px !important;
text-align: center !important;
font-size: 11px !important;
color: #888 !important;
background-color: #1a1a1a !important;
font-weight: normal !important;
`;
return header;
};
// Row 1: Headers
container.appendChild(createHeader('Driver:'));
container.appendChild(createHeader('Rotate:'));
container.appendChild(createHeader('D_Scale:'));
// Row 2: Interactive inputs
const driverOptions = ["None", ...widget.parent.layerManager.getSplineWidgets()
.map(w => w.value.name)
.filter(name => name !== widget.value.name)];
container.appendChild(createDropdown(driverOptions, configObj.driver || "None", widget, 'driver', updateDrivenConfigValue));
container.appendChild(createNumberInput(widget, configObj, 'rotate', -360, 360, 1, 0));
container.appendChild(createNumberInput(widget, configObj, 'd_scale', 0.0, 1.0, 0.01, 2));
menu.appendChild(container);
document.body.appendChild(menu);
console.log("Menu appended to body, position:", position);
// Prevent menu close and native context menu when interacting with menu
menu.onclick = (e) => e.stopPropagation();
menu.onmousedown = (e) => e.stopPropagation();
menu.oncontextmenu = (e) => { e.preventDefault(); e.stopPropagation(); };
// Hide menu when clicking outside
const hideMenu = (e) => {
if (menu && !menu.contains(e.target)) {
console.log("Hiding menu");
menu.remove();
document.removeEventListener('click', hideMenu);
document.removeEventListener('contextmenu', hideMenu);
document.removeEventListener('mousedown', hideMenu);
}
};
// While menu is open, suppress browser context menu globally (capture)
const preventBrowserMenu = (e) => { e.preventDefault(); };
// Add listeners after a short delay to prevent immediate closure
setTimeout(() => {
document.addEventListener('click', hideMenu, true);
document.addEventListener('contextmenu', hideMenu, true);
document.addEventListener('mousedown', hideMenu, true);
document.addEventListener('contextmenu', preventBrowserMenu, true);
}, 100);
// Ensure cleanup of global preventer when menu is removed
const observer = new MutationObserver(() => {
if (!document.getElementById('custom-driven-toggle-menu')) {
document.removeEventListener('contextmenu', preventBrowserMenu, true);
observer.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: false });
}
// Helper to create a dropdown select element
function createDropdown(options, selectedValue, widget, field, updateCallback) {
const select = document.createElement('select');
select.style.cssText = `
width: 100% !important;
padding: 2px 3px !important;
background-color: #1a1a1a !important;
border: 1px solid #333 !important;
color: #ddd !important;
font-family: Arial, sans-serif !important;
font-size: 11px !important;
outline: none !important;
cursor: pointer !important;
`;
select.onfocus = () => {
select.style.borderColor = '#555';
select.style.backgroundColor = '#222';
};
select.onblur = () => {
select.style.borderColor = '#333';
select.style.backgroundColor = '#1a1a1a';
};
select.onclick = (e) => e.stopPropagation();
select.onmousedown = (e) => e.stopPropagation();
select.onchange = (e) => {
e.stopPropagation();
if (updateCallback) {
updateCallback(widget, field, select.value);
}
};
options.forEach(opt => {
const option = document.createElement('option');
option.value = opt;
option.textContent = opt;
if (opt === selectedValue) {
option.selected = true;
}
select.appendChild(option);
});
return select;
}
// Simple interpolation dropdown menu shown near click position
export function showInterpolationMenu(event, widget, position) {
try { event?.preventDefault?.(); event?.stopPropagation?.(); } catch {}
// Remove any existing menu
const existing = document.getElementById('custom-interp-menu');
if (existing) existing.remove();
const menu = document.createElement('div');
menu.id = 'custom-interp-menu';
menu.className = 'litegraph litecontextmenu litemenubar-panel';
menu.style.cssText = `
position: absolute !important;
left: ${position.x}px !important;
top: ${position.y + 10}px !important;
background-color: #1a1a1a !important;
border: 1px solid #000 !important;
box-shadow: 0 2px 6px rgba(0,0,0,0.8) !important;
z-index: 10000 !important;
padding: 4px !important;
border-radius: 0px !important;
min-width: 120px !important;
display: block !important;
font-family: Arial, sans-serif !important;
font-size: 11px !important;
transform: translateX(-40%) !important;
`;
// Check if this is a box layer
const isBoxLayer = widget.value && widget.value.type === 'box_layer';
const options = isBoxLayer ? ['linear', 'basis'] : ['linear', 'cardinal', 'basis', 'points'];
const current = isBoxLayer ? (widget.value.box_interpolation || 'linear') : (widget.value.interpolation || 'linear');
const list = document.createElement('div');
list.style.cssText = `
display: flex !important;
flex-direction: column !important;
gap: 2px !important;
padding: 2px !important;
`;
options.forEach(opt => {
const item = document.createElement('div');
item.textContent = opt;
item.style.cssText = `
padding: 4px 6px !important;
cursor: pointer !important;
color: ${opt === current ? '#fff' : '#ddd'} !important;
background-color: ${opt === current ? '#333' : 'transparent'} !important;
`;
item.onmouseover = () => { item.style.backgroundColor = '#2a2a2a'; item.style.color = '#fff'; };
item.onmouseout = () => { item.style.backgroundColor = (opt === current ? '#333' : 'transparent'); item.style.color = (opt === current ? '#fff' : '#ddd'); };
item.onclick = (e) => {
e.stopPropagation();
const w = widget;
// Check if this is a box layer to update the correct value field
if (w.value && w.value.type === 'box_layer') {
w.value.box_interpolation = opt;
} else {
w.value.interpolation = opt;
}
const node = w.parent;
if (node?.layerManager?.getActiveWidget() === w && node.editor?.layerRenderer) {
node.editor.layerRenderer.render();
}
node?.setDirtyCanvas?.(true, true);
menu.remove();
};
list.appendChild(item);
});
menu.appendChild(list);
document.body.appendChild(menu);
// Prevent native context interactions inside
menu.onclick = (e) => e.stopPropagation();
menu.onmousedown = (e) => e.stopPropagation();
menu.oncontextmenu = (e) => { e.preventDefault(); e.stopPropagation(); };
const hide = (e) => {
if (menu && !menu.contains(e.target)) {
menu.remove();
document.removeEventListener('click', hide, true);
document.removeEventListener('contextmenu', hide, true);
document.removeEventListener('mousedown', hide, true);
}
};
setTimeout(() => {
document.addEventListener('click', hide, true);
document.addEventListener('contextmenu', hide, true);
document.addEventListener('mousedown', hide, true);
}, 50);
}
// Ref image selection dropdown menu
export function showRefSelectionMenu(event, widget, position, onSelect) {
try { event?.preventDefault?.(); event?.stopPropagation?.(); } catch {}
const existing = document.getElementById('ref-select-menu');
if (existing) existing.remove();
const menu = document.createElement('div');
menu.id = 'ref-select-menu';
menu.className = 'litegraph litecontextmenu litemenubar-panel';
menu.style.cssText = `
position: absolute !important;
left: ${position.x}px !important;
top: ${position.y + 10}px !important;
background-color: #1a1a1a !important;
border: 1px solid #000 !important;
box-shadow: 0 2px 6px rgba(0,0,0,0.8) !important;
z-index: 10000 !important;
padding: 4px !important;
border-radius: 0px !important;
min-width: 140px !important;
display: block !important;
font-family: Arial, sans-serif !important;
font-size: 11px !important;
transform: translateX(-40%) !important;
`;
const options = widget?._getRefOptions?.() || ['no_ref', 'ref_1', 'ref_2', 'ref_3', 'ref_4', 'ref_5'];
const current = widget?.value?.ref_selection || 'no_ref';
const list = document.createElement('div');
list.style.cssText = `
display: flex !important;
flex-direction: column !important;
gap: 2px !important;
padding: 2px !important;
`;
options.forEach(opt => {
const item = document.createElement('div');
item.textContent = opt;
item.style.cssText = `
padding: 4px 6px !important;
cursor: pointer !important;
color: ${opt === current ? '#fff' : '#ddd'} !important;
background-color: ${opt === current ? '#333' : 'transparent'} !important;
`;
item.onmouseover = () => { item.style.backgroundColor = '#2a2a2a'; item.style.color = '#fff'; };
item.onmouseout = () => { item.style.backgroundColor = (opt === current ? '#333' : 'transparent'); item.style.color = (opt === current ? '#fff' : '#ddd'); };
item.onclick = async (e) => {
e.stopPropagation();
if (typeof onSelect === 'function') {
onSelect(opt);
} else if (widget) {
widget.value.ref_selection = opt;
// Reload image dimensions from file when switching to a ref selection
// This ensures we get the latest image data if the file was updated
if (opt !== 'no_ref' && widget.value.ref_attachment?.entries) {
const parts = opt.split('_');
const idx = parts.length > 1 ? parseInt(parts[1], 10) : 1;
const arrayIndex = Number.isFinite(idx) ? Math.max(0, idx - 1) : 0;
const attachment = widget.value.ref_attachment.entries[arrayIndex];
if (attachment && attachment.path) {
// Reload dimensions from the actual image file
try {
const img = new Image();
const cacheBust = Date.now() + Math.random().toString(36).substring(2, 9);
img.src = `${attachment.path}?v=${cacheBust}`;
await new Promise((resolve, reject) => {
img.onload = () => {
// Update attachment with fresh dimensions
attachment.width = img.width;
attachment.height = img.height;
resolve();
};
img.onerror = (err) => reject(err);
// Timeout after 2 seconds
setTimeout(() => resolve(), 2000);
});
} catch (e) {
console.warn('Failed to reload image dimensions:', e);
}
}
}
widget.parent?.setDirtyCanvas?.(true, true);
// Clear ref image cache to force reload with new selection
widget.parent?.editor?.layerRenderer?.clearRefImageCache?.();
// Force render to update the displayed reference image
widget.parent?.editor?.layerRenderer?.render?.();
}
menu.remove();
};
list.appendChild(item);
});
menu.appendChild(list);
document.body.appendChild(menu);
const cleanup = (ev) => {
const target = ev?.target;
if (target && menu.contains(target)) return;
menu.remove();
document.removeEventListener('mousedown', cleanup, true);
document.removeEventListener('contextmenu', cleanup, true);
document.removeEventListener('keydown', onEsc, true);
};
const onEsc = (ev) => {
if (ev.key === 'Escape') {
menu.remove();
document.removeEventListener('mousedown', cleanup, true);
document.removeEventListener('contextmenu', cleanup, true);
document.removeEventListener('keydown', onEsc, true);
}
};
setTimeout(() => {
document.addEventListener('mousedown', cleanup, true);
document.addEventListener('contextmenu', cleanup, true);
document.addEventListener('keydown', onEsc, true);
}, 0);
}
// Helper to create static number display (non-interactive for UI-only)
function createStaticNumberDisplay(value, min, max, precision = 1) {
const container = document.createElement('div');
container.style.cssText = `
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 2px 3px !important;
background-color: #1a1a1a !important;
border: 1px solid #333 !important;
color: #ddd !important;
font-family: Arial, sans-serif !important;
font-size: 11px !important;
text-align: center !important;
`;
container.textContent = value.toFixed(precision);
return container;
}
// Function to display a custom HTML context menu for easing configuration
export function showCustomEasingMenu(event, widget, position) {
console.log("showCustomEasingMenu called.");
// Remove any existing custom menu to prevent duplicates
const existingMenu = document.getElementById('custom-easing-menu');
if (existingMenu) {
existingMenu.remove();
}
// Initialization is now handled by the widget's value setter.
// We can rely on widget.value.easingConfig existing.
const configObj = widget.value.easingConfig;
// Ensure fields exist as a fallback.
if (!configObj.path) configObj.path = "each";
if (typeof configObj.strength !== 'number') configObj.strength = 1.0;
const menu = document.createElement('div');
menu.id = 'custom-easing-menu';
menu.className = 'litegraph litecontextmenu litemenubar-panel';
menu.style.cssText = `
position: absolute !important;
left: ${position.x}px !important;
top: ${position.y + 40}px !important;
background-color: #1a1a1a !important;
border: 1px solid #000 !important;
box-shadow: 0 2px 6px rgba(0,0,0,0.8) !important;
z-index: 10000 !important;
padding: 4px !important;
border-radius: 0px !important;
min-width: 50 !important;
display: block !important;
font-family: Arial, sans-serif !important;
font-size: 11px !important;
`;
// Create grid container (3 columns: Path | Strength | Accel)
const container = document.createElement('div');
container.style.cssText = `
display: grid !important;
grid-template-columns: 1fr 1fr 1fr !important;
grid-template-rows: auto auto !important;
gap: 2px !important;
width: 100% !important;
`;
// Helper to create header label
const createHeader = (text) => {
const header = document.createElement('div');
header.textContent = text;
header.style.cssText = `
padding: 2px 4px !important;
text-align: center !important;
font-size: 11px !important;
color: #888 !important;
background-color: #1a1a1a !important;
font-weight: normal !important;
`;
return header;
};
// Row 1: Headers
container.appendChild(createHeader('Path:'));
container.appendChild(createHeader('Strength:'));
container.appendChild(createHeader('Accel:'));
// Row 2: Controls
container.appendChild(createDropdown(['each', 'full', 'alternate'], configObj.path, widget, 'path', updateEasingConfigValue));
container.appendChild(createEasingNumberInput(widget, configObj, 'strength', 0.0, 2.0, 0.1, 1));
container.appendChild(createEasingNumberInput(widget, configObj, 'acceleration', -1.0, 1.0, 0.01, 2));
menu.appendChild(container);
document.body.appendChild(menu);
console.log("Easing menu appended to body, position:", position);
// Prevent menu close when interacting with inputs
menu.onclick = (e) => e.stopPropagation();
menu.onmousedown = (e) => e.stopPropagation();
// Hide menu when clicking outside
const hideMenu = (e) => {
if (menu && !menu.contains(e.target)) {
console.log("Hiding easing menu");
menu.remove();
document.removeEventListener('click', hideMenu);
document.removeEventListener('contextmenu', hideMenu);
document.removeEventListener('mousedown', hideMenu);
}
};
// Add listeners after a short delay to prevent immediate closure
setTimeout(() => {
document.addEventListener('click', hideMenu);
document.addEventListener('contextmenu', hideMenu);
document.addEventListener('mousedown', hideMenu);
}, 100);
}
export function getSlotInPosition(canvasX, canvasY) {
const slot = LGraphNode.prototype.getSlotInPosition?.call(this, canvasX, canvasY);
if (!slot) {
// Check if click is on a spline widget
for (const widget of this.widgets || []) {
if (!widget.last_y) continue;
// Check if click Y is within this widget's bounds
const widgetTop = this.pos[1] + widget.last_y;
const widgetBottom = widgetTop + LiteGraph.NODE_WIDGET_HEIGHT;
if (canvasY >= widgetTop && canvasY <= widgetBottom) {
// Found the widget at this position
const isSplineLike =
(widget instanceof PowerSplineWidget) ||
(widget instanceof HandDrawLayerWidget) ||
(widget instanceof BoxLayerWidget) ||
widget.name?.startsWith("spline_") ||
widget.name?.startsWith("box_");
if (isSplineLike) {
// Use the actual hitAreas.drivenToggle.bounds from the widget
if (widget.hitAreas && widget.hitAreas.drivenToggle) {
const drivenToggleBounds = widget.hitAreas.drivenToggle.bounds;
// drivenToggleBounds is [posX, toggleBgWidth] relative to the widget's draw area
// We need to convert these to canvas coordinates
const toggleAbsXStart = this.pos[0] + drivenToggleBounds[0];
// Reduce the width of the hit area to be more precise - use the actual toggle width
// The toggle width is approximately height * 0.72 (toggle radius * 2)
const toggleWidth = LiteGraph.NODE_WIDGET_HEIGHT * 0.72;
const toggleAbsXEnd = toggleAbsXStart + toggleWidth;
const toggleAbsYStart = widgetTop; // The widget's top is the start Y
const toggleAbsYEnd = widgetBottom; // The widget's bottom is the end Y
if (canvasX >= toggleAbsXStart && canvasX <= toggleAbsXEnd &&
canvasY >= toggleAbsYStart && canvasY <= toggleAbsYEnd) {
return { widget: widget, output: { type: "DRIVEN_TOGGLE" } };
}
}
// Check for easing area (same pattern as driven toggle)
if (widget.hitAreas && widget.hitAreas.easingVal) {
const easingBounds = widget.hitAreas.easingVal.bounds;
// easingBounds is [posX, width] relative to the widget's draw area
const easingAbsXStart = this.pos[0] + easingBounds[0];
const easingAbsXEnd = easingAbsXStart + easingBounds[1];
const easingAbsYStart = widgetTop;
const easingAbsYEnd = widgetBottom;
if (canvasX >= easingAbsXStart && canvasX <= easingAbsXEnd &&
canvasY >= easingAbsYStart && canvasY <= easingAbsYEnd) {
return { widget: widget, output: { type: "EASING_AREA" } };
}
}
}
// If click is outside the toggle button, then select the spline widget
return { widget: widget, output: { type: "SPLINE WIDGET" } };
}
}
}
return slot;
}
// Function to display custom HTML context menu for layer actions
export function showCustomLayerMenu(event, widget, node, position) {
console.log("showCustomLayerMenu called.");
// Stop native context menu if invoked from a right-click
try { event?.preventDefault?.(); event?.stopPropagation?.(); } catch {}
// Remove any existing custom menu to prevent duplicates
const existingMenu = document.getElementById('custom-layer-menu');
if (existingMenu) {
existingMenu.remove();
}
const menu = document.createElement('div');
menu.id = 'custom-layer-menu';
menu.className = 'litegraph litecontextmenu litemenubar-panel';
menu.style.cssText = `
position: absolute !important;
left: ${position.x}px !important;
top: ${position.y}px !important;
background-color: #1a1a1a !important;
border: 1px solid #000 !important;
box-shadow: 0 2px 6px rgba(0,0,0,0.8) !important;
z-index: 10000 !important;
padding: 4px !important;
border-radius: 0px !important;
min-width: 120px !important;
display: block !important;
font-family: Arial, sans-serif !important;
font-size: 11px !important;
`;
const splineWidgets = node.widgets.filter(w =>
(w instanceof PowerSplineWidget) ||
(w instanceof HandDrawLayerWidget) ||
(w instanceof BoxLayerWidget) ||
w.name?.startsWith("spline_") ||
w.name?.startsWith("box_")
);
const splineIndex = splineWidgets.indexOf(widget);
const isBoxLayerWidget = (widget instanceof BoxLayerWidget) || widget.name?.startsWith("box_");
const canMoveUp = splineIndex > 0;
const canMoveDown = splineIndex < splineWidgets.length - 1;
// Helper to create menu item
const createMenuItem = (icon, text, onClick, disabled = false) => {
const item = document.createElement('div');
item.style.cssText = `
padding: 4px 8px !important;
cursor: ${disabled ? 'default' : 'pointer'} !important;
color: ${disabled ? '#555' : '#ddd'} !important;
background-color: #1a1a1a !important;
user-select: none !important;
display: flex !important;
align-items: center !important;
gap: 6px !important;
`;
if (!disabled) {
item.onmouseover = () => {
item.style.backgroundColor = '#2a2a2a';
};
item.onmouseout = () => {
item.style.backgroundColor = '#1a1a1a';
};
item.onclick = (e) => {
e.stopPropagation();
onClick();
menu.remove();
document.removeEventListener('click', hideMenu);
document.removeEventListener('contextmenu', hideMenu);
document.removeEventListener('mousedown', hideMenu);
};
}
const iconSpan = document.createElement('span');
iconSpan.textContent = icon;
iconSpan.style.cssText = `
font-size: 12px !important;
width: 16px !important;
text-align: center !important;
`;
const textSpan = document.createElement('span');
textSpan.textContent = text;
item.appendChild(iconSpan);
item.appendChild(textSpan);
return item;
};
// Helper to create separator
const createSeparator = () => {
const separator = document.createElement('div');
separator.style.cssText = `
height: 1px !important;
background-color: #333 !important;
margin: 2px 0 !important;
`;
return separator;
};
// Add menu items
menu.appendChild(createMenuItem('✏️', 'Rename', () => {
const canvas = app.canvas;
// Ensure value container exists to avoid null access after refresh
if (!widget.value) widget.value = {};
const currentName = widget.value?.name || "Spline";
canvas.prompt("Spline Name", currentName, (v) => {
const newName = v || "Spline";
const otherSplineWidgets = node.widgets.filter(w => w !== widget && w.name?.startsWith("spline_"));
const existingNames = otherSplineWidgets.map(w => w?.value?.name).filter(n => !!n);
const oldName = widget.value.name; // Store old name before changing
widget.value.name = node.generateUniqueName(newName, existingNames);
// Update driver references in other layers that were using this layer as a driver
if (oldName && oldName !== widget.value.name) {
node.widgets.forEach(w => {
if (w !== widget && w.name?.startsWith("spline_") && w.value) {
// Check both active driven config and preserved config
let needsUpdate = false;
// Check active driven config
if (w.value.driven && w.value.driven.driver === oldName) {
w.value.driven.driver = widget.value.name;
needsUpdate = true;
}
// Check preserved driven config (when toggle is off)
if (w.value._drivenConfig && w.value._drivenConfig.driver === oldName) {
w.value._drivenConfig.driver = widget.value.name;
needsUpdate = true;
}
// Log the update for debugging
if (needsUpdate) {
console.log(`Updated driver reference in layer "${w.value.name}" from "${oldName}" to "${widget.value.name}"`);
}
}
});
}
node.setDirtyCanvas(true, true);
});
}));
menu.appendChild(createSeparator());
// Safely read/initialize toggle state
const isOn = !!(widget.value && widget.value.on);
menu.appendChild(createMenuItem(
isOn ? '⚫' : '🟢',
isOn ? 'Toggle Off' : 'Toggle On',
() => {
if (!widget.value) widget.value = {};
if (typeof widget.value.on !== 'boolean') widget.value.on = false;
widget.value.on = !widget.value.on;
node.setDirtyCanvas(true, true);
}
));
menu.appendChild(createMenuItem('⬆️', 'Move Up', () => {
const index = node.widgets.indexOf(widget);
const otherIndex = node.widgets.indexOf(splineWidgets[splineIndex - 1]);
const temp = node.widgets[otherIndex];
node.widgets[otherIndex] = widget;
node.widgets[index] = temp;
node.setDirtyCanvas(true, true);
}, !canMoveUp));
menu.appendChild(createMenuItem('⬇️', 'Move Down', () => {
const index = node.widgets.indexOf(widget);
const otherIndex = node.widgets.indexOf(splineWidgets[splineIndex + 1]);
const temp = node.widgets[otherIndex];
node.widgets[otherIndex] = widget;
node.widgets[index] = temp;
node.setDirtyCanvas(true, true);
}, !canMoveDown));
if (isBoxLayerWidget) {
const hasPoints = Array.isArray(widget.value?.box_keys) && widget.value.box_keys.length > 0;
menu.appendChild(createMenuItem('[]', 'Clear All Points', () => {
widget.onClearAllClick?.(event ?? null, null, node);
node?.setDirtyCanvas?.(true, true);
}, !hasPoints));
}
menu.appendChild(createMenuItem('🗑️', 'Remove', () => {
node.layerManager.removeSpline(widget);
}));
document.body.appendChild(menu);
console.log("Layer menu appended to body, position:", position);
// Prevent menu close and native context menu when interacting with menu
menu.onclick = (e) => e.stopPropagation();
menu.onmousedown = (e) => e.stopPropagation();
menu.oncontextmenu = (e) => { e.preventDefault(); e.stopPropagation(); };
// Hide menu when clicking outside (but ignore clicks inside LiteGraph prompt/dialogs)
const hideMenu = (e) => {
const target = e && e.target;
const withinDialog = target && (target.closest?.('.litegraph .dialog') || target.closest?.('.litegraph.liteprompt') || target.closest?.('.litedialog'));
if (withinDialog) {
return; // keep menu open while interacting with prompt
}
if (menu && !menu.contains(target)) {
console.log("Hiding layer menu");
menu.remove();
document.removeEventListener('click', hideMenu, true);
document.removeEventListener('contextmenu', hideMenu, true);
document.removeEventListener('mousedown', hideMenu, true);
}
};
// While menu is open, suppress browser context menu globally (capture)
const preventBrowserMenu = (e) => { e.preventDefault(); };
// Add listeners after a short delay to prevent immediate closure
setTimeout(() => {
// Use capture so we can filter early and not propagate into generic closers
document.addEventListener('click', hideMenu, true);
document.addEventListener('contextmenu', hideMenu, true);
document.addEventListener('mousedown', hideMenu, true);
document.addEventListener('contextmenu', preventBrowserMenu, true);
}, 100);
// Ensure cleanup of global preventer when menu is removed
const observer = new MutationObserver(() => {
if (!document.getElementById('custom-layer-menu')) {
document.removeEventListener('contextmenu', preventBrowserMenu, true);
observer.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: false });
}
export function getSlotMenuOptions(slot, event) {
// Prevent native context menu when we will show custom ones
try { event?.preventDefault?.(); event?.stopPropagation?.(); } catch {}
// Check if the slot is specifically for the DRIVEN_TOGGLE
if (slot?.output?.type === "DRIVEN_TOGGLE") {
// Get position from canvas - LiteGraph stores the last pointer position
const canvas = app.canvas;
// Try multiple ways to get the screen position
let x = 100;
let y = 100;
// Option 1: Try to get from last_mouse_position (canvas screen coords)
if (canvas.last_mouse_position) {
x = canvas.last_mouse_position[0];
y = canvas.last_mouse_position[1];
}
// Option 2: Try canvas_mouse (might be available)
else if (canvas.canvas_mouse) {
x = canvas.canvas_mouse[0];
y = canvas.canvas_mouse[1];
}
// Option 3: Convert graph coordinates to screen coordinates
else if (canvas.graph_mouse && canvas.ds) {
const graphX = canvas.graph_mouse[0];
const graphY = canvas.graph_mouse[1];
// Convert from graph space to canvas space
x = graphX * canvas.ds.scale + canvas.ds.offset[0];
y = graphY * canvas.ds.scale + canvas.ds.offset[1];
// Convert from canvas space to screen space
const canvasRect = canvas.canvas.getBoundingClientRect();
x += canvasRect.left;
y += canvasRect.top;
}
console.log("Menu position calculated:", {x, y});
// Use a slight delay to ensure the menu shows after the event completes
setTimeout(() => {
showCustomDrivenToggleMenu(event, slot.widget, { x, y });
}, 10);
// Return false to prevent LiteGraph from showing its default menu
return false;
}
// Check if the slot is specifically for the EASING_AREA
if (slot?.output?.type === "EASING_AREA") {
// Get position from canvas - same logic as driven toggle
const canvas = app.canvas;
// Try multiple ways to get the screen position
let x = 100;
let y = 100;
// Option 1: Try to get from last_mouse_position (canvas screen coords)
if (canvas.last_mouse_position) {
x = canvas.last_mouse_position[0];
y = canvas.last_mouse_position[1];
}
// Option 2: Try canvas_mouse (might be available)
else if (canvas.canvas_mouse) {
x = canvas.canvas_mouse[0];
y = canvas.canvas_mouse[1];
}
// Option 3: Convert graph coordinates to screen coordinates
else if (canvas.graph_mouse && canvas.ds) {
const graphX = canvas.graph_mouse[0];
const graphY = canvas.graph_mouse[1];
// Convert from graph space to canvas space
x = graphX * canvas.ds.scale + canvas.ds.offset[0];
y = graphY * canvas.ds.scale + canvas.ds.offset[1];
// Convert from canvas space to screen space
const canvasRect = canvas.canvas.getBoundingClientRect();
x += canvasRect.left;
y += canvasRect.top;
}
console.log("Easing menu position calculated:", {x, y});
// Use a slight delay to ensure the menu shows after the event completes
setTimeout(() => {
showCustomEasingMenu(event, slot.widget, { x, y });
}, 10);
// Return false to prevent LiteGraph from showing its default menu
return false;
}
// Check if the slot is for a spline widget (layer right-click)
const widget = slot?.widget;
const isLayerWidget = widget &&
((widget instanceof PowerSplineWidget) ||
(widget instanceof HandDrawLayerWidget) ||
(widget instanceof BoxLayerWidget) ||
widget.name?.startsWith("spline_") ||
widget.name?.startsWith("box_"));
if (isLayerWidget) {
const canvas = app.canvas;
// Try multiple ways to get the screen position
let x = 100;
let y = 100;
// Option 1: Try to get from last_mouse_position (canvas screen coords)
if (canvas.last_mouse_position) {
x = canvas.last_mouse_position[0];
y = canvas.last_mouse_position[1];
}
// Option 2: Try canvas_mouse (might be available)
else if (canvas.canvas_mouse) {
x = canvas.canvas_mouse[0];
y = canvas.canvas_mouse[1];
}
// Option 3: Convert graph coordinates to screen coordinates
else if (canvas.graph_mouse && canvas.ds) {
const graphX = canvas.graph_mouse[0];
const graphY = canvas.graph_mouse[1];
// Convert from graph space to canvas space
x = graphX * canvas.ds.scale + canvas.ds.offset[0];
y = graphY * canvas.ds.scale + canvas.ds.offset[1];
// Convert from canvas space to screen space
const canvasRect = canvas.canvas.getBoundingClientRect();
x += canvasRect.left;
y += canvasRect.top;
}
console.log("Layer menu position calculated:", {x, y});
// Use a slight delay to ensure the menu shows after the event completes
setTimeout(() => {
showCustomLayerMenu(event, widget, this, { x, y });
}, 10);
// Return false to prevent LiteGraph from showing its default menu
return false;
}
return LGraphNode.prototype.getSlotMenuOptions?.call(this, slot);
}