Files
filliptm-ComfyUI_FL-Path-An…/web/nodes/FL_PathAnimator.js
T
2025-11-03 10:16:52 -05:00

1821 lines
65 KiB
JavaScript

/**
* File: FL_PathAnimator.js
* Project: ComfyUI_FL-Path-Animator
*
* Interactive path animator with modal drawing editor
*/
import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
// SVG Icon Helper Functions
const Icons = {
pencil: () => `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>`,
pin: () => `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>`,
trash: () => `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`,
cursor: () => `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/></svg>`,
image: () => `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
xCircle: () => `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`,
lock: () => `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`,
edit: () => `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
close: () => `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
arrowRight: () => `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>`,
target: () => `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>`,
};
function moveWidgetToTop(node, widget) {
if (!widget) return;
// Find the widget's current index
const widgetIndex = node.widgets.indexOf(widget);
if (widgetIndex > 0) {
// Remove from current position
node.widgets.splice(widgetIndex, 1);
// Insert at the beginning
node.widgets.unshift(widget);
}
}
app.registerExtension({
name: "FillNodes.PathAnimator",
async nodeCreated(node) {
if (node.comfyClass === "FL_PathAnimator") {
// Find the paths_data widget immediately
const pathsDataWidget = node.widgets.find(w => w.name === "paths_data");
if (!pathsDataWidget) {
console.error("FL_PathAnimator: 'paths_data' widget not found!");
return;
}
// Move it to the top of the widget list so it renders first
moveWidgetToTop(node, pathsDataWidget);
// Initialize cached background image storage on the widget
pathsDataWidget._cachedBackgroundImage = null;
// Add "Edit Paths" button
const editButton = node.addWidget("button", "Edit Paths", null, () => {
openPathEditor(node, pathsDataWidget);
});
// Add path count display (readonly)
const pathCountWidget = node.addWidget("text", "Path Count", "0 paths", null);
pathCountWidget.disabled = true;
// Update path count when paths change
function updatePathCount() {
try {
const data = JSON.parse(pathsDataWidget.value);
const count = data.paths ? data.paths.length : 0;
const staticCount = data.paths ? data.paths.filter(p => p.isSinglePoint || p.points.length === 1).length : 0;
const motionCount = count - staticCount;
pathCountWidget.value = `${count} path${count !== 1 ? 's' : ''} (${staticCount} static, ${motionCount} motion)`;
} catch (e) {
pathCountWidget.value = "0 paths";
}
}
// Initial update
updatePathCount();
// Store update function for later use
node._updatePathCount = updatePathCount;
}
}
});
function openPathEditor(node, pathsDataWidget) {
// Get frame dimensions from node widgets
const frameWidthWidget = node.widgets.find(w => w.name === "frame_width");
const frameHeightWidget = node.widgets.find(w => w.name === "frame_height");
const frameWidth = frameWidthWidget ? frameWidthWidget.value : 512;
const frameHeight = frameHeightWidget ? frameHeightWidget.value : 512;
// Create and show modal
const modal = new PathEditorModal(node, pathsDataWidget, frameWidth, frameHeight);
modal.show();
}
class PathEditorModal {
constructor(node, pathsDataWidget, frameWidth, frameHeight) {
this.node = node;
this.pathsDataWidget = pathsDataWidget;
this.frameWidth = frameWidth;
this.frameHeight = frameHeight;
this.paths = [];
this.currentPath = null;
this.selectedPathIndex = -1;
this.isDrawing = false;
this.tool = 'pencil'; // pencil, eraser, select, point
this.currentColor = this.getRandomColor();
this.backgroundImage = null;
this.canvasScale = 1.0;
this.canvasOffsetX = 0;
this.canvasOffsetY = 0;
this.pathThickness = 3; // Visual thickness for displaying paths
this.shiftPressed = false; // Track shift key state
this.backgroundOpacity = 1.0; // Background image opacity (0.5 to 1.0)
this.animationOffset = 0; // For animated directional indicators
this.animationFrame = null; // Animation frame ID
// Load existing paths
this.loadPaths();
// Load cached background image if it exists
this.loadCachedBackgroundImage();
// Create modal elements
this.createModal();
// Setup keyboard handlers
this.setupKeyboardHandlers();
// Start animation loop for directional indicators
this.startAnimation();
}
startAnimation() {
const animate = () => {
// Increment animation offset for directional flow
this.animationOffset += 0.5;
if (this.animationOffset > 20) {
this.animationOffset = 0;
}
this.render();
this.animationFrame = requestAnimationFrame(animate);
};
this.animationFrame = requestAnimationFrame(animate);
}
stopAnimation() {
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
}
setupKeyboardHandlers() {
this.keydownHandler = (e) => {
// Track shift key
if (e.key === 'Shift') {
this.shiftPressed = true;
}
// Escape to save and close
if (e.key === 'Escape') {
this.savePaths();
this.close();
}
// Ctrl+V to paste image from clipboard
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
console.log('FL_PathAnimator: Ctrl+V detected');
e.preventDefault();
e.stopPropagation();
// Try to read from clipboard using modern API
this.pasteFromClipboard();
}
};
this.keyupHandler = (e) => {
if (e.key === 'Shift') {
this.shiftPressed = false;
}
};
this.pasteHandler = (e) => {
console.log('FL_PathAnimator: Paste handler called');
e.preventDefault();
e.stopPropagation();
this.handlePaste(e);
};
document.addEventListener('keydown', this.keydownHandler);
document.addEventListener('keyup', this.keyupHandler);
console.log('FL_PathAnimator: Keyboard handlers registered');
}
attachPasteListener() {
// Attach paste listener to container after it's created
if (this.container) {
this.container.addEventListener('paste', this.pasteHandler);
document.addEventListener('paste', this.pasteHandler);
console.log('FL_PathAnimator: Paste handlers attached to container and document');
}
}
loadCachedBackgroundImage() {
// Check if there's a cached background image
if (this.pathsDataWidget._cachedBackgroundImage) {
const img = new Image();
img.onload = () => {
this.backgroundImage = img;
if (this.canvas) {
this.canvas.width = img.width;
this.canvas.height = img.height;
this.render();
}
};
img.src = this.pathsDataWidget._cachedBackgroundImage;
}
}
async pasteFromClipboard() {
console.log('FL_PathAnimator: Attempting to read from clipboard using Clipboard API');
try {
// Check if Clipboard API is available
if (!navigator.clipboard || !navigator.clipboard.read) {
console.log('FL_PathAnimator: Clipboard API not available, falling back to paste event');
return;
}
const clipboardItems = await navigator.clipboard.read();
console.log('FL_PathAnimator: Read', clipboardItems.length, 'items from clipboard');
for (const clipboardItem of clipboardItems) {
console.log('FL_PathAnimator: Clipboard item types:', clipboardItem.types);
for (const type of clipboardItem.types) {
if (type.startsWith('image/')) {
console.log('FL_PathAnimator: Found image type:', type);
const blob = await clipboardItem.getType(type);
this.loadImageFromBlob(blob);
return; // Only load first image
}
}
}
console.log('FL_PathAnimator: No image found in clipboard');
} catch (err) {
console.error('FL_PathAnimator: Error reading from clipboard:', err);
console.log('FL_PathAnimator: You may need to grant clipboard permission');
}
}
loadImageFromBlob(blob) {
console.log('FL_PathAnimator: Loading image from blob, size:', blob.size);
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
this.backgroundImage = img;
// Update canvas size to match image
this.canvas.width = img.width;
this.canvas.height = img.height;
// Cache the image data URL for later use
this.pathsDataWidget._cachedBackgroundImage = event.target.result;
this.render();
console.log('FL_PathAnimator: Image pasted successfully -', img.width, 'x', img.height);
};
img.onerror = () => {
console.error('FL_PathAnimator: Failed to load pasted image');
};
img.src = event.target.result;
};
reader.onerror = () => {
console.error('FL_PathAnimator: Failed to read image blob');
};
reader.readAsDataURL(blob);
}
handlePaste(e) {
console.log('FL_PathAnimator: Paste event triggered');
// Get clipboard items
const items = e.clipboardData?.items;
if (!items) {
console.log('FL_PathAnimator: No clipboard items found');
return;
}
console.log('FL_PathAnimator: Clipboard has', items.length, 'items');
// Look for image items
for (let i = 0; i < items.length; i++) {
const item = items[i];
console.log('FL_PathAnimator: Item', i, 'type:', item.type);
// Check if item is an image
if (item.type.indexOf('image') !== -1) {
console.log('FL_PathAnimator: Found image in clipboard via paste event');
const blob = item.getAsFile();
if (!blob) {
console.log('FL_PathAnimator: Failed to get blob from clipboard item');
continue;
}
// Use the shared loadImageFromBlob method
this.loadImageFromBlob(blob);
break; // Only paste the first image found
}
}
}
loadPaths() {
try {
const data = JSON.parse(this.pathsDataWidget.value);
this.paths = data.paths || [];
} catch (e) {
console.error("Error loading paths:", e);
this.paths = [];
}
}
savePaths() {
const data = {
paths: this.paths,
canvas_size: {
width: this.canvas.width,
height: this.canvas.height
}
};
this.pathsDataWidget.value = JSON.stringify(data);
// Update path count display
if (this.node._updatePathCount) {
this.node._updatePathCount();
}
}
getRandomColor() {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2'];
return colors[Math.floor(Math.random() * colors.length)];
}
createModal() {
// Create modal overlay
this.overlay = document.createElement('div');
this.overlay.className = 'fl-path-editor-overlay';
this.overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(4px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
`;
// Create modal container
this.container = document.createElement('div');
this.container.className = 'fl-path-editor-container';
this.container.tabIndex = 0; // Make container focusable for paste events
this.container.style.cssText = `
background: linear-gradient(145deg, #2d2d2d, #252525);
border-radius: 12px;
border: 1px solid #3a3a3a;
width: 95%;
height: 95%;
max-width: 2000px;
max-height: 1400px;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(255, 255, 255, 0.05);
animation: slideIn 0.3s ease-out;
outline: none;
`;
// Create header
this.createHeader();
// Create main content area
this.createMainContent();
// Create footer
this.createFooter();
this.overlay.appendChild(this.container);
// Close on overlay click
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) {
this.close();
}
});
}
createHeader() {
const header = document.createElement('div');
header.style.cssText = `
padding: 20px 24px 16px 24px;
border-bottom: 1px solid #404040;
background: linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%);
display: flex;
flex-direction: column;
gap: 16px;
border-radius: 12px 12px 0 0;
`;
// Top row with title and close button
const topRow = document.createElement('div');
topRow.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
`;
const titleContainer = document.createElement('div');
titleContainer.style.cssText = `
display: flex;
flex-direction: column;
gap: 4px;
`;
const title = document.createElement('h2');
title.innerHTML = `${Icons.edit()} <span style="margin-left: 8px;">Path Animator Editor</span>`;
title.style.cssText = `
margin: 0;
color: #fff;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.5px;
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
display: flex;
align-items: center;
`;
const subtitle = document.createElement('div');
subtitle.textContent = 'Press ESC to save & close | Hold SHIFT for straight lines | CTRL+V to paste image';
subtitle.style.cssText = `
color: #888;
font-size: 12px;
font-weight: 400;
`;
titleContainer.appendChild(title);
titleContainer.appendChild(subtitle);
const closeBtn = document.createElement('button');
closeBtn.innerHTML = Icons.close();
closeBtn.style.cssText = `
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: #fff;
cursor: pointer;
padding: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
`;
closeBtn.onmouseover = () => {
closeBtn.style.background = 'rgba(255, 77, 77, 0.8)';
closeBtn.style.borderColor = 'rgba(255, 77, 77, 1)';
closeBtn.style.transform = 'scale(1.05)';
};
closeBtn.onmouseout = () => {
closeBtn.style.background = 'rgba(255, 255, 255, 0.05)';
closeBtn.style.borderColor = 'rgba(255, 255, 255, 0.1)';
closeBtn.style.transform = 'scale(1)';
};
closeBtn.onclick = () => this.close();
topRow.appendChild(titleContainer);
topRow.appendChild(closeBtn);
// Controls row with sliders
const controlsRow = document.createElement('div');
controlsRow.style.cssText = `
display: flex;
gap: 32px;
align-items: center;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.05);
`;
// Path Width slider
const widthControl = this.createSliderControl(
'Path Width',
1, 10, this.pathThickness,
(value) => {
this.pathThickness = value;
}
);
// Background Opacity slider
const opacityControl = this.createSliderControl(
'Background Opacity',
50, 100, this.backgroundOpacity * 100,
(value) => {
this.backgroundOpacity = value / 100;
},
'%'
);
controlsRow.appendChild(widthControl);
controlsRow.appendChild(opacityControl);
header.appendChild(topRow);
header.appendChild(controlsRow);
this.container.appendChild(header);
}
createSliderControl(label, min, max, defaultValue, onChange, suffix = '') {
const container = document.createElement('div');
container.style.cssText = `
flex: 1;
display: flex;
align-items: center;
gap: 12px;
`;
const labelEl = document.createElement('label');
labelEl.textContent = label;
labelEl.style.cssText = `
color: #fff;
font-size: 13px;
font-weight: 500;
min-width: 120px;
opacity: 0.9;
`;
const sliderContainer = document.createElement('div');
sliderContainer.style.cssText = `
flex: 1;
display: flex;
align-items: center;
gap: 12px;
`;
const slider = document.createElement('input');
slider.type = 'range';
slider.min = min;
slider.max = max;
slider.value = defaultValue;
slider.style.cssText = `
flex: 1;
cursor: pointer;
accent-color: #4ECDC4;
height: 6px;
`;
const valueDisplay = document.createElement('div');
valueDisplay.textContent = defaultValue + suffix;
valueDisplay.style.cssText = `
color: #4ECDC4;
font-size: 14px;
font-weight: bold;
min-width: 50px;
text-align: right;
`;
slider.oninput = (e) => {
const value = parseInt(e.target.value);
valueDisplay.textContent = value + suffix;
onChange(value);
this.render();
};
sliderContainer.appendChild(slider);
sliderContainer.appendChild(valueDisplay);
container.appendChild(labelEl);
container.appendChild(sliderContainer);
return container;
}
createMainContent() {
const content = document.createElement('div');
content.style.cssText = `
flex: 1;
display: flex;
overflow: hidden;
`;
// Create toolbar
this.createToolbar(content);
// Create canvas area
this.createCanvasArea(content);
// Create sidebar
this.createSidebar(content);
this.container.appendChild(content);
}
createToolbarButton(iconSvg, title, isActive = false) {
const btn = document.createElement('button');
btn.innerHTML = iconSvg;
btn.title = title;
btn.style.cssText = `
width: 50px;
height: 50px;
border: 2px solid ${isActive ? '#4ECDC4' : 'rgba(255, 255, 255, 0.15)'};
background: ${isActive ? 'rgba(78, 205, 196, 0.2)' : 'rgba(255, 255, 255, 0.05)'};
color: #fff;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
box-shadow: ${isActive ? '0 0 12px rgba(78, 205, 196, 0.3)' : 'none'};
display: flex;
align-items: center;
justify-content: center;
padding: 0;
`;
btn.onmouseover = () => {
if (!isActive) {
btn.style.background = 'rgba(255, 255, 255, 0.1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.25)';
btn.style.transform = 'scale(1.05)';
}
};
btn.onmouseout = () => {
if (!isActive) {
btn.style.background = 'rgba(255, 255, 255, 0.05)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.15)';
btn.style.transform = 'scale(1)';
}
};
return btn;
}
createToolbar(parent) {
const toolbar = document.createElement('div');
toolbar.style.cssText = `
width: 70px;
background: linear-gradient(180deg, #1e1e1e 0%, #181818 100%);
border-right: 1px solid #3a3a3a;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 8px;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.3);
`;
// Add image upload button
const uploadBtn = this.createToolbarButton(Icons.image(), 'Load Background Image');
uploadBtn.onclick = () => this.loadImage();
toolbar.appendChild(uploadBtn);
// Add clear image button
const clearImgBtn = this.createToolbarButton(Icons.xCircle(), 'Clear Background Image');
clearImgBtn.onclick = () => this.clearImage();
toolbar.appendChild(clearImgBtn);
// Add separator
const separator = document.createElement('div');
separator.style.cssText = `
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
margin: 8px 0;
`;
toolbar.appendChild(separator);
const tools = [
{ name: 'pencil', icon: Icons.pencil(), title: 'Draw Path (Motion)' },
{ name: 'point', icon: Icons.pin(), title: 'Add Static Point (Anchor)' },
{ name: 'eraser', icon: Icons.trash(), title: 'Erase Path' },
{ name: 'select', icon: Icons.cursor(), title: 'Select Path' },
];
const toolButtons = [];
tools.forEach(tool => {
const btn = this.createToolbarButton(tool.icon, tool.title, this.tool === tool.name);
btn.dataset.tool = tool.name;
btn.onclick = () => {
this.tool = tool.name;
// Update canvas cursor
if (this.tool === 'point') {
this.canvas.style.cursor = 'crosshair';
} else if (this.tool === 'pencil') {
this.canvas.style.cursor = 'crosshair';
} else if (this.tool === 'eraser') {
this.canvas.style.cursor = 'not-allowed';
} else if (this.tool === 'select') {
this.canvas.style.cursor = 'pointer';
}
// Update all tool button states
toolButtons.forEach(tb => {
const isActive = tb.dataset.tool === tool.name;
tb.style.border = `2px solid ${isActive ? '#4ECDC4' : 'rgba(255, 255, 255, 0.15)'}`;
tb.style.background = isActive ? 'rgba(78, 205, 196, 0.2)' : 'rgba(255, 255, 255, 0.05)';
tb.style.boxShadow = isActive ? '0 0 12px rgba(78, 205, 196, 0.3)' : 'none';
});
};
toolButtons.push(btn);
toolbar.appendChild(btn);
});
// Add another separator
const separator2 = document.createElement('div');
separator2.style.cssText = separator.style.cssText;
toolbar.appendChild(separator2);
// Add Lock Perimeter button
const lockPerimeterBtn = this.createToolbarButton(Icons.lock(), 'Lock Perimeter - Add static shapes around border');
lockPerimeterBtn.onclick = () => this.lockPerimeter();
toolbar.appendChild(lockPerimeterBtn);
// Add another separator
const separator3 = document.createElement('div');
separator3.style.cssText = separator.style.cssText;
toolbar.appendChild(separator3);
// Add clear all button
const clearBtn = this.createToolbarButton(Icons.trash(), 'Clear All Paths');
clearBtn.style.marginTop = 'auto';
clearBtn.onclick = () => {
if (confirm('Clear all paths?')) {
this.paths = [];
this.selectedPathIndex = -1;
this.updateSidebar();
this.render();
}
};
toolbar.appendChild(clearBtn);
parent.appendChild(toolbar);
}
loadImage() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
this.backgroundImage = img;
// Update canvas size to match image
this.canvas.width = img.width;
this.canvas.height = img.height;
// Cache the image data URL for later use
this.pathsDataWidget._cachedBackgroundImage = event.target.result;
this.render();
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
}
};
input.click();
}
clearImage() {
if (confirm('Clear background image?')) {
this.backgroundImage = null;
this.pathsDataWidget._cachedBackgroundImage = null;
// Reset canvas to frame dimensions
this.canvas.width = this.frameWidth;
this.canvas.height = this.frameHeight;
this.render();
}
}
lockPerimeter() {
// Prompt user for number of points
const numPoints = prompt('How many shapes around the perimeter?', '12');
if (!numPoints || isNaN(numPoints) || numPoints < 1) return;
const count = parseInt(numPoints);
const w = this.canvas.width;
const h = this.canvas.height;
const perimeter = 2 * (w + h);
const spacing = perimeter / count;
// Create paths around the perimeter
for (let i = 0; i < count; i++) {
const d = i * spacing;
let x, y;
if (d < w) {
// Top edge
x = d;
y = 0;
} else if (d < w + h) {
// Right edge
x = w;
y = d - w;
} else if (d < 2 * w + h) {
// Bottom edge
x = w - (d - w - h);
y = h;
} else {
// Left edge
x = 0;
y = h - (d - 2 * w - h);
}
// Create a static path (single point) at this position
const path = {
id: 'path_' + Date.now() + '_' + i,
name: 'Perimeter ' + (i + 1),
points: [{ x: Math.round(x), y: Math.round(y) }],
color: this.getRandomColor(),
isSinglePoint: true,
startTime: 0.0,
endTime: 1.0,
interpolation: 'linear',
visibilityMode: 'pop'
};
this.paths.push(path);
}
this.updateSidebar();
this.render();
}
createCanvasArea(parent) {
const canvasContainer = document.createElement('div');
canvasContainer.style.cssText = `
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at center, #1e1e1e 0%, #0a0a0a 100%);
position: relative;
overflow: hidden;
padding: 20px;
`;
this.canvas = document.createElement('canvas');
this.canvas.width = this.frameWidth;
this.canvas.height = this.frameHeight;
this.canvas.style.cssText = `
border: 1px solid #4a4a4a;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
cursor: crosshair;
max-width: 100%;
max-height: 100%;
border-radius: 4px;
`;
this.ctx = this.canvas.getContext('2d');
// Setup canvas event listeners
this.setupCanvasEvents();
canvasContainer.appendChild(this.canvas);
parent.appendChild(canvasContainer);
// Initial render
this.render();
}
setupCanvasEvents() {
this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
this.canvas.addEventListener('mouseup', (e) => this.onMouseUp(e));
this.canvas.addEventListener('mouseleave', (e) => this.onMouseUp(e));
}
getCanvasCoords(e) {
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY
};
}
onMouseDown(e) {
const pos = this.getCanvasCoords(e);
if (this.tool === 'pencil') {
this.isDrawing = true;
this.currentPath = {
id: 'path_' + Date.now(),
name: 'Path ' + (this.paths.length + 1),
points: [pos],
color: this.currentColor,
closed: false,
isSinglePoint: false,
startTime: 0.0,
endTime: 1.0,
interpolation: 'linear',
visibilityMode: 'pop'
};
} else if (this.tool === 'point') {
// Add single static point
const path = {
id: 'path_' + Date.now(),
name: 'Static ' + (this.paths.filter(p => p.isSinglePoint).length + 1),
points: [pos],
color: this.currentColor,
isSinglePoint: true,
startTime: 0.0,
endTime: 1.0,
interpolation: 'linear',
visibilityMode: 'pop'
};
this.paths.push(path);
this.selectedPathIndex = this.paths.length - 1;
this.currentColor = this.getRandomColor();
this.updateSidebar();
this.render();
} else if (this.tool === 'select') {
// Find clicked path (returns -1 if no path found)
this.selectedPathIndex = this.findPathAtPoint(pos);
// selectedPathIndex will be -1 if clicking empty space, which deselects
this.updateSidebar();
this.render();
} else if (this.tool === 'eraser') {
// Erase path at this point
const pathIndex = this.findPathAtPoint(pos);
if (pathIndex !== -1) {
this.paths.splice(pathIndex, 1);
this.selectedPathIndex = -1;
this.updateSidebar();
this.render();
}
}
}
onMouseMove(e) {
if (this.isDrawing && this.tool === 'pencil') {
const pos = this.getCanvasCoords(e);
// If shift is pressed, draw straight line from last point
if (this.shiftPressed && this.currentPath.points.length > 0) {
// Replace the preview point if it exists, or add it
const lastPoint = this.currentPath.points[this.currentPath.points.length - 1];
// Determine if line should be horizontal, vertical, or diagonal
const dx = Math.abs(pos.x - lastPoint.x);
const dy = Math.abs(pos.y - lastPoint.y);
let constrainedPos;
if (dx > dy * 2) {
// Horizontal
constrainedPos = { x: pos.x, y: lastPoint.y };
} else if (dy > dx * 2) {
// Vertical
constrainedPos = { x: lastPoint.x, y: pos.y };
} else {
// Diagonal 45 degrees
const dist = Math.min(dx, dy);
constrainedPos = {
x: lastPoint.x + (pos.x > lastPoint.x ? dist : -dist),
y: lastPoint.y + (pos.y > lastPoint.y ? dist : -dist)
};
}
// Store original last point count to know if we're previewing
if (!this.shiftPreviewPoint) {
this.shiftPreviewPoint = true;
this.currentPath.points.push(constrainedPos);
} else {
this.currentPath.points[this.currentPath.points.length - 1] = constrainedPos;
}
this.render();
} else {
// Normal drawing - clear shift preview flag
this.shiftPreviewPoint = false;
// Only add point if it's far enough from the last point (smoothing)
const lastPoint = this.currentPath.points[this.currentPath.points.length - 1];
const dist = Math.sqrt(Math.pow(pos.x - lastPoint.x, 2) + Math.pow(pos.y - lastPoint.y, 2));
if (dist > 3) { // Minimum distance between points
this.currentPath.points.push(pos);
this.render();
}
}
}
}
onMouseUp(e) {
if (this.isDrawing && this.currentPath) {
// Clear shift preview flag
this.shiftPreviewPoint = false;
if (this.currentPath.points.length > 1) {
this.paths.push(this.currentPath);
this.selectedPathIndex = this.paths.length - 1;
this.currentColor = this.getRandomColor();
this.updateSidebar();
} else if (this.currentPath.points.length === 1) {
// Single click became a static point
this.currentPath.isSinglePoint = true;
this.currentPath.name = 'Static ' + (this.paths.filter(p => p.isSinglePoint).length + 1);
this.paths.push(this.currentPath);
this.selectedPathIndex = this.paths.length - 1;
this.currentColor = this.getRandomColor();
this.updateSidebar();
}
this.currentPath = null;
this.isDrawing = false;
this.render();
}
}
findPathAtPoint(point, baseThreshold = 10) {
// Scale threshold based on canvas resolution
const scale = this.getRenderScale();
const threshold = baseThreshold * scale;
// Check single points first (easier to select)
for (let i = this.paths.length - 1; i >= 0; i--) {
const path = this.paths[i];
if (path.isSinglePoint || path.points.length === 1) {
const p = path.points[0];
const dist = Math.sqrt(Math.pow(point.x - p.x, 2) + Math.pow(point.y - p.y, 2));
if (dist < threshold) {
return i;
}
}
}
// Then check multi-point paths
for (let i = this.paths.length - 1; i >= 0; i--) {
const path = this.paths[i];
if (!path.isSinglePoint && path.points.length > 1) {
for (let j = 0; j < path.points.length - 1; j++) {
const p1 = path.points[j];
const p2 = path.points[j + 1];
// Check if point is near line segment
const dist = this.distanceToSegment(point, p1, p2);
if (dist < threshold) {
return i;
}
}
}
}
return -1;
}
distanceToSegment(point, p1, p2) {
const A = point.x - p1.x;
const B = point.y - p1.y;
const C = p2.x - p1.x;
const D = p2.y - p1.y;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
let param = -1;
if (lenSq !== 0) param = dot / lenSq;
let xx, yy;
if (param < 0) {
xx = p1.x;
yy = p1.y;
} else if (param > 1) {
xx = p2.x;
yy = p2.y;
} else {
xx = p1.x + param * C;
yy = p1.y + param * D;
}
const dx = point.x - xx;
const dy = point.y - yy;
return Math.sqrt(dx * dx + dy * dy);
}
getRenderScale() {
// Calculate scale factor based on canvas dimensions
// Use 512 as the base resolution for consistent rendering
const baseResolution = 512;
const minDimension = Math.min(this.canvas.width, this.canvas.height);
return minDimension / baseResolution;
}
render() {
if (!this.ctx) return;
// Clear canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Draw background image with opacity
if (this.backgroundImage && this.backgroundImage.complete) {
this.ctx.save();
this.ctx.globalAlpha = this.backgroundOpacity;
this.ctx.drawImage(this.backgroundImage, 0, 0, this.canvas.width, this.canvas.height);
this.ctx.restore();
} else {
this.ctx.fillStyle = '#333';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
// Draw saved paths
this.paths.forEach((path, index) => {
this.drawPath(path, index === this.selectedPathIndex);
});
// Draw current path being drawn
if (this.currentPath) {
this.drawPath(this.currentPath, true);
}
}
drawPath(path, isSelected = false) {
const isSinglePoint = path.isSinglePoint || path.points.length === 1;
const scale = this.getRenderScale();
const neonGreen = '#00FF41'; // Neon green for selection
if (isSinglePoint) {
// Draw single point as a square (static anchor)
const point = path.points[0];
const baseSize = isSelected ? 14 : 8;
const size = baseSize * scale;
this.ctx.fillStyle = isSelected ? neonGreen : path.color;
this.ctx.fillRect(point.x - size / 2, point.y - size / 2, size, size);
// Draw border
this.ctx.strokeStyle = isSelected ? neonGreen : '#fff';
this.ctx.lineWidth = (isSelected ? 2 : 2) * scale;
this.ctx.strokeRect(point.x - size / 2, point.y - size / 2, size, size);
// Draw label for static points
if (isSelected) {
this.ctx.fillStyle = neonGreen;
this.ctx.font = `bold ${12 * scale}px sans-serif`;
this.ctx.fillText('Static', point.x + 10 * scale, point.y - 10 * scale);
}
} else if (path.points.length >= 2) {
// Draw multi-point path (motion path)
this.ctx.beginPath();
this.ctx.moveTo(path.points[0].x, path.points[0].y);
for (let i = 1; i < path.points.length; i++) {
this.ctx.lineTo(path.points[i].x, path.points[i].y);
}
this.ctx.strokeStyle = isSelected ? neonGreen : path.color;
this.ctx.lineWidth = (isSelected ? this.pathThickness + 0.1 : this.pathThickness) * scale;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
this.ctx.stroke();
// Draw animated directional flow indicators (dashed overlay)
this.ctx.save();
this.ctx.beginPath();
this.ctx.moveTo(path.points[0].x, path.points[0].y);
for (let i = 1; i < path.points.length; i++) {
this.ctx.lineTo(path.points[i].x, path.points[i].y);
}
// Animated dashed line showing direction
const dashLength = 10 * scale;
const gapLength = 10 * scale;
this.ctx.setLineDash([dashLength, gapLength]);
this.ctx.lineDashOffset = -this.animationOffset * scale; // Negative for forward motion
this.ctx.strokeStyle = isSelected ? 'rgba(0, 255, 65, 0.8)' : 'rgba(255, 255, 255, 0.6)';
this.ctx.lineWidth = Math.max(1, this.pathThickness * 0.5) * scale;
this.ctx.stroke();
this.ctx.restore();
// Draw points
if (isSelected) {
path.points.forEach((point, idx) => {
// Draw outer ring
this.ctx.beginPath();
this.ctx.arc(point.x, point.y, Math.max(6, this.pathThickness + 2) * scale, 0, Math.PI * 2);
this.ctx.fillStyle = neonGreen;
this.ctx.fill();
// Draw inner point
this.ctx.beginPath();
this.ctx.arc(point.x, point.y, Math.max(3, this.pathThickness * 0.6) * scale, 0, Math.PI * 2);
this.ctx.fillStyle = '#000';
this.ctx.fill();
// Draw point numbers
if (path.points.length < 20) { // Only if not too many points
this.ctx.fillStyle = neonGreen;
this.ctx.font = `bold ${10 * scale}px sans-serif`;
this.ctx.fillText(idx, point.x + 8 * scale, point.y - 8 * scale);
}
});
// Draw label for motion paths
const midPoint = path.points[Math.floor(path.points.length / 2)];
this.ctx.fillStyle = neonGreen;
this.ctx.font = `bold ${12 * scale}px sans-serif`;
this.ctx.fillText(`Motion (${path.points.length} pts)`, midPoint.x + 10 * scale, midPoint.y - 10 * scale);
}
}
}
createSidebar(parent) {
this.sidebar = document.createElement('div');
this.sidebar.style.cssText = `
width: 280px;
background: #1e1e1e;
border-left: 1px solid #444;
padding: 15px;
overflow-y: auto;
`;
const title = document.createElement('h3');
title.textContent = 'Paths';
title.style.cssText = `
margin: 0 0 15px 0;
color: #fff;
font-size: 14px;
font-weight: 500;
`;
this.sidebar.appendChild(title);
this.pathList = document.createElement('div');
this.pathList.style.cssText = `
display: flex;
flex-direction: column;
gap: 8px;
`;
this.sidebar.appendChild(this.pathList);
parent.appendChild(this.sidebar);
this.updateSidebar();
}
updateSidebar() {
if (!this.pathList) return;
this.pathList.innerHTML = '';
this.paths.forEach((path, index) => {
const isSinglePoint = path.isSinglePoint || path.points.length === 1;
const neonGreen = '#00FF41';
const isSelected = index === this.selectedPathIndex;
const item = document.createElement('div');
item.style.cssText = `
padding: 10px;
background: ${isSelected ? 'rgba(0, 255, 65, 0.15)' : '#2b2b2b'};
border: 2px solid ${isSelected ? neonGreen : '#444'};
border-radius: 4px;
cursor: pointer;
color: #fff;
font-size: 12px;
display: flex;
flex-direction: column;
gap: 6px;
transition: all 0.2s ease;
`;
const topRow = document.createElement('div');
topRow.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
`;
const info = document.createElement('div');
info.style.cssText = `
display: flex;
align-items: center;
gap: 8px;
flex: 1;
`;
const colorBox = document.createElement('div');
colorBox.style.cssText = `
width: 16px;
height: 16px;
background: ${isSelected ? neonGreen : path.color};
border-radius: ${isSinglePoint ? '2px' : '50%'};
border: 2px solid ${isSelected ? neonGreen : (isSinglePoint ? '#fff' : 'transparent')};
`;
const nameContainer = document.createElement('div');
nameContainer.style.cssText = `
display: flex;
flex-direction: column;
gap: 2px;
`;
const name = document.createElement('span');
name.textContent = path.name || `Path ${index + 1}`;
name.style.cssText = `
font-weight: 500;
color: ${isSelected ? neonGreen : '#fff'};
`;
const typeLabel = document.createElement('span');
typeLabel.innerHTML = isSinglePoint
? `${Icons.target()} <span style="margin-left: 4px;">Static (1 pt)</span>`
: `${Icons.arrowRight()} <span style="margin-left: 4px;">Motion (${path.points.length} pts)</span>`;
typeLabel.style.cssText = `
font-size: 10px;
color: ${isSelected ? neonGreen : (isSinglePoint ? '#F7DC6F' : '#4ECDC4')};
display: flex;
align-items: center;
`;
nameContainer.appendChild(name);
nameContainer.appendChild(typeLabel);
info.appendChild(colorBox);
info.appendChild(nameContainer);
const deleteBtn = document.createElement('button');
deleteBtn.textContent = '✕';
deleteBtn.style.cssText = `
background: rgba(255, 77, 77, 0.2);
border: 1px solid rgba(255, 77, 77, 0.4);
border-radius: 4px;
color: #ff4d4d;
cursor: pointer;
font-size: 14px;
padding: 4px 8px;
transition: all 0.2s ease;
`;
deleteBtn.onmouseover = () => {
deleteBtn.style.background = 'rgba(255, 77, 77, 0.4)';
deleteBtn.style.borderColor = 'rgba(255, 77, 77, 0.8)';
};
deleteBtn.onmouseout = () => {
deleteBtn.style.background = 'rgba(255, 77, 77, 0.2)';
deleteBtn.style.borderColor = 'rgba(255, 77, 77, 0.4)';
};
deleteBtn.onclick = (e) => {
e.stopPropagation();
this.paths.splice(index, 1);
this.selectedPathIndex = -1;
this.updateSidebar();
this.render();
};
topRow.appendChild(info);
topRow.appendChild(deleteBtn);
item.appendChild(topRow);
// Add timeline controls if selected
if (isSelected) {
const timelineControls = this.createTimelineControls(path, index);
item.appendChild(timelineControls);
}
item.onclick = (e) => {
// Don't trigger selection if clicking on controls
if (e.target.closest('.timeline-controls')) return;
this.selectedPathIndex = index;
this.updateSidebar();
this.render();
};
this.pathList.appendChild(item);
});
}
createTimelineControls(path, pathIndex) {
const container = document.createElement('div');
container.className = 'timeline-controls';
container.style.cssText = `
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
gap: 12px;
`;
// Timeline Range Slider
const timelineSection = document.createElement('div');
timelineSection.style.cssText = `
display: flex;
flex-direction: column;
gap: 6px;
`;
const timelineLabel = document.createElement('label');
timelineLabel.textContent = 'Timeline Range';
timelineLabel.style.cssText = `
color: #fff;
font-size: 11px;
font-weight: 500;
opacity: 0.9;
`;
const timelineSliderContainer = document.createElement('div');
timelineSliderContainer.style.cssText = `
position: relative;
height: 40px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
padding: 8px;
`;
// Create range track
const rangeTrack = document.createElement('div');
rangeTrack.style.cssText = `
position: absolute;
left: 8px;
right: 8px;
top: 50%;
transform: translateY(-50%);
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
`;
// Create active range indicator
const activeRange = document.createElement('div');
const startPercent = (path.startTime || 0) * 100;
const endPercent = (path.endTime || 1) * 100;
activeRange.style.cssText = `
position: absolute;
left: ${startPercent}%;
width: ${endPercent - startPercent}%;
height: 100%;
background: #4ECDC4;
border-radius: 3px;
`;
rangeTrack.appendChild(activeRange);
// Create start handle
const startHandle = this.createRangeHandle('Start', startPercent, true);
// Create end handle
const endHandle = this.createRangeHandle('End', endPercent, false);
// Add drag functionality
this.setupRangeHandleDrag(startHandle, endHandle, activeRange, path, pathIndex, true);
this.setupRangeHandleDrag(endHandle, startHandle, activeRange, path, pathIndex, false);
timelineSliderContainer.appendChild(rangeTrack);
timelineSliderContainer.appendChild(startHandle);
timelineSliderContainer.appendChild(endHandle);
// Timeline values display
const valuesDisplay = document.createElement('div');
valuesDisplay.style.cssText = `
display: flex;
justify-content: space-between;
font-size: 10px;
color: #888;
margin-top: 4px;
`;
valuesDisplay.innerHTML = `
<span>Start: ${Math.round(startPercent)}%</span>
<span>End: ${Math.round(endPercent)}%</span>
`;
timelineSection.appendChild(timelineLabel);
timelineSection.appendChild(timelineSliderContainer);
timelineSection.appendChild(valuesDisplay);
// Interpolation Dropdown
const interpolationSection = document.createElement('div');
interpolationSection.style.cssText = `
display: flex;
flex-direction: column;
gap: 6px;
`;
const interpLabel = document.createElement('label');
interpLabel.textContent = 'Interpolation';
interpLabel.style.cssText = `
color: #fff;
font-size: 11px;
font-weight: 500;
opacity: 0.9;
`;
const interpSelect = document.createElement('select');
interpSelect.style.cssText = `
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: #fff;
padding: 6px;
font-size: 11px;
cursor: pointer;
`;
const interpolationTypes = [
{ value: 'linear', label: 'Linear' },
{ value: 'ease-in', label: 'Ease In' },
{ value: 'ease-out', label: 'Ease Out' },
{ value: 'ease-in-out', label: 'Ease In-Out' }
];
interpolationTypes.forEach(type => {
const option = document.createElement('option');
option.value = type.value;
option.textContent = type.label;
option.selected = (path.interpolation || 'linear') === type.value;
interpSelect.appendChild(option);
});
interpSelect.onchange = (e) => {
e.stopPropagation();
path.interpolation = e.target.value;
this.savePaths();
};
interpolationSection.appendChild(interpLabel);
interpolationSection.appendChild(interpSelect);
// Visibility Mode Dropdown
const visibilitySection = document.createElement('div');
visibilitySection.style.cssText = `
display: flex;
flex-direction: column;
gap: 6px;
`;
const visLabel = document.createElement('label');
visLabel.textContent = 'Visibility Mode';
visLabel.style.cssText = `
color: #fff;
font-size: 11px;
font-weight: 500;
opacity: 0.9;
`;
const visSelect = document.createElement('select');
visSelect.style.cssText = `
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: #fff;
padding: 6px;
font-size: 11px;
cursor: pointer;
`;
const visibilityModes = [
{ value: 'pop', label: 'Pop (Appear/Disappear)' },
{ value: 'static', label: 'Static (Always Visible)' }
];
visibilityModes.forEach(mode => {
const option = document.createElement('option');
option.value = mode.value;
option.textContent = mode.label;
option.selected = (path.visibilityMode || 'pop') === mode.value;
visSelect.appendChild(option);
});
visSelect.onchange = (e) => {
e.stopPropagation();
path.visibilityMode = e.target.value;
this.savePaths();
};
visibilitySection.appendChild(visLabel);
visibilitySection.appendChild(visSelect);
container.appendChild(timelineSection);
container.appendChild(interpolationSection);
container.appendChild(visibilitySection);
return container;
}
createRangeHandle(label, position, isStart) {
const handle = document.createElement('div');
handle.style.cssText = `
position: absolute;
left: ${position}%;
top: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
background: #4ECDC4;
border: 2px solid #fff;
border-radius: 50%;
cursor: ${isStart ? 'e-resize' : 'w-resize'};
z-index: 10;
transition: transform 0.1s ease;
`;
handle.onmouseover = () => {
handle.style.transform = 'translate(-50%, -50%) scale(1.2)';
};
handle.onmouseout = () => {
handle.style.transform = 'translate(-50%, -50%) scale(1)';
};
handle.dataset.label = label;
return handle;
}
setupRangeHandleDrag(handle, otherHandle, activeRange, path, pathIndex, isStart) {
let isDragging = false;
let container = null;
const onMouseDown = (e) => {
e.stopPropagation();
isDragging = true;
container = handle.parentElement;
document.body.style.cursor = isStart ? 'e-resize' : 'w-resize';
};
const onMouseMove = (e) => {
if (!isDragging || !container) return;
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const percent = Math.max(0, Math.min(100, (x / rect.width) * 100));
// Get other handle position
const otherPercent = parseFloat(otherHandle.style.left);
// Constrain to not cross other handle
let constrainedPercent;
if (isStart) {
constrainedPercent = Math.min(percent, otherPercent - 1);
} else {
constrainedPercent = Math.max(percent, otherPercent + 1);
}
// Update handle position
handle.style.left = `${constrainedPercent}%`;
// Update active range
const startPercent = isStart ? constrainedPercent : parseFloat(otherHandle.style.left);
const endPercent = isStart ? parseFloat(otherHandle.style.left) : constrainedPercent;
activeRange.style.left = `${startPercent}%`;
activeRange.style.width = `${endPercent - startPercent}%`;
// Update path data
if (isStart) {
path.startTime = constrainedPercent / 100;
} else {
path.endTime = constrainedPercent / 100;
}
// Update display
const valuesDisplay = container.parentElement.querySelector('div:last-child');
if (valuesDisplay) {
valuesDisplay.innerHTML = `
<span>Start: ${Math.round(startPercent)}%</span>
<span>End: ${Math.round(endPercent)}%</span>
`;
}
};
const onMouseUp = () => {
if (isDragging) {
isDragging = false;
document.body.style.cursor = '';
this.savePaths();
}
};
handle.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
createFooter() {
const footer = document.createElement('div');
footer.style.cssText = `
padding: 15px 20px;
border-top: 1px solid #444;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
`;
const statsContainer = document.createElement('div');
statsContainer.style.cssText = `
color: #888;
font-size: 12px;
`;
const staticCount = this.paths.filter(p => p.isSinglePoint || p.points.length === 1).length;
const motionCount = this.paths.length - staticCount;
statsContainer.textContent = `Total: ${this.paths.length} paths (${staticCount} static, ${motionCount} motion)`;
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
gap: 10px;
`;
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.style.cssText = `
padding: 8px 20px;
background: #444;
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
font-size: 14px;
`;
cancelBtn.onclick = () => this.close();
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Save Paths';
saveBtn.style.cssText = `
padding: 8px 20px;
background: #4ECDC4;
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
font-size: 14px;
font-weight: 500;
`;
saveBtn.onclick = () => {
this.savePaths();
this.close();
};
buttonContainer.appendChild(cancelBtn);
buttonContainer.appendChild(saveBtn);
footer.appendChild(statsContainer);
footer.appendChild(buttonContainer);
this.container.appendChild(footer);
}
show() {
// Add CSS animations if not already added
if (!document.getElementById('fl-path-animator-styles')) {
const style = document.createElement('style');
style.id = 'fl-path-animator-styles';
style.textContent = `
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
`;
document.head.appendChild(style);
}
document.body.appendChild(this.overlay);
// Attach paste listener after modal is shown
this.attachPasteListener();
// Focus the container so it can receive paste events
setTimeout(() => {
this.container.focus();
console.log('FL_PathAnimator: Container focused');
}, 100);
}
close() {
// Stop animation loop
this.stopAnimation();
// Remove keyboard handlers
document.removeEventListener('keydown', this.keydownHandler);
document.removeEventListener('keyup', this.keyupHandler);
// Remove paste handlers from both container and document
if (this.container) {
this.container.removeEventListener('paste', this.pasteHandler);
}
document.removeEventListener('paste', this.pasteHandler);
console.log('FL_PathAnimator: All event listeners removed');
// Fade out animation
this.overlay.style.animation = 'fadeIn 0.15s ease-in reverse';
this.container.style.animation = 'slideIn 0.15s ease-in reverse';
setTimeout(() => {
if (this.overlay.parentNode) {
document.body.removeChild(this.overlay);
}
}, 150);
}
}