power spline fix + anim

This commit is contained in:
JJ
2025-11-07 17:37:09 +00:00
parent b61c00a516
commit 8dd907d63c
7 changed files with 540 additions and 128 deletions
-16
View File
@@ -250,13 +250,6 @@ async function extractImageFromSourceNode(sourceNodeObj) {
}
const sourceNode = sourceNodeObj.node;
console.log(`Attempting to extract image from source node:`, {
id: sourceNode.id,
type: sourceNode.type,
widgets: sourceNode.widgets ? sourceNode.widgets.map(w => ({name: w.name, type: w.type})) : [],
properties: sourceNode.properties
});
try {
// Different node types store image data differently
switch (sourceNode.type) {
@@ -403,15 +396,6 @@ async function extractImageFromSourceNode(sourceNodeObj) {
}
}
// Debug: log all available information about the source node
console.log(`Debug: No image found in source node ${sourceNode.type}. Available data:`, {
id: sourceNode.id,
type: sourceNode.type,
widgets: sourceNode.widgets?.map(w => ({name: w.name, type: w.type, value: typeof w.value === 'object' ? JSON.stringify(w.value) : w.value})),
properties: sourceNode.properties,
outputs: sourceNode.outputs
});
return null;
} catch (error) {
console.error(`Error extracting image from source node ${sourceNode.type}:`, error);
+77 -13
View File
@@ -13,7 +13,7 @@ export class HandDrawLayerWidget extends RgthreeBaseWidget {
this.hitAreas = {
toggle: { bounds: [0,0], onDown: this.onToggleDown },
name: { bounds: [0,0] },
drawLabel: { bounds: [0,0] },
drawLabel: { bounds: [0,0], onClick: this.onEditToggle },
repeatDec: { bounds: [0,0], onClick: this.onRepeatDec },
repeatVal: { bounds: [0,0], onClick: this.onRepeatClick },
repeatInc: { bounds: [0,0], onClick: this.onRepeatInc },
@@ -82,6 +82,11 @@ export class HandDrawLayerWidget extends RgthreeBaseWidget {
this.hitAreas.toggle.bounds = drawTogglePart(ctx, { posX, posY, height, value: this.value.on });
posX += this.hitAreas.toggle.bounds[1] + innerMargin;
// Match normal layers: dim contents when layer is off
if (!this.value.on) {
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
}
// Name
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = 'left';
@@ -101,11 +106,33 @@ export class HandDrawLayerWidget extends RgthreeBaseWidget {
this.hitAreas.repeatAny.bounds = [repeatL[0], repeatR[0] + repeatR[1] - repeatL[0]];
rposX -= drawNumberWidgetPart.WIDTH_TOTAL + 10;
// Draw label in the Interpolation slot (70px)
// Replace the "draw" text with a centered edit icon button occupying the 70px slot
const labelWidth = 70;
// Armed only when editor is in per-layer edit mode for this widget
const armed = (node?.editor?._handdrawMode === 'edit') && (node?.editor?._handdrawEditWidget === this);
const slotX = rposX - labelWidth; // left edge of 70px slot
const slotY = posY + (height - (height * 0.8)) / 2; // slight vertical inset
const slotH = height * 0.8;
ctx.save();
ctx.fillStyle = armed ? '#0d3b4a' : LiteGraph.WIDGET_BGCOLOR;
ctx.strokeStyle = armed ? '#2cc6ff' : LiteGraph.WIDGET_OUTLINE_COLOR;
ctx.lineWidth = armed ? 2 : 1;
ctx.beginPath();
ctx.roundRect(slotX, slotY, labelWidth, slotH, [6]);
ctx.fill();
ctx.stroke();
// Pencil glyph centered in slot
ctx.textAlign = 'center';
ctx.fillText('draw', rposX - labelWidth / 2, midY);
this.hitAreas.drawLabel.bounds = [rposX - labelWidth, labelWidth];
ctx.textBaseline = 'middle';
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.font = `${Math.max(11, Math.min(16, slotH * 0.7))}px monospace`;
ctx.fillText('✏️', slotX + labelWidth / 2, slotY + slotH / 2 + 0.5);
ctx.restore();
// Entire 70px area is clickable to toggle edit mode
this.hitAreas.drawLabel.bounds = [slotX, labelWidth];
// Advance position accounting only for the slot width
rposX -= labelWidth + 10;
// Z-Pause
@@ -170,15 +197,45 @@ export class HandDrawLayerWidget extends RgthreeBaseWidget {
ctx.textAlign = 'left';
const nameText = fitString(ctx, this.value.name || 'Handdraw', rposX - posX - innerMargin);
ctx.fillText(nameText, posX, midY);
this.hitAreas.name.bounds = [posX, 0, rposX - posX, height];
// Make name hit area wide and simple (x + width only) to avoid y-mismatch
this.hitAreas.name.bounds = [posX, rposX - posX];
// Activate layer when clicking the name area
this.hitAreas.name.onDown = (event, pos, node) => {
if (!node.layerManager) return false;
const current = node.layerManager.getActiveWidget?.();
if (current === this) {
node.layerManager.setActiveWidget(null);
} else {
node.layerManager.setActiveWidget(this);
}
return true;
};
this.hitAreas.name.onClick = (event, pos, node) => true; // handled onDown
ctx.restore();
}
onMouseDown(event, pos, node) {
// Selecting the row should activate this layer
onEditToggle(event, pos, node) {
// Ensure this layer is active
if (node.layerManager) {
node.layerManager.setActiveWidget(this);
}
// Toggle handdraw mode on the editor, mirroring top-row pencil
if (node?.editor) {
if (node.editor._handdrawMode === 'edit' && node.editor._handdrawEditWidget === this) {
node.editor.exitHanddrawMode?.(false);
} else {
node.editor.enterHanddrawMode?.('edit', this);
}
node.setDirtyCanvas(true, true);
// Re-render canvas overlays
try { node.editor.layerRenderer?.render(); } catch {}
}
this.cancelMouseDown?.();
return true;
}
onMouseDown(event, pos, node) {
// Activation is handled via hitAreas.name.onDown; do not duplicate here
return super.onMouseDown?.(event, pos, node);
}
@@ -294,20 +351,27 @@ export function commitHanddrawPath(node, points) {
const lm = node.layerManager;
if (!lm) return;
const active = lm.getActiveWidget?.();
const mode = node?.editor?._handdrawMode || 'off';
const editWidget = node?.editor?._handdrawEditWidget || null;
const isHand = active && active.value?.type === 'handdraw';
const pointsStore = JSON.stringify(points || []);
if (isHand) {
active.value.points_store = pointsStore;
if (mode === 'edit' && editWidget) {
// Replace points in the targeted handdraw layer
editWidget.value.points_store = pointsStore;
// Avoid disarming edit mode due to active-layer refresh
if (node?.editor) node.editor._suppressHanddrawExitOnce = true;
lm.setActiveWidget(editWidget);
node.setDirtyCanvas(true, true);
if (node.editor?.layerRenderer) node.editor.layerRenderer.render();
if (node.editor?.updatePath) node.editor.updatePath();
return active;
return editWidget;
}
// create new handdraw layer
// In 'create' mode, or fallback: create a new handdraw layer for each stroke
if (lm.addNewHanddraw) {
const w = lm.addNewHanddraw('Handdraw');
const w = lm.addNewHanddraw('Handdraw', /*activate*/ false);
w.value.points_store = pointsStore;
// Make the new handdraw layer active
// Suppress one-shot exit of create mode during programmatic switch
if (node?.editor) node.editor._suppressHanddrawExitOnce = true;
lm.setActiveWidget(w);
node.setDirtyCanvas(true, true);
if (node.editor?.layerRenderer) node.editor.layerRenderer.render();
+236 -9
View File
@@ -18,27 +18,228 @@ export class LayerRenderer {
// Store metadata for inactive layers to enable hit detection
// Format: [{ widget: widgetRef, points: [...], panel: panelRef }, ...]
this.inactiveLayerMetadata = [];
// Flow animation for inactive layers (non-"points" types)
this._dashAnimOffset = 0;
this._lastDashUpdateMs = 0;
this._dashTimer = setInterval(() => {
this._dashAnimOffset = (this._dashAnimOffset + 2) % 1000;
try { this.updateInactiveDash(); } catch {}
}, 80);
}
// Apply/update marching-dash on inactive paths via DOM attributes
updateInactiveDash() {
if (!this.splineEditor) return;
const svg = this.vis && this.vis.canvas ? this.vis.canvas() : null;
if (!svg) return;
// If disabled, strip dash styling and stop
if (this.splineEditor._inactiveFlowEnabled === false) {
const allPaths = svg.getElementsByTagName('path');
for (const p of allPaths) {
if (p.hasAttribute('stroke-dasharray')) p.removeAttribute('stroke-dasharray');
if (p.hasAttribute('stroke-dashoffset')) p.removeAttribute('stroke-dashoffset');
if (p.dataset && 'dashOffset' in p.dataset) delete p.dataset.dashOffset;
}
return;
}
const paths = svg.getElementsByTagName('path');
// If any target path lacks a dasharray, force an immediate apply (skip throttle)
let needsInitialApply = false;
for (const el of paths) {
if (el.getAttribute) {
const stroke = (el.getAttribute('stroke') || '').toLowerCase();
const sw = Number(el.getAttribute('stroke-width') || '0');
if ((stroke.includes('120,70,180') || stroke.includes('#7846b4') || stroke.includes('255,127,14') || stroke.includes('#ff7f0e')) && sw > 1 && !el.hasAttribute('stroke-dasharray')) {
needsInitialApply = true; break;
}
}
}
// Throttle dash updates to reduce load during active drawing
const now = Date.now();
const minInterval = this.splineEditor._handdrawActive ? 120 : 60; // slower while drawing
if (!needsInitialApply && (now - this._lastDashUpdateMs < minInterval)) return;
this._lastDashUpdateMs = now;
const norm = (s) => (s || '').replace(/\s+/g, '').toLowerCase();
const isInactiveTarget = (p) => {
const stroke = norm(p.getAttribute('stroke'));
const blue = '#1f77b4'; // active normal
const yellow = '#d7c400'; // active handdraw
if (stroke === blue || stroke === '#1f77b4' || stroke === yellow || stroke === '#d7c400') return false;
// Orange inactive (rgba or rgb) or purple inactive
const isOrange = stroke.includes('255,127,14') || stroke.includes('#ff7f0e') || stroke.includes('rgba(255,127,14') || stroke.includes('rgb(255,127,14)');
const isPurple = stroke.includes('120,70,180') || stroke.includes('#7846b4') || stroke.includes('rgb(120,70,180)');
if (!(isOrange || isPurple)) return false;
// Exclude thin 1px orange (points type) by stroke-width
const sw = Number(p.getAttribute('stroke-width') || '0');
if (isOrange && sw <= 1.01) return false;
return true;
};
// Helper easing functions (return 0..1 shape factor)
const easeValue = (t, mode) => {
t = Math.max(0, Math.min(1, t));
switch (mode) {
case 'in': return t; // grow along path
case 'out': return 1 - t; // shrink along path
case 'in_out': return Math.sin(Math.PI * t); // squeezed at ends, widest in middle
case 'out_in': return 1 - Math.sin(Math.PI * t); // widest at ends, squeezed middle
case 'linear':
default: return 1; // constant strip size
}
};
// Split candidates by color to preserve draw order mapping
const phase = ((this._dashAnimOffset % 200) / 200);
const purplePaths = [];
const orangePaths = [];
for (const p of paths) {
if (!isInactiveTarget(p)) continue;
const stroke = norm(p.getAttribute('stroke'));
const isPurple = stroke.includes('120,70,180') || stroke.includes('#7846b4') || stroke.includes('rgb(120,70,180)');
const list = isPurple ? purplePaths : orangePaths;
const len = (typeof p.getTotalLength === 'function') ? p.getTotalLength() : 0;
list.push({ p, len });
}
const purpleMetas = (this.inactiveLayerMetadata || []).filter(d => d?.widget?.value?.type === 'handdraw');
const orangeMetas = (this.inactiveLayerMetadata || []).filter(d => (d?.widget?.value?.type !== 'handdraw') && ((d?.widget?.value?.interpolation || 'linear') !== 'points'));
const applyFor = (pairs, metas) => {
const count = Math.min(pairs.length, metas.length);
for (let idx = 0; idx < count; idx++) {
const { p, len: pathLength } = pairs[idx];
const easingMode = metas[idx]?.widget?.value?.easing || 'linear';
const segments = Math.max(14, Math.min(100, Math.round(pathLength / 36)));
const baseDash = 10;
const baseGap = 6;
const minFactor = 0.25;
const maxFactor = 1.6;
const pattern = [];
let sum = 0;
for (let i = 0; i < segments; i++) {
const t = (i / segments + phase) % 1;
const f = easeValue(t, easingMode);
const dash = Math.max(2, baseDash * (minFactor + (maxFactor - minFactor) * f));
const gap = Math.max(2, baseGap * (1.0 - 0.2 * f));
pattern.push(dash, gap);
sum += dash + gap;
}
if (sum > 0 && pathLength > 0) {
const scale = pathLength / sum;
for (let k = 0; k < pattern.length; k++) pattern[k] = Math.max(1, pattern[k] * scale);
}
p.setAttribute('stroke-dasharray', pattern.join(' '));
const dashOffsetPx = -phase * (pathLength || 1);
p.setAttribute('stroke-dashoffset', String(dashOffsetPx));
}
};
applyFor(purplePaths, purpleMetas);
applyFor(orangePaths, orangeMetas);
}
// Apply/update easing-shaped dash for ACTIVE handdraw layer (yellow line)
updateActiveHanddrawDash() {
if (!this.splineEditor || this.splineEditor._inactiveFlowEnabled === false) return;
const now = Date.now();
const minInterval = this.splineEditor._handdrawActive ? 120 : 60;
if ((this._lastDashUpdateMs || 0) && (now - this._lastDashUpdateMs < minInterval)) return;
this._lastDashUpdateMs = now;
const svg = this.vis && this.vis.canvas ? this.vis.canvas() : null;
if (!svg) return;
const active = this.node?.layerManager?.getActiveWidget?.();
if (!active || active.value?.type !== 'handdraw') return;
const easingMode = active.value?.easing || 'linear';
const paths = svg.getElementsByTagName('path');
const norm = (s) => (s || '').replace(/\s+/g, '').toLowerCase();
const yellow = '#d7c400';
// Easing factor
const easeValue = (t, mode) => {
t = Math.max(0, Math.min(1, t));
switch (mode) {
case 'in': return t;
case 'out': return 1 - t;
case 'in_out': return Math.sin(Math.PI * t);
case 'out_in': return 1 - Math.sin(Math.PI * t);
case 'linear':
default: return 1;
}
};
const phase = ((this._dashAnimOffset % 200) / 200);
for (const p of paths) {
const stroke = norm(p.getAttribute('stroke'));
if (stroke !== yellow && stroke !== '#d7c400') continue;
const pathLength = (typeof p.getTotalLength === 'function') ? p.getTotalLength() : 0;
const segments = Math.max(14, Math.min(100, Math.round(pathLength / 36)));
const baseDash = 10;
const baseGap = 6;
const minFactor = 0.35;
const maxFactor = 1.45;
const pattern = [];
let sum = 0;
for (let i = 0; i < segments; i++) {
const t = (i / segments + phase) % 1;
const f = easeValue(t, easingMode);
const dash = Math.max(2, baseDash * (minFactor + (maxFactor - minFactor) * f));
const gap = baseGap;
pattern.push(dash, gap);
sum += dash + gap;
}
if (sum > 0 && pathLength > 0) {
const scale = pathLength / sum;
for (let k = 0; k < pattern.length; k++) pattern[k] = Math.max(1, pattern[k] * scale);
}
p.setAttribute('stroke-dasharray', pattern.join(' '));
const dashOffsetPx = -phase * (pathLength || 1);
p.setAttribute('stroke-dashoffset', String(dashOffsetPx));
}
}
/**
* Main render method. Clears previous drawings and redraws all layers.
*/
render() {
// During drag operations, avoid rebuilding the scene graph.
// Recreating marks while Protovis drag behavior is active breaks internal state
// and causes errors like reading undefined 'x'/'fix'. Just re-render in place.
const forceRebuild = !!this.splineEditor._forceRebuildNextRender;
if (forceRebuild) {
this.splineEditor._forceRebuildNextRender = false;
}
if (!forceRebuild && (this.splineEditor.isDragging || this.splineEditor.isDraggingAll || this.splineEditor.isScalingAll || this.splineEditor.isRotatingAll)) {
this.vis.render();
return;
}
this.clearLayers();
const activeWidget = this.node.layerManager.getActiveWidget();
const allWidgets = this.node.layerManager.getSplineWidgets();
const isDrawing = !!this.splineEditor._handdrawActive;
const handdrawMode = this.splineEditor._handdrawMode || 'off';
const activeIsHand = !!(activeWidget && activeWidget.value && activeWidget.value.type === 'handdraw');
// Render inactive and off layers - each in its own isolated panel
allWidgets.forEach(widget => {
if (widget !== activeWidget) {
// While drawing in 'create' mode, also render the currently selected layer as inactive
const treatActiveAsInactive = isDrawing && handdrawMode === 'create' && widget === activeWidget;
if (widget !== activeWidget || treatActiveAsInactive) {
this.drawInactiveLayer(widget);
}
});
// Draw the active layer on top in its dedicated panel
if (activeWidget) {
if (activeWidget && !(isDrawing && handdrawMode === 'create')) {
// Normal case: draw the active widget unless we're creating a new handdraw stroke
this.drawActiveLayer(activeWidget);
}
if (this.splineEditor._handdrawActive && this.splineEditor.points && this.splineEditor.points.length > 0) {
// During handdraw drawing, render a live preview on top (even if another layer is selected)
// No active widget yet (e.g., first-ever handdraw stroke). Render a live preview
// using handdraw styling so users can see the stroke while drawing.
const pseudoWidget = { value: { interpolation: 'linear', type: 'handdraw' } };
this.drawActiveLayer(pseudoWidget);
}
// Ensure active panel is always the last child so it renders on top
@@ -51,6 +252,14 @@ export class LayerRenderer {
// Apply the changes to the SVG
this.vis.render();
// Avoid extra DOM work while freehand drawing; timer will animate
if (!this.splineEditor._handdrawActive) {
this.updateInactiveDash();
this.updateActiveHanddrawDash();
} else {
// still update lazily by throttle
this.updateActiveHanddrawDash();
}
}
/**
@@ -148,7 +357,11 @@ export class LayerRenderer {
}
return '#1f77b4';
})
.lineWidth(() => interpolation === 'points' ? 1 : 3);
.lineWidth(() => {
// Handdraw (active) should have same thickness as normal lines
if (isHanddraw) return 3;
return interpolation === 'points' ? 1 : 3;
});
// ALWAYS draw dots - bigger for points mode, all circles
// Use closures to ensure drag handlers can find points via indexOf()
@@ -157,7 +370,7 @@ export class LayerRenderer {
.left(d => d.x)
.top(d => d.y)
.radius(() => {
if (isHanddraw) return 4; // smaller points for handdraw
if (isHanddraw) return 2; // make yellow hand-draw points smaller
return interpolation === 'points' ? 9 : 6;
})
.shape((dot) => {
@@ -213,12 +426,12 @@ export class LayerRenderer {
drawInactiveLayer(widget) {
const isOff = !widget.value.on;
const isHanddraw = widget?.value?.type === 'handdraw';
const strokeColor = isOff
let strokeColor = isOff
? "rgba(255, 255, 255, 0.1)"
: (isHanddraw ? "rgba(70, 40, 110, 0.7)" : "rgba(255, 127, 14, 0.5)");
: (isHanddraw ? "rgba(120, 70, 180, 0.85)" : "rgba(255, 127, 14, 0.5)");
const fillColor = isOff
? "rgba(255, 255, 255, 0.1)"
: (isHanddraw ? "rgba(70, 40, 110, 0.5)" : "rgba(255, 127, 14, 0.4)");
: (isHanddraw ? "rgba(120, 70, 180, 0.6)" : "rgba(255, 127, 14, 0.4)");
let points;
try {
@@ -243,6 +456,10 @@ export class LayerRenderer {
});
const interpolation = widget.value.interpolation || 'linear';
// For inactive points layers (non-handdraw), reduce line opacity to 20%
if (!isHanddraw && !isOff && interpolation === 'points') {
strokeColor = "rgba(255, 127, 14, 0.2)";
}
// Snapshot points to avoid closure issues
const pointsSnapshot = [...points];
@@ -285,13 +502,14 @@ export class LayerRenderer {
// ALWAYS draw line
const lineWidthMain = (interpolation === 'points') ? (isHanddraw ? 3 : 1) : 3;
layerPanel.add(pv.Line).events("none")
const inactiveLine = layerPanel.add(pv.Line).events("none")
.data(renderPoints)
.left(d => d.x)
.top(d => d.y)
.interpolate(interpolation === 'points' ? 'linear' : interpolation)
.strokeStyle(strokeColor)
.lineWidth(lineWidthMain);
// Note: dash animation is applied post-render via updateInactiveDash()
// If this is a handdraw layer in inactive (purple) state, render line-only and skip markers/dots
if (isHanddraw) {
@@ -308,8 +526,17 @@ export class LayerRenderer {
.interpolate('linear')
.strokeStyle(strokeColor)
.lineWidth(lineWidthPts);
// Do not draw circles for inactive layers. Optionally keep first-point triangle for non-handdraw.
// For points-type layers, also show point markers when inactive (non-handdraw only)
if (!isHanddraw && pointsSnapshot.length > 0) {
layerPanel.add(pv.Dot).events("none")
.data(pointsSnapshot)
.left(d => d.x)
.top(d => d.y)
.radius(7.0)
.shape('circle')
.strokeStyle(strokeColor)
.fillStyle(fillColor);
// Keep first-point triangle marker for direction
layerPanel.add(pv.Dot).events("none")
.data([pointsSnapshot[0]])
.left(d => d.x)
+39 -24
View File
@@ -69,7 +69,7 @@ async function saveRefImageToCache(base64Data) {
try {
// Create a cache key to track the current ref_image
const currentHash = await simpleHash(base64Data);
sessionStorage.setItem('spline-editor-ref-image-hash', currentHash);
safeSetSessionItem('spline-editor-ref-image-hash', currentHash);
// Try to save the file to the bg folder via the backend API
try {
@@ -86,7 +86,7 @@ async function saveRefImageToCache(base64Data) {
if (result.success) {
// Save to sessionStorage as backup
sessionStorage.setItem('spline-editor-cached-ref-image', JSON.stringify({
safeSetSessionItem('spline-editor-cached-ref-image', JSON.stringify({
base64: base64Data,
type: 'image/png',
name: 'ref_image.jpg',
@@ -98,7 +98,7 @@ async function saveRefImageToCache(base64Data) {
} else {
console.error('Backend error saving ref image:', result.error);
// Fallback to sessionStorage only
sessionStorage.setItem('spline-editor-cached-ref-image', JSON.stringify({
safeSetSessionItem('spline-editor-cached-ref-image', JSON.stringify({
base64: base64Data,
type: 'image/png',
name: 'ref_image.jpg',
@@ -110,7 +110,7 @@ async function saveRefImageToCache(base64Data) {
} else {
console.error('Failed to save ref image via API:', response.status);
// Fallback to sessionStorage only
sessionStorage.setItem('spline-editor-cached-ref-image', JSON.stringify({
safeSetSessionItem('spline-editor-cached-ref-image', JSON.stringify({
base64: base64Data,
type: 'image/png',
name: 'ref_image.jpg',
@@ -123,7 +123,7 @@ async function saveRefImageToCache(base64Data) {
console.warn('API save failed, using sessionStorage fallback:', error);
// Fallback to sessionStorage if API fails
sessionStorage.setItem('spline-editor-cached-ref-image', JSON.stringify({
safeSetSessionItem('spline-editor-cached-ref-image', JSON.stringify({
base64: base64Data,
type: 'image/png',
name: 'ref_image.jpg',
@@ -233,17 +233,27 @@ class SplineLayerManager {
this.node = node;
this.activeWidget = null;
this.node.splineWidgetsCounter = 0;
this._noActiveSelection = false;
}
setActiveWidget(widget) {
if (this.activeWidget === widget) {
console.log("[SplineLayerManager] setActiveWidget: already active:", widget?.value?.name);
return;
}
console.log("[SplineLayerManager] setActiveWidget: switching from", this.activeWidget?.value?.name, "to", widget?.value?.name);
// Track explicit "no selection" state
this._noActiveSelection = (widget == null);
// If the editor is in handdraw create/edit mode, disarm it when switching layers
// Exception: when switch is initiated by handdraw commit flow, one-shot suppress exit
try {
const ed = this.node?.editor;
const suppressOnce = !!ed?._suppressHanddrawExitOnce;
if (ed) ed._suppressHanddrawExitOnce = false; // always reset flag
if (ed && ed._handdrawMode && ed._handdrawMode !== 'off' && !suppressOnce) {
ed.exitHanddrawMode?.(false);
}
} catch {}
this.activeWidget = widget;
if (this.node.editor) {
console.log("[SplineLayerManager] setActiveWidget: notifying editor");
this.node.editor.onActiveLayerChanged(); // Notify canvas
}
this.node.setDirtyCanvas(true, true);
@@ -254,6 +264,7 @@ class SplineLayerManager {
this.activeWidget = null;
}
if (!this.activeWidget) {
if (this._noActiveSelection) return null;
const splines = this.getSplineWidgets();
if (splines.length > 0) {
// Find the first visible/active spline widget, not just any spline
@@ -331,7 +342,7 @@ class SplineLayerManager {
return widget;
}
addNewHanddraw(name) {
addNewHanddraw(name, activate = true) {
this.node.splineWidgetsCounter++;
const widget = new HandDrawLayerWidget("spline_" + this.node.splineWidgetsCounter);
@@ -357,11 +368,13 @@ class SplineLayerManager {
}
this.node.updateNodeHeight();
this.setActiveWidget(widget);
if (this.node.editor && this.node.editor.vis) {
this.node.editor.vis.render();
if (activate) {
this.setActiveWidget(widget);
if (this.node.editor && this.node.editor.vis) {
this.node.editor.vis.render();
}
this.node.setDirtyCanvas(true, true);
}
this.node.setDirtyCanvas(true, true);
return widget;
}
@@ -624,6 +637,11 @@ class SplineLayerManager {
}
}
// Safe sessionStorage setter to avoid quota errors
function safeSetSessionItem(key, value) {
try { sessionStorage.setItem(key, value); } catch (e) { /* ignore quota/unsupported */ }
}
app.registerExtension({
name: 'WanVideoWrapper_QQ.PowerSplineEditor',
@@ -826,19 +844,19 @@ app.registerExtension({
}
this.hasSplineWidgets = function() {
return this.widgets && this.widgets.some(w => w instanceof PowerSplineWidget);
return this.widgets && this.widgets.some(w => (w instanceof PowerSplineWidget) || (w instanceof HandDrawLayerWidget));
};
this.allSplinesState = function() {
const splineWidgets = this.widgets.filter(w => w instanceof PowerSplineWidget);
if (!splineWidgets.length) return false;
return splineWidgets.every(w => w.value.on);
const layerWidgets = this.widgets.filter(w => (w instanceof PowerSplineWidget) || (w instanceof HandDrawLayerWidget));
if (!layerWidgets.length) return false;
return layerWidgets.every(w => w.value.on);
};
this.toggleAllSplines = function() {
const splineWidgets = this.widgets.filter(w => w instanceof PowerSplineWidget);
const layerWidgets = this.widgets.filter(w => (w instanceof PowerSplineWidget) || (w instanceof HandDrawLayerWidget));
const newState = !this.allSplinesState();
splineWidgets.forEach(w => w.value.on = newState);
layerWidgets.forEach(w => w.value.on = newState);
this.setDirtyCanvas(true, true);
};
@@ -1192,8 +1210,6 @@ app.registerExtension({
alert('Could not retrieve reference image from connected node. Make sure an image node is connected to the ref_image input.');
return;
}
console.log('Successfully retrieved reference image from connected node, updating...');
// Store the original reference image separately to use as base for overlays
this.originalRefImageData = {
@@ -1222,7 +1238,6 @@ app.registerExtension({
this.editor.refreshBackgroundImage();
}
console.log('Reference image updated successfully from connected node');
} catch (error) {
console.error('Error updating reference image from connected node:', error);
alert('Error updating reference image: ' + error.message);
@@ -1818,7 +1833,7 @@ app.registerExtension({
// Also save dimensions to sessionStorage for persistence across page refreshes
try {
sessionStorage.setItem(`spline-editor-dims-${this.uuid}`, JSON.stringify({
safeSetSessionItem(`spline-editor-dims-${this.uuid}`, JSON.stringify({
width: dims.width,
height: dims.height
}));
@@ -1837,7 +1852,7 @@ app.registerExtension({
try {
const size = JSON.stringify(this.imgData).length;
if (size < 640 * 480) { // 1MB limit
sessionStorage.setItem(`spline-editor-img-${this.uuid}`, JSON.stringify(this.imgData));
safeSetSessionItem(`spline-editor-img-${this.uuid}`, JSON.stringify(this.imgData));
} else {
console.warn("Spline Editor: Image not saved to session storage because it is too large.", size);
}
+123 -40
View File
@@ -59,17 +59,52 @@ export default class SplineEditor2 {
// Get interpolation from active widget
this.interpolation = 'linear';
// Handdraw mode (UI/UX first pass)
this._handdrawActive = false;
// Handdraw modes: 'off' | 'create' | 'edit'
this._handdrawMode = 'off';
this._handdrawActive = false; // legacy flag used by renderer
this._handdrawEditWidget = null;
this._handdrawPath = [];
this.enterHanddrawMode = () => {
// Global toggle for inactive flow animation (default ON)
this._inactiveFlowEnabled = true;
this.enterHanddrawMode = (mode = 'create', widget = null) => {
this._handdrawMode = mode;
this._handdrawEditWidget = mode === 'edit' ? widget : null;
this._handdrawActive = true;
this._handdrawPath = [];
// Install right-click handler to exit edit mode reliably
if (this._teardownHanddrawListeners) {
try { this._teardownHanddrawListeners(); } catch {}
this._teardownHanddrawListeners = null;
}
const canvasEl = this.vis?.canvas?.();
if ((mode === 'edit' || mode === 'create') && canvasEl) {
const onMouseDown = (ev) => {
// Middle click exits handdraw modes (edit/create), preserving current curve
if (ev && ev.button === 1) {
ev.preventDefault?.(); ev.stopPropagation?.();
try { this.exitHanddrawMode(false); } catch {}
try { this.layerRenderer?.render(); } catch {}
try { this.node?.setDirtyCanvas?.(true, true); } catch {}
}
};
canvasEl.addEventListener('mousedown', onMouseDown, true);
this._teardownHanddrawListeners = () => {
canvasEl.removeEventListener('mousedown', onMouseDown, true);
};
}
};
this.exitHanddrawMode = (commit = false) => {
// Only cancel UI state; no commit normalization here (handled in mousedown mouseup flow)
// Only cancel UI state; no commit normalization here (handled in mousedown/mouseup flow)
this._handdrawMode = 'off';
this._handdrawEditWidget = null;
this._handdrawActive = false;
this._handdrawPath = [];
// Remove temporary listeners
if (this._teardownHanddrawListeners) {
try { this._teardownHanddrawListeners(); } catch {}
this._teardownHanddrawListeners = null;
}
// Re-render to update any UI state
try { this.layerRenderer?.render(); } catch {}
};
@@ -333,8 +368,9 @@ export default class SplineEditor2 {
// Normalize and commit
const norm = this.normalizePoints(simplified);
try { this.node?.commitHanddraw?.(norm); } catch {}
this._handdrawActive = false;
// After committing, remain in current mode; clear temporary path
this._handdrawPath = [];
this._handdrawActive = (this._handdrawMode !== 'off');
// Reload active points from widget and re-render
this.points = this.getActivePoints();
this.layerRenderer.render();
@@ -408,10 +444,19 @@ export default class SplineEditor2 {
this.updatePath();
}
else if (pv.event.button === 2) {
// Open custom canvas context menu and suppress native one
// Right-click behavior: open custom canvas context menu and suppress native one
try { pv.event.preventDefault?.(); pv.event.stopPropagation?.(); } catch {}
const menuEl = this.node.contextMenu;
// Update first menu item label based on active layer type
try {
const activeWidget = this.getActiveWidget?.();
const isHanddraw = !!(activeWidget && activeWidget.value && activeWidget.value.type === 'handdraw');
const firstItem = this.node.menuItems && this.node.menuItems[0];
if (firstItem) {
firstItem.textContent = isHanddraw ? 'Edit' : 'Invert point order';
}
} catch {}
menuEl.style.display = 'block';
menuEl.style.left = `${pv.event.clientX}px`;
menuEl.style.top = `${pv.event.clientY}px`;
@@ -445,6 +490,13 @@ export default class SplineEditor2 {
document.addEventListener('keydown', onEsc, true);
}, 0);
}
// Middle click: exit any handdraw mode (edit/create) and keep current edits
else if (pv.event.button === 1) {
try { this.exitHanddrawMode(false); } catch {}
try { this.layerRenderer?.render(); } catch {}
try { this.node?.setDirtyCanvas?.(true, true); } catch {}
return this;
}
})
this.dragStartHandler = (d) => {
@@ -479,10 +531,21 @@ export default class SplineEditor2 {
return;
}
// Alt+Middle-Click: Translation (any point)
// Alt + Middle-Click: Translation (any point)
if (pv.event.altKey && pv.event.button === 1) {
this.isDraggingAll = true;
this.dragStartPos = { x: mouseX, y: mouseY };
// Snapshot original positions to keep a rigid translation anchored at clicked point
this.translateAllSnapshot = {
points: this.points.map(p => ({ ...p })),
pivotIndex: this.i,
pivotX: this.points[this.i].x,
pivotY: this.points[this.i].y,
offsetX: this.dragOffset ? this.dragOffset.x : 0,
offsetY: this.dragOffset ? this.dragOffset.y : 0,
};
// Provide a neutral fix object so Protovis drag doesn't apply extra offsets
try { if (dot && typeof dot === 'object') { dot.fix = { x: 0, y: 0 }; } } catch {}
this.isDragging = true;
return;
}
@@ -505,6 +568,7 @@ export default class SplineEditor2 {
this.isDragging = true;
if (pv.event.button === 2 && this.i !== 0 && this.i !== this.points.length - 1) {
this.points.splice(this.i--, 1);
this._forceRebuildNextRender = true;
this.layerRenderer.render();
}
};
@@ -516,6 +580,9 @@ export default class SplineEditor2 {
if (this.pathElements !== null) {
this.updatePath();
}
// Clear any protovis drag fix offsets left on data objects
try { if (Array.isArray(this.points)) { for (const p of this.points) { if (p && p.fix !== undefined) delete p.fix; } } } catch {}
this.translateAllSnapshot = null;
this.dragOffset = null;
this.isDragging = false;
this.isDraggingAll = false;
@@ -541,20 +608,20 @@ export default class SplineEditor2 {
// Rotate all points around the anchor point
for (let j = 0; j < this.originalPoints.length; j++) {
const originalPoint = this.originalPoints[j];
if (j === this.anchorIndex) {
// Keep anchor point fixed
this.points[j] = { ...this.anchorPoint };
this.points[j].x = this.anchorPoint.x;
this.points[j].y = this.anchorPoint.y;
this.points[j].highlighted = !!originalPoint.highlighted;
} else {
const originalPoint = this.originalPoints[j];
const vecX = originalPoint.x - this.anchorPoint.x;
const vecY = originalPoint.y - this.anchorPoint.y;
const rotatedX = vecX * cos - vecY * sin;
const rotatedY = vecX * sin + vecY * cos;
this.points[j] = {
x: this.anchorPoint.x + rotatedX,
y: this.anchorPoint.y + rotatedY,
highlighted: originalPoint.highlighted || false
};
this.points[j].x = this.anchorPoint.x + rotatedX;
this.points[j].y = this.anchorPoint.y + rotatedY;
this.points[j].highlighted = !!originalPoint.highlighted;
}
}
this.layerRenderer.render();
@@ -562,15 +629,22 @@ export default class SplineEditor2 {
}
if (this.isDraggingAll && this.dragStartPos) {
const deltaX = adjustedX - this.dragStartPos.x;
const deltaY = adjustedY - this.dragStartPos.y;
this.points = this.points.map(point => ({
x: point.x + deltaX,
y: point.y + deltaY,
highlighted: point.highlighted || false
}));
this.dragStartPos.x = adjustedX;
this.dragStartPos.y = adjustedY;
// Rigid translation anchored at originally clicked point using snapshot
const snap = this.translateAllSnapshot;
const desiredX = adjustedX + (snap ? snap.offsetX : (this.dragOffset ? this.dragOffset.x : 0));
const desiredY = adjustedY + (snap ? snap.offsetY : (this.dragOffset ? this.dragOffset.y : 0));
const basePivotX = snap ? snap.pivotX : this.dragStartPos.x;
const basePivotY = snap ? snap.pivotY : this.dragStartPos.y;
const deltaX = desiredX - basePivotX;
const deltaY = desiredY - basePivotY;
try { if (d && typeof d === 'object') { d.fix = { x: 0, y: 0 }; } } catch {}
const basePoints = snap ? snap.points : this.points;
for (let j = 0; j < this.points.length; j++) {
const bp = basePoints[j] || this.points[j];
this.points[j].x = bp.x + deltaX;
this.points[j].y = bp.y + deltaY;
this.points[j].highlighted = !!this.points[j].highlighted;
}
this.layerRenderer.render();
return;
}
@@ -584,18 +658,18 @@ export default class SplineEditor2 {
// Scale all points relative to the anchor point
for (let j = 0; j < this.originalPoints.length; j++) {
const originalPoint = this.originalPoints[j];
if (j === this.anchorIndex) {
// Keep anchor point fixed
this.points[j] = { ...this.anchorPoint };
this.points[j].x = this.anchorPoint.x;
this.points[j].y = this.anchorPoint.y;
this.points[j].highlighted = !!originalPoint.highlighted;
} else {
const originalPoint = this.originalPoints[j];
const vecX = originalPoint.x - this.anchorPoint.x;
const vecY = originalPoint.y - this.anchorPoint.y;
this.points[j] = {
x: this.anchorPoint.x + vecX * clampedScaleFactor,
y: this.anchorPoint.y + vecY * clampedScaleFactor,
highlighted: originalPoint.highlighted || false
};
this.points[j].x = this.anchorPoint.x + vecX * clampedScaleFactor;
this.points[j].y = this.anchorPoint.y + vecY * clampedScaleFactor;
this.points[j].highlighted = !!originalPoint.highlighted;
}
}
this.layerRenderer.render();
@@ -604,11 +678,9 @@ export default class SplineEditor2 {
if (!this.isDraggingAll && !this.isScalingAll && !this.isRotatingAll) {
if (this.dragOffset) {
this.points[this.i] = {
x: adjustedX + this.dragOffset.x,
y: adjustedY + this.dragOffset.y,
highlighted: this.points[this.i].highlighted || false
};
this.points[this.i].x = adjustedX + this.dragOffset.x;
this.points[this.i].y = adjustedY + this.dragOffset.y;
this.points[this.i].highlighted = !!this.points[this.i].highlighted;
}
this.layerRenderer.render();
}
@@ -616,14 +688,16 @@ export default class SplineEditor2 {
this.mouseOverHandler = (d) => {
this.hoverIndex = this.points.indexOf(d);
this.layerRenderer.render();
// Avoid rebuilding the scene on hover; a simple re-render is sufficient
if (this.layerRenderer?.vis) this.layerRenderer.vis.render();
};
this.mouseOutHandler = () => {
if (!this.isDragging) {
this.hoverIndex = -1;
}
this.layerRenderer.render();
// Avoid rebuilding the scene on hover out
if (this.layerRenderer?.vis) this.layerRenderer.vis.render();
};
this.backgroundImage = this.vis.add(pv.Image).visible(false)
@@ -1056,10 +1130,19 @@ export default class SplineEditor2 {
document.addEventListener('contextmenu', hideOpenMenus, true);
switch (index) {
case 0:
// Invert point order
// Context action depends on active layer type
e.preventDefault();
this.points.reverse();
this.updatePath();
const aw = this.getActiveWidget?.();
const isHand = !!(aw && aw.value && aw.value.type === 'handdraw');
if (isHand) {
// Enter edit mode for the active handdraw layer
try { this.enterHanddrawMode('edit', aw); } catch {}
try { this.layerRenderer?.render(); } catch {}
} else {
// Invert point order for non-handdraw layers
this.points.reverse();
this.updatePath();
}
this.node.contextMenu.style.display = 'none';
break;
case 1:
+48 -12
View File
@@ -153,7 +153,13 @@ export class TopRowWidget extends RgthreeBaseWidget {
const refreshButtonWidth = availableWidth * 0.15;
const bgImgDropdownWidth = availableWidth * 0.15;
const drawButtonWidth = Math.max(20, Math.min(28, availableWidth * 0.05));
const dimensionsAreaWidth = availableWidth - (refreshButtonWidth + spacing + bgImgDropdownWidth + spacing + drawButtonWidth + spacing);
// Account for both the pencil button and the animation toggle button
const dimensionsAreaWidth = availableWidth - (
refreshButtonWidth + spacing +
bgImgDropdownWidth + spacing +
drawButtonWidth + spacing + // ✏️
drawButtonWidth + spacing // 〰
);
// Calculate total width and starting position to center everything
const totalWidth = refreshButtonWidth + spacing + bgImgDropdownWidth + spacing + dimensionsAreaWidth;
@@ -199,7 +205,8 @@ export class TopRowWidget extends RgthreeBaseWidget {
posX += bgImgDropdownWidth + spacing;
// Small Draw icon button between overlay picker and dimensions
const isArmed = !!(node?.editor?._handdrawActive);
// Armed when in create-new-stroke mode (separate from per-layer edit)
const isArmed = (node?.editor?._handdrawMode === 'create');
drawWidgetButton(
ctx,
{ size: [drawButtonWidth, height], pos: [posX, posY] },
@@ -208,29 +215,58 @@ export class TopRowWidget extends RgthreeBaseWidget {
);
// Add blue outline when armed to match active layer style
if (isArmed) {
// Slightly inset outline to overlay nicely
// Slightly inset outline to overlay nicely; match thickness with layer edit button (2px)
const pad = 0.5;
drawRoundedRectangle(ctx, {
pos: [posX + pad, posY + pad],
size: [drawButtonWidth - pad * 2, height - pad * 2],
colorBackground: 'transparent',
colorStroke: '#2cc6ff',
borderRadius: 4
});
ctx.save();
ctx.strokeStyle = '#2cc6ff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(posX + pad, posY + pad, drawButtonWidth - pad * 2, height - pad * 2, [4]);
ctx.stroke();
ctx.restore();
}
this.hitAreas.drawButton = { bounds: [posX, drawButtonWidth], onClick: (e, p, n) => {
// Toggle draw mode on the editor; canvas will handle capture on mousedown
if (n?.editor) {
if (n.editor._handdrawActive) {
if (n.editor._handdrawMode === 'create') {
n.editor.exitHanddrawMode?.(false);
} else {
n.editor.enterHanddrawMode?.();
// enter create-new-stroke mode (each stroke creates a new handdraw layer)
n.editor.enterHanddrawMode?.('create');
}
n.setDirtyCanvas(true, true);
}
return true;
}};
posX += drawButtonWidth + spacing;
// Animation toggle icon for inactive flow animation (default ON)
const isAnimOn = !!(node?.editor?._inactiveFlowEnabled ?? true);
drawWidgetButton(
ctx,
{ size: [drawButtonWidth, height], pos: [posX, posY] },
"〰", // wavy line icon
isAnimOn
);
if (isAnimOn) {
const pad = 0.5;
ctx.save();
ctx.strokeStyle = '#2cc6ff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(posX + pad, posY + pad, drawButtonWidth - pad * 2, height - pad * 2, [4]);
ctx.stroke();
ctx.restore();
}
this.hitAreas.animToggleButton = { bounds: [posX, drawButtonWidth], onClick: (e, p, n) => {
if (n?.editor) {
n.editor._inactiveFlowEnabled = !n.editor._inactiveFlowEnabled;
try { n.editor.layerRenderer?.updateInactiveDash?.(); } catch {}
n.setDirtyCanvas(true, true);
}
return true;
}};
posX += drawButtonWidth + spacing;
// Draw rounded area for width/height controls
const roundedAreaX = posX;
+17 -14
View File
@@ -267,20 +267,27 @@ export class PowerSplineWidget extends RgthreeBaseWidget {
ctx.textAlign = "left";
const nameText = fitString(ctx, this.value.name || "Spline", rposX - posX - innerMargin);
ctx.fillText(nameText, posX, midY);
this.hitAreas.name.bounds = [posX, 0, rposX - posX, height];
// Make name hit area wide and simple (x + width only) to avoid y-mismatch
this.hitAreas.name.bounds = [posX, rposX - posX];
// Activate layer when clicking the name area (onDown for immediacy)
this.hitAreas.name.onDown = (event, pos, node) => {
if (!node.layerManager) return false;
const current = node.layerManager.getActiveWidget?.();
// Toggle off if clicking the currently active layer name
if (current === this) {
node.layerManager.setActiveWidget(null);
} else {
node.layerManager.setActiveWidget(this);
}
return true;
};
this.hitAreas.name.onClick = (event, pos, node) => true; // handled onDown
ctx.restore();
}
onMouseDown(event, pos, node) {
// Check if the click was on the toggle button
if (this.hitAreas.toggle && this.clickWasWithinBounds(pos, this.hitAreas.toggle.bounds)) {
// If it was on the toggle, do not set active widget.
// The onToggleDown handler will take care of the toggle action.
} else if (node.layerManager) {
// Otherwise, set this widget as active
node.layerManager.setActiveWidget(this);
}
// Activation is handled via hitAreas.name.onDown; do not duplicate here
return super.onMouseDown?.(event, pos, node);
}
@@ -508,11 +515,7 @@ export class PowerSplineWidget extends RgthreeBaseWidget {
onMouseUp(event, pos, node) {
super.onMouseUp(event, pos, node);
this.haveMouseMovedValue = false;
// Set this widget as active when clicked
if (node.layerManager) {
node.layerManager.setActiveWidget(this);
}
// Activation handled by hitAreas; no action here
}
onDrivenToggleDown(event, pos, node) {