import { app } from '../../../scripts/app.js'; import { showCustomDrivenToggleMenu, showCustomEasingMenu } from './context_menu.js'; import { initializeDrivenConfig, initializeEasingConfig, toggleDrivenState, prepareDrivenMenu } from './persistence.js'; import { NodeSizeManager } from './node_size_manager.js'; import { PowerSplineHeaderWidget } from './layer_header.js'; import { PowerSplineWidget } from './layer_type_spline.js'; import { HandDrawLayerWidget } from './layer_type_draw.js'; import { BoxLayerWidget } from './layer_type_box.js'; import { TopRowWidget } from './canvas_top_row.js'; import { binarySearch, fitString, measureText, isLowQuality, drawRoundedRectangle, drawWidgetButton, drawTogglePart, drawNumberWidgetPart, RgthreeBaseWidget } from './drawing_utils.js'; // Re-export for convenience export { NodeSizeManager, PowerSplineHeaderWidget, PowerSplineWidget, HandDrawLayerWidget, BoxLayerWidget, TopRowWidget, binarySearch, fitString, measureText, isLowQuality, drawRoundedRectangle, drawWidgetButton, drawTogglePart, drawNumberWidgetPart, RgthreeBaseWidget }; /** * Transform mouse coordinates from canvas space to video/image space * @param {Object} splineEditor - The spline editor instance * @param {number} mouseX - Mouse X coordinate in canvas space * @param {number} mouseY - Mouse Y coordinate in canvas space * @returns {Object} Transformed coordinates in video/image space */ export function transformMouseToVideoSpace(splineEditor, mouseX, mouseY) { // If video is loaded, transform from canvas to video coordinates if (splineEditor.videoMetadata && splineEditor.videoScale !== undefined && splineEditor.videoScale !== null) { const videoScale = splineEditor.videoScale; const videoOffsetX = splineEditor.videoOffsetX || 0; const videoOffsetY = splineEditor.videoOffsetY || 0; // Directly convert from canvas to original media pixel coordinates const origX = (mouseX - videoOffsetX) / videoScale; const origY = (mouseY - videoOffsetY) / videoScale; return { x: origX, y: origY }; } // Fallback to image space transformation if (splineEditor.originalImageWidth && splineEditor.originalImageHeight && splineEditor.scale > 0) { const canvasScale = splineEditor.scale; const offsetX = splineEditor.offsetX || 0; const offsetY = splineEditor.offsetY || 0; // Directly convert from canvas to original media pixel coordinates const origX = (mouseX - offsetX) / canvasScale; const origY = (mouseY - offsetY) / canvasScale; return { x: origX, y: origY }; } // No transformation needed return { x: mouseX, y: mouseY }; } /** * Transform coordinates from video/image space to canvas space * @param {Object} splineEditor - The spline editor instance * @param {number} x - X coordinate in video/image space * @param {number} y - Y coordinate in video/image space * @returns {Object} Transformed coordinates in canvas space */ export function transformVideoToCanvasSpace(splineEditor, x, y) { // If video is loaded, transform from video to canvas coordinates if (splineEditor.videoMetadata && splineEditor.videoScale !== undefined && splineEditor.videoScale !== null) { const videoScale = splineEditor.videoScale; const videoOffsetX = splineEditor.videoOffsetX || 0; const videoOffsetY = splineEditor.videoOffsetY || 0; // Directly convert from original media pixel coordinates to canvas coordinates const canvasX = (x * videoScale) + videoOffsetX; const canvasY = (y * videoScale) + videoOffsetY; return { x: canvasX, y: canvasY }; } // Fallback to image space transformation if (splineEditor.originalImageWidth && splineEditor.originalImageHeight && splineEditor.scale > 0) { const canvasScale = splineEditor.scale; const offsetX = splineEditor.offsetX || 0; const offsetY = splineEditor.offsetY || 0; // Directly convert from original media pixel coordinates to canvas coordinates const canvasX = (x * canvasScale) + offsetX; const canvasY = (y * canvasScale) + offsetY; return { x: canvasX, y: canvasY }; } // No transformation needed return { x, y }; } //from melmass // IMPORTANT: These must match config/constants.py - keep them in sync! export const BOX_BASE_RADIUS = 200; export const POINT_BASE_RADIUS = 8; export const BOX_BORDER_BAND = 20; export function makeUUID() { let dt = new Date().getTime() const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = ((dt + Math.random() * 16) % 16) | 0 dt = Math.floor(dt / 16) return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16) }) return uuid } export const loadScript = ( FILE_URL, async = true, type = 'text/javascript', ) => { return new Promise((resolve, reject) => { try { // Check if the script already exists const existingScript = document.querySelector(`script[src="${FILE_URL}"]`) if (existingScript) { resolve({ status: true, message: 'Script already loaded' }) return } const scriptEle = document.createElement('script') scriptEle.type = type scriptEle.async = async scriptEle.src = FILE_URL scriptEle.addEventListener('load', (ev) => { resolve({ status: true }) }) scriptEle.addEventListener('error', (ev) => { reject({ status: false, message: `Failed to load the script ${FILE_URL}`, }) }) document.body.appendChild(scriptEle) } catch (error) { reject(error) } }) } export const create_documentation_stylesheet = () => { const tag = 'qq-splineditor-stylesheet' let styleTag = document.head.querySelector(tag) if (!styleTag) { styleTag = document.createElement('style') styleTag.type = 'text/css' styleTag.id = tag styleTag.innerHTML = ` .spline-editor { position: relative; font: 12px monospace; line-height: 1.5em; padding: 0px; z-index: 1; overflow: visible; display: block; isolation: isolate; } ` document.head.appendChild(styleTag) } } // === DIMENSIONS WIDGET (Simplified version for backward compatibility) === export class DimensionsWidget extends RgthreeBaseWidget { constructor(name = "DimensionsWidget") { super(name); this.type = "custom"; this.options = { serialize: false }; this.value = {}; this.haveMouseMovedValue = false; this.hitAreas = { widthDec: { bounds: [0, 0] }, widthVal: { bounds: [0, 0] }, widthInc: { bounds: [0, 0] }, widthAny: { bounds: [0, 0] }, heightDec: { bounds: [0, 0] }, heightVal: { bounds: [0, 0] }, heightInc: { bounds: [0, 0] }, heightAny: { bounds: [0, 0] }, }; } draw(ctx, node, w, posY, height) { // This widget is now replaced by TopRowWidget, but kept for backward compatibility // It will not be displayed return; } // Keep the methods for backward compatibility stepWidth(node, step) { const widthWidget = node.widgets?.find(w => w.name === "mask_width"); if (widthWidget) { const newValue = widthWidget.value + step; widthWidget.value = node.sizeManager ? node.sizeManager.constrainCanvasWidth(newValue) : Math.max(64, newValue); if (widthWidget.callback) { widthWidget.callback(widthWidget.value); } node.setDirtyCanvas(true, true); } } stepHeight(node, step) { const heightWidget = node.widgets?.find(w => w.name === "mask_height"); if (heightWidget) { const newValue = heightWidget.value + step; heightWidget.value = node.sizeManager ? node.sizeManager.constrainCanvasHeight(newValue) : Math.max(64, newValue); if (heightWidget.callback) { heightWidget.callback(heightWidget.value); } node.setDirtyCanvas(true, true); } } promptWidth(node) { if (this.haveMouseMovedValue) return; const widthWidget = node.widgets?.find(w => w.name === "mask_width"); if (widthWidget) { const canvas = app.canvas; canvas.prompt("Width", widthWidget.value, (v) => { const newValue = Number(v); widthWidget.value = node.sizeManager ? node.sizeManager.constrainCanvasWidth(newValue) : Math.max(64, newValue); if (widthWidget.callback) { widthWidget.callback(widthWidget.value); } }); } } promptHeight(node) { if (this.haveMouseMovedValue) return; const heightWidget = node.widgets?.find(w => w.name === "mask_height"); if (heightWidget) { const canvas = app.canvas; canvas.prompt("Height", heightWidget.value, (v) => { const newValue = Number(v); heightWidget.value = node.sizeManager ? node.sizeManager.constrainCanvasHeight(newValue) : Math.max(64, newValue); if (heightWidget.callback) { heightWidget.callback(heightWidget.value); } }); } } dragWidth(node, event) { if (event.deltaX) { this.haveMouseMovedValue = true; const widthWidget = node.widgets?.find(w => w.name === "mask_width"); if (widthWidget) { const newValue = widthWidget.value + event.deltaX * 2; widthWidget.value = node.sizeManager ? node.sizeManager.constrainCanvasWidth(newValue) : Math.max(64, newValue); if (widthWidget.callback) { widthWidget.callback(widthWidget.value); } node.setDirtyCanvas(true, true); } } } dragHeight(node, event) { if (event.deltaX) { this.haveMouseMovedValue = true; const heightWidget = node.widgets?.find(w => w.name === "mask_height"); if (heightWidget) { const newValue = heightWidget.value + event.deltaX * 2; heightWidget.value = node.sizeManager ? node.sizeManager.constrainCanvasHeight(newValue) : Math.max(64, newValue); if (heightWidget.callback) { heightWidget.callback(heightWidget.value); } node.setDirtyCanvas(true, true); } } } onMouseUp(event, pos, node) { super.onMouseUp(event, pos, node); this.haveMouseMovedValue = false; } computeSize(width) { return [width, 0]; // Zero height since it's not displayed } } // === INTERPOLATION WIDGET === export class InterpolationWidget extends RgthreeBaseWidget { constructor(name = "InterpolationWidget") { super(name); this.type = "custom"; this.options = { serialize: false }; this.value = {}; this.haveMouseMovedValue = false; this.hitAreas = { interpDec: { bounds: [0, 0] }, interpVal: { bounds: [0, 0] }, interpInc: { bounds: [0, 0] }, interpAny: { bounds: [0, 0] }, }; } draw(ctx, node, w, posY, height) { const margin = 130; const innerMargin = 10; const midY = posY + height * 0.5; ctx.save(); drawRoundedRectangle(ctx, { pos: [margin, posY], size: [node.size[0] - margin * 2, height] }); if (isLowQuality()) { ctx.restore(); return; } ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; ctx.textBaseline = "middle"; const interpWidget = node.widgets?.find(w => w.name === "interpolation"); const interpValue = interpWidget ? interpWidget.value : "linear"; // Interpolation modes - display abbreviated versions const interpModes = ['linear', 'cardinal', 'basis', 'points']; const interpShortNames = ['linear', 'cardinal', 'basis', 'points']; const interpIndex = interpModes.indexOf(interpValue); const interpDisplayText = interpIndex >= 0 ? interpShortNames[interpIndex] : 'linear'; // Label const interpLabel = "interpolation:"; const maxLabelWidth = Math.max( ctx.measureText("width:").width, ctx.measureText("start_pause:").width, ctx.measureText("offset:").width, ctx.measureText(interpLabel).width ); // Calculate interpolation text width const interpTextWidth = Math.max( ctx.measureText("cardinal").width, ctx.measureText("linear").width, ctx.measureText("basis").width, ctx.measureText("points").width ) + 20; // Add padding const arrowWidth = 9; const innerMargin2 = 3; const totalWidth = maxLabelWidth + innerMargin + arrowWidth + innerMargin2 + interpTextWidth + innerMargin2 + arrowWidth; const startX = (node.size[0] - totalWidth) / 2; let posX = startX; // Draw "interpolation:" label (right-aligned) ctx.textAlign = "right"; ctx.fillText(interpLabel, posX + maxLabelWidth, midY); posX += maxLabelWidth + innerMargin; // Left arrow bounds this.hitAreas.interpDec.bounds = [posX, arrowWidth]; ctx.textAlign = "center"; ctx.fillText("?", posX + arrowWidth / 2, midY); posX += arrowWidth + innerMargin2; // Text bounds this.hitAreas.interpVal.bounds = [posX, interpTextWidth]; ctx.fillText(interpDisplayText, posX + interpTextWidth / 2, midY); posX += interpTextWidth + innerMargin2; // Right arrow bounds this.hitAreas.interpInc.bounds = [posX, arrowWidth]; ctx.fillText("?", posX + arrowWidth / 2, midY); // Combined bounds for all interp controls this.hitAreas.interpAny.bounds = [this.hitAreas.interpDec.bounds[0], arrowWidth + innerMargin2 + interpTextWidth + innerMargin2 + arrowWidth]; this.hitAreas.interpDec.onClick = () => this.stepInterp(node, -1); this.hitAreas.interpInc.onClick = () => this.stepInterp(node, 1); this.hitAreas.interpVal.onClick = () => this.cycleInterp(node); ctx.restore(); } stepInterp(node, step) { const widget = node.widgets?.find(w => w.name === "interpolation"); if (widget) { const modes = ['linear', 'cardinal', 'basis', 'points']; const currentIndex = modes.indexOf(widget.value); const newIndex = (currentIndex + step + modes.length) % modes.length; widget.value = modes[newIndex]; if (widget.callback) widget.callback(widget.value); node.setDirtyCanvas(true, true); } } cycleInterp(node) { // Clicking the text cycles through modes this.stepInterp(node, 1); } computeSize(width) { return [width, LiteGraph.NODE_WIDGET_HEIGHT]; } } // === DRIVER ROTATION & D_SCALE WIDGET (Combined driver_rotation and driver_d_scale) === export class DriverRotationDScaleWidget extends RgthreeBaseWidget { constructor(name = "DriverRotationDScaleWidget") { super(name); this.type = "custom"; this.options = { serialize: false }; this.value = {}; this.haveMouseMovedValue = false; this.hitAreas = { rotationDec: { bounds: [0, 0] }, rotationVal: { bounds: [0, 0] }, rotationInc: { bounds: [0, 0] }, rotationAny: { bounds: [0, 0] }, dScaleDec: { bounds: [0, 0] }, dScaleVal: { bounds: [0, 0] }, dScaleInc: { bounds: [0, 0] }, dScaleAny: { bounds: [0, 0] }, }; } draw(ctx, node, w, posY, height) { const margin = 40; const innerMargin = 10; const spacingBetweenGroups = 20; const midY = posY + height * 0.5; ctx.save(); drawRoundedRectangle(ctx, { pos: [margin, posY], size: [node.size[0] - margin * 2, height] }); if (isLowQuality()) { ctx.restore(); return; } ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; ctx.textBaseline = "middle"; const rotationWidget = node.widgets?.find(w => w.name === "driver_rotation"); const rotationValue = rotationWidget ? rotationWidget.value : 0; const dScaleWidget = node.widgets?.find(w => w.name === "driver_d_scale"); const dScaleValue = dScaleWidget ? dScaleWidget.value : 1.0; // Labels const rotationLabel = "driver_rotation:"; const dScaleLabel = "driver_d_scale:"; const maxLeftLabelWidth = Math.max( ctx.measureText("width:").width, ctx.measureText("driver_rotation:").width ); const maxRightLabelWidth = Math.max( ctx.measureText("height:").width, ctx.measureText("driver_d_scale:").width ); // Total width: left label + controls + spacing + right label + controls const totalWidth = maxLeftLabelWidth + innerMargin + drawNumberWidgetPart.WIDTH_TOTAL + spacingBetweenGroups + maxRightLabelWidth + innerMargin + drawNumberWidgetPart.WIDTH_TOTAL; const startX = (node.size[0] - totalWidth) / 2; let posX = startX; // Draw "driver_rotation:" label (right-aligned) ctx.textAlign = "right"; ctx.fillText(rotationLabel, posX + maxLeftLabelWidth, midY); posX += maxLeftLabelWidth + innerMargin; // Draw rotation control (with ? suffix) const [rLeftArrow, rText, rRightArrow] = drawNumberWidgetPart(ctx, { posX: posX, posY, height, value: rotationValue, direction: 1, suffix: "?" }); this.hitAreas.rotationDec.bounds = rLeftArrow; this.hitAreas.rotationVal.bounds = rText; this.hitAreas.rotationInc.bounds = rRightArrow; this.hitAreas.rotationAny.bounds = [rLeftArrow[0], rRightArrow[0] + rRightArrow[1] - rLeftArrow[0]]; this.hitAreas.rotationDec.onClick = () => this.stepRotation(node, -15); this.hitAreas.rotationInc.onClick = () => this.stepRotation(node, 15); this.hitAreas.rotationVal.onClick = () => this.promptRotation(node); this.hitAreas.rotationAny.onMove = (event) => this.dragRotation(node, event); posX += drawNumberWidgetPart.WIDTH_TOTAL + spacingBetweenGroups; // Draw "driver_d_scale:" label (right-aligned) ctx.textAlign = "right"; ctx.fillText(dScaleLabel, posX + maxRightLabelWidth, midY); posX += maxRightLabelWidth + innerMargin; // Draw d_scale control const [sLeftArrow, sText, sRightArrow] = drawNumberWidgetPart(ctx, { posX: posX, posY, height, value: dScaleValue, direction: 1, precision: 2 }); this.hitAreas.dScaleDec.bounds = sLeftArrow; this.hitAreas.dScaleVal.bounds = sText; this.hitAreas.dScaleInc.bounds = sRightArrow; this.hitAreas.dScaleAny.bounds = [sLeftArrow[0], sRightArrow[0] + sRightArrow[1] - sLeftArrow[0]]; this.hitAreas.dScaleDec.onClick = () => this.stepDScale(node, -0.01); this.hitAreas.dScaleInc.onClick = () => this.stepDScale(node, 0.01); this.hitAreas.dScaleVal.onClick = () => this.promptDScale(node); this.hitAreas.dScaleAny.onMove = (event) => this.dragDScale(node, event); ctx.restore(); } stepRotation(node, step) { const widget = node.widgets?.find(w => w.name === "driver_rotation"); if (widget) { widget.value = Math.max(-360, Math.min(360, widget.value + step)); if (widget.callback) widget.callback(widget.value); node.setDirtyCanvas(true, true); } } promptRotation(node) { if (this.haveMouseMovedValue) return; const widget = node.widgets?.find(w => w.name === "driver_rotation"); if (widget) { app.canvas.prompt("driver_rotation", widget.value, (v) => { widget.value = Math.max(-360, Math.min(360, Number(v))); if (widget.callback) widget.callback(widget.value); }); } } dragRotation(node, event) { if (event.deltaX) { this.haveMouseMovedValue = true; const widget = node.widgets?.find(w => w.name === "driver_rotation"); if (widget) { widget.value = Math.max(-360, Math.min(360, widget.value + event.deltaX)); if (widget.callback) widget.callback(widget.value); node.setDirtyCanvas(true, true); } } } stepDScale(node, step) { const widget = node.widgets?.find(w => w.name === "driver_d_scale"); if (widget) { widget.value = Math.max(0.0, Math.min(1.0, widget.value + step)); if (widget.callback) widget.callback(widget.value); node.setDirtyCanvas(true, true); } } promptDScale(node) { if (this.haveMouseMovedValue) return; const widget = node.widgets?.find(w => w.name === "driver_d_scale"); if (widget) { app.canvas.prompt("driver_d_scale", widget.value, (v) => { widget.value = Math.max(0.0, Math.min(1.0, Number(v))); if (widget.callback) widget.callback(widget.value); }); } } dragDScale(node, event) { if (event.deltaX) { this.haveMouseMovedValue = true; const widget = node.widgets?.find(w => w.name === "driver_d_scale"); if (widget) { widget.value = Math.max(0.0, Math.min(1.0, widget.value + event.deltaX * 0.01)); // Scale for float precision if (widget.callback) widget.callback(widget.value); node.setDirtyCanvas(true, true); } } } onMouseUp(event, pos, node) { super.onMouseUp(event, pos, node); this.haveMouseMovedValue = false; } computeSize(width) { return [width, LiteGraph.NODE_WIDGET_HEIGHT]; } } // === PAUSE WIDGET (Combined start_pause and end_pause) === export class PauseWidget extends RgthreeBaseWidget { constructor(name = "PauseWidget") { super(name); this.type = "custom"; this.options = { serialize: false }; this.value = {}; this.haveMouseMovedValue = false; this.hitAreas = { startPauseDec: { bounds: [0, 0] }, startPauseVal: { bounds: [0, 0] }, startPauseInc: { bounds: [0, 0] }, startPauseAny: { bounds: [0, 0] }, endPauseDec: { bounds: [0, 0] }, endPauseVal: { bounds: [0, 0] }, endPauseInc: { bounds: [0, 0] }, endPauseAny: { bounds: [0, 0] }, }; } draw(ctx, node, w, posY, height) { const margin = 40; const innerMargin = 10; const spacingBetweenGroups = 20; // Space between start_pause and end_pause const midY = posY + height * 0.5; ctx.save(); drawRoundedRectangle(ctx, { pos: [margin, posY], size: [node.size[0] - margin * 2, height] }); if (isLowQuality()) { ctx.restore(); return; } ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; ctx.textBaseline = "middle"; const startPauseWidget = node.widgets?.find(w => w.name === "start_pause"); const startPauseValue = startPauseWidget ? startPauseWidget.value : 0; const endPauseWidget = node.widgets?.find(w => w.name === "end_pause"); const endPauseValue = endPauseWidget ? endPauseWidget.value : 0; // Labels const startLabel = "start_pause:"; const endLabel = "end_pause:"; const maxLeftLabelWidth = Math.max( ctx.measureText("width:").width, ctx.measureText("start_pause:").width, ctx.measureText("offset:").width, ctx.measureText("interpolation:").width ); const maxRightLabelWidth = Math.max( ctx.measureText("height:").width, ctx.measureText("end_pause:").width, ctx.measureText("repeat:").width ); // Total width: left label + controls + spacing + right label + controls const totalWidth = maxLeftLabelWidth + innerMargin + drawNumberWidgetPart.WIDTH_TOTAL + spacingBetweenGroups + maxRightLabelWidth + innerMargin + drawNumberWidgetPart.WIDTH_TOTAL; const startX = (node.size[0] - totalWidth) / 2; let posX = startX; // Draw "start_pause:" label (right-aligned) ctx.textAlign = "right"; ctx.fillText(startLabel, posX + maxLeftLabelWidth, midY); posX += maxLeftLabelWidth + innerMargin; // Draw start_pause control const [spLeftArrow, spText, spRightArrow] = drawNumberWidgetPart(ctx, { posX: posX, posY, height, value: startPauseValue, direction: 1, }); this.hitAreas.startPauseDec.bounds = spLeftArrow; this.hitAreas.startPauseVal.bounds = spText; this.hitAreas.startPauseInc.bounds = spRightArrow; this.hitAreas.startPauseAny.bounds = [spLeftArrow[0], spRightArrow[0] + spRightArrow[1] - spLeftArrow[0]]; this.hitAreas.startPauseDec.onClick = () => this.stepStartPause(node, -1); this.hitAreas.startPauseInc.onClick = () => this.stepStartPause(node, 1); this.hitAreas.startPauseVal.onClick = () => this.promptStartPause(node); this.hitAreas.startPauseAny.onMove = (event) => this.dragStartPause(node, event); posX += drawNumberWidgetPart.WIDTH_TOTAL + spacingBetweenGroups; // Draw "end_pause:" label (right-aligned) ctx.textAlign = "right"; ctx.fillText(endLabel, posX + maxRightLabelWidth, midY); posX += maxRightLabelWidth + innerMargin; // Draw end_pause control const [epLeftArrow, epText, epRightArrow] = drawNumberWidgetPart(ctx, { posX: posX, posY, height, value: endPauseValue, direction: 1, }); this.hitAreas.endPauseDec.bounds = epLeftArrow; this.hitAreas.endPauseVal.bounds = epText; this.hitAreas.endPauseInc.bounds = epRightArrow; this.hitAreas.endPauseAny.bounds = [epLeftArrow[0], epRightArrow[0] + epRightArrow[1] - epLeftArrow[0]]; this.hitAreas.endPauseDec.onClick = () => this.stepEndPause(node, -1); this.hitAreas.endPauseInc.onClick = () => this.stepEndPause(node, 1); this.hitAreas.endPauseVal.onClick = () => this.promptEndPause(node); this.hitAreas.endPauseAny.onMove = (event) => this.dragEndPause(node, event); ctx.restore(); } stepStartPause(node, step) { const widget = node.widgets?.find(w => w.name === "start_pause"); if (widget) { widget.value = Math.max(0, widget.value + step); if (widget.callback) widget.callback(widget.value); node.setDirtyCanvas(true, true); } } promptStartPause(node) { if (this.haveMouseMovedValue) return; const widget = node.widgets?.find(w => w.name === "start_pause"); if (widget) { app.canvas.prompt("start_pause", widget.value, (v) => { widget.value = Math.max(0, Number(v)); if (widget.callback) widget.callback(widget.value); }); } } dragStartPause(node, event) { if (event.deltaX) { this.haveMouseMovedValue = true; const widget = node.widgets?.find(w => w.name === "start_pause"); if (widget) { widget.value = Math.max(0, widget.value + event.deltaX); if (widget.callback) widget.callback(widget.value); node.setDirtyCanvas(true, true); } } } stepEndPause(node, step) { const widget = node.widgets?.find(w => w.name === "end_pause"); if (widget) { widget.value = Math.max(0, widget.value + step); if (widget.callback) widget.callback(widget.value); node.setDirtyCanvas(true, true); } } promptEndPause(node) { if (this.haveMouseMovedValue) return; const widget = node.widgets?.find(w => w.name === "end_pause"); if (widget) { app.canvas.prompt("end_pause", widget.value, (v) => { widget.value = Math.max(0, Number(v)); if (widget.callback) widget.callback(widget.value); }); } } dragEndPause(node, event) { if (event.deltaX) { this.haveMouseMovedValue = true; const widget = node.widgets?.find(w => w.name === "end_pause"); if (widget) { widget.value = Math.max(0, widget.value + event.deltaX); if (widget.callback) widget.callback(widget.value); node.setDirtyCanvas(true, true); } } } onMouseUp(event, pos, node) { super.onMouseUp(event, pos, node); this.haveMouseMovedValue = false; } computeSize(width) { return [width, LiteGraph.NODE_WIDGET_HEIGHT]; } } // === OFFSET REPEAT WIDGET (Combined offset and repeat) === export class OffsetRepeatWidget extends RgthreeBaseWidget { constructor(name = "OffsetRepeatWidget") { super(name); this.type = "custom"; this.options = { serialize: false }; this.value = {}; this.haveMouseMovedValue = false; this.hitAreas = { offsetDec: { bounds: [0, 0] }, offsetVal: { bounds: [0, 0] }, offsetInc: { bounds: [0, 0] }, offsetAny: { bounds: [0, 0] }, repeatDec: { bounds: [0, 0] }, repeatVal: { bounds: [0, 0] }, repeatInc: { bounds: [0, 0] }, repeatAny: { bounds: [0, 0] }, }; } draw(ctx, node, w, posY, height) { const margin = 40; const innerMargin = 10; const spacingBetweenGroups = 20; // Space between offset and repeat const midY = posY + height * 0.5; ctx.save(); drawRoundedRectangle(ctx, { pos: [margin, posY], size: [node.size[0] - margin * 2, height] }); if (isLowQuality()) { ctx.restore(); return; } ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; ctx.textBaseline = "middle"; const offsetWidget = node.widgets?.find(w => w.name === "offset"); const offsetValue = offsetWidget ? offsetWidget.value : 0; const repeatWidget = node.widgets?.find(w => w.name === "repeat"); const repeatValue = repeatWidget ? repeatWidget.value : 1; // Labels const offsetLabel = "offset:"; const repeatLabel = "repeat:"; const maxLeftLabelWidth = Math.max( ctx.measureText("width:").width, ctx.measureText("start_pause:").width, ctx.measureText("offset:").width, ctx.measureText("interpolation:").width ); const maxRightLabelWidth = Math.max( ctx.measureText("height:").width, ctx.measureText("end_pause:").width, ctx.measureText("repeat:").width ); // Total width: left label + controls + spacing + right label + controls const totalWidth = maxLeftLabelWidth + innerMargin + drawNumberWidgetPart.WIDTH_TOTAL + spacingBetweenGroups + maxRightLabelWidth + innerMargin + drawNumberWidgetPart.WIDTH_TOTAL; const startX = (node.size[0] - totalWidth) / 2; let posX = startX; // Draw "offset:" label (right-aligned) ctx.textAlign = "right"; ctx.fillText(offsetLabel, posX + maxLeftLabelWidth, midY); posX += maxLeftLabelWidth + innerMargin; // Draw offset control const [oLeftArrow, oText, oRightArrow] = drawNumberWidgetPart(ctx, { posX: posX, posY, height, value: offsetValue, direction: 1, }); this.hitAreas.offsetDec.bounds = oLeftArrow; this.hitAreas.offsetVal.bounds = oText; this.hitAreas.offsetInc.bounds = oRightArrow; this.hitAreas.offsetAny.bounds = [oLeftArrow[0], oRightArrow[0] + oRightArrow[1] - oLeftArrow[0]]; this.hitAreas.offsetDec.onClick = () => this.stepOffset(node, -1); this.hitAreas.offsetInc.onClick = () => this.stepOffset(node, 1); this.hitAreas.offsetVal.onClick = () => this.promptOffset(node); this.hitAreas.offsetAny.onMove = (event) => this.dragOffset(node, event); posX += drawNumberWidgetPart.WIDTH_TOTAL + spacingBetweenGroups; // Draw "repeat:" label (right-aligned) ctx.textAlign = "right"; ctx.fillText(repeatLabel, posX + maxRightLabelWidth, midY); posX += maxRightLabelWidth + innerMargin; // Draw repeat control const [rLeftArrow, rText, rRightArrow] = drawNumberWidgetPart(ctx, { posX: posX, posY, height, value: repeatValue, direction: 1, }); this.hitAreas.repeatDec.bounds = rLeftArrow; this.hitAreas.repeatVal.bounds = rText; this.hitAreas.repeatInc.bounds = rRightArrow; this.hitAreas.repeatAny.bounds = [rLeftArrow[0], rRightArrow[0] + rRightArrow[1] - rLeftArrow[0]]; this.hitAreas.repeatDec.onClick = () => this.stepRepeat(node, -1); this.hitAreas.repeatInc.onClick = () => this.stepRepeat(node, 1); this.hitAreas.repeatVal.onClick = () => this.promptRepeat(node); this.hitAreas.repeatAny.onMove = (event) => this.dragRepeat(node, event); ctx.restore(); } stepOffset(node, step) { const widget = node.widgets?.find(w => w.name === "offset"); if (widget) { widget.value = Math.max(-100, Math.min(100, widget.value + step)); if (widget.callback) widget.callback(widget.value); node.setDirtyCanvas(true, true); } } promptOffset(node) { if (this.haveMouseMovedValue) return; const widget = node.widgets?.find(w => w.name === "offset"); if (widget) { app.canvas.prompt("offset", widget.value, (v) => { widget.value = Math.max(-100, Math.min(100, Number(v))); if (widget.callback) widget.callback(widget.value); }); } } dragOffset(node, event) { if (event.deltaX) { this.haveMouseMovedValue = true; const widget = node.widgets?.find(w => w.name === "offset"); if (widget) { widget.value = Math.max(-100, Math.min(100, widget.value + event.deltaX)); if (widget.callback) widget.callback(widget.value); node.setDirtyCanvas(true, true); } } } stepRepeat(node, step) { const widget = node.widgets?.find(w => w.name === "repeat"); if (widget) { widget.value = Math.max(1, Math.min(20, widget.value + step)); if (widget.callback) widget.callback(widget.value); node.setDirtyCanvas(true, true); } } promptRepeat(node) { if (this.haveMouseMovedValue) return; const widget = node.widgets?.find(w => w.name === "repeat"); if (widget) { app.canvas.prompt("repeat", widget.value, (v) => { widget.value = Math.max(1, Math.min(20, Number(v))); if (widget.callback) widget.callback(widget.value); }); } } dragRepeat(node, event) { if (event.deltaX) { this.haveMouseMovedValue = true; const widget = node.widgets?.find(w => w.name === "repeat"); if (widget) { widget.value = Math.max(1, Math.min(20, widget.value + Math.round(event.deltaX / 10))); if (widget.callback) widget.callback(widget.value); node.setDirtyCanvas(true, true); } } } onMouseUp(event, pos, node) { super.onMouseUp(event, pos, node); this.haveMouseMovedValue = false; } computeSize(width) { return [width, LiteGraph.NODE_WIDGET_HEIGHT]; } }