12189 lines
417 KiB
JavaScript
12189 lines
417 KiB
JavaScript
const { invoke } = window.__TAURI__.core;
|
||
const { listen } = window.__TAURI__.event;
|
||
import * as fitCurve from "/fit-curve.js";
|
||
import { Bezier } from "/bezier.js";
|
||
import { Quadtree } from "./quadtree.js";
|
||
import {
|
||
createNewFileDialog,
|
||
showNewFileDialog,
|
||
closeDialog,
|
||
} from "./newfile.js";
|
||
import {
|
||
createStartScreen,
|
||
updateStartScreen,
|
||
showStartScreen,
|
||
hideStartScreen,
|
||
} from "./startscreen.js";
|
||
import {
|
||
titleCase,
|
||
getMousePositionFraction,
|
||
getKeyframesSurrounding,
|
||
lerpColor,
|
||
lerp,
|
||
camelToWords,
|
||
generateWaveform,
|
||
floodFillRegion,
|
||
getShapeAtPoint,
|
||
hslToRgb,
|
||
drawCheckerboardBackground,
|
||
hexToHsl,
|
||
hsvToRgb,
|
||
hexToHsv,
|
||
rgbToHex,
|
||
clamp,
|
||
drawBorderedRect,
|
||
drawCenteredText,
|
||
drawHorizontallyCenteredText,
|
||
deepMerge,
|
||
getPointNearBox,
|
||
arraysAreEqual,
|
||
drawRegularPolygon,
|
||
getFileExtension,
|
||
createModal,
|
||
deeploop,
|
||
signedAngleBetweenVectors,
|
||
rotateAroundPoint,
|
||
getRotatedBoundingBox,
|
||
rotateAroundPointIncremental,
|
||
rgbToHsv,
|
||
multiplyMatrices,
|
||
growBoundingBox,
|
||
createMissingTexturePattern,
|
||
distanceToLineSegment,
|
||
} from "./utils.js";
|
||
import {
|
||
backgroundColor,
|
||
darkMode,
|
||
foregroundColor,
|
||
frameWidth,
|
||
gutterHeight,
|
||
highlight,
|
||
iconSize,
|
||
triangleSize,
|
||
labelColor,
|
||
layerHeight,
|
||
layerWidth,
|
||
scrubberColor,
|
||
shade,
|
||
shadow,
|
||
} from "./styles.js";
|
||
import { Icon } from "./icon.js";
|
||
import { AlphaSelectionBar, ColorSelectorWidget, ColorWidget, HueSelectionBar, SaturationValueSelectionGradient, TimelineWindow, TimelineWindowV2, VirtualPiano, PianoRollEditor, Widget } from "./widgets.js";
|
||
import { nodeTypes, SignalType, getPortClass, NodeCategory, getCategories, getNodesByCategory } from "./nodeTypes.js";
|
||
|
||
// State management
|
||
import {
|
||
context,
|
||
config,
|
||
pointerList,
|
||
startProps,
|
||
getShortcut,
|
||
loadConfig,
|
||
saveConfig,
|
||
addRecentFile
|
||
} from "./state.js";
|
||
|
||
// Data models
|
||
import {
|
||
Frame,
|
||
TempFrame,
|
||
tempFrame,
|
||
Keyframe,
|
||
AnimationCurve,
|
||
AnimationData
|
||
} from "./models/animation.js";
|
||
import {
|
||
VectorLayer,
|
||
AudioTrack,
|
||
VideoLayer,
|
||
initializeLayerDependencies
|
||
} from "./models/layer.js";
|
||
import {
|
||
BaseShape,
|
||
TempShape,
|
||
Shape,
|
||
initializeShapeDependencies
|
||
} from "./models/shapes.js";
|
||
import {
|
||
GraphicsObject,
|
||
initializeGraphicsObjectDependencies
|
||
} from "./models/graphics-object.js";
|
||
import { createRoot } from "./models/root.js";
|
||
import { actions, initializeActions, updateAutomationName } from "./actions/index.js";
|
||
|
||
// Layout system
|
||
import { defaultLayouts, getLayout, getLayoutNames } from "./layouts.js";
|
||
import { buildLayout, loadLayoutByKeyOrName, saveCustomLayout, serializeLayout } from "./layoutmanager.js";
|
||
|
||
const {
|
||
writeTextFile: writeTextFile,
|
||
readTextFile: readTextFile,
|
||
writeFile: writeFile,
|
||
readFile: readFile,
|
||
} = window.__TAURI__.fs;
|
||
const {
|
||
open: openFileDialog,
|
||
save: saveFileDialog,
|
||
message: messageDialog,
|
||
confirm: confirmDialog,
|
||
} = window.__TAURI__.dialog;
|
||
const { documentDir, join, basename, appLocalDataDir } = window.__TAURI__.path;
|
||
const { Menu, MenuItem, PredefinedMenuItem, Submenu } = window.__TAURI__.menu;
|
||
const { PhysicalPosition, LogicalPosition } = window.__TAURI__.dpi;
|
||
const { getCurrentWindow } = window.__TAURI__.window;
|
||
const { getVersion } = window.__TAURI__.app;
|
||
|
||
// Supported file extensions
|
||
const imageExtensions = ["png", "gif", "avif", "jpg", "jpeg"];
|
||
const audioExtensions = ["mp3", "wav", "aiff", "ogg", "flac"];
|
||
const videoExtensions = ["mp4", "mov", "avi", "mkv", "webm", "m4v"];
|
||
const midiExtensions = ["mid", "midi"];
|
||
const beamExtensions = ["beam"];
|
||
|
||
// import init, { CoreInterface } from './pkg/lightningbeam_core.js';
|
||
|
||
window.onerror = (message, source, lineno, colno, error) => {
|
||
invoke("error", { msg: `${message} at ${source}:${lineno}:${colno}\n${error?.stack || ''}` });
|
||
};
|
||
|
||
window.addEventListener('unhandledrejection', (event) => {
|
||
invoke("error", { msg: `Unhandled Promise Rejection: ${event.reason?.stack || event.reason}` });
|
||
});
|
||
|
||
function forwardConsole(fnName, dest) {
|
||
const original = console[fnName];
|
||
console[fnName] = (...args) => {
|
||
const error = new Error();
|
||
const stackLines = error.stack.split("\n");
|
||
|
||
let message = args.join(" "); // Join all arguments into a single string
|
||
const location = stackLines.length>1 ? stackLines[1].match(/([a-zA-Z0-9_-]+\.js:\d+)/) : stackLines.toString();
|
||
|
||
if (fnName === "error") {
|
||
// Send the full stack trace for errors
|
||
invoke(dest, { msg: `${message}\nStack trace:\n${stackLines.slice(1).join("\n")}` });
|
||
} else {
|
||
// For other log levels, just extract the file and line number
|
||
invoke(dest, { msg: `${location ? location[0] : 'unknown'}: ${message}` });
|
||
}
|
||
|
||
original(location ? location[0] : 'unknown', ...args); // Pass all arguments to the original console method
|
||
};
|
||
}
|
||
|
||
forwardConsole('trace', "trace");
|
||
forwardConsole('log', "trace");
|
||
forwardConsole('debug', "debug");
|
||
forwardConsole('info', "info");
|
||
forwardConsole('warn', "warn");
|
||
forwardConsole('error', "error");
|
||
|
||
console.log("*** Starting Lightningbeam ***")
|
||
|
||
// Debug flags
|
||
const debugQuadtree = false;
|
||
const debugPaintbucket = false;
|
||
|
||
const macOS = navigator.userAgent.includes("Macintosh");
|
||
|
||
let simplifyPolyline = simplify;
|
||
|
||
let greetInputEl;
|
||
let greetMsgEl;
|
||
let rootPane;
|
||
|
||
let canvases = [];
|
||
|
||
let debugCurves = [];
|
||
let debugPoints = [];
|
||
|
||
// context.mode is now in context.context.mode (defined in state.js)
|
||
|
||
let minSegmentSize = 5;
|
||
let maxSmoothAngle = 0.6;
|
||
|
||
let undoStack = [];
|
||
let redoStack = [];
|
||
let lastSaveIndex = 0;
|
||
|
||
let layoutElements = [];
|
||
|
||
// Version changes:
|
||
// 1.4: addShape uses frame as a reference instead of object
|
||
// 1.6: object coordinates are created relative to their location
|
||
|
||
let minFileVersion = "1.3";
|
||
let maxFileVersion = "2.1";
|
||
|
||
let filePath = undefined;
|
||
let fileExportPath = undefined;
|
||
|
||
let state = "normal";
|
||
|
||
let lastFrameTime;
|
||
|
||
let uiDirty = false;
|
||
let layersDirty = false;
|
||
let menuDirty = false;
|
||
let outlinerDirty = false;
|
||
let infopanelDirty = false;
|
||
let lastErrorMessage = null; // To keep track of the last error
|
||
let repeatCount = 0;
|
||
|
||
let clipboard = [];
|
||
|
||
const CONFIG_FILE_PATH = "config.json";
|
||
const defaultConfig = {};
|
||
|
||
let tools = {
|
||
select: {
|
||
icon: "/assets/select.svg",
|
||
properties: {
|
||
selectedObjects: {
|
||
type: "text",
|
||
label: "Selected Object",
|
||
enabled: () => context.selection.length == 1,
|
||
value: {
|
||
get: () => {
|
||
if (context.selection.length == 1) {
|
||
return context.selection[0].name;
|
||
} else if (context.selection.length == 0) {
|
||
return "";
|
||
} else {
|
||
return "<multiple>";
|
||
}
|
||
},
|
||
set: (val) => {
|
||
if (context.selection.length == 1) {
|
||
actions.setName.create(context.selection[0], val);
|
||
}
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
transform: {
|
||
icon: "/assets/transform.svg",
|
||
properties: {},
|
||
},
|
||
draw: {
|
||
icon: "/assets/draw.svg",
|
||
properties: {
|
||
lineWidth: {
|
||
type: "number",
|
||
label: "Line Width",
|
||
},
|
||
simplifyMode: {
|
||
type: "enum",
|
||
options: ["corners", "smooth", "verbatim"], // "auto"],
|
||
label: "Line Mode",
|
||
},
|
||
fillShape: {
|
||
type: "boolean",
|
||
label: "Fill Shape",
|
||
},
|
||
},
|
||
},
|
||
rectangle: {
|
||
icon: "/assets/rectangle.svg",
|
||
properties: {
|
||
lineWidth: {
|
||
type: "number",
|
||
label: "Line Width",
|
||
},
|
||
fillShape: {
|
||
type: "boolean",
|
||
label: "Fill Shape",
|
||
},
|
||
},
|
||
},
|
||
ellipse: {
|
||
icon: "assets/ellipse.svg",
|
||
properties: {
|
||
lineWidth: {
|
||
type: "number",
|
||
label: "Line Width",
|
||
},
|
||
fillShape: {
|
||
type: "boolean",
|
||
label: "Fill Shape",
|
||
},
|
||
},
|
||
},
|
||
paint_bucket: {
|
||
icon: "/assets/paint_bucket.svg",
|
||
properties: {
|
||
fillGaps: {
|
||
type: "number",
|
||
label: "Fill Gaps",
|
||
min: 1,
|
||
},
|
||
},
|
||
},
|
||
eyedropper: {
|
||
icon: "/assets/eyedropper.svg",
|
||
properties: {
|
||
dropperColor: {
|
||
type: "enum",
|
||
options: ["Fill color", "Stroke color"],
|
||
label: "Color"
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
let mouseEvent;
|
||
|
||
// Note: context, config, pointerList, startProps, getShortcut, loadConfig, saveConfig, addRecentFile
|
||
// are now imported from state.js
|
||
|
||
// Note: actions object is now imported from ./actions/index.js
|
||
// The actions will be initialized later with dependencies via initializeActions()
|
||
|
||
// Expose context and actions for UI testing
|
||
window.context = context;
|
||
window.actions = actions;
|
||
window.addKeyframeAtPlayhead = addKeyframeAtPlayhead;
|
||
window.updateVideoFrames = null; // Will be set after function is defined
|
||
|
||
// IPC Benchmark function - run from console: testIPCBenchmark()
|
||
window.testIPCBenchmark = async function() {
|
||
const { invoke, Channel } = window.__TAURI__.core;
|
||
|
||
// Test sizes: 1KB, 10KB, 50KB, 100KB, 500KB, 1MB, 2MB, 5MB
|
||
const testSizes = [
|
||
1024, // 1 KB
|
||
10 * 1024, // 10 KB
|
||
50 * 1024, // 50 KB
|
||
100 * 1024, // 100 KB
|
||
500 * 1024, // 500 KB
|
||
1024 * 1024, // 1 MB
|
||
2 * 1024 * 1024, // 2 MB
|
||
5 * 1024 * 1024 // 5 MB
|
||
];
|
||
|
||
console.log('\n=== IPC Benchmark Starting ===\n');
|
||
console.log('Size (KB)\tJS Total (ms)\tJS IPC (ms)\tJS Recv (ms)\tThroughput (MB/s)');
|
||
console.log('─'.repeat(80));
|
||
|
||
for (const sizeBytes of testSizes) {
|
||
const t_start = performance.now();
|
||
|
||
let receivedData = null;
|
||
const dataPromise = new Promise((resolve, reject) => {
|
||
const channel = new Channel();
|
||
|
||
channel.onmessage = (data) => {
|
||
const t_recv_start = performance.now();
|
||
receivedData = data;
|
||
const t_recv_end = performance.now();
|
||
resolve(t_recv_end - t_recv_start);
|
||
};
|
||
|
||
invoke('video_ipc_benchmark', {
|
||
sizeBytes: sizeBytes,
|
||
channel: channel
|
||
}).catch(reject);
|
||
});
|
||
|
||
const recv_time = await dataPromise;
|
||
const t_after_ipc = performance.now();
|
||
|
||
const total_time = t_after_ipc - t_start;
|
||
const ipc_time = total_time - recv_time;
|
||
const size_kb = sizeBytes / 1024;
|
||
const size_mb = sizeBytes / (1024 * 1024);
|
||
const throughput = size_mb / (total_time / 1000);
|
||
|
||
console.log(`${size_kb.toFixed(0)}\t\t${total_time.toFixed(2)}\t\t${ipc_time.toFixed(2)}\t\t${recv_time.toFixed(2)}\t\t${throughput.toFixed(2)}`);
|
||
|
||
// Small delay between tests
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
}
|
||
|
||
console.log('\n=== IPC Benchmark Complete ===\n');
|
||
console.log('Run again with: testIPCBenchmark()');
|
||
};
|
||
|
||
function uuidv4() {
|
||
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
|
||
(
|
||
+c ^
|
||
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))
|
||
).toString(16),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Generate a consistent pastel color from a UUID string
|
||
* Uses hash of UUID to ensure same UUID always produces same color
|
||
*/
|
||
function uuidToColor(uuid) {
|
||
// Simple hash function
|
||
let hash = 0;
|
||
for (let i = 0; i < uuid.length; i++) {
|
||
hash = uuid.charCodeAt(i) + ((hash << 5) - hash);
|
||
hash = hash & hash; // Convert to 32-bit integer
|
||
}
|
||
|
||
// Generate HSL color with fixed saturation and lightness for pastel appearance
|
||
const hue = Math.abs(hash % 360);
|
||
const saturation = 65; // Medium saturation for pleasant pastels
|
||
const lightness = 70; // Light enough to be pastel but readable
|
||
|
||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||
}
|
||
|
||
function vectorDist(a, b) {
|
||
return Math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y));
|
||
}
|
||
|
||
function getMousePos(canvas, evt, skipOffsets = false, skipZoom = false) {
|
||
var rect = canvas.getBoundingClientRect();
|
||
let offsetX = canvas.offsetX || 0;
|
||
let offsetY = canvas.offsetY || 0;
|
||
let zoomLevel = canvas.zoomLevel || 1;
|
||
if (skipOffsets) {
|
||
offsetX = 0;
|
||
offsetY = 0;
|
||
}
|
||
return {
|
||
x: (evt.clientX + offsetX - rect.left) / (skipZoom ? 1 : zoomLevel),
|
||
y: (evt.clientY + offsetY - rect.top) / (skipZoom ? 1 : zoomLevel),
|
||
};
|
||
}
|
||
|
||
function getProperty(context, path) {
|
||
let pointer = context;
|
||
let pathComponents = path.split(".");
|
||
for (let component of pathComponents) {
|
||
pointer = pointer[component];
|
||
}
|
||
return pointer;
|
||
}
|
||
|
||
function setProperty(context, path, value) {
|
||
let pointer = context;
|
||
let pathComponents = path.split(".");
|
||
let finalComponent = pathComponents.pop();
|
||
for (let component of pathComponents) {
|
||
pointer = pointer[component];
|
||
}
|
||
pointer[finalComponent] = value;
|
||
}
|
||
|
||
function selectCurve(context, mouse) {
|
||
let mouseTolerance = 15;
|
||
let closestDist = mouseTolerance;
|
||
let closestCurve = undefined;
|
||
let closestShape = undefined;
|
||
|
||
// Get visible shapes from Layer using AnimationData
|
||
let currentTime = context.activeObject?.currentTime || 0;
|
||
let layer = context.activeObject?.activeLayer;
|
||
if (!layer) return undefined;
|
||
|
||
// AudioTracks don't have shapes, so return early
|
||
if (!layer.getVisibleShapes) return undefined;
|
||
|
||
for (let shape of layer.getVisibleShapes(currentTime)) {
|
||
if (
|
||
mouse.x > shape.boundingBox.x.min - mouseTolerance &&
|
||
mouse.x < shape.boundingBox.x.max + mouseTolerance &&
|
||
mouse.y > shape.boundingBox.y.min - mouseTolerance &&
|
||
mouse.y < shape.boundingBox.y.max + mouseTolerance
|
||
) {
|
||
for (let curve of shape.curves) {
|
||
let dist = vectorDist(mouse, curve.project(mouse));
|
||
if (dist <= closestDist) {
|
||
closestDist = dist;
|
||
closestCurve = curve;
|
||
closestShape = shape;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (closestCurve) {
|
||
return { curve: closestCurve, shape: closestShape };
|
||
} else {
|
||
return undefined;
|
||
}
|
||
}
|
||
function selectVertex(context, mouse) {
|
||
let mouseTolerance = 15;
|
||
let closestDist = mouseTolerance;
|
||
let closestVertex = undefined;
|
||
let closestShape = undefined;
|
||
|
||
// Get visible shapes from Layer using AnimationData
|
||
let currentTime = context.activeObject?.currentTime || 0;
|
||
let layer = context.activeObject?.activeLayer;
|
||
if (!layer) return undefined;
|
||
|
||
// AudioTracks don't have shapes, so return early
|
||
if (!layer.getVisibleShapes) return undefined;
|
||
|
||
for (let shape of layer.getVisibleShapes(currentTime)) {
|
||
if (
|
||
mouse.x > shape.boundingBox.x.min - mouseTolerance &&
|
||
mouse.x < shape.boundingBox.x.max + mouseTolerance &&
|
||
mouse.y > shape.boundingBox.y.min - mouseTolerance &&
|
||
mouse.y < shape.boundingBox.y.max + mouseTolerance
|
||
) {
|
||
for (let vertex of shape.vertices) {
|
||
let dist = vectorDist(mouse, vertex.point);
|
||
if (dist <= closestDist) {
|
||
closestDist = dist;
|
||
closestVertex = vertex;
|
||
closestShape = shape;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (closestVertex) {
|
||
return { vertex: closestVertex, shape: closestShape };
|
||
} else {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
function moldCurve(curve, mouse, oldMouse, epsilon = 0.01) {
|
||
// Step 1: Find the closest point on the curve to the old mouse position
|
||
const projection = curve.project(oldMouse);
|
||
let t = projection.t;
|
||
const P1 = curve.points[1];
|
||
const P2 = curve.points[2];
|
||
// Make copies of the control points to avoid editing the original curve
|
||
const newP1 = { ...P1 };
|
||
const newP2 = { ...P2 };
|
||
|
||
// Step 2: Create new Bezier curves with the control points slightly offset
|
||
const offsetP1 = { x: P1.x + epsilon, y: P1.y + epsilon };
|
||
const offsetP2 = { x: P2.x + epsilon, y: P2.y + epsilon };
|
||
const offsetCurveP1 = new Bezier(
|
||
curve.points[0],
|
||
offsetP1,
|
||
curve.points[2],
|
||
curve.points[3],
|
||
);
|
||
const offsetCurveP2 = new Bezier(
|
||
curve.points[0],
|
||
curve.points[1],
|
||
offsetP2,
|
||
curve.points[3],
|
||
);
|
||
|
||
// Step 3: See where the same point lands on the offset curves
|
||
const offset1 = offsetCurveP1.compute(t);
|
||
const offset2 = offsetCurveP2.compute(t);
|
||
|
||
// Step 4: Calculate derivatives with respect to control points
|
||
const derivativeP1 = {
|
||
x: (offset1.x - projection.x) / epsilon,
|
||
y: (offset1.y - projection.y) / epsilon,
|
||
};
|
||
const derivativeP2 = {
|
||
x: (offset2.x - projection.x) / epsilon,
|
||
y: (offset2.y - projection.y) / epsilon,
|
||
};
|
||
|
||
// Step 5: Use the derivatives to move the projected point to the mouse
|
||
const deltaX = mouse.x - projection.x;
|
||
const deltaY = mouse.y - projection.y;
|
||
|
||
newP1.x = newP1.x + (deltaX / derivativeP1.x) * (1 - t * t);
|
||
newP1.y = newP1.y + (deltaY / derivativeP1.y) * (1 - t * t);
|
||
newP2.x = newP2.x + (deltaX / derivativeP2.x) * t * t;
|
||
newP2.y = newP2.y + (deltaY / derivativeP2.y) * t * t;
|
||
|
||
// Return the updated Bezier curve
|
||
return new Bezier(curve.points[0], newP1, newP2, curve.points[3]);
|
||
}
|
||
|
||
function deriveControlPoints(S, A, E, e1, e2, t) {
|
||
// Deriving the control points is effectively "doing what
|
||
// we talk about in the section", in code:
|
||
|
||
const v1 = {
|
||
x: A.x - (A.x - e1.x) / (1 - t),
|
||
y: A.y - (A.y - e1.y) / (1 - t),
|
||
};
|
||
const v2 = {
|
||
x: A.x - (A.x - e2.x) / t,
|
||
y: A.y - (A.y - e2.y) / t,
|
||
};
|
||
|
||
const C1 = {
|
||
x: S.x + (v1.x - S.x) / t,
|
||
y: S.y + (v1.y - S.y) / t,
|
||
};
|
||
const C2 = {
|
||
x: E.x + (v2.x - E.x) / (1 - t),
|
||
y: E.y + (v2.y - E.y) / (1 - t),
|
||
};
|
||
|
||
return { v1, v2, C1, C2 };
|
||
}
|
||
|
||
function regionToBbox(region) {
|
||
return {
|
||
x: {
|
||
min: Math.min(region.x1, region.x2),
|
||
max: Math.max(region.x1, region.x2),
|
||
},
|
||
y: {
|
||
min: Math.min(region.y1, region.y2),
|
||
max: Math.max(region.y1, region.y2),
|
||
},
|
||
};
|
||
}
|
||
|
||
function hitTest(candidate, object) {
|
||
let bbox = object.bbox();
|
||
if (candidate.x.min) {
|
||
// We're checking a bounding box
|
||
if (
|
||
candidate.x.min < bbox.x.max &&
|
||
candidate.x.max > bbox.x.min &&
|
||
candidate.y.min < bbox.y.max &&
|
||
candidate.y.max > bbox.y.min
|
||
) {
|
||
return true;
|
||
} else {
|
||
return false;
|
||
}
|
||
} else {
|
||
// We're checking a point
|
||
if (
|
||
candidate.x > bbox.x.min &&
|
||
candidate.x < bbox.x.max &&
|
||
candidate.y > bbox.y.min &&
|
||
candidate.y < bbox.y.max
|
||
) {
|
||
return true;
|
||
} else {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
function undo() {
|
||
let action = undoStack.pop();
|
||
if (action) {
|
||
actions[action.name].rollback(action.action);
|
||
redoStack.push(action);
|
||
updateUI();
|
||
updateMenu();
|
||
} else {
|
||
console.log("No actions to undo");
|
||
updateMenu();
|
||
}
|
||
}
|
||
|
||
function redo() {
|
||
let action = redoStack.pop();
|
||
if (action) {
|
||
actions[action.name].execute(action.action);
|
||
undoStack.push(action);
|
||
updateUI();
|
||
updateMenu();
|
||
} else {
|
||
console.log("No actions to redo");
|
||
updateMenu();
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Animation system classes (Frame, TempFrame, Keyframe, AnimationCurve, AnimationData)
|
||
// have been moved to src/models/animation.js and are imported at the top of this file
|
||
// ============================================================================
|
||
|
||
// ============================================================================
|
||
// Layer system classes (VectorLayer, AudioTrack, VideoLayer)
|
||
// have been moved to src/models/layer.js and are imported at the top of this file
|
||
// ============================================================================
|
||
|
||
// ============================================================================
|
||
// Shape classes (BaseShape, TempShape, Shape)
|
||
// have been moved to src/models/shapes.js and are imported at the top of this file
|
||
// ============================================================================
|
||
|
||
// ============================================================================
|
||
// GraphicsObject class
|
||
// has been moved to src/models/graphics-object.js and is imported at the top of this file
|
||
// ============================================================================
|
||
|
||
// Initialize layer and shape dependencies now that all classes are loaded
|
||
initializeLayerDependencies({
|
||
GraphicsObject,
|
||
Shape,
|
||
TempShape,
|
||
updateUI,
|
||
updateMenu,
|
||
updateLayers,
|
||
vectorDist,
|
||
minSegmentSize,
|
||
debugQuadtree,
|
||
debugCurves,
|
||
debugPoints,
|
||
debugPaintbucket,
|
||
d3: window.d3,
|
||
actions,
|
||
});
|
||
|
||
initializeShapeDependencies({
|
||
growBoundingBox,
|
||
lerp,
|
||
lerpColor,
|
||
uuidToColor,
|
||
simplifyPolyline,
|
||
fitCurve,
|
||
createMissingTexturePattern,
|
||
debugQuadtree,
|
||
d3: window.d3,
|
||
});
|
||
|
||
initializeGraphicsObjectDependencies({
|
||
growBoundingBox,
|
||
getRotatedBoundingBox,
|
||
multiplyMatrices,
|
||
uuidToColor,
|
||
});
|
||
|
||
// ============ ROOT OBJECT INITIALIZATION ============
|
||
// Extracted to: models/root.js
|
||
let _rootInternal = createRoot();
|
||
console.log('[INIT] Setting root.frameRate to config.framerate:', config.framerate);
|
||
_rootInternal.frameRate = config.framerate;
|
||
console.log('[INIT] root.frameRate is now:', _rootInternal.frameRate);
|
||
|
||
// Make root a global variable with getter/setter to catch reassignments
|
||
let __root = new Proxy(_rootInternal, {
|
||
get(target, prop) {
|
||
return Reflect.get(target, prop);
|
||
},
|
||
set(target, prop, value) {
|
||
return Reflect.set(target, prop, value);
|
||
}
|
||
});
|
||
|
||
Object.defineProperty(globalThis, 'root', {
|
||
get() {
|
||
return __root;
|
||
},
|
||
set(newRoot) {
|
||
__root = newRoot;
|
||
},
|
||
configurable: true,
|
||
enumerable: true
|
||
});
|
||
|
||
async function greet() {
|
||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||
greetMsgEl.textContent = await invoke("greet", { name: greetInputEl.value });
|
||
}
|
||
|
||
window.addEventListener("DOMContentLoaded", () => {
|
||
rootPane = document.querySelector("#root");
|
||
rootPane.appendChild(createPane(panes.toolbar));
|
||
rootPane.addEventListener("pointermove", (e) => {
|
||
mouseEvent = e;
|
||
});
|
||
let [_toolbar, panel] = splitPane(
|
||
rootPane,
|
||
10,
|
||
true,
|
||
createPane(panes.timeline),
|
||
);
|
||
let [stageAndTimeline, _infopanel] = splitPane(
|
||
panel,
|
||
70,
|
||
false,
|
||
createPane(panes.infopanel),
|
||
);
|
||
let [_timeline, _stage] = splitPane(
|
||
stageAndTimeline,
|
||
30,
|
||
false,
|
||
createPane(panes.stage),
|
||
);
|
||
|
||
// Initialize audio system on startup
|
||
(async () => {
|
||
try {
|
||
console.log('Initializing audio system...');
|
||
const result = await invoke('audio_init', { bufferSize: config.audioBufferSize });
|
||
console.log('Audio system initialized:', result);
|
||
} catch (error) {
|
||
if (error === 'Audio already initialized') {
|
||
console.log('Audio system already initialized');
|
||
} else {
|
||
console.error('Failed to initialize audio system:', error);
|
||
}
|
||
}
|
||
})();
|
||
});
|
||
|
||
window.addEventListener("resize", () => {
|
||
updateAll();
|
||
});
|
||
|
||
window.addEventListener("click", function (event) {
|
||
const popupMenu = document.getElementById("popupMenu");
|
||
|
||
// If the menu exists and the click is outside the menu and any button with the class 'paneButton', remove the menu
|
||
if (
|
||
popupMenu &&
|
||
!popupMenu.contains(event.target) &&
|
||
!event.target.classList.contains("paneButton")
|
||
) {
|
||
popupMenu.remove(); // Remove the menu from the DOM
|
||
}
|
||
});
|
||
|
||
window.addEventListener("contextmenu", async (e) => {
|
||
e.preventDefault()
|
||
// const menu = await Menu.new({
|
||
// items: [
|
||
// ],
|
||
// });
|
||
// menu.popup({ x: e.clientX, y: e.clientY });
|
||
})
|
||
|
||
window.addEventListener("keydown", (e) => {
|
||
// let shortcuts = {}
|
||
// for (let shortcut of config.shortcuts) {
|
||
// shortcut = shortcut.split("+")
|
||
// TODO
|
||
// }
|
||
if (
|
||
e.target.tagName === "INPUT" ||
|
||
e.target.tagName === "TEXTAREA" ||
|
||
e.target.isContentEditable
|
||
) {
|
||
return; // Do nothing if the event target is an input field, textarea, or contenteditable element
|
||
}
|
||
// console.log(e);
|
||
let mod = macOS ? e.metaKey : e.ctrlKey;
|
||
let key = (mod ? "<mod>" : "") + e.key;
|
||
switch (key) {
|
||
case config.shortcuts.playAnimation:
|
||
console.log("Spacebar pressed");
|
||
playPause();
|
||
e.preventDefault(); // Prevent spacebar from clicking focused buttons
|
||
break;
|
||
case config.shortcuts.selectAll:
|
||
e.preventDefault();
|
||
break;
|
||
// TODO: put these in shortcuts
|
||
case "<mod>ArrowRight":
|
||
advance();
|
||
e.preventDefault();
|
||
break;
|
||
case "ArrowRight":
|
||
if (context.selection.length) {
|
||
const layer = context.activeObject.activeLayer;
|
||
const time = context.activeObject.currentTime || 0;
|
||
let oldPositions = {};
|
||
let newPositions = {};
|
||
|
||
for (let item of context.selection) {
|
||
const oldX = layer.animationData.interpolate(`object.${item.idx}.x`, time) || item.x;
|
||
const oldY = layer.animationData.interpolate(`object.${item.idx}.y`, time) || item.y;
|
||
oldPositions[item.idx] = { x: oldX, y: oldY };
|
||
item.x = oldX + 1;
|
||
newPositions[item.idx] = { x: item.x, y: item.y };
|
||
}
|
||
|
||
actions.moveObjects.create(context.selection, layer, time, oldPositions, newPositions);
|
||
}
|
||
e.preventDefault();
|
||
break;
|
||
case "<mod>ArrowLeft":
|
||
rewind();
|
||
break;
|
||
case "ArrowLeft":
|
||
if (context.selection.length) {
|
||
const layer = context.activeObject.activeLayer;
|
||
const time = context.activeObject.currentTime || 0;
|
||
let oldPositions = {};
|
||
let newPositions = {};
|
||
|
||
for (let item of context.selection) {
|
||
const oldX = layer.animationData.interpolate(`object.${item.idx}.x`, time) || item.x;
|
||
const oldY = layer.animationData.interpolate(`object.${item.idx}.y`, time) || item.y;
|
||
oldPositions[item.idx] = { x: oldX, y: oldY };
|
||
item.x = oldX - 1;
|
||
newPositions[item.idx] = { x: item.x, y: item.y };
|
||
}
|
||
|
||
actions.moveObjects.create(context.selection, layer, time, oldPositions, newPositions);
|
||
}
|
||
e.preventDefault();
|
||
break;
|
||
case "ArrowUp":
|
||
if (context.selection.length) {
|
||
const layer = context.activeObject.activeLayer;
|
||
const time = context.activeObject.currentTime || 0;
|
||
let oldPositions = {};
|
||
let newPositions = {};
|
||
|
||
for (let item of context.selection) {
|
||
const oldX = layer.animationData.interpolate(`object.${item.idx}.x`, time) || item.x;
|
||
const oldY = layer.animationData.interpolate(`object.${item.idx}.y`, time) || item.y;
|
||
oldPositions[item.idx] = { x: oldX, y: oldY };
|
||
item.y = oldY - 1;
|
||
newPositions[item.idx] = { x: item.x, y: item.y };
|
||
}
|
||
|
||
actions.moveObjects.create(context.selection, layer, time, oldPositions, newPositions);
|
||
}
|
||
e.preventDefault();
|
||
break;
|
||
case "ArrowDown":
|
||
if (context.selection.length) {
|
||
const layer = context.activeObject.activeLayer;
|
||
const time = context.activeObject.currentTime || 0;
|
||
let oldPositions = {};
|
||
let newPositions = {};
|
||
|
||
for (let item of context.selection) {
|
||
const oldX = layer.animationData.interpolate(`object.${item.idx}.x`, time) || item.x;
|
||
const oldY = layer.animationData.interpolate(`object.${item.idx}.y`, time) || item.y;
|
||
oldPositions[item.idx] = { x: oldX, y: oldY };
|
||
item.y = oldY + 1;
|
||
newPositions[item.idx] = { x: item.x, y: item.y };
|
||
}
|
||
|
||
actions.moveObjects.create(context.selection, layer, time, oldPositions, newPositions);
|
||
}
|
||
e.preventDefault();
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
});
|
||
|
||
async function playPause() {
|
||
context.playing = !context.playing;
|
||
if (context.playing) {
|
||
// Reset to start if we're at the end
|
||
const duration = context.activeObject.duration;
|
||
if (duration > 0 && context.activeObject.currentTime >= duration) {
|
||
context.activeObject.currentTime = 0;
|
||
}
|
||
|
||
// Sync playhead position with DAW backend before starting
|
||
try {
|
||
await invoke('audio_seek', { seconds: context.activeObject.currentTime });
|
||
} catch (error) {
|
||
console.error('Failed to seek audio:', error);
|
||
}
|
||
|
||
// Start DAW backend audio playback
|
||
try {
|
||
await invoke('audio_play');
|
||
} catch (error) {
|
||
console.error('Failed to start audio playback:', error);
|
||
}
|
||
|
||
// Re-enable auto-scroll when playback starts
|
||
if (context.pianoRollEditor) {
|
||
context.pianoRollEditor.autoScrollEnabled = true;
|
||
}
|
||
|
||
playbackLoop();
|
||
} else {
|
||
// Stop recording if active
|
||
if (context.isRecording) {
|
||
console.log('playPause - stopping recording for clip:', context.recordingClipId);
|
||
try {
|
||
await invoke('audio_stop_recording');
|
||
context.isRecording = false;
|
||
context.recordingTrackId = null;
|
||
context.recordingClipId = null;
|
||
console.log('Recording stopped by play/pause button');
|
||
|
||
// Update record button appearance if it exists
|
||
if (context.recordButton) {
|
||
context.recordButton.className = "playback-btn playback-btn-record";
|
||
context.recordButton.title = "Record";
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to stop recording:', error);
|
||
}
|
||
}
|
||
|
||
// Stop DAW backend audio playback
|
||
try {
|
||
await invoke('audio_stop');
|
||
} catch (error) {
|
||
console.error('Failed to stop audio playback:', error);
|
||
}
|
||
}
|
||
|
||
// Update play/pause button appearance if it exists
|
||
if (context.playPauseButton) {
|
||
context.playPauseButton.className = context.playing ? "playback-btn playback-btn-pause" : "playback-btn playback-btn-play";
|
||
context.playPauseButton.title = context.playing ? "Pause" : "Play";
|
||
}
|
||
}
|
||
|
||
// Playback animation loop - redraws UI while playing
|
||
// Note: Time is synchronized from DAW via PlaybackPosition events
|
||
function playbackLoop() {
|
||
// Redraw stage and timeline
|
||
updateUI();
|
||
if (context.timelineWidget?.requestRedraw) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
|
||
if (context.playing) {
|
||
const duration = context.activeObject.duration;
|
||
|
||
// Check if we've reached the end (but allow infinite playback when recording)
|
||
if (context.isRecording || (duration > 0 && context.activeObject.currentTime < duration)) {
|
||
// Continue playing
|
||
requestAnimationFrame(playbackLoop);
|
||
} else {
|
||
// Animation finished
|
||
context.playing = false;
|
||
|
||
// Stop DAW backend audio playback
|
||
invoke('audio_stop').catch(error => {
|
||
console.error('Failed to stop audio playback:', error);
|
||
});
|
||
|
||
// Update play/pause button appearance
|
||
if (context.playPauseButton) {
|
||
context.playPauseButton.className = "playback-btn playback-btn-play";
|
||
context.playPauseButton.title = "Play";
|
||
}
|
||
|
||
for (let audioTrack of context.activeObject.audioTracks) {
|
||
for (let i in audioTrack.sounds) {
|
||
let sound = audioTrack.sounds[i];
|
||
sound.player.stop();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update video frames for all VideoLayers in the scene
|
||
async function updateVideoFrames(currentTime) {
|
||
// Recursively find all VideoLayers in the scene
|
||
function findVideoLayers(obj) {
|
||
const videoLayers = [];
|
||
if (obj.layers) {
|
||
for (let layer of obj.layers) {
|
||
if (layer.type === 'video') {
|
||
videoLayers.push(layer);
|
||
}
|
||
}
|
||
}
|
||
// Recursively check children (GraphicsObjects can contain other GraphicsObjects)
|
||
if (obj.children) {
|
||
for (let child of obj.children) {
|
||
videoLayers.push(...findVideoLayers(child));
|
||
}
|
||
}
|
||
return videoLayers;
|
||
}
|
||
|
||
const videoLayers = findVideoLayers(context.activeObject);
|
||
|
||
// Update all video layers in parallel
|
||
await Promise.all(videoLayers.map(layer => layer.updateFrame(currentTime)));
|
||
|
||
// Note: No updateUI() call here - renderUI() will draw after awaiting this function
|
||
}
|
||
|
||
// Expose updateVideoFrames globally
|
||
window.updateVideoFrames = updateVideoFrames;
|
||
|
||
// Single-step forward by one frame/second
|
||
function advance() {
|
||
if (context.timelineWidget?.timelineState?.timeFormat === "frames") {
|
||
context.activeObject.currentTime += 1 / context.activeObject.frameRate;
|
||
} else {
|
||
context.activeObject.currentTime += 1;
|
||
}
|
||
|
||
// Sync timeline playhead position
|
||
if (context.timelineWidget?.timelineState) {
|
||
context.timelineWidget.timelineState.currentTime = context.activeObject.currentTime;
|
||
}
|
||
|
||
// Sync DAW backend
|
||
invoke('audio_seek', { seconds: context.activeObject.currentTime });
|
||
|
||
// Update video frames
|
||
updateVideoFrames(context.activeObject.currentTime);
|
||
|
||
updateLayers();
|
||
updateMenu();
|
||
updateUI();
|
||
if (context.timelineWidget?.requestRedraw) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
}
|
||
|
||
// Calculate which MIDI notes are currently playing at a given time (efficient binary search)
|
||
function getPlayingNotesAtTime(time) {
|
||
const playingNotes = [];
|
||
|
||
// Check all MIDI tracks
|
||
for (const track of context.activeObject.audioTracks) {
|
||
if (track.type !== 'midi') continue;
|
||
|
||
// Check all clips in the track
|
||
for (const clip of track.clips) {
|
||
if (!clip.notes || clip.notes.length === 0) continue;
|
||
|
||
// Check if current time is within the clip's range
|
||
const clipLocalTime = time - clip.startTime;
|
||
if (clipLocalTime < 0 || clipLocalTime > clip.duration) {
|
||
continue;
|
||
}
|
||
|
||
// Binary search to find the first note that might be playing
|
||
// Notes are sorted by start_time
|
||
let left = 0;
|
||
let right = clip.notes.length - 1;
|
||
let firstCandidate = clip.notes.length;
|
||
|
||
while (left <= right) {
|
||
const mid = Math.floor((left + right) / 2);
|
||
const note = clip.notes[mid];
|
||
const noteEndTime = note.start_time + note.duration;
|
||
|
||
if (noteEndTime <= clipLocalTime) {
|
||
// This note ends before current time, search right
|
||
left = mid + 1;
|
||
} else {
|
||
// This note might be playing or starts after current time
|
||
firstCandidate = mid;
|
||
right = mid - 1;
|
||
}
|
||
}
|
||
|
||
// Check notes from firstCandidate onwards until we find one that starts after current time
|
||
for (let i = firstCandidate; i < clip.notes.length; i++) {
|
||
const note = clip.notes[i];
|
||
|
||
// If note starts after current time, we're done with this clip
|
||
if (note.start_time > clipLocalTime) {
|
||
break;
|
||
}
|
||
|
||
// Check if note is currently playing
|
||
const noteEndTime = note.start_time + note.duration;
|
||
if (note.start_time <= clipLocalTime && clipLocalTime < noteEndTime) {
|
||
playingNotes.push(note.note);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return playingNotes;
|
||
}
|
||
|
||
// Handle audio events pushed from Rust via Tauri event system
|
||
async function handleAudioEvent(event) {
|
||
switch (event.type) {
|
||
case 'PlaybackPosition':
|
||
// Sync frontend time with DAW time
|
||
if (context.playing) {
|
||
// Quantize time to framerate for animation playback
|
||
const framerate = context.activeObject.frameRate;
|
||
const frameDuration = 1 / framerate;
|
||
const quantizedTime = Math.floor(event.time / frameDuration) * frameDuration;
|
||
|
||
context.activeObject.currentTime = quantizedTime;
|
||
if (context.timelineWidget?.timelineState) {
|
||
context.timelineWidget.timelineState.currentTime = quantizedTime;
|
||
}
|
||
|
||
// Update video frames
|
||
updateVideoFrames(quantizedTime);
|
||
|
||
// Update time display
|
||
if (context.updateTimeDisplay) {
|
||
context.updateTimeDisplay();
|
||
}
|
||
|
||
// Update piano widget with currently playing notes
|
||
if (context.pianoWidget && context.pianoRedraw) {
|
||
const playingNotes = getPlayingNotesAtTime(quantizedTime);
|
||
context.pianoWidget.setPlayingNotes(playingNotes);
|
||
context.pianoRedraw();
|
||
}
|
||
|
||
// Update piano roll editor to show playhead
|
||
if (context.pianoRollRedraw) {
|
||
context.pianoRollRedraw();
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'RecordingStarted':
|
||
console.log('[FRONTEND] RecordingStarted - track:', event.track_id, 'clip:', event.clip_id);
|
||
context.recordingClipId = event.clip_id;
|
||
|
||
// Create the clip object in the audio track
|
||
const recordingTrack = context.activeObject.audioTracks.find(t => t.audioTrackId === event.track_id);
|
||
if (recordingTrack) {
|
||
const startTime = context.activeObject.currentTime || 0;
|
||
console.log('[FRONTEND] Creating clip object for clip', event.clip_id, 'on track', event.track_id, 'at time', startTime);
|
||
recordingTrack.clips.push({
|
||
clipId: event.clip_id,
|
||
name: recordingTrack.name,
|
||
poolIndex: null, // Will be set when recording stops
|
||
startTime: startTime,
|
||
duration: 0, // Will grow as recording progresses
|
||
offset: 0,
|
||
loading: true,
|
||
waveform: []
|
||
});
|
||
|
||
updateLayers();
|
||
if (context.timelineWidget?.requestRedraw) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
} else {
|
||
console.error('[FRONTEND] Could not find audio track', event.track_id, 'for RecordingStarted event');
|
||
}
|
||
break;
|
||
|
||
case 'RecordingProgress':
|
||
// Update clip duration in UI
|
||
console.log('Recording progress - clip:', event.clip_id, 'duration:', event.duration);
|
||
updateRecordingClipDuration(event.clip_id, event.duration);
|
||
break;
|
||
|
||
case 'GraphNodeAdded':
|
||
console.log('[FRONTEND] GraphNodeAdded event - track:', event.track_id, 'node_id:', event.node_id, 'node_type:', event.node_type);
|
||
// Resolve the pending promise with the correct backend ID
|
||
if (window.pendingNodeUpdate) {
|
||
const { drawflowNodeId, nodeType, resolve } = window.pendingNodeUpdate;
|
||
if (nodeType === event.node_type && resolve) {
|
||
console.log('[FRONTEND] Resolving promise for node', drawflowNodeId, 'with backend ID:', event.node_id);
|
||
resolve(event.node_id);
|
||
window.pendingNodeUpdate = null;
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'RecordingStopped':
|
||
console.log('[FRONTEND] RecordingStopped event - clip:', event.clip_id, 'pool_index:', event.pool_index, 'waveform peaks:', event.waveform?.length);
|
||
console.log('[FRONTEND] Current recording state - isRecording:', context.isRecording, 'recordingClipId:', context.recordingClipId);
|
||
await finalizeRecording(event.clip_id, event.pool_index, event.waveform);
|
||
|
||
// Always clear recording state when we receive RecordingStopped
|
||
console.log('[FRONTEND] Clearing recording state after RecordingStopped event');
|
||
context.isRecording = false;
|
||
context.recordingTrackId = null;
|
||
context.recordingClipId = null;
|
||
|
||
// Update record button appearance
|
||
if (context.recordButton) {
|
||
context.recordButton.className = "playback-btn playback-btn-record";
|
||
context.recordButton.title = "Record";
|
||
}
|
||
break;
|
||
|
||
case 'RecordingError':
|
||
console.error('Recording error:', event.message);
|
||
alert('Recording error: ' + event.message);
|
||
context.isRecording = false;
|
||
context.recordingTrackId = null;
|
||
context.recordingClipId = null;
|
||
break;
|
||
|
||
case 'MidiRecordingProgress':
|
||
// Update MIDI clip during recording with current duration and notes
|
||
const progressMidiTrack = context.activeObject.audioTracks.find(t => t.audioTrackId === event.track_id);
|
||
if (progressMidiTrack) {
|
||
const progressClip = progressMidiTrack.clips.find(c => c.clipId === event.clip_id);
|
||
if (progressClip) {
|
||
console.log('[MIDI_PROGRESS] Updating clip', event.clip_id, '- duration:', event.duration, 'notes:', event.notes.length, 'loading:', progressClip.loading);
|
||
progressClip.duration = event.duration;
|
||
progressClip.loading = false; // Make sure clip is not in loading state
|
||
// Convert backend note format to frontend format
|
||
progressClip.notes = event.notes.map(([start_time, note, velocity, duration]) => ({
|
||
note: note,
|
||
start_time: start_time,
|
||
duration: duration,
|
||
velocity: velocity
|
||
}));
|
||
console.log('[MIDI_PROGRESS] Clip now has', progressClip.notes.length, 'notes');
|
||
|
||
// Request redraw to show updated clip
|
||
updateLayers();
|
||
if (context.timelineWidget) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
} else {
|
||
console.log('[MIDI_PROGRESS] Could not find clip', event.clip_id);
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'MidiRecordingStopped':
|
||
console.log('[FRONTEND] ========== MidiRecordingStopped EVENT ==========');
|
||
console.log('[FRONTEND] Event details - track:', event.track_id, 'clip:', event.clip_id, 'notes:', event.note_count);
|
||
|
||
// Find the track and update the clip
|
||
const midiTrack = context.activeObject.audioTracks.find(t => t.audioTrackId === event.track_id);
|
||
console.log('[FRONTEND] Found MIDI track:', midiTrack ? midiTrack.name : 'NOT FOUND');
|
||
|
||
if (midiTrack) {
|
||
console.log('[FRONTEND] Track has', midiTrack.clips.length, 'clips:', midiTrack.clips.map(c => `{id:${c.clipId}, name:"${c.name}", loading:${c.loading}}`));
|
||
|
||
// Find the clip we created when recording started
|
||
let existingClip = midiTrack.clips.find(c => c.clipId === event.clip_id);
|
||
console.log('[FRONTEND] Found existing clip:', existingClip ? `id:${existingClip.clipId}, name:"${existingClip.name}", loading:${existingClip.loading}` : 'NOT FOUND');
|
||
|
||
if (existingClip) {
|
||
// Fetch the clip data from the backend
|
||
try {
|
||
console.log('[FRONTEND] Fetching MIDI clip data from backend...');
|
||
const clipData = await invoke('audio_get_midi_clip_data', {
|
||
trackId: event.track_id,
|
||
clipId: event.clip_id
|
||
});
|
||
console.log('[FRONTEND] Received clip data:', clipData);
|
||
|
||
// Update the clip with the recorded notes
|
||
console.log('[FRONTEND] Updating clip - before:', { loading: existingClip.loading, name: existingClip.name, duration: existingClip.duration, noteCount: existingClip.notes?.length });
|
||
existingClip.loading = false;
|
||
existingClip.name = `MIDI Clip (${event.note_count} notes)`;
|
||
existingClip.duration = clipData.duration;
|
||
existingClip.notes = clipData.notes;
|
||
console.log('[FRONTEND] Updating clip - after:', { loading: existingClip.loading, name: existingClip.name, duration: existingClip.duration, noteCount: existingClip.notes?.length });
|
||
} catch (error) {
|
||
console.error('[FRONTEND] Failed to fetch MIDI clip data:', error);
|
||
existingClip.loading = false;
|
||
existingClip.name = `MIDI Clip (failed)`;
|
||
}
|
||
} else {
|
||
console.error('[FRONTEND] Could not find clip', event.clip_id, 'on track', event.track_id);
|
||
}
|
||
|
||
// Request redraw to show the clip with recorded notes
|
||
updateLayers();
|
||
if (context.timelineWidget) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
}
|
||
|
||
// Clear recording state
|
||
console.log('[FRONTEND] Clearing MIDI recording state');
|
||
context.isRecording = false;
|
||
context.recordingTrackId = null;
|
||
context.recordingClipId = null;
|
||
|
||
// Update record button appearance
|
||
if (context.recordButton) {
|
||
context.recordButton.className = "playback-btn playback-btn-record";
|
||
context.recordButton.title = "Record";
|
||
}
|
||
|
||
console.log('[FRONTEND] MIDI recording complete - recorded', event.note_count, 'notes');
|
||
break;
|
||
|
||
case 'GraphPresetLoaded':
|
||
// Preset loaded - layers are already populated during graph reload
|
||
console.log('GraphPresetLoaded event received for track:', event.track_id);
|
||
break;
|
||
|
||
case 'NoteOn':
|
||
// MIDI note started - update virtual piano visual feedback
|
||
if (context.pianoWidget) {
|
||
context.pianoWidget.pressedKeys.add(event.note);
|
||
if (context.pianoRedraw) {
|
||
context.pianoRedraw();
|
||
}
|
||
}
|
||
// Update MIDI activity timestamp
|
||
context.lastMidiInputTime = Date.now();
|
||
console.log('[NoteOn] Set lastMidiInputTime to:', context.lastMidiInputTime);
|
||
|
||
// Start animation loop to keep redrawing the MIDI indicator
|
||
if (!context.midiIndicatorAnimating) {
|
||
context.midiIndicatorAnimating = true;
|
||
const animateMidiIndicator = () => {
|
||
if (context.timelineWidget && context.timelineWidget.requestRedraw) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
|
||
// Keep animating for 1 second after last MIDI input
|
||
const elapsed = Date.now() - context.lastMidiInputTime;
|
||
if (elapsed < 1000) {
|
||
requestAnimationFrame(animateMidiIndicator);
|
||
} else {
|
||
context.midiIndicatorAnimating = false;
|
||
}
|
||
};
|
||
requestAnimationFrame(animateMidiIndicator);
|
||
}
|
||
break;
|
||
|
||
case 'NoteOff':
|
||
// MIDI note stopped - update virtual piano visual feedback
|
||
if (context.pianoWidget) {
|
||
context.pianoWidget.pressedKeys.delete(event.note);
|
||
if (context.pianoRedraw) {
|
||
context.pianoRedraw();
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Set up Tauri event listener for audio events
|
||
listen('audio-event', (tauriEvent) => {
|
||
handleAudioEvent(tauriEvent.payload);
|
||
});
|
||
|
||
function updateRecordingClipDuration(clipId, duration) {
|
||
// Find the clip in the active object's audio tracks and update its duration
|
||
for (const audioTrack of context.activeObject.audioTracks) {
|
||
const clip = audioTrack.clips.find(c => c.clipId === clipId);
|
||
if (clip) {
|
||
clip.duration = duration;
|
||
updateLayers();
|
||
if (context.timelineWidget?.requestRedraw) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function finalizeRecording(clipId, poolIndex, waveform) {
|
||
console.log('Finalizing recording - clipId:', clipId, 'poolIndex:', poolIndex, 'waveform length:', waveform?.length);
|
||
|
||
// Find the clip and update it with the pool index and waveform
|
||
for (const audioTrack of context.activeObject.audioTracks) {
|
||
const clip = audioTrack.clips.find(c => c.clipId === clipId);
|
||
if (clip) {
|
||
console.log('Found clip to finalize:', clip);
|
||
clip.poolIndex = poolIndex;
|
||
clip.loading = false;
|
||
clip.waveform = waveform;
|
||
console.log('Clip after update:', clip);
|
||
console.log('Waveform sample:', waveform?.slice(0, 5));
|
||
|
||
updateLayers();
|
||
if (context.timelineWidget?.requestRedraw) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
console.error('Could not find clip to finalize:', clipId);
|
||
}
|
||
|
||
// Single-step backward by one frame/second
|
||
function rewind() {
|
||
if (context.timelineWidget?.timelineState?.timeFormat === "frames") {
|
||
context.activeObject.currentTime -= 1 / context.activeObject.frameRate;
|
||
} else {
|
||
context.activeObject.currentTime -= 1;
|
||
}
|
||
|
||
// Sync timeline playhead position
|
||
if (context.timelineWidget?.timelineState) {
|
||
context.timelineWidget.timelineState.currentTime = context.activeObject.currentTime;
|
||
}
|
||
|
||
// Sync DAW backend
|
||
invoke('audio_seek', { seconds: context.activeObject.currentTime });
|
||
|
||
updateLayers();
|
||
updateMenu();
|
||
updateUI();
|
||
if (context.timelineWidget?.requestRedraw) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
}
|
||
|
||
async function goToStart() {
|
||
context.activeObject.currentTime = 0;
|
||
|
||
// Sync timeline playhead position
|
||
if (context.timelineWidget?.timelineState) {
|
||
context.timelineWidget.timelineState.currentTime = 0;
|
||
}
|
||
|
||
// Sync with DAW backend
|
||
try {
|
||
await invoke('audio_seek', { seconds: 0 });
|
||
} catch (error) {
|
||
console.error('Failed to seek audio:', error);
|
||
}
|
||
|
||
updateLayers();
|
||
updateUI();
|
||
if (context.timelineWidget?.requestRedraw) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
}
|
||
|
||
async function goToEnd() {
|
||
const duration = context.activeObject.duration;
|
||
context.activeObject.currentTime = duration;
|
||
|
||
// Sync timeline playhead position
|
||
if (context.timelineWidget?.timelineState) {
|
||
context.timelineWidget.timelineState.currentTime = duration;
|
||
}
|
||
|
||
// Sync with DAW backend
|
||
try {
|
||
await invoke('audio_seek', { seconds: duration });
|
||
} catch (error) {
|
||
console.error('Failed to seek audio:', error);
|
||
}
|
||
|
||
updateLayers();
|
||
updateUI();
|
||
if (context.timelineWidget?.requestRedraw) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
}
|
||
|
||
async function toggleRecording() {
|
||
const { invoke } = window.__TAURI__.core;
|
||
|
||
if (context.isRecording) {
|
||
// Stop recording
|
||
console.log('[FRONTEND] toggleRecording - stopping recording for clip:', context.recordingClipId);
|
||
try {
|
||
// Check if we're recording MIDI or audio
|
||
const track = context.activeObject.audioTracks.find(t => t.audioTrackId === context.recordingTrackId);
|
||
const isMidiRecording = track && track.type === 'midi';
|
||
|
||
console.log('[FRONTEND] Stopping recording - isMIDI:', isMidiRecording, 'track type:', track?.type, 'track ID:', context.recordingTrackId);
|
||
|
||
if (isMidiRecording) {
|
||
console.log('[FRONTEND] Calling audio_stop_midi_recording...');
|
||
await invoke('audio_stop_midi_recording');
|
||
console.log('[FRONTEND] audio_stop_midi_recording returned successfully');
|
||
} else {
|
||
console.log('[FRONTEND] Calling audio_stop_recording...');
|
||
await invoke('audio_stop_recording');
|
||
console.log('[FRONTEND] audio_stop_recording returned successfully');
|
||
}
|
||
|
||
console.log('[FRONTEND] Clearing recording state in toggleRecording');
|
||
context.isRecording = false;
|
||
context.recordingTrackId = null;
|
||
context.recordingClipId = null;
|
||
} catch (error) {
|
||
console.error('[FRONTEND] Failed to stop recording:', error);
|
||
}
|
||
} else {
|
||
// Start recording - check if activeLayer is a track
|
||
const audioTrack = context.activeObject.activeLayer;
|
||
if (!audioTrack || !(audioTrack instanceof AudioTrack)) {
|
||
alert('Please select a track to record to');
|
||
return;
|
||
}
|
||
|
||
if (audioTrack.audioTrackId === null) {
|
||
alert('Track not properly initialized');
|
||
return;
|
||
}
|
||
|
||
// Start recording at current playhead position
|
||
const startTime = context.activeObject.currentTime || 0;
|
||
|
||
// Check if this is a MIDI track or audio track
|
||
if (audioTrack.type === 'midi') {
|
||
// MIDI recording
|
||
console.log('[FRONTEND] Starting MIDI recording on track', audioTrack.audioTrackId, 'at time', startTime);
|
||
try {
|
||
// First, create a MIDI clip at the current playhead position
|
||
const clipDuration = 4.0; // Default clip duration of 4 seconds (can be extended by recording)
|
||
const clipId = await invoke('audio_create_midi_clip', {
|
||
trackId: audioTrack.audioTrackId,
|
||
startTime: startTime,
|
||
duration: clipDuration
|
||
});
|
||
|
||
console.log('[FRONTEND] Created MIDI clip with ID:', clipId);
|
||
|
||
// Add clip to track immediately (similar to MIDI import)
|
||
audioTrack.clips.push({
|
||
clipId: clipId,
|
||
name: 'Recording...',
|
||
startTime: startTime,
|
||
duration: clipDuration,
|
||
notes: [],
|
||
loading: true
|
||
});
|
||
|
||
// Update UI to show the recording clip
|
||
updateLayers();
|
||
if (context.timelineWidget) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
|
||
// Now start MIDI recording
|
||
await invoke('audio_start_midi_recording', {
|
||
trackId: audioTrack.audioTrackId,
|
||
clipId: clipId,
|
||
startTime: startTime
|
||
});
|
||
|
||
context.isRecording = true;
|
||
context.recordingTrackId = audioTrack.audioTrackId;
|
||
context.recordingClipId = clipId;
|
||
console.log('[FRONTEND] MIDI recording started successfully');
|
||
|
||
// Start playback so the timeline moves (if not already playing)
|
||
if (!context.playing) {
|
||
await playPause();
|
||
}
|
||
} catch (error) {
|
||
console.error('[FRONTEND] Failed to start MIDI recording:', error);
|
||
alert('Failed to start MIDI recording: ' + error);
|
||
}
|
||
} else {
|
||
// Audio recording
|
||
console.log('[FRONTEND] Starting audio recording on track', audioTrack.audioTrackId, 'at time', startTime);
|
||
try {
|
||
await invoke('audio_start_recording', {
|
||
trackId: audioTrack.audioTrackId,
|
||
startTime: startTime
|
||
});
|
||
context.isRecording = true;
|
||
context.recordingTrackId = audioTrack.audioTrackId;
|
||
console.log('[FRONTEND] Audio recording started successfully, waiting for RecordingStarted event');
|
||
|
||
// Start playback so the timeline moves (if not already playing)
|
||
if (!context.playing) {
|
||
await playPause();
|
||
}
|
||
} catch (error) {
|
||
console.error('[FRONTEND] Failed to start audio recording:', error);
|
||
alert('Failed to start audio recording: ' + error);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function newWindow(path) {
|
||
invoke("create_window", {app: window.__TAURI__.app, path: path})
|
||
}
|
||
|
||
async function _newFile(width, height, fps, layoutKey) {
|
||
console.log('[_newFile] REPLACING ROOT - Creating new file with fps:', fps, 'layout:', layoutKey);
|
||
console.trace('[_newFile] Stack trace for root replacement:');
|
||
|
||
const oldRoot = root;
|
||
console.log('[_newFile] Old root:', oldRoot, 'frameRate:', oldRoot?.frameRate);
|
||
|
||
// Reset audio engine to clear any previous session data
|
||
try {
|
||
await invoke('audio_reset');
|
||
} catch (error) {
|
||
console.warn('Failed to reset audio engine:', error);
|
||
}
|
||
|
||
// Determine initial child type based on layout
|
||
const initialChildType = layoutKey === 'audioDaw' ? 'midi' : 'layer';
|
||
root = new GraphicsObject("root", initialChildType);
|
||
|
||
// Switch to the selected layout if provided
|
||
if (layoutKey) {
|
||
config.currentLayout = layoutKey;
|
||
config.defaultLayout = layoutKey;
|
||
console.log('[_newFile] Switching to layout:', layoutKey);
|
||
switchLayout(layoutKey);
|
||
|
||
// Set default time format to measures for music mode
|
||
if (layoutKey === 'audioDaw' && context.timelineWidget?.timelineState) {
|
||
context.timelineWidget.timelineState.timeFormat = 'measures';
|
||
// Show metronome button for audio projects
|
||
if (context.metronomeGroup) {
|
||
context.metronomeGroup.style.display = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Define frameRate as a non-configurable property with a backing variable
|
||
let _frameRate = fps;
|
||
Object.defineProperty(root, 'frameRate', {
|
||
get() {
|
||
return _frameRate;
|
||
},
|
||
set(value) {
|
||
console.log('[frameRate setter] Setting frameRate to:', value, 'from:', _frameRate);
|
||
console.trace('[frameRate setter] Stack trace:');
|
||
_frameRate = value;
|
||
},
|
||
enumerable: true,
|
||
configurable: false
|
||
});
|
||
|
||
console.log('[_newFile] Immediately after setting frameRate:', root.frameRate);
|
||
console.log('[_newFile] Checking if property exists:', 'frameRate' in root);
|
||
console.log('[_newFile] Property descriptor:', Object.getOwnPropertyDescriptor(root, 'frameRate'));
|
||
|
||
console.log('[_newFile] New root:', root, 'frameRate:', root.frameRate);
|
||
console.log('[_newFile] After setting, root.frameRate:', root.frameRate);
|
||
console.log('[_newFile] root object:', root);
|
||
console.log('[_newFile] Before objectStack - root.frameRate:', root.frameRate);
|
||
context.objectStack = [root];
|
||
console.log('[_newFile] After objectStack - root.frameRate:', root.frameRate);
|
||
context.selection = [];
|
||
context.shapeselection = [];
|
||
config.fileWidth = width;
|
||
config.fileHeight = height;
|
||
config.framerate = fps;
|
||
filePath = undefined;
|
||
console.log('[_newFile] Before saveConfig - root.frameRate:', root.frameRate);
|
||
saveConfig();
|
||
console.log('[_newFile] After saveConfig - root.frameRate:', root.frameRate);
|
||
undoStack.length = 0; // Clear without breaking reference
|
||
redoStack.length = 0; // Clear without breaking reference
|
||
console.log('[_newFile] Before updateUI - root.frameRate:', root.frameRate);
|
||
|
||
// Ensure there's an active layer - set to first layer if none is active
|
||
if (!context.activeObject.activeLayer && context.activeObject.layers.length > 0) {
|
||
context.activeObject.activeLayer = context.activeObject.layers[0];
|
||
}
|
||
|
||
updateUI();
|
||
console.log('[_newFile] After updateUI - root.frameRate:', root.frameRate);
|
||
updateLayers();
|
||
console.log('[_newFile] After updateLayers - root.frameRate:', root.frameRate);
|
||
updateMenu();
|
||
console.log('[_newFile] After updateMenu - root.frameRate:', root.frameRate);
|
||
console.log('[_newFile] At end of _newFile, root.frameRate:', root.frameRate);
|
||
}
|
||
|
||
async function newFile() {
|
||
if (
|
||
await confirmDialog("Create a new file? Unsaved work will be lost.", {
|
||
title: "New file",
|
||
kind: "warning",
|
||
})
|
||
) {
|
||
showNewFileDialog(config);
|
||
}
|
||
}
|
||
|
||
async function _save(path) {
|
||
try {
|
||
function replacer(key, value) {
|
||
if (key === "parent") {
|
||
return undefined; // Avoid circular references
|
||
}
|
||
return value;
|
||
}
|
||
// for (let action of undoStack) {
|
||
// console.log(action.name);
|
||
// }
|
||
|
||
// Serialize audio pool (files < 10MB embedded, larger files saved as relative paths)
|
||
let audioPool = [];
|
||
try {
|
||
audioPool = await invoke('audio_serialize_pool', { projectPath: path });
|
||
} catch (error) {
|
||
console.warn('Failed to serialize audio pool:', error);
|
||
// Continue saving without audio pool - user may not have audio initialized
|
||
}
|
||
|
||
// Serialize track graphs (node graphs for each track)
|
||
const trackGraphs = {};
|
||
for (const track of root.audioTracks) {
|
||
if (track.audioTrackId !== null) {
|
||
try {
|
||
const graphJson = await invoke('audio_serialize_track_graph', {
|
||
trackId: track.audioTrackId,
|
||
projectPath: path
|
||
});
|
||
trackGraphs[track.idx] = graphJson;
|
||
} catch (error) {
|
||
console.warn(`Failed to serialize graph for track ${track.name}:`, error);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Serialize current layout structure (panes, splits, sizes)
|
||
const serializedLayout = serializeLayout(rootPane);
|
||
|
||
const fileData = {
|
||
version: "2.0.0",
|
||
width: config.fileWidth,
|
||
height: config.fileHeight,
|
||
fps: config.framerate,
|
||
layoutState: serializedLayout, // Save current layout structure
|
||
actions: undoStack,
|
||
json: root.toJSON(),
|
||
// Audio pool at the end for human readability
|
||
audioPool: audioPool,
|
||
// Track graphs for instruments/effects
|
||
trackGraphs: trackGraphs,
|
||
};
|
||
if (config.debug) {
|
||
// Pretty print file structure when debugging
|
||
const contents = JSON.stringify(fileData, null, 2);
|
||
await writeTextFile(path, contents);
|
||
} else {
|
||
const contents = JSON.stringify(fileData);
|
||
await writeTextFile(path, contents);
|
||
}
|
||
filePath = path;
|
||
addRecentFile(path);
|
||
lastSaveIndex = undoStack.length;
|
||
updateMenu();
|
||
console.log(`${path} saved successfully!`);
|
||
} catch (error) {
|
||
console.error("Error saving text file:", error);
|
||
}
|
||
}
|
||
|
||
async function save() {
|
||
if (filePath) {
|
||
_save(filePath);
|
||
} else {
|
||
saveAs();
|
||
}
|
||
}
|
||
|
||
async function saveAs() {
|
||
const filename = filePath ? await basename(filePath) : "untitled.beam";
|
||
const path = await saveFileDialog({
|
||
filters: [
|
||
{
|
||
name: "Lightningbeam files (.beam)",
|
||
extensions: ["beam"],
|
||
},
|
||
],
|
||
defaultPath: await join(await documentDir(), filename),
|
||
});
|
||
if (path != undefined) _save(path);
|
||
}
|
||
|
||
/**
|
||
* Handle missing audio files by prompting the user to locate them
|
||
* @param {number[]} missingIndices - Array of pool indices that failed to load
|
||
* @param {Object[]} audioPool - The audio pool entries from the project file
|
||
* @param {string} projectPath - Path to the project file
|
||
*/
|
||
async function handleMissingAudioFiles(missingIndices, audioPool, projectPath) {
|
||
const { open } = window.__TAURI__.dialog;
|
||
|
||
for (const poolIndex of missingIndices) {
|
||
const entry = audioPool[poolIndex];
|
||
if (!entry) continue;
|
||
|
||
const message = `Cannot find audio file:\n${entry.name}\n\nExpected location: ${entry.relativePath || 'embedded'}\n\nWould you like to locate this file?`;
|
||
|
||
const result = await window.__TAURI__.dialog.confirm(message, {
|
||
title: 'Missing Audio File',
|
||
kind: 'warning',
|
||
okLabel: 'Locate File',
|
||
cancelLabel: 'Skip'
|
||
});
|
||
|
||
if (result) {
|
||
// Let user browse for the file
|
||
const selected = await open({
|
||
title: `Locate ${entry.name}`,
|
||
multiple: false,
|
||
filters: [{
|
||
name: 'Audio Files',
|
||
extensions: audioExtensions
|
||
}]
|
||
});
|
||
|
||
if (selected) {
|
||
try {
|
||
await invoke('audio_resolve_missing_file', {
|
||
poolIndex: poolIndex,
|
||
newPath: selected
|
||
});
|
||
console.log(`Successfully loaded ${entry.name} from ${selected}`);
|
||
} catch (error) {
|
||
console.error(`Failed to load ${entry.name}:`, error);
|
||
await messageDialog(
|
||
`Failed to load file: ${error}`,
|
||
{ title: "Load Error", kind: "error" }
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function _open(path, returnJson = false) {
|
||
document.body.style.cursor = "wait"
|
||
closeDialog();
|
||
try {
|
||
const contents = await readTextFile(path);
|
||
let file = JSON.parse(contents);
|
||
if (file.version == undefined) {
|
||
await messageDialog("Could not read file version!", {
|
||
title: "Load error",
|
||
kind: "error",
|
||
});
|
||
document.body.style.cursor = "default"
|
||
return;
|
||
}
|
||
if (file.version >= minFileVersion) {
|
||
if (file.version < maxFileVersion) {
|
||
if (returnJson) {
|
||
if (file.json == undefined) {
|
||
await messageDialog(
|
||
"Could not import from this file. Re-save it with a current version of Lightningbeam.",
|
||
);
|
||
}
|
||
document.body.style.cursor = "default"
|
||
return file.json;
|
||
} else {
|
||
await _newFile(file.width, file.height, file.fps);
|
||
if (file.actions == undefined) {
|
||
await messageDialog("File has no content!", {
|
||
title: "Parse error",
|
||
kind: "error",
|
||
});
|
||
document.body.style.cursor = "default"
|
||
return;
|
||
}
|
||
|
||
const objectOffsets = {};
|
||
const frameIDs = []
|
||
if (file.version < "1.7.5") {
|
||
for (let action of file.actions) {
|
||
if (!(action.name in actions)) {
|
||
await messageDialog(
|
||
`Invalid action ${action.name}. File may be corrupt.`,
|
||
{ title: "Error", kind: "error" },
|
||
);
|
||
document.body.style.cursor = "default"
|
||
return;
|
||
}
|
||
|
||
console.log(action.name);
|
||
// Data fixes
|
||
if (file.version <= "1.5") {
|
||
// Fix coordinates of objects
|
||
if (action.name == "group") {
|
||
let bbox;
|
||
for (let i of action.action.shapes) {
|
||
const shape = pointerList[i];
|
||
if (bbox == undefined) {
|
||
bbox = shape.bbox();
|
||
} else {
|
||
growBoundingBox(bbox, shape.bbox());
|
||
}
|
||
}
|
||
for (let i of action.action.objects) {
|
||
const object = pointerList[i]; // TODO: rotated bbox
|
||
if (bbox == undefined) {
|
||
bbox = object.bbox();
|
||
} else {
|
||
growBoundingBox(bbox, object.bbox());
|
||
}
|
||
}
|
||
const position = {
|
||
x: (bbox.x.min + bbox.x.max) / 2,
|
||
y: (bbox.y.min + bbox.y.max) / 2,
|
||
};
|
||
action.action.position = position;
|
||
objectOffsets[action.action.groupUuid] = position;
|
||
for (let shape of action.action.shapes) {
|
||
objectOffsets[shape] = position
|
||
}
|
||
} else if (action.name == "editFrame") {
|
||
for (let key in action.action.newState) {
|
||
if (key in objectOffsets) {
|
||
action.action.newState[key].x += objectOffsets[key].x;
|
||
action.action.newState[key].y += objectOffsets[key].y;
|
||
}
|
||
}
|
||
for (let key in action.action.oldState) {
|
||
if (key in objectOffsets) {
|
||
action.action.oldState[key].x += objectOffsets[key].x;
|
||
action.action.oldState[key].y += objectOffsets[key].y;
|
||
}
|
||
}
|
||
} else if (action.name == "addKeyframe") {
|
||
for (let id in objectOffsets) {
|
||
objectOffsets[action.action.uuid.slice(0,8) + id.slice(8)] = objectOffsets[id]
|
||
}
|
||
} else if (action.name == "editShape") {
|
||
if (action.action.shape in objectOffsets) {
|
||
console.log("editing shape")
|
||
for (let curve of action.action.newCurves) {
|
||
for (let point of curve.points) {
|
||
point.x -= objectOffsets[action.action.shape].x
|
||
point.y -= objectOffsets[action.action.shape].y
|
||
}
|
||
}
|
||
for (let curve of action.action.oldCurves) {
|
||
for (let point of curve.points) {
|
||
point.x -= objectOffsets[action.action.shape].x
|
||
point.y -= objectOffsets[action.action.shape].y
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (file.version <= "1.6") {
|
||
// Fix copy-paste
|
||
if (action.name == "duplicateObject") {
|
||
const obj = pointerList[action.action.object];
|
||
const objJson = obj.toJSON(true);
|
||
objJson.idx =
|
||
action.action.uuid.slice(0, 8) +
|
||
action.action.object.slice(8);
|
||
action.action.items = [objJson];
|
||
action.action.object = "root";
|
||
action.action.frame = root.currentFrame.idx;
|
||
}
|
||
}
|
||
|
||
await actions[action.name].execute(action.action);
|
||
undoStack.push(action);
|
||
}
|
||
} else {
|
||
if (file.version < "1.7.7") {
|
||
function setParentReferences(obj, parentIdx = null) {
|
||
if (obj.type === "GraphicsObject") {
|
||
obj.parent = parentIdx; // Set the parent property
|
||
}
|
||
|
||
Object.values(obj).forEach(child => {
|
||
if (typeof child === 'object' && child !== null) setParentReferences(child, obj.type === "GraphicsObject" ? obj.idx : parentIdx);
|
||
})
|
||
}
|
||
setParentReferences(file.json)
|
||
console.log(file.json)
|
||
}
|
||
if (file.version < "1.7.6") {
|
||
function restoreLineColors(obj) {
|
||
// Step 1: Create colorMapping dictionary
|
||
const colorMapping = (obj.actions || []).reduce((map, action) => {
|
||
if (action.name === "addShape" && action.action.curves.length > 0) {
|
||
map[action.action.uuid] = action.action.curves[0].color;
|
||
}
|
||
return map;
|
||
}, {});
|
||
|
||
// Step 2: Recursive pass to add colors from colorMapping back to curves
|
||
function recurse(item) {
|
||
if (item?.curves && item.idx && colorMapping[item.idx]) {
|
||
item.curves.forEach(curve => {
|
||
if (Array.isArray(curve)) curve.push(colorMapping[item.idx]);
|
||
});
|
||
}
|
||
Object.values(item).forEach(value => {
|
||
if (typeof value === 'object' && value !== null) recurse(value);
|
||
});
|
||
}
|
||
|
||
recurse(obj);
|
||
}
|
||
|
||
restoreLineColors(file)
|
||
|
||
function restoreAudio(obj) {
|
||
const audioSrcMapping = (obj.actions || []).reduce((map, action) => {
|
||
if (action.name === "addAudio") {
|
||
map[action.action.layeruuid] = action.action;
|
||
}
|
||
return map;
|
||
}, {});
|
||
|
||
function recurse(item) {
|
||
if (item.type=="AudioTrack" && audioSrcMapping[item.idx]) {
|
||
const action = audioSrcMapping[item.idx]
|
||
item.sounds[action.uuid] = {
|
||
start: action.frameNum,
|
||
src: action.audiosrc,
|
||
uuid: action.uuid
|
||
}
|
||
}
|
||
Object.values(item).forEach(value => {
|
||
if (typeof value === 'object' && value !== null) recurse(value);
|
||
});
|
||
}
|
||
|
||
recurse(obj);
|
||
}
|
||
|
||
restoreAudio(file)
|
||
}
|
||
// disabled for now
|
||
// for (let action of file.actions) {
|
||
// undoStack.push(action)
|
||
// }
|
||
root = GraphicsObject.fromJSON(file.json)
|
||
|
||
// Restore frameRate property with getter/setter (same pattern as in _newFile)
|
||
// This is needed because GraphicsObject.fromJSON creates a new object without frameRate
|
||
let _frameRate = config.framerate; // frameRate was set from file.fps in _newFile call above
|
||
Object.defineProperty(root, 'frameRate', {
|
||
get() {
|
||
return _frameRate;
|
||
},
|
||
set(value) {
|
||
console.log('[frameRate setter] Setting frameRate to:', value, 'from:', _frameRate);
|
||
console.trace('[frameRate setter] Stack trace:');
|
||
_frameRate = value;
|
||
},
|
||
enumerable: true,
|
||
configurable: false
|
||
});
|
||
console.log('[openFile] After restoring frameRate property, root.frameRate:', root.frameRate);
|
||
|
||
context.objectStack = [root]
|
||
}
|
||
|
||
// Reset audio engine to clear any previous session data
|
||
try {
|
||
await invoke('audio_reset');
|
||
} catch (error) {
|
||
console.warn('Failed to reset audio engine:', error);
|
||
}
|
||
|
||
// Load audio pool if present
|
||
if (file.audioPool && file.audioPool.length > 0) {
|
||
console.log('[JS] Loading audio pool with', file.audioPool.length, 'entries');
|
||
|
||
// Validate audioPool entries - skip if they don't have the expected structure
|
||
const validEntries = file.audioPool.filter(entry => {
|
||
// Check basic structure
|
||
if (!entry || typeof entry.name !== 'string' || typeof entry.pool_index !== 'number') {
|
||
console.warn('[JS] Skipping invalid audio pool entry (bad structure):', entry);
|
||
return false;
|
||
}
|
||
|
||
// Log the full entry structure for debugging
|
||
console.log('[JS] Validating entry:', JSON.stringify({
|
||
name: entry.name,
|
||
pool_index: entry.pool_index,
|
||
has_embedded_data: !!entry.embedded_data,
|
||
embedded_data_keys: entry.embedded_data ? Object.keys(entry.embedded_data) : [],
|
||
relative_path: entry.relative_path,
|
||
all_keys: Object.keys(entry)
|
||
}, null, 2));
|
||
|
||
// Check if it has either embedded data or a valid file path
|
||
const hasEmbedded = entry.embedded_data &&
|
||
entry.embedded_data.data_base64 &&
|
||
entry.embedded_data.format;
|
||
const hasValidPath = entry.relative_path &&
|
||
entry.relative_path.length > 0 &&
|
||
!entry.relative_path.startsWith('<embedded:');
|
||
|
||
if (!hasEmbedded && !hasValidPath) {
|
||
console.warn('[JS] Skipping invalid audio pool entry (no valid data or path):', {
|
||
name: entry.name,
|
||
pool_index: entry.pool_index,
|
||
hasEmbedded: !!entry.embedded_data,
|
||
relativePath: entry.relative_path
|
||
});
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
|
||
if (validEntries.length === 0) {
|
||
console.warn('[JS] No valid audio pool entries found, skipping audio pool load');
|
||
} else {
|
||
validEntries.forEach((entry, i) => {
|
||
console.log(`[JS] Entry ${i}:`, JSON.stringify({
|
||
pool_index: entry.pool_index,
|
||
name: entry.name,
|
||
hasEmbedded: !!entry.embedded_data,
|
||
hasPath: !!entry.relative_path,
|
||
relativePath: entry.relative_path,
|
||
embeddedFormat: entry.embedded_data?.format,
|
||
embeddedSize: entry.embedded_data?.data_base64?.length
|
||
}, null, 2));
|
||
});
|
||
|
||
try {
|
||
const missingIndices = await invoke('audio_load_pool', {
|
||
entries: validEntries,
|
||
projectPath: path
|
||
});
|
||
|
||
// If there are missing files, show a dialog to help user locate them
|
||
if (missingIndices.length > 0) {
|
||
await handleMissingAudioFiles(missingIndices, validEntries, path);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load audio pool:', error);
|
||
await messageDialog(
|
||
`Failed to load audio files: ${error}`,
|
||
{ title: "Audio Load Error", kind: "warning" }
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
lastSaveIndex = undoStack.length;
|
||
filePath = path;
|
||
// Tauri thinks it is setting the title here, but it isn't getting updated
|
||
await getCurrentWindow().setTitle(await basename(filePath));
|
||
addRecentFile(path);
|
||
|
||
// Ensure there's an active layer - set to first layer if none is active
|
||
if (!context.activeObject.activeLayer && context.activeObject.layers.length > 0) {
|
||
context.activeObject.activeLayer = context.activeObject.layers[0];
|
||
}
|
||
|
||
// Restore layout if saved and preference is enabled
|
||
console.log('[JS] Layout restoration check:', {
|
||
restoreLayoutFromFile: config.restoreLayoutFromFile,
|
||
hasLayoutState: !!file.layoutState,
|
||
layoutState: file.layoutState
|
||
});
|
||
|
||
if (config.restoreLayoutFromFile && file.layoutState) {
|
||
try {
|
||
console.log('[JS] Restoring saved layout:', file.layoutState);
|
||
// Clear existing layout
|
||
while (rootPane.firstChild) {
|
||
rootPane.removeChild(rootPane.firstChild);
|
||
}
|
||
layoutElements.length = 0;
|
||
canvases.length = 0;
|
||
|
||
// Build layout from saved state
|
||
buildLayout(rootPane, file.layoutState, panes, createPane, splitPane);
|
||
|
||
// Update UI after layout change
|
||
updateAll();
|
||
updateUI();
|
||
console.log('[JS] Layout restored successfully');
|
||
} catch (error) {
|
||
console.error('[JS] Failed to restore layout, using default:', error);
|
||
}
|
||
} else {
|
||
console.log('[JS] Skipping layout restoration');
|
||
}
|
||
|
||
// Restore audio tracks and clips to the Rust backend
|
||
// The fromJSON method only creates JavaScript objects,
|
||
// but doesn't initialize them in the audio engine
|
||
for (const audioTrack of context.activeObject.audioTracks) {
|
||
// First, initialize the track in the Rust backend
|
||
if (audioTrack.audioTrackId === null) {
|
||
console.log(`[JS] Initializing track ${audioTrack.name} in audio engine`);
|
||
try {
|
||
await audioTrack.initializeTrack();
|
||
} catch (error) {
|
||
console.error(`[JS] Failed to initialize track ${audioTrack.name}:`, error);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Then restore clips if any
|
||
if (audioTrack.clips && audioTrack.clips.length > 0) {
|
||
console.log(`[JS] Restoring ${audioTrack.clips.length} clips for track ${audioTrack.name}`);
|
||
for (const clip of audioTrack.clips) {
|
||
try {
|
||
// Handle MIDI clips differently from audio clips
|
||
if (audioTrack.type === 'midi') {
|
||
// For MIDI clips, restore the notes
|
||
if (clip.notes && clip.notes.length > 0) {
|
||
// Create the clip first
|
||
await invoke('audio_create_midi_clip', {
|
||
trackId: audioTrack.audioTrackId,
|
||
startTime: clip.startTime,
|
||
duration: clip.duration
|
||
});
|
||
|
||
// Update with notes
|
||
const noteData = clip.notes.map(note => [
|
||
note.startTime || note.start_time,
|
||
note.note,
|
||
note.velocity,
|
||
note.duration
|
||
]);
|
||
|
||
await invoke('audio_update_midi_clip_notes', {
|
||
trackId: audioTrack.audioTrackId,
|
||
clipId: clip.clipId,
|
||
notes: noteData
|
||
});
|
||
|
||
console.log(`[JS] Restored MIDI clip ${clip.name} with ${clip.notes.length} notes`);
|
||
}
|
||
} else {
|
||
// For audio clips, restore from pool
|
||
await invoke('audio_add_clip', {
|
||
trackId: audioTrack.audioTrackId,
|
||
poolIndex: clip.poolIndex,
|
||
startTime: clip.startTime,
|
||
duration: clip.duration,
|
||
offset: clip.offset || 0.0
|
||
});
|
||
console.log(`[JS] Restored clip ${clip.name} at poolIndex ${clip.poolIndex}`);
|
||
|
||
// Generate waveform for the restored clip
|
||
try {
|
||
const fileInfo = await invoke('audio_get_pool_file_info', {
|
||
poolIndex: clip.poolIndex
|
||
});
|
||
const duration = fileInfo[0];
|
||
const targetPeaks = Math.floor(duration * 300);
|
||
const clampedPeaks = Math.max(1000, Math.min(20000, targetPeaks));
|
||
|
||
const waveform = await invoke('audio_get_pool_waveform', {
|
||
poolIndex: clip.poolIndex,
|
||
targetPeaks: clampedPeaks
|
||
});
|
||
|
||
clip.waveform = waveform;
|
||
console.log(`[JS] Generated waveform for clip ${clip.name} (${waveform.length} peaks)`);
|
||
} catch (waveformError) {
|
||
console.error(`[JS] Failed to generate waveform for clip ${clip.name}:`, waveformError);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`[JS] Failed to restore clip ${clip.name}:`, error);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Restore track graph (node graph for instruments/effects)
|
||
if (file.trackGraphs && file.trackGraphs[audioTrack.idx]) {
|
||
try {
|
||
await invoke('audio_load_track_graph', {
|
||
trackId: audioTrack.audioTrackId,
|
||
presetJson: file.trackGraphs[audioTrack.idx],
|
||
projectPath: path
|
||
});
|
||
console.log(`[JS] Restored graph for track ${audioTrack.name}`);
|
||
} catch (error) {
|
||
console.error(`[JS] Failed to restore graph for track ${audioTrack.name}:`, error);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Trigger UI and timeline redraw after all waveforms are loaded
|
||
updateUI();
|
||
updateLayers();
|
||
if (context.timelineWidget) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
}
|
||
} else {
|
||
await messageDialog(
|
||
`File ${path} was created in a newer version of Lightningbeam and cannot be opened in this version.`,
|
||
{ title: "File version mismatch", kind: "error" },
|
||
);
|
||
}
|
||
} else {
|
||
await messageDialog(
|
||
`File ${path} is too old to be opened in this version of Lightningbeam.`,
|
||
{ title: "File version mismatch", kind: "error" },
|
||
);
|
||
}
|
||
} catch (e) {
|
||
console.log(e);
|
||
if (e instanceof SyntaxError) {
|
||
await messageDialog(`Could not parse ${path}, ${e.message}`, {
|
||
title: "Error",
|
||
kind: "error",
|
||
});
|
||
} else if (
|
||
e instanceof String &&
|
||
e.startsWith("failed to read file as text")
|
||
) {
|
||
await messageDialog(
|
||
`Could not parse ${path}, is it actually a Lightningbeam file?`,
|
||
{ title: "Error", kind: "error" },
|
||
);
|
||
} else {
|
||
console.error(e);
|
||
await messageDialog(
|
||
`Error replaying file: ${e}`,
|
||
{ title: "Error", kind: "error" },
|
||
);
|
||
}
|
||
}
|
||
document.body.style.cursor = "default"
|
||
}
|
||
|
||
async function open() {
|
||
const path = await openFileDialog({
|
||
multiple: false,
|
||
directory: false,
|
||
filters: [
|
||
{
|
||
name: "Lightningbeam files (.beam)",
|
||
extensions: ["beam"],
|
||
},
|
||
],
|
||
defaultPath: await documentDir(),
|
||
});
|
||
console.log(path);
|
||
if (path) {
|
||
document.body.style.cursor = "wait"
|
||
setTimeout(()=>_open(path),10);
|
||
}
|
||
}
|
||
|
||
function revert() {
|
||
for (let _ = 0; undoStack.length > lastSaveIndex; _++) {
|
||
undo();
|
||
}
|
||
}
|
||
|
||
async function importFile() {
|
||
// Define filters in consistent order
|
||
const allFilters = [
|
||
{
|
||
name: "Image files",
|
||
extensions: imageExtensions,
|
||
},
|
||
{
|
||
name: "Audio files",
|
||
extensions: audioExtensions,
|
||
},
|
||
{
|
||
name: "Video files",
|
||
extensions: videoExtensions,
|
||
},
|
||
{
|
||
name: "MIDI files",
|
||
extensions: midiExtensions,
|
||
},
|
||
{
|
||
name: "Lightningbeam files",
|
||
extensions: beamExtensions,
|
||
},
|
||
];
|
||
|
||
// Reorder filters to put last used filter first
|
||
const filterIndex = config.lastImportFilterIndex || 0;
|
||
const reorderedFilters = [
|
||
allFilters[filterIndex],
|
||
...allFilters.filter((_, i) => i !== filterIndex)
|
||
];
|
||
|
||
const path = await openFileDialog({
|
||
multiple: false,
|
||
directory: false,
|
||
filters: reorderedFilters,
|
||
defaultPath: await documentDir(),
|
||
title: "Import File",
|
||
});
|
||
const imageMimeTypes = [
|
||
"image/jpeg", // JPEG
|
||
"image/png", // PNG
|
||
"image/gif", // GIF
|
||
"image/webp", // WebP
|
||
// "image/svg+xml",// SVG
|
||
"image/bmp", // BMP
|
||
// "image/tiff", // TIFF
|
||
// "image/x-icon", // ICO
|
||
// "image/heif", // HEIF
|
||
// "image/avif" // AVIF
|
||
];
|
||
const audioMimeTypes = [
|
||
"audio/mpeg", // MP3
|
||
// "audio/wav", // WAV
|
||
// "audio/ogg", // OGG
|
||
// "audio/webm", // WebM
|
||
// "audio/aac", // AAC
|
||
// "audio/flac", // FLAC
|
||
// "audio/midi", // MIDI
|
||
// "audio/x-wav", // X-WAV (older WAV files)
|
||
// "audio/opus" // Opus
|
||
];
|
||
if (path) {
|
||
const filename = await basename(path);
|
||
const ext = getFileExtension(filename);
|
||
|
||
// Detect and save which filter was used based on file extension
|
||
let usedFilterIndex = 0;
|
||
if (audioExtensions.includes(ext)) {
|
||
usedFilterIndex = 1; // Audio
|
||
} else if (videoExtensions.includes(ext)) {
|
||
usedFilterIndex = 2; // Video
|
||
} else if (midiExtensions.includes(ext)) {
|
||
usedFilterIndex = 3; // MIDI
|
||
} else if (beamExtensions.includes(ext)) {
|
||
usedFilterIndex = 4; // Lightningbeam
|
||
} else {
|
||
usedFilterIndex = 0; // Image (default)
|
||
}
|
||
|
||
// Save to config for next time
|
||
config.lastImportFilterIndex = usedFilterIndex;
|
||
saveConfig();
|
||
|
||
if (ext == "beam") {
|
||
function reassignIdxs(json) {
|
||
if (json.idx in pointerList) {
|
||
json.idx = uuidv4();
|
||
}
|
||
deeploop(json, (key, item) => {
|
||
if (item.idx in pointerList) {
|
||
item.idx = uuidv4();
|
||
}
|
||
});
|
||
}
|
||
function assignUUIDs(obj, existing) {
|
||
const uuidCache = {}; // Cache to store UUIDs for existing values
|
||
|
||
function replaceUuids(obj) {
|
||
for (const [key, value] of Object.entries(obj)) {
|
||
if (typeof value === "object" && value !== null) {
|
||
replaceUuids(value);
|
||
} else if (value in existing && key != "name") {
|
||
if (!uuidCache[value]) {
|
||
uuidCache[value] = uuidv4();
|
||
}
|
||
obj[key] = uuidCache[value];
|
||
}
|
||
}
|
||
}
|
||
|
||
function replaceReferences(obj) {
|
||
for (const [key, value] of Object.entries(obj)) {
|
||
if (key in existing) {
|
||
obj[uuidCache[key]] = obj[key];
|
||
delete obj[key]
|
||
}
|
||
if (typeof value === "object" && value !== null) {
|
||
replaceReferences(value);
|
||
} else if (value in uuidCache) {
|
||
obj[key] = value
|
||
}
|
||
}
|
||
}
|
||
|
||
// Start the recursion with the provided object
|
||
replaceUuids(obj);
|
||
replaceReferences(obj)
|
||
|
||
return obj; // Return the updated object
|
||
}
|
||
|
||
const json = await _open(path, true);
|
||
if (json == undefined) return;
|
||
assignUUIDs(json, pointerList);
|
||
createModal(outliner, json, (object) => {
|
||
actions.importObject.create(object);
|
||
});
|
||
updateOutliner();
|
||
} else if (audioExtensions.includes(ext)) {
|
||
// Handle audio files - pass file path directly to backend
|
||
actions.addAudio.create(path, context.activeObject, filename);
|
||
} else if (videoExtensions.includes(ext)) {
|
||
// Handle video files
|
||
actions.addVideo.create(path, context.activeObject, filename);
|
||
} else if (midiExtensions.includes(ext)) {
|
||
// Handle MIDI files
|
||
actions.addMIDI.create(path, context.activeObject, filename);
|
||
} else {
|
||
// Handle image files - convert to data URL
|
||
const { dataURL, mimeType } = await convertToDataURL(
|
||
path,
|
||
imageMimeTypes,
|
||
);
|
||
if (imageMimeTypes.indexOf(mimeType) != -1) {
|
||
actions.addImageObject.create(50, 50, dataURL, 0, context.activeObject);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function quit() {
|
||
if (undoStack.length > lastSaveIndex) {
|
||
if (
|
||
await confirmDialog("Are you sure you want to quit?", {
|
||
title: "Really quit?",
|
||
kind: "warning",
|
||
})
|
||
) {
|
||
getCurrentWindow().close();
|
||
}
|
||
} else {
|
||
getCurrentWindow().close();
|
||
}
|
||
}
|
||
|
||
function copy() {
|
||
// Phase 6: Check if timeline has selected keyframes first
|
||
if (context.timelineWidget && context.timelineWidget.copySelectedKeyframes()) {
|
||
// Keyframes were copied, don't copy objects/shapes
|
||
return;
|
||
}
|
||
|
||
// Otherwise, copy objects and shapes as usual
|
||
clipboard = [];
|
||
for (let object of context.selection) {
|
||
clipboard.push(object.toJSON(true));
|
||
}
|
||
for (let shape of context.shapeselection) {
|
||
clipboard.push(shape.toJSON(true));
|
||
}
|
||
}
|
||
|
||
function paste() {
|
||
// Phase 6: Check if timeline has keyframes in clipboard first
|
||
if (context.timelineWidget && context.timelineWidget.pasteKeyframes()) {
|
||
// Keyframes were pasted
|
||
return;
|
||
}
|
||
|
||
// Otherwise, paste objects and shapes as usual
|
||
// for (let item of clipboard) {
|
||
// if (item instanceof GraphicsObject) {
|
||
// console.log(item);
|
||
// // context.activeObject.addObject(item.copy())
|
||
// actions.duplicateObject.create(item);
|
||
// }
|
||
// }
|
||
actions.duplicateObject.create(clipboard);
|
||
updateUI();
|
||
}
|
||
|
||
function delete_action() {
|
||
if (context.selection.length || context.shapeselection.length) {
|
||
actions.deleteObjects.create(context.selection, context.shapeselection);
|
||
context.selection = [];
|
||
}
|
||
updateUI();
|
||
}
|
||
|
||
function addFrame() {
|
||
if (
|
||
context.activeObject.currentFrameNum >=
|
||
context.activeObject.activeLayer.frames.length
|
||
) {
|
||
actions.addFrame.create();
|
||
}
|
||
}
|
||
|
||
function addKeyframe() {
|
||
actions.addKeyframe.create();
|
||
}
|
||
|
||
/**
|
||
* Add keyframes to AnimationData curves at the current playhead position
|
||
* For new timeline system (Phase 5)
|
||
*/
|
||
function addKeyframeAtPlayhead() {
|
||
console.log('addKeyframeAtPlayhead called');
|
||
|
||
// Get the timeline widget and current time
|
||
if (!context.timelineWidget) {
|
||
console.warn('Timeline widget not available');
|
||
return;
|
||
}
|
||
|
||
const currentTime = context.timelineWidget.timelineState.currentTime;
|
||
console.log(`Current time: ${currentTime}`);
|
||
|
||
// Determine which object to add keyframes to based on selection
|
||
let targetObjects = [];
|
||
|
||
// If shapes are selected, add keyframes to those shapes
|
||
if (context.shapeselection && context.shapeselection.length > 0) {
|
||
console.log(`Found ${context.shapeselection.length} selected shapes`);
|
||
targetObjects = context.shapeselection;
|
||
}
|
||
// If objects are selected, add keyframes to those objects
|
||
else if (context.selection && context.selection.length > 0) {
|
||
console.log(`Found ${context.selection.length} selected objects`);
|
||
targetObjects = context.selection;
|
||
}
|
||
// Otherwise, if no selection, don't do anything
|
||
else {
|
||
console.log('No shapes or objects selected to add keyframes to');
|
||
console.log('context.shapeselection:', context.shapeselection);
|
||
console.log('context.selection:', context.selection);
|
||
return;
|
||
}
|
||
|
||
// For each selected object/shape, add keyframes to all its curves
|
||
for (let obj of targetObjects) {
|
||
// Determine if this is a shape or an object
|
||
const isShape = obj.constructor.name !== 'GraphicsObject';
|
||
|
||
// Find which layer this object/shape belongs to
|
||
let animationData = null;
|
||
|
||
if (isShape) {
|
||
// For shapes, find the layer recursively
|
||
const findShapeLayer = (searchObj) => {
|
||
for (let layer of searchObj.children) {
|
||
if (layer.shapes && layer.shapes.includes(obj)) {
|
||
animationData = layer.animationData;
|
||
return true;
|
||
}
|
||
if (layer.children) {
|
||
for (let child of layer.children) {
|
||
if (findShapeLayer(child)) return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
findShapeLayer(context.activeObject);
|
||
} else {
|
||
// For objects (groups), find the parent layer
|
||
for (let layer of context.activeObject.allLayers) {
|
||
if (layer.children && layer.children.includes(obj)) {
|
||
animationData = layer.animationData;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!animationData) continue;
|
||
|
||
// Special handling for shapes: duplicate shape with incremented shapeIndex
|
||
if (isShape) {
|
||
// Find the layer that contains this shape
|
||
let parentLayer = null;
|
||
const findShapeLayerObj = (searchObj) => {
|
||
for (let layer of searchObj.children) {
|
||
if (layer.shapes && layer.shapes.includes(obj)) {
|
||
parentLayer = layer;
|
||
return true;
|
||
}
|
||
if (layer.children) {
|
||
for (let child of layer.children) {
|
||
if (findShapeLayerObj(child)) return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
findShapeLayerObj(context.activeObject);
|
||
|
||
if (parentLayer) {
|
||
// Find the highest shapeIndex for this shapeId
|
||
const shapesWithSameId = parentLayer.shapes.filter(s => s.shapeId === obj.shapeId);
|
||
let maxShapeIndex = 0;
|
||
for (let shape of shapesWithSameId) {
|
||
maxShapeIndex = Math.max(maxShapeIndex, shape.shapeIndex || 0);
|
||
}
|
||
const newShapeIndex = maxShapeIndex + 1;
|
||
|
||
// Duplicate the shape with new shapeIndex
|
||
const shapeJSON = obj.toJSON(false); // Don't randomize UUIDs
|
||
shapeJSON.idx = uuidv4(); // But do create a new idx for the duplicate
|
||
shapeJSON.shapeIndex = newShapeIndex;
|
||
const newShape = Shape.fromJSON(shapeJSON, parentLayer);
|
||
parentLayer.shapes.push(newShape);
|
||
|
||
// Add keyframes to all shape curves (exists, zOrder, shapeIndex)
|
||
// This allows controlling timing, z-order, and morphing
|
||
const existsCurve = animationData.getOrCreateCurve(`shape.${obj.shapeId}.exists`);
|
||
const existsValue = existsCurve.interpolate(currentTime);
|
||
if (existsValue === null) {
|
||
// No previous keyframe, default to visible
|
||
existsCurve.addKeyframe(new Keyframe(currentTime, 1, 'hold'));
|
||
} else {
|
||
// Add keyframe with current interpolated value
|
||
existsCurve.addKeyframe(new Keyframe(currentTime, existsValue, 'hold'));
|
||
}
|
||
|
||
const zOrderCurve = animationData.getOrCreateCurve(`shape.${obj.shapeId}.zOrder`);
|
||
const zOrderValue = zOrderCurve.interpolate(currentTime);
|
||
if (zOrderValue === null) {
|
||
// No previous keyframe, find current z-order from layer
|
||
const currentZOrder = parentLayer.shapes.indexOf(obj);
|
||
zOrderCurve.addKeyframe(new Keyframe(currentTime, currentZOrder, 'hold'));
|
||
} else {
|
||
// Add keyframe with current interpolated value
|
||
zOrderCurve.addKeyframe(new Keyframe(currentTime, zOrderValue, 'hold'));
|
||
}
|
||
|
||
const shapeIndexCurve = animationData.getOrCreateCurve(`shape.${obj.shapeId}.shapeIndex`);
|
||
// Check if a keyframe already exists at this time to preserve its interpolation type
|
||
const framerate = context.config?.framerate || 24;
|
||
const timeResolution = (1 / framerate) / 2;
|
||
const existingShapeIndexKf = shapeIndexCurve.getKeyframeAtTime(currentTime, timeResolution);
|
||
const interpolationType = existingShapeIndexKf ? existingShapeIndexKf.interpolation : 'linear';
|
||
const shapeIndexKeyframe = new Keyframe(currentTime, newShapeIndex, interpolationType);
|
||
// Preserve easeIn/easeOut if they exist
|
||
if (existingShapeIndexKf && existingShapeIndexKf.easeIn) shapeIndexKeyframe.easeIn = existingShapeIndexKf.easeIn;
|
||
if (existingShapeIndexKf && existingShapeIndexKf.easeOut) shapeIndexKeyframe.easeOut = existingShapeIndexKf.easeOut;
|
||
shapeIndexCurve.addKeyframe(shapeIndexKeyframe);
|
||
|
||
console.log(`Created new shape version with shapeIndex ${newShapeIndex} at time ${currentTime}`);
|
||
}
|
||
} else {
|
||
// For objects (not shapes), add keyframes to all curves
|
||
const curves = [];
|
||
const prefix = `child.${obj.idx}.`;
|
||
|
||
for (let curveName in animationData.curves) {
|
||
if (curveName.startsWith(prefix)) {
|
||
curves.push(animationData.curves[curveName]);
|
||
}
|
||
}
|
||
|
||
// For each curve, add a keyframe at the current time with the interpolated value
|
||
for (let curve of curves) {
|
||
// Get the current interpolated value at this time
|
||
const currentValue = curve.interpolate(currentTime);
|
||
|
||
// Check if there's already a keyframe at this exact time
|
||
const existingKeyframe = curve.keyframes.find(kf => Math.abs(kf.time - currentTime) < 0.001);
|
||
|
||
if (existingKeyframe) {
|
||
// Update the existing keyframe's value
|
||
existingKeyframe.value = currentValue;
|
||
console.log(`Updated keyframe at time ${currentTime} on ${curve.parameter}`);
|
||
} else {
|
||
// Create a new keyframe
|
||
const newKeyframe = new Keyframe(
|
||
currentTime,
|
||
currentValue,
|
||
'linear' // Default to linear interpolation
|
||
);
|
||
|
||
curve.addKeyframe(newKeyframe);
|
||
console.log(`Added keyframe at time ${currentTime} on ${curve.parameter} with value ${currentValue}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Trigger a redraw of the timeline
|
||
if (context.timelineWidget.requestRedraw) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
|
||
console.log(`Added keyframes at time ${currentTime} for ${targetObjects.length} object(s)`);
|
||
}
|
||
|
||
function deleteFrame() {
|
||
let frame = context.activeObject.currentFrame;
|
||
let layer = context.activeObject.activeLayer;
|
||
if (frame) {
|
||
actions.deleteFrame.create(frame, layer);
|
||
}
|
||
}
|
||
async function about() {
|
||
messageDialog(
|
||
`Lightningbeam version ${await getVersion()}\nDeveloped by Skyler Lehmkuhl`,
|
||
{ title: "About", kind: "info" },
|
||
);
|
||
}
|
||
|
||
// Export stuff that's all crammed in here and needs refactored
|
||
function createProgressModal() {
|
||
// Check if the modal already exists
|
||
const existingModal = document.getElementById('progressModal');
|
||
if (existingModal) {
|
||
existingModal.style.display = 'flex';
|
||
return; // If the modal already exists, do nothing
|
||
}
|
||
|
||
// Create modal container with a unique ID
|
||
const modal = document.createElement('div');
|
||
modal.id = 'progressModal'; // Give the modal a unique ID
|
||
modal.style.position = 'fixed';
|
||
modal.style.top = '0';
|
||
modal.style.left = '0';
|
||
modal.style.width = '100%';
|
||
modal.style.height = '100%';
|
||
modal.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
|
||
modal.style.display = 'flex';
|
||
modal.style.justifyContent = 'center';
|
||
modal.style.alignItems = 'center';
|
||
modal.style.zIndex = '9999';
|
||
|
||
// Create inner modal box
|
||
const modalContent = document.createElement('div');
|
||
modalContent.style.backgroundColor = backgroundColor;
|
||
modalContent.style.padding = '20px';
|
||
modalContent.style.borderRadius = '8px';
|
||
modalContent.style.textAlign = 'center';
|
||
modalContent.style.minWidth = '300px';
|
||
|
||
// Create progress bar
|
||
const progressBar = document.createElement('progress');
|
||
progressBar.id = 'progressBar';
|
||
progressBar.value = 0;
|
||
progressBar.max = 100;
|
||
progressBar.style.width = '100%';
|
||
|
||
// Create text to show the current frame info
|
||
const progressText = document.createElement('p');
|
||
progressText.id = 'progressText';
|
||
progressText.innerText = 'Initializing...';
|
||
|
||
// Append elements to modalContent
|
||
modalContent.appendChild(progressBar);
|
||
modalContent.appendChild(progressText);
|
||
|
||
// Append modalContent to modal
|
||
modal.appendChild(modalContent);
|
||
|
||
// Append modal to body
|
||
document.body.appendChild(modal);
|
||
}
|
||
|
||
|
||
async function setupVideoExport(ext, path, canvas, exportContext) {
|
||
createProgressModal();
|
||
|
||
await LibAVWebCodecs.load();
|
||
console.log("Codecs loaded");
|
||
|
||
let target;
|
||
let muxer;
|
||
let videoEncoder;
|
||
let videoConfig;
|
||
let audioEncoder;
|
||
let audioConfig;
|
||
const frameTimeMicroseconds = parseInt(1_000_000 / config.framerate)
|
||
const oldContext = context;
|
||
context = exportContext;
|
||
|
||
const oldRootFrame = root.currentFrameNum
|
||
const bitrate = 1e6
|
||
|
||
// Choose muxer and encoder configuration based on file extension
|
||
if (ext === "mp4") {
|
||
target = new Mp4Muxer.ArrayBufferTarget();
|
||
// TODO: add video options dialog for width, height, bitrate
|
||
muxer = new Mp4Muxer.Muxer({
|
||
target: target,
|
||
video: {
|
||
codec: 'avc',
|
||
width: config.fileWidth,
|
||
height: config.fileHeight,
|
||
frameRate: config.framerate,
|
||
},
|
||
fastStart: 'in-memory',
|
||
firstTimestampBehavior: 'offset',
|
||
});
|
||
|
||
videoConfig = {
|
||
codec: 'avc1.42001f',
|
||
width: config.fileWidth,
|
||
height: config.fileHeight,
|
||
bitrate: bitrate,
|
||
};
|
||
|
||
// Todo: add configuration for mono/stereo
|
||
audioConfig = {
|
||
codec: 'mp4a.40.2', // AAC codec
|
||
sampleRate: 44100,
|
||
numberOfChannels: 2, // Mono
|
||
bitrate: 64000,
|
||
};
|
||
} else if (ext === "webm") {
|
||
target = new WebMMuxer.ArrayBufferTarget();
|
||
muxer = new WebMMuxer.Muxer({
|
||
target: target,
|
||
video: {
|
||
codec: 'V_VP9',
|
||
width: config.fileWidth,
|
||
height: config.fileHeight,
|
||
frameRate: config.framerate,
|
||
},
|
||
firstTimestampBehavior: 'offset',
|
||
});
|
||
|
||
videoConfig = {
|
||
codec: 'vp09.00.10.08',
|
||
width: config.fileWidth,
|
||
height: config.fileHeight,
|
||
bitrate: bitrate,
|
||
bitrateMode: "constant",
|
||
};
|
||
|
||
audioConfig = {
|
||
codec: 'opus', // Use Opus codec for WebM
|
||
sampleRate: 48000,
|
||
numberOfChannels: 2,
|
||
bitrate: 64000,
|
||
}
|
||
}
|
||
|
||
// Initialize the video encoder
|
||
videoEncoder = new VideoEncoder({
|
||
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta, undefined, undefined, frameTimeMicroseconds),
|
||
error: (e) => console.error(e),
|
||
});
|
||
|
||
videoEncoder.configure(videoConfig);
|
||
|
||
// audioEncoder = new AudioEncoder({
|
||
// output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
|
||
// error: (e) => console.error(e),
|
||
// });
|
||
|
||
// audioEncoder.configure(audioConfig)
|
||
|
||
async function finishEncoding() {
|
||
const progressText = document.getElementById('progressText');
|
||
progressText.innerText = 'Finalizing...';
|
||
const progressBar = document.getElementById('progressBar');
|
||
progressBar.value = 100;
|
||
await videoEncoder.flush();
|
||
muxer.finalize();
|
||
await writeFile(path, new Uint8Array(target.buffer));
|
||
const modal = document.getElementById('progressModal');
|
||
modal.style.display = 'none';
|
||
document.querySelector("body").style.cursor = "default";
|
||
}
|
||
|
||
const processFrame = async (currentFrame) => {
|
||
if (currentFrame < root.maxFrame) {
|
||
// Update progress bar
|
||
const progressText = document.getElementById('progressText');
|
||
progressText.innerText = `Rendering frame ${currentFrame + 1} of ${root.maxFrame}`;
|
||
const progressBar = document.getElementById('progressBar');
|
||
const progress = Math.round(((currentFrame + 1) / root.maxFrame) * 100);
|
||
progressBar.value = progress;
|
||
|
||
root.setFrameNum(currentFrame);
|
||
exportContext.ctx.fillStyle = "white";
|
||
exportContext.ctx.rect(0, 0, config.fileWidth, config.fileHeight);
|
||
exportContext.ctx.fill();
|
||
root.draw(exportContext.ctx);
|
||
const frame = new VideoFrame(
|
||
await LibAVWebCodecs.createImageBitmap(canvas),
|
||
{ timestamp: currentFrame * frameTimeMicroseconds }
|
||
);
|
||
|
||
// Encode frame
|
||
const keyFrame = currentFrame % 60 === 0; // Every 60th frame is a key frame
|
||
videoEncoder.encode(frame, { keyFrame });
|
||
frame.close();
|
||
|
||
currentFrame++;
|
||
setTimeout(() => processFrame(currentFrame), 4);
|
||
} else {
|
||
// Once all frames are processed, reset context and export
|
||
context = oldContext;
|
||
root.setFrameNum(oldRootFrame);
|
||
finishEncoding();
|
||
}
|
||
};
|
||
|
||
processFrame(0);
|
||
}
|
||
|
||
async function render() {
|
||
document.querySelector("body").style.cursor = "wait";
|
||
const path = await saveFileDialog({
|
||
filters: [
|
||
{
|
||
name: "WebM files (.webm)",
|
||
extensions: ["webm"],
|
||
},
|
||
{
|
||
name: "MP4 files (.mp4)",
|
||
extensions: ["mp4"],
|
||
},
|
||
{
|
||
name: "APNG files (.png)",
|
||
extensions: ["png"],
|
||
},
|
||
{
|
||
name: "Packed HTML player (.html)",
|
||
extensions: ["html"],
|
||
},
|
||
],
|
||
defaultPath: await join(await documentDir(), "untitled.webm"),
|
||
});
|
||
if (path != undefined) {
|
||
// SVG balks on images
|
||
// let ctx = new C2S(fileWidth, fileHeight)
|
||
// context.ctx = ctx
|
||
// root.draw(context)
|
||
// let serializedSVG = ctx.getSerializedSvg()
|
||
// await writeTextFile(path, serializedSVG)
|
||
// fileExportPath = path
|
||
// console.log("wrote SVG")
|
||
|
||
const ext = path.split(".").pop().toLowerCase();
|
||
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = config.fileWidth; // Set desired width
|
||
canvas.height = config.fileHeight; // Set desired height
|
||
let exportContext = {
|
||
...context,
|
||
ctx: canvas.getContext("2d"),
|
||
selectionRect: undefined,
|
||
selection: [],
|
||
shapeselection: [],
|
||
};
|
||
|
||
|
||
switch (ext) {
|
||
case "mp4":
|
||
case "webm":
|
||
await setupVideoExport(ext, path, canvas, exportContext);
|
||
break;
|
||
case "html":
|
||
fetch("/player.html")
|
||
.then((response) => {
|
||
if (!response.ok) {
|
||
throw new Error("Network response was not ok");
|
||
}
|
||
return response.text(); // Read the response body as a string
|
||
})
|
||
.then((data) => {
|
||
// TODO: strip out the stuff tauri injects
|
||
let json = JSON.stringify({
|
||
fileWidth: config.fileWidth,
|
||
fileHeight: config.fileHeight,
|
||
root: root.toJSON(),
|
||
});
|
||
data = data.replace('"${file}"', json);
|
||
console.log(data); // The content of the file as a string
|
||
})
|
||
.catch((error) => {
|
||
// TODO: alert
|
||
console.error(
|
||
"There was a problem with the fetch operation:",
|
||
error,
|
||
);
|
||
});
|
||
|
||
break;
|
||
case "png":
|
||
const frames = [];
|
||
canvas = document.createElement("canvas");
|
||
canvas.width = config.fileWidth; // Set desired width
|
||
canvas.height = config.fileHeight; // Set desired height
|
||
|
||
|
||
for (let i = 0; i < root.maxFrame; i++) {
|
||
root.currentFrameNum = i;
|
||
exportContext.ctx.fillStyle = "white";
|
||
exportContext.ctx.rect(0, 0, config.fileWidth, config.fileHeight);
|
||
exportContext.ctx.fill();
|
||
root.draw(exportContext);
|
||
|
||
// Convert the canvas content to a PNG image (this is the "frame" we add to the APNG)
|
||
const imageData = exportContext.ctx.getImageData(
|
||
0,
|
||
0,
|
||
canvas.width,
|
||
canvas.height,
|
||
);
|
||
|
||
// Step 2: Create a frame buffer (Uint8Array) from the image data
|
||
const frameBuffer = new Uint8Array(imageData.data.buffer);
|
||
|
||
frames.push(frameBuffer); // Add the frame buffer to the frames array
|
||
}
|
||
|
||
// Step 3: Use UPNG.js to create the animated PNG
|
||
const apng = UPNG.encode(
|
||
frames,
|
||
canvas.width,
|
||
canvas.height,
|
||
0,
|
||
parseInt(100 / config.framerate),
|
||
);
|
||
|
||
// Step 4: Save the APNG file (in Tauri, use writeFile or in the browser, download it)
|
||
const apngBlob = new Blob([apng], { type: "image/png" });
|
||
|
||
// If you're using Tauri:
|
||
await writeFile(
|
||
path, // The destination file path for saving
|
||
new Uint8Array(await apngBlob.arrayBuffer()),
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
document.querySelector("body").style.cursor = "default";
|
||
}
|
||
|
||
async function exportAudio() {
|
||
// Get the project duration from context
|
||
const duration = context.activeObject.duration || 60;
|
||
|
||
// Show a simple dialog to get export settings
|
||
const dialog = document.createElement('div');
|
||
dialog.style.cssText = `
|
||
position: fixed;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: var(--bg-color, #2a2a2a);
|
||
border: 1px solid var(--border-color, #555);
|
||
padding: 20px;
|
||
border-radius: 8px;
|
||
z-index: 10000;
|
||
color: var(--text-color, #eee);
|
||
min-width: 400px;
|
||
`;
|
||
|
||
dialog.innerHTML = `
|
||
<style>
|
||
#export-format option,
|
||
#export-sample-rate option,
|
||
#export-bit-depth option {
|
||
background: #333 !important;
|
||
color: #eee !important;
|
||
}
|
||
</style>
|
||
<h2 style="margin-top: 0;">Export Audio</h2>
|
||
<div style="margin-bottom: 15px;">
|
||
<label style="display: block; margin-bottom: 5px;">Format:</label>
|
||
<select id="export-format" style="width: 100%; padding: 5px; background: var(--input-bg, #333); color: var(--text-color, #eee); border: 1px solid var(--border-color, #555); border-radius: 4px;">
|
||
<option value="wav">WAV</option>
|
||
<option value="flac">FLAC</option>
|
||
</select>
|
||
</div>
|
||
<div style="margin-bottom: 15px;">
|
||
<label style="display: block; margin-bottom: 5px;">Sample Rate:</label>
|
||
<select id="export-sample-rate" style="width: 100%; padding: 5px; background: var(--input-bg, #333); color: var(--text-color, #eee); border: 1px solid var(--border-color, #555); border-radius: 4px;">
|
||
<option value="44100">44100 Hz</option>
|
||
<option value="48000" selected>48000 Hz</option>
|
||
<option value="96000">96000 Hz</option>
|
||
</select>
|
||
</div>
|
||
<div style="margin-bottom: 15px;">
|
||
<label style="display: block; margin-bottom: 5px;">Bit Depth:</label>
|
||
<select id="export-bit-depth" style="width: 100%; padding: 5px; background: var(--input-bg, #333); color: var(--text-color, #eee); border: 1px solid var(--border-color, #555); border-radius: 4px;">
|
||
<option value="16">16-bit</option>
|
||
<option value="24" selected>24-bit</option>
|
||
</select>
|
||
</div>
|
||
<div style="margin-bottom: 15px;">
|
||
<label style="display: block; margin-bottom: 5px;">End Time (seconds):</label>
|
||
<input type="number" id="export-end-time" value="${duration.toFixed(2)}" min="0.1" step="0.1" style="width: 100%; padding: 5px; background: var(--input-bg, #333); color: var(--text-color, #eee); border: 1px solid var(--border-color, #555); border-radius: 4px;">
|
||
</div>
|
||
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||
<button id="export-cancel" style="padding: 8px 16px; background: var(--button-bg, #444); color: var(--text-color, #eee); border: 1px solid var(--border-color, #555); border-radius: 4px; cursor: pointer;">Cancel</button>
|
||
<button id="export-ok" style="padding: 8px 16px; background: var(--primary-color, #0078d7); color: white; border: none; border-radius: 4px; cursor: pointer;">Export</button>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(dialog);
|
||
|
||
return new Promise((resolve) => {
|
||
dialog.querySelector('#export-cancel').addEventListener('click', () => {
|
||
document.body.removeChild(dialog);
|
||
resolve(null);
|
||
});
|
||
|
||
dialog.querySelector('#export-ok').addEventListener('click', async () => {
|
||
const format = dialog.querySelector('#export-format').value;
|
||
const sampleRate = parseInt(dialog.querySelector('#export-sample-rate').value);
|
||
const bitDepth = parseInt(dialog.querySelector('#export-bit-depth').value);
|
||
const endTime = parseFloat(dialog.querySelector('#export-end-time').value);
|
||
|
||
document.body.removeChild(dialog);
|
||
|
||
// Show file save dialog
|
||
const path = await saveFileDialog({
|
||
filters: [
|
||
{
|
||
name: format.toUpperCase() + " files",
|
||
extensions: [format],
|
||
},
|
||
],
|
||
defaultPath: await join(await documentDir(), `export.${format}`),
|
||
});
|
||
|
||
if (path) {
|
||
try {
|
||
document.querySelector("body").style.cursor = "wait";
|
||
|
||
await invoke('audio_export', {
|
||
outputPath: path,
|
||
format: format,
|
||
sampleRate: sampleRate,
|
||
channels: 2,
|
||
bitDepth: bitDepth,
|
||
mp3Bitrate: 320,
|
||
startTime: 0.0,
|
||
endTime: endTime,
|
||
});
|
||
|
||
document.querySelector("body").style.cursor = "default";
|
||
alert('Audio exported successfully!');
|
||
} catch (error) {
|
||
document.querySelector("body").style.cursor = "default";
|
||
console.error('Export failed:', error);
|
||
alert('Export failed: ' + error);
|
||
}
|
||
}
|
||
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateScrollPosition(zoomFactor) {
|
||
if (context.mousePos) {
|
||
for (let canvas of canvases) {
|
||
canvas.offsetX =
|
||
(canvas.offsetX + context.mousePos.x) * zoomFactor - context.mousePos.x;
|
||
canvas.offsetY =
|
||
(canvas.offsetY + context.mousePos.y) * zoomFactor - context.mousePos.y;
|
||
canvas.zoomLevel = context.zoomLevel
|
||
}
|
||
}
|
||
}
|
||
|
||
function zoomIn() {
|
||
let zoomFactor = 2;
|
||
if (context.zoomLevel < 8) {
|
||
context.zoomLevel *= zoomFactor;
|
||
updateScrollPosition(zoomFactor);
|
||
updateUI();
|
||
updateMenu();
|
||
}
|
||
}
|
||
function zoomOut() {
|
||
let zoomFactor = 0.5;
|
||
if (context.zoomLevel > 1 / 8) {
|
||
context.zoomLevel *= zoomFactor;
|
||
updateScrollPosition(zoomFactor);
|
||
updateUI();
|
||
updateMenu();
|
||
}
|
||
}
|
||
function resetZoom() {
|
||
context.zoomLevel = 1;
|
||
recenter()
|
||
}
|
||
|
||
function recenter() {
|
||
for (let canvas of canvases) {
|
||
canvas.offsetX = canvas.offsetY = 0;
|
||
}
|
||
updateUI();
|
||
updateMenu();
|
||
}
|
||
|
||
function stage() {
|
||
let stage = document.createElement("canvas");
|
||
// let scroller = document.createElement("div")
|
||
// let stageWrapper = document.createElement("div")
|
||
stage.className = "stage";
|
||
// stage.width = config.fileWidth
|
||
// stage.height = config.fileHeight
|
||
stage.offsetX = 0;
|
||
stage.offsetY = 0;
|
||
stage.zoomLevel = context.zoomLevel
|
||
|
||
let lastResizeTime = 0;
|
||
const throttleIntervalMs = 20;
|
||
|
||
function updateStageCanvasSize() {
|
||
const canvasStyles = window.getComputedStyle(stage);
|
||
|
||
stage.width = parseInt(canvasStyles.width);
|
||
stage.height = parseInt(canvasStyles.height);
|
||
updateUI();
|
||
renderAll();
|
||
}
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
const currentTime = Date.now();
|
||
|
||
if (currentTime - lastResizeTime > throttleIntervalMs) {
|
||
lastResizeTime = currentTime;
|
||
updateStageCanvasSize();
|
||
}
|
||
});
|
||
resizeObserver.observe(stage);
|
||
updateStageCanvasSize();
|
||
|
||
stage.addEventListener("wheel", (event) => {
|
||
event.preventDefault();
|
||
|
||
// Check if this is a pinch-zoom gesture (ctrlKey is set on trackpad pinch)
|
||
if (event.ctrlKey) {
|
||
// Pinch zoom - zoom in/out based on deltaY
|
||
const zoomFactor = event.deltaY > 0 ? 0.95 : 1.05;
|
||
const oldZoom = context.zoomLevel;
|
||
context.zoomLevel = Math.max(1/8, Math.min(8, context.zoomLevel * zoomFactor));
|
||
|
||
// Update scroll position to zoom towards mouse
|
||
if (context.mousePos) {
|
||
const actualZoomFactor = context.zoomLevel / oldZoom;
|
||
stage.offsetX = (stage.offsetX + context.mousePos.x) * actualZoomFactor - context.mousePos.x;
|
||
stage.offsetY = (stage.offsetY + context.mousePos.y) * actualZoomFactor - context.mousePos.y;
|
||
}
|
||
|
||
updateUI();
|
||
updateMenu();
|
||
} else {
|
||
// Regular scroll
|
||
const deltaX = event.deltaX * config.scrollSpeed;
|
||
const deltaY = event.deltaY * config.scrollSpeed;
|
||
|
||
stage.offsetX += deltaX;
|
||
stage.offsetY += deltaY;
|
||
const currentTime = Date.now();
|
||
if (currentTime - lastResizeTime > throttleIntervalMs) {
|
||
lastResizeTime = currentTime;
|
||
updateUI();
|
||
}
|
||
}
|
||
});
|
||
// scroller.className = "scroll"
|
||
// stageWrapper.className = "stageWrapper"
|
||
// let selectionRect = document.createElement("div")
|
||
// selectionRect.className = "selectionRect"
|
||
// for (let i of ["nw", "ne", "se", "sw"]) {
|
||
// let cornerRotateRect = document.createElement("div")
|
||
// cornerRotateRect.classList.add("cornerRotateRect")
|
||
// cornerRotateRect.classList.add(i)
|
||
// cornerRotateRect.addEventListener('mouseup', (e) => {
|
||
// const newEvent = new MouseEvent(e.type, e);
|
||
// stage.dispatchEvent(newEvent)
|
||
// })
|
||
// cornerRotateRect.addEventListener('mousemove', (e) => {
|
||
// const newEvent = new MouseEvent(e.type, e);
|
||
// stage.dispatchEvent(newEvent)
|
||
// })
|
||
// selectionRect.appendChild(cornerRotateRect)
|
||
// }
|
||
// for (let i of ["nw", "n", "ne", "e", "se", "s", "sw", "w"]) {
|
||
// let cornerRect = document.createElement("div")
|
||
// cornerRect.classList.add("cornerRect")
|
||
// cornerRect.classList.add(i)
|
||
// cornerRect.addEventListener('mousedown', (e) => {
|
||
// let bbox = undefined;
|
||
// let selection = {}
|
||
// for (let item of context.selection) {
|
||
// if (bbox==undefined) {
|
||
// bbox = structuredClone(item.bbox())
|
||
// } else {
|
||
// growBoundingBox(bbox, item.bbox())
|
||
// }
|
||
// selection[item.idx] = {x: item.x, y: item.y, scale_x: item.scale_x, scale_y: item.scale_y}
|
||
// }
|
||
// if (bbox != undefined) {
|
||
// context.dragDirection = i
|
||
// context.activeTransform = {
|
||
// initial: {
|
||
// x: {min: bbox.x.min, max: bbox.x.max},
|
||
// y: {min: bbox.y.min, max: bbox.y.max},
|
||
// selection: selection
|
||
// },
|
||
// current: {
|
||
// x: {min: bbox.x.min, max: bbox.x.max},
|
||
// y: {min: bbox.y.min, max: bbox.y.max},
|
||
// selection: structuredClone(selection)
|
||
// }
|
||
// }
|
||
// context.activeObject.currentFrame.saveState()
|
||
// }
|
||
// })
|
||
// cornerRect.addEventListener('mouseup', (e) => {
|
||
// const newEvent = new MouseEvent(e.type, e);
|
||
// stage.dispatchEvent(newEvent)
|
||
// })
|
||
// cornerRect.addEventListener('mousemove', (e) => {
|
||
// const newEvent = new MouseEvent(e.type, e);
|
||
// stage.dispatchEvent(newEvent)
|
||
// })
|
||
// selectionRect.appendChild(cornerRect)
|
||
// }
|
||
|
||
stage.addEventListener("drop", (e) => {
|
||
e.preventDefault();
|
||
let mouse = getMousePos(stage, e);
|
||
const imageTypes = [
|
||
"image/png",
|
||
"image/gif",
|
||
"image/avif",
|
||
"image/jpeg",
|
||
"image/webp", //'image/svg+xml' // Disabling SVG until we can export them nicely
|
||
];
|
||
const audioTypes = ["audio/mpeg"];
|
||
if (e.dataTransfer.items) {
|
||
let i = 0;
|
||
for (let item of e.dataTransfer.items) {
|
||
if (item.kind == "file") {
|
||
let file = item.getAsFile();
|
||
if (imageTypes.includes(file.type)) {
|
||
let img = new Image();
|
||
let reader = new FileReader();
|
||
|
||
// Read the file as a data URL
|
||
reader.readAsDataURL(file);
|
||
reader.ix = i;
|
||
|
||
reader.onload = function (event) {
|
||
let imgsrc = event.target.result; // This is the data URL
|
||
actions.addImageObject.create(
|
||
mouse.x,
|
||
mouse.y,
|
||
imgsrc,
|
||
reader.ix,
|
||
context.activeObject,
|
||
);
|
||
};
|
||
|
||
reader.onerror = function (error) {
|
||
console.error("Error reading file as data URL", error);
|
||
};
|
||
} else if (audioTypes.includes(file.type)) {
|
||
let reader = new FileReader();
|
||
|
||
// Read the file as a data URL
|
||
reader.readAsDataURL(file);
|
||
reader.onload = function (event) {
|
||
let audiosrc = event.target.result;
|
||
actions.addAudio.create(
|
||
audiosrc,
|
||
context.activeObject,
|
||
file.name,
|
||
);
|
||
};
|
||
}
|
||
i++;
|
||
}
|
||
}
|
||
} else {
|
||
}
|
||
});
|
||
stage.addEventListener("dragover", (e) => {
|
||
e.preventDefault();
|
||
});
|
||
canvases.push(stage);
|
||
// stageWrapper.appendChild(stage)
|
||
// stageWrapper.appendChild(selectionRect)
|
||
// scroller.appendChild(stageWrapper)
|
||
stage.addEventListener("pointerdown", (e) => {
|
||
console.log("POINTERDOWN EVENT - context.mode:", context.mode);
|
||
let mouse = getMousePos(stage, e);
|
||
console.log("Mouse position:", mouse);
|
||
root.handleMouseEvent("mousedown", mouse.x, mouse.y)
|
||
mouse = context.activeObject.transformMouse(mouse);
|
||
let selection;
|
||
switch (context.mode) {
|
||
case "rectangle":
|
||
case "ellipse":
|
||
case "draw":
|
||
// context.mouseDown = true;
|
||
// context.activeShape = new Shape(mouse.x, mouse.y, context, uuidv4());
|
||
// context.lastMouse = mouse;
|
||
break;
|
||
case "select":
|
||
// No longer need keyframe check with AnimationData system
|
||
selection = selectVertex(context, mouse);
|
||
if (selection) {
|
||
context.dragging = true;
|
||
context.activeCurve = undefined;
|
||
context.activeVertex = {
|
||
current: {
|
||
point: {
|
||
x: selection.vertex.point.x,
|
||
y: selection.vertex.point.y,
|
||
},
|
||
startCurves: structuredClone(selection.vertex.startCurves),
|
||
endCurves: structuredClone(selection.vertex.endCurves),
|
||
},
|
||
initial: selection.vertex,
|
||
shape: selection.shape,
|
||
startmouse: { x: mouse.x, y: mouse.y },
|
||
};
|
||
} else {
|
||
selection = selectCurve(context, mouse);
|
||
if (selection) {
|
||
context.dragging = true;
|
||
context.activeVertex = undefined;
|
||
context.activeCurve = {
|
||
initial: selection.curve,
|
||
current: new Bezier(selection.curve.points).setColor(
|
||
selection.curve.color,
|
||
),
|
||
shape: selection.shape,
|
||
startmouse: { x: mouse.x, y: mouse.y },
|
||
};
|
||
} else {
|
||
let selected = false;
|
||
let child;
|
||
if (context.selection.length) {
|
||
for (child of context.selection) {
|
||
if (hitTest(mouse, child)) {
|
||
context.dragging = true;
|
||
context.lastMouse = mouse;
|
||
const layer = context.activeObject.activeLayer;
|
||
const time = context.activeObject.currentTime || 0;
|
||
context.activeAction = actions.moveObjects.initialize(
|
||
context.selection,
|
||
layer,
|
||
time,
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!context.dragging) {
|
||
// Have to iterate in reverse order to grab the frontmost object when two overlap
|
||
for (
|
||
let i = context.activeObject.activeLayer.children.length - 1;
|
||
i >= 0;
|
||
i--
|
||
) {
|
||
child = context.activeObject.activeLayer.children[i];
|
||
|
||
// Check if child exists using AnimationData curves
|
||
let currentTime = context.activeObject.currentTime || 0;
|
||
let childX = context.activeObject.activeLayer.animationData.interpolate(`child.${child.idx}.x`, currentTime);
|
||
let childY = context.activeObject.activeLayer.animationData.interpolate(`child.${child.idx}.y`, currentTime);
|
||
|
||
// Skip if child doesn't have position data at current time
|
||
if (childX === null || childY === null) continue;
|
||
|
||
// let bbox = child.bbox()
|
||
if (hitTest(mouse, child)) {
|
||
if (context.selection.indexOf(child) != -1) {
|
||
// dragging = true
|
||
}
|
||
child.saveState();
|
||
if (e.shiftKey) {
|
||
context.selection.push(child);
|
||
} else {
|
||
context.selection = [child];
|
||
}
|
||
context.dragging = true;
|
||
selected = true;
|
||
context.activeAction = actions.editFrame.initialize(
|
||
context.activeObject.currentFrame,
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
if (!selected) {
|
||
context.oldselection = context.selection;
|
||
context.oldshapeselection = context.shapeselection;
|
||
context.selection = [];
|
||
context.shapeselection = [];
|
||
if (
|
||
context.oldselection.length ||
|
||
context.oldshapeselection.length
|
||
) {
|
||
actions.select.create();
|
||
}
|
||
context.oldselection = context.selection;
|
||
context.oldshapeselection = context.selection;
|
||
context.selectionRect = {
|
||
x1: mouse.x,
|
||
x2: mouse.x,
|
||
y1: mouse.y,
|
||
y2: mouse.y,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
case "transform":
|
||
let bbox = undefined;
|
||
selection = {};
|
||
for (let item of context.selection) {
|
||
if (bbox == undefined) {
|
||
bbox = getRotatedBoundingBox(item);
|
||
} else {
|
||
growBoundingBox(bbox, getRotatedBoundingBox(item));
|
||
}
|
||
selection[item.idx] = {
|
||
x: item.x,
|
||
y: item.y,
|
||
scale_x: item.scale_x,
|
||
scale_y: item.scale_y,
|
||
rotation: item.rotation,
|
||
};
|
||
}
|
||
let transformPoint = getPointNearBox(bbox, mouse, 10);
|
||
if (transformPoint) {
|
||
context.dragDirection = transformPoint;
|
||
context.activeTransform = {
|
||
initial: {
|
||
x: { min: bbox.x.min, max: bbox.x.max },
|
||
y: { min: bbox.y.min, max: bbox.y.max },
|
||
rotation: 0,
|
||
selection: selection,
|
||
},
|
||
current: {
|
||
x: { min: bbox.x.min, max: bbox.x.max },
|
||
y: { min: bbox.y.min, max: bbox.y.max },
|
||
rotation: 0,
|
||
selection: structuredClone(selection),
|
||
},
|
||
};
|
||
context.activeAction = actions.transformObjects.initialize(
|
||
context.activeObject.currentFrame,
|
||
context.selection,
|
||
transformPoint,
|
||
mouse,
|
||
);
|
||
} else {
|
||
transformPoint = getPointNearBox(bbox, mouse, 30, false);
|
||
if (transformPoint) {
|
||
stage.style.cursor = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='currentColor' class='bi bi-arrow-counterclockwise' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2z'/%3E%3Cpath d='M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466'/%3E%3C/svg%3E") 12 12, auto`;
|
||
context.dragDirection = "r";
|
||
context.activeTransform = {
|
||
initial: {
|
||
x: { min: bbox.x.min, max: bbox.x.max },
|
||
y: { min: bbox.y.min, max: bbox.y.max },
|
||
rotation: 0,
|
||
mouse: { x: mouse.x, y: mouse.y },
|
||
selection: selection,
|
||
},
|
||
current: {
|
||
x: { min: bbox.x.min, max: bbox.x.max },
|
||
y: { min: bbox.y.min, max: bbox.y.max },
|
||
rotation: 0,
|
||
mouse: { x: mouse.x, y: mouse.y },
|
||
selection: structuredClone(selection),
|
||
},
|
||
};
|
||
context.activeAction = actions.transformObjects.initialize(
|
||
context.activeObject.currentFrame,
|
||
context.selection,
|
||
"r",
|
||
mouse,
|
||
);
|
||
} else {
|
||
stage.style.cursor = "default";
|
||
}
|
||
}
|
||
break;
|
||
case "paint_bucket":
|
||
// Paint bucket is now handled in Layer.mousedown (line ~3458)
|
||
break;
|
||
case "eyedropper":
|
||
const ctx = stage.getContext("2d")
|
||
const imageData = ctx.getImageData(mouse.x, mouse.y, 1, 1); // Get pixel at (x, y)
|
||
const data = imageData.data; // The pixel data is in the `data` array
|
||
const hsv = rgbToHsv(...data)
|
||
if (context.dropperColor == "Fill color") {
|
||
for (let el of document.querySelectorAll(".color-field.fill")) {
|
||
el.setColor(hsv, 'ff')
|
||
}
|
||
} else {
|
||
for (let el of document.querySelectorAll(".color-field.stroke")) {
|
||
el.setColor(hsv, 'ff')
|
||
}
|
||
}
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
context.lastMouse = mouse;
|
||
updateUI();
|
||
updateInfopanel();
|
||
});
|
||
stage.mouseup = (e) => {
|
||
context.mouseDown = false;
|
||
context.dragging = false;
|
||
context.dragDirection = undefined;
|
||
context.selectionRect = undefined;
|
||
let mouse = getMousePos(stage, e);
|
||
root.handleMouseEvent("mouseup", mouse.x, mouse.y)
|
||
mouse = context.activeObject.transformMouse(mouse);
|
||
switch (context.mode) {
|
||
case "draw":
|
||
// if (context.activeShape) {
|
||
// context.activeShape.addLine(mouse.x, mouse.y);
|
||
// context.activeShape.simplify(context.simplifyMode);
|
||
// actions.addShape.create(context.activeObject, context.activeShape);
|
||
// context.activeShape = undefined;
|
||
// }
|
||
break;
|
||
case "rectangle":
|
||
case "ellipse":
|
||
// actions.addShape.create(context.activeObject, context.activeShape);
|
||
// context.activeShape = undefined;
|
||
break;
|
||
case "select":
|
||
if (context.activeAction) {
|
||
actions[context.activeAction.type].finalize(
|
||
context.activeAction,
|
||
context.activeObject.currentFrame,
|
||
);
|
||
} else if (context.activeVertex) {
|
||
let newCurves = [];
|
||
for (let i in context.activeVertex.shape.curves) {
|
||
if (i in context.activeVertex.current.startCurves) {
|
||
newCurves.push(context.activeVertex.current.startCurves[i]);
|
||
} else if (i in context.activeVertex.current.endCurves) {
|
||
newCurves.push(context.activeVertex.current.endCurves[i]);
|
||
} else {
|
||
newCurves.push(context.activeVertex.shape.curves[i]);
|
||
}
|
||
}
|
||
actions.editShape.create(context.activeVertex.shape, newCurves);
|
||
} else if (context.activeCurve) {
|
||
let newCurves = [];
|
||
for (let curve of context.activeCurve.shape.curves) {
|
||
if (curve == context.activeCurve.initial) {
|
||
newCurves.push(context.activeCurve.current);
|
||
} else {
|
||
newCurves.push(curve);
|
||
}
|
||
}
|
||
actions.editShape.create(context.activeCurve.shape, newCurves);
|
||
// Add the shape to selection after editing
|
||
if (e.shiftKey) {
|
||
if (!context.shapeselection.includes(context.activeCurve.shape)) {
|
||
context.shapeselection.push(context.activeCurve.shape);
|
||
}
|
||
} else {
|
||
context.shapeselection = [context.activeCurve.shape];
|
||
}
|
||
actions.select.create();
|
||
} else if (context.selection.length) {
|
||
actions.select.create();
|
||
// actions.editFrame.create(context.activeObject.currentFrame)
|
||
} else if (context.shapeselection.length) {
|
||
actions.select.create();
|
||
}
|
||
break;
|
||
case "transform":
|
||
if (context.activeAction) {
|
||
actions[context.activeAction.type].finalize(
|
||
context.activeAction,
|
||
context.activeObject.currentFrame,
|
||
);
|
||
}
|
||
// actions.editFrame.create(context.activeObject.currentFrame)
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
context.lastMouse = mouse;
|
||
context.activeCurve = undefined;
|
||
updateUI();
|
||
updateMenu();
|
||
updateInfopanel();
|
||
};
|
||
stage.addEventListener("pointerup", stage.mouseup);
|
||
stage.addEventListener("pointermove", (e) => {
|
||
let mouse = getMousePos(stage, e);
|
||
root.handleMouseEvent("mousemove", mouse.x, mouse.y)
|
||
mouse = context.activeObject.transformMouse(mouse);
|
||
context.mousePos = mouse;
|
||
// if mouse is released, even if it happened outside the stage
|
||
if (
|
||
e.buttons == 0 &&
|
||
(context.mouseDown ||
|
||
context.dragging ||
|
||
context.dragDirection ||
|
||
context.selectionRect)
|
||
) {
|
||
stage.mouseup(e);
|
||
return;
|
||
}
|
||
switch (context.mode) {
|
||
case "draw":
|
||
stage.style.cursor = "default";
|
||
context.activeCurve = undefined;
|
||
if (context.activeShape) {
|
||
if (vectorDist(mouse, context.lastMouse) > minSegmentSize) {
|
||
context.activeShape.addLine(mouse.x, mouse.y);
|
||
context.lastMouse = mouse;
|
||
}
|
||
}
|
||
break;
|
||
case "rectangle":
|
||
stage.style.cursor = "default";
|
||
context.activeCurve = undefined;
|
||
// if (context.activeShape) {
|
||
// context.activeShape.clear();
|
||
// context.activeShape.addLine(mouse.x, context.activeShape.starty);
|
||
// context.activeShape.addLine(mouse.x, mouse.y);
|
||
// context.activeShape.addLine(context.activeShape.startx, mouse.y);
|
||
// context.activeShape.addLine(
|
||
// context.activeShape.startx,
|
||
// context.activeShape.starty,
|
||
// );
|
||
// context.activeShape.update();
|
||
// }
|
||
// break;
|
||
case "ellipse":
|
||
stage.style.cursor = "default";
|
||
context.activeCurve = undefined;
|
||
// if (context.activeShape) {
|
||
// let midX = (mouse.x + context.activeShape.startx) / 2;
|
||
// let midY = (mouse.y + context.activeShape.starty) / 2;
|
||
// let xDiff = (mouse.x - context.activeShape.startx) / 2;
|
||
// let yDiff = (mouse.y - context.activeShape.starty) / 2;
|
||
// let ellipseConst = 0.552284749831; // (4/3)*tan(pi/(2n)) where n=4
|
||
// context.activeShape.clear();
|
||
// context.activeShape.addCurve(
|
||
// new Bezier(
|
||
// midX,
|
||
// context.activeShape.starty,
|
||
// midX + ellipseConst * xDiff,
|
||
// context.activeShape.starty,
|
||
// mouse.x,
|
||
// midY - ellipseConst * yDiff,
|
||
// mouse.x,
|
||
// midY,
|
||
// ),
|
||
// );
|
||
// context.activeShape.addCurve(
|
||
// new Bezier(
|
||
// mouse.x,
|
||
// midY,
|
||
// mouse.x,
|
||
// midY + ellipseConst * yDiff,
|
||
// midX + ellipseConst * xDiff,
|
||
// mouse.y,
|
||
// midX,
|
||
// mouse.y,
|
||
// ),
|
||
// );
|
||
// context.activeShape.addCurve(
|
||
// new Bezier(
|
||
// midX,
|
||
// mouse.y,
|
||
// midX - ellipseConst * xDiff,
|
||
// mouse.y,
|
||
// context.activeShape.startx,
|
||
// midY + ellipseConst * yDiff,
|
||
// context.activeShape.startx,
|
||
// midY,
|
||
// ),
|
||
// );
|
||
// context.activeShape.addCurve(
|
||
// new Bezier(
|
||
// context.activeShape.startx,
|
||
// midY,
|
||
// context.activeShape.startx,
|
||
// midY - ellipseConst * yDiff,
|
||
// midX - ellipseConst * xDiff,
|
||
// context.activeShape.starty,
|
||
// midX,
|
||
// context.activeShape.starty,
|
||
// ),
|
||
// );
|
||
// }
|
||
// break;
|
||
case "select":
|
||
stage.style.cursor = "default";
|
||
if (context.dragging) {
|
||
if (context.activeVertex) {
|
||
let vert = context.activeVertex;
|
||
let mouseDelta = {
|
||
x: mouse.x - vert.startmouse.x,
|
||
y: mouse.y - vert.startmouse.y,
|
||
};
|
||
vert.current.point.x = vert.initial.point.x + mouseDelta.x;
|
||
vert.current.point.y = vert.initial.point.y + mouseDelta.y;
|
||
for (let i in vert.current.startCurves) {
|
||
let curve = vert.current.startCurves[i];
|
||
let oldCurve = vert.initial.startCurves[i];
|
||
curve.points[0] = vert.current.point;
|
||
curve.points[1] = {
|
||
x: oldCurve.points[1].x + mouseDelta.x,
|
||
y: oldCurve.points[1].y + mouseDelta.y,
|
||
};
|
||
}
|
||
for (let i in vert.current.endCurves) {
|
||
let curve = vert.current.endCurves[i];
|
||
let oldCurve = vert.initial.endCurves[i];
|
||
curve.points[3] = {
|
||
x: vert.current.point.x,
|
||
y: vert.current.point.y,
|
||
};
|
||
curve.points[2] = {
|
||
x: oldCurve.points[2].x + mouseDelta.x,
|
||
y: oldCurve.points[2].y + mouseDelta.y,
|
||
};
|
||
}
|
||
} else if (context.activeCurve) {
|
||
context.activeCurve.current.points = moldCurve(
|
||
context.activeCurve.initial,
|
||
mouse,
|
||
context.activeCurve.startmouse,
|
||
).points;
|
||
} else {
|
||
// TODO: Add user preference for keyframing behavior:
|
||
// - Auto-keyframe (current): create/update keyframe at current time
|
||
// - Edit previous (Flash-style): update most recent keyframe before current time
|
||
// - Ephemeral (Blender-style): changes don't persist without manual keyframe
|
||
// Could also add modifier key (e.g. Shift) to toggle between modes
|
||
|
||
// Move selected children (groups) using AnimationData with auto-keyframing
|
||
for (let child of context.selection) {
|
||
let currentTime = context.activeObject.currentTime || 0;
|
||
let layer = context.activeObject.activeLayer;
|
||
|
||
// Get current position from AnimationData
|
||
let childX = layer.animationData.interpolate(`child.${child.idx}.x`, currentTime);
|
||
let childY = layer.animationData.interpolate(`child.${child.idx}.y`, currentTime);
|
||
|
||
// Skip if child doesn't have position data
|
||
if (childX === null || childY === null) continue;
|
||
|
||
// Update position
|
||
let newX = childX + (mouse.x - context.lastMouse.x);
|
||
let newY = childY + (mouse.y - context.lastMouse.y);
|
||
|
||
// Auto-keyframe: create/update keyframe at current time
|
||
layer.animationData.addKeyframe(`child.${child.idx}.x`, new Keyframe(currentTime, newX, 'linear'));
|
||
layer.animationData.addKeyframe(`child.${child.idx}.y`, new Keyframe(currentTime, newY, 'linear'));
|
||
|
||
// Trigger timeline redraw
|
||
if (context.timelineWidget && context.timelineWidget.requestRedraw) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
}
|
||
}
|
||
} else if (context.selectionRect) {
|
||
context.selectionRect.x2 = mouse.x;
|
||
context.selectionRect.y2 = mouse.y;
|
||
context.selection = [];
|
||
context.shapeselection = [];
|
||
for (let child of context.activeObject.activeLayer.children) {
|
||
if (hitTest(regionToBbox(context.selectionRect), child)) {
|
||
context.selection.push(child);
|
||
}
|
||
}
|
||
// Use getVisibleShapes instead of currentFrame.shapes
|
||
let currentTime = context.activeObject?.currentTime || 0;
|
||
let layer = context.activeObject?.activeLayer;
|
||
if (layer) {
|
||
for (let shape of layer.getVisibleShapes(currentTime)) {
|
||
if (hitTest(regionToBbox(context.selectionRect), shape)) {
|
||
context.shapeselection.push(shape);
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
let selection = selectVertex(context, mouse);
|
||
if (selection) {
|
||
context.activeCurve = undefined;
|
||
context.activeVertex = {
|
||
current: selection.vertex,
|
||
initial: {
|
||
point: {
|
||
x: selection.vertex.point.x,
|
||
y: selection.vertex.point.y,
|
||
},
|
||
startCurves: structuredClone(selection.vertex.startCurves),
|
||
endCurves: structuredClone(selection.vertex.endCurves),
|
||
},
|
||
shape: selection.shape,
|
||
startmouse: { x: mouse.x, y: mouse.y },
|
||
};
|
||
} else {
|
||
context.activeVertex = undefined;
|
||
selection = selectCurve(context, mouse);
|
||
if (selection) {
|
||
context.activeCurve = {
|
||
current: selection.curve,
|
||
initial: new Bezier(selection.curve.points).setColor(
|
||
selection.curve.color,
|
||
),
|
||
shape: selection.shape,
|
||
startmouse: mouse,
|
||
};
|
||
} else {
|
||
context.activeCurve = undefined;
|
||
}
|
||
}
|
||
}
|
||
context.lastMouse = mouse;
|
||
break;
|
||
case "transform":
|
||
// stage.style.cursor = "nw-resize"
|
||
let bbox = undefined;
|
||
for (let item of context.selection) {
|
||
if (bbox == undefined) {
|
||
bbox = getRotatedBoundingBox(item);
|
||
} else {
|
||
growBoundingBox(bbox, getRotatedBoundingBox(item));
|
||
}
|
||
}
|
||
if (bbox == undefined) break;
|
||
let point = getPointNearBox(bbox, mouse, 10);
|
||
if (point) {
|
||
stage.style.cursor = `${point}-resize`;
|
||
} else {
|
||
point = getPointNearBox(bbox, mouse, 30, false);
|
||
if (point) {
|
||
stage.style.cursor = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='currentColor' class='bi bi-arrow-counterclockwise' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2z'/%3E%3Cpath d='M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466'/%3E%3C/svg%3E") 12 12, auto`;
|
||
} else {
|
||
stage.style.cursor = "default";
|
||
}
|
||
}
|
||
// if (context.dragDirection) {
|
||
// let initial = context.activeTransform.initial
|
||
// let current = context.activeTransform.current
|
||
// let initialSelection = context.activeTransform.initial.selection
|
||
// if (context.dragDirection.indexOf('n') != -1) {
|
||
// current.y.min = mouse.y
|
||
// } else if (context.dragDirection.indexOf('s') != -1) {
|
||
// current.y.max = mouse.y
|
||
// }
|
||
// if (context.dragDirection.indexOf('w') != -1) {
|
||
// current.x.min = mouse.x
|
||
// } else if (context.dragDirection.indexOf('e') != -1) {
|
||
// current.x.max = mouse.x
|
||
// }
|
||
// // Calculate the translation difference between current and initial values
|
||
// let delta_x = current.x.min - initial.x.min;
|
||
// let delta_y = current.y.min - initial.y.min;
|
||
|
||
// if (context.dragDirection == 'r') {
|
||
// let pivot = {
|
||
// x: (initial.x.min+initial.x.max)/2,
|
||
// y: (initial.y.min+initial.y.max)/2,
|
||
// }
|
||
// current.rotation = signedAngleBetweenVectors(pivot, initial.mouse, mouse)
|
||
// const {dx, dy} = rotateAroundPointIncremental(current.x.min, current.y.min, pivot, current.rotation)
|
||
// // delta_x -= dx
|
||
// // delta_y -= dy
|
||
// // console.log(dx, dy)
|
||
// }
|
||
|
||
// // This is probably unnecessary since initial rotation is 0
|
||
// const delta_rot = current.rotation - initial.rotation
|
||
|
||
// // Calculate the scaling factor based on the difference between current and initial values
|
||
// const scale_x_ratio = (current.x.max - current.x.min) / (initial.x.max - initial.x.min);
|
||
// const scale_y_ratio = (current.y.max - current.y.min) / (initial.y.max - initial.y.min);
|
||
|
||
// for (let idx in initialSelection) {
|
||
// let item = context.activeObject.currentFrame.keys[idx]
|
||
// let xoffset = initialSelection[idx].x - initial.x.min
|
||
// let yoffset = initialSelection[idx].y - initial.y.min
|
||
// item.x = initial.x.min + delta_x + xoffset * scale_x_ratio
|
||
// item.y = initial.y.min + delta_y + yoffset * scale_y_ratio
|
||
// item.scale_x = initialSelection[idx].scale_x * scale_x_ratio
|
||
// item.scale_y = initialSelection[idx].scale_y * scale_y_ratio
|
||
// item.rotation = initialSelection[idx].rotation + delta_rot
|
||
// }
|
||
// }
|
||
if (context.activeAction) {
|
||
actions[context.activeAction.type].update(
|
||
context.activeAction,
|
||
mouse,
|
||
);
|
||
}
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
updateUI();
|
||
});
|
||
stage.addEventListener("dblclick", (e) => {
|
||
context.mouseDown = false;
|
||
context.dragging = false;
|
||
context.dragDirection = undefined;
|
||
context.selectionRect = undefined;
|
||
let mouse = getMousePos(stage, e);
|
||
mouse = context.activeObject.transformMouse(mouse);
|
||
modeswitcher: switch (context.mode) {
|
||
case "select":
|
||
for (let i = context.activeObject.activeLayer.children.length - 1; i >= 0; i--) {
|
||
let child = context.activeObject.activeLayer.children[i];
|
||
// Check if child exists at current time using AnimationData
|
||
// null means no exists curve (defaults to visible)
|
||
const existsValue = context.activeObject.activeLayer.animationData.interpolate(
|
||
`object.${child.idx}.exists`,
|
||
context.activeObject.currentTime
|
||
);
|
||
if (existsValue !== null && existsValue <= 0) continue;
|
||
if (hitTest(mouse, child)) {
|
||
context.objectStack.push(child);
|
||
context.selection = [];
|
||
context.shapeselection = [];
|
||
updateUI();
|
||
updateLayers();
|
||
updateMenu();
|
||
updateInfopanel();
|
||
break modeswitcher;
|
||
}
|
||
}
|
||
// we didn't click on a child, go up a level
|
||
if (context.activeObject.parent) {
|
||
context.selection = [context.activeObject];
|
||
context.activeObject.setTime(0);
|
||
context.shapeselection = [];
|
||
context.objectStack.pop();
|
||
updateUI();
|
||
updateLayers();
|
||
updateMenu();
|
||
updateInfopanel();
|
||
}
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
});
|
||
return stage;
|
||
}
|
||
|
||
function toolbar() {
|
||
let tools_scroller = document.createElement("div");
|
||
tools_scroller.className = "toolbar";
|
||
for (let tool in tools) {
|
||
let toolbtn = document.createElement("button");
|
||
toolbtn.className = "toolbtn";
|
||
toolbtn.setAttribute("data-tool", tool); // For UI testing
|
||
let icon = document.createElement("img");
|
||
icon.className = "icon";
|
||
icon.src = tools[tool].icon;
|
||
toolbtn.appendChild(icon);
|
||
tools_scroller.appendChild(toolbtn);
|
||
toolbtn.addEventListener("click", () => {
|
||
context.mode = tool;
|
||
updateInfopanel();
|
||
updateUI();
|
||
console.log(`Switched tool to ${tool}`);
|
||
});
|
||
}
|
||
let tools_break = document.createElement("div");
|
||
tools_break.className = "horiz_break";
|
||
tools_scroller.appendChild(tools_break);
|
||
let fillColor = document.createElement("div");
|
||
let strokeColor = document.createElement("div");
|
||
fillColor.className = "color-field";
|
||
strokeColor.className = "color-field";
|
||
fillColor.classList.add("fill")
|
||
strokeColor.classList.add("stroke")
|
||
fillColor.setColor = (hsv, alpha) => {
|
||
const rgb = hsvToRgb(...hsv)
|
||
const color = rgbToHex(rgb.r, rgb.g, rgb.b) + alpha
|
||
fillColor.style.setProperty("--color", color);
|
||
fillColor.color = color;
|
||
fillColor.hsv = hsv
|
||
fillColor.alpha = alpha
|
||
context.fillStyle = color;
|
||
};
|
||
strokeColor.setColor = (hsv, alpha) => {
|
||
const rgb = hsvToRgb(...hsv)
|
||
const color = rgbToHex(rgb.r, rgb.g, rgb.b) + alpha
|
||
strokeColor.style.setProperty("--color", color);
|
||
strokeColor.color = color;
|
||
strokeColor.hsv = hsv
|
||
strokeColor.alpha = alpha
|
||
context.strokeStyle = color;
|
||
};
|
||
fillColor.setColor([0, 1, 1], 'ff');
|
||
strokeColor.setColor([0,0,0], 'ff');
|
||
fillColor.style.setProperty("--label-text", `"Fill color:"`);
|
||
strokeColor.style.setProperty("--label-text", `"Stroke color:"`);
|
||
fillColor.type = "color";
|
||
fillColor.value = "#ff0000";
|
||
strokeColor.value = "#000000";
|
||
let evtListener;
|
||
let padding = 10;
|
||
let gradwidth = 25;
|
||
let ccwidth = 300;
|
||
let mainSize = ccwidth - (3 * padding + gradwidth);
|
||
let colorClickHandler = (e) => {
|
||
let colorCvs = document.querySelector("#color-cvs");
|
||
if (colorCvs == null) {
|
||
console.log("creating new one");
|
||
colorCvs = document.createElement("canvas");
|
||
colorCvs.id = "color-cvs";
|
||
document.body.appendChild(colorCvs);
|
||
colorCvs.width = ccwidth;
|
||
colorCvs.height = 500;
|
||
colorCvs.style.width = "300px";
|
||
colorCvs.style.height = "500px";
|
||
colorCvs.style.position = "absolute";
|
||
colorCvs.style.left = "500px";
|
||
colorCvs.style.top = "500px";
|
||
colorCvs.style.boxShadow = "0 2px 2px rgba(0,0,0,0.2)";
|
||
colorCvs.style.cursor = "crosshair";
|
||
colorCvs.currentColor = "#00ffba88";
|
||
colorCvs.currentHSV = [0,0,0]
|
||
colorCvs.currentAlpha = 1
|
||
|
||
colorCvs.colorSelectorWidget = new ColorSelectorWidget(0, 0, colorCvs)
|
||
|
||
colorCvs.draw = function () {
|
||
let ctx = colorCvs.getContext("2d");
|
||
colorCvs.colorSelectorWidget.draw(ctx)
|
||
|
||
};
|
||
colorCvs.addEventListener("pointerdown", (e) => {
|
||
colorCvs.clickedMainGradient = false;
|
||
colorCvs.clickedHueGradient = false;
|
||
colorCvs.clickedAlphaGradient = false;
|
||
let mouse = getMousePos(colorCvs, e);
|
||
colorCvs.colorSelectorWidget.handleMouseEvent("mousedown", mouse.x, mouse.y)
|
||
colorCvs.colorEl.setColor(colorCvs.currentHSV, colorCvs.currentAlpha);
|
||
colorCvs.draw();
|
||
});
|
||
|
||
window.addEventListener("pointerup", (e) => {
|
||
let mouse = getMousePos(colorCvs, e);
|
||
colorCvs.clickedMainGradient = false;
|
||
colorCvs.clickedHueGradient = false;
|
||
colorCvs.clickedAlphaGradient = false;
|
||
|
||
colorCvs.colorSelectorWidget.handleMouseEvent("mouseup", mouse.x, mouse.y)
|
||
if (e.target != colorCvs) {
|
||
colorCvs.style.display = "none";
|
||
window.removeEventListener("pointermove", evtListener);
|
||
}
|
||
});
|
||
} else {
|
||
colorCvs.style.display = "block";
|
||
}
|
||
evtListener = window.addEventListener("pointermove", (e) => {
|
||
let mouse = getMousePos(colorCvs, e);
|
||
colorCvs.colorSelectorWidget.handleMouseEvent("mousemove", mouse.x, mouse.y)
|
||
colorCvs.draw()
|
||
colorCvs.colorEl.setColor(colorCvs.currentHSV, colorCvs.currentAlpha);
|
||
});
|
||
// Get mouse coordinates relative to the viewport
|
||
const mouseX = e.clientX + window.scrollX;
|
||
const mouseY = e.clientY + window.scrollY;
|
||
|
||
const divWidth = colorCvs.offsetWidth;
|
||
const divHeight = colorCvs.offsetHeight;
|
||
const windowWidth = window.innerWidth;
|
||
const windowHeight = window.innerHeight;
|
||
|
||
// Default position to the mouse cursor
|
||
let left = mouseX;
|
||
let top = mouseY;
|
||
|
||
// If the window is narrower than twice the width, center horizontally
|
||
if (windowWidth < divWidth * 2) {
|
||
left = (windowWidth - divWidth) / 2;
|
||
} else {
|
||
// If it would overflow on the right side, position it to the left of the cursor
|
||
if (left + divWidth > windowWidth) {
|
||
left = mouseX - divWidth;
|
||
}
|
||
}
|
||
|
||
// If the window is shorter than twice the height, center vertically
|
||
if (windowHeight < divHeight * 2) {
|
||
top = (windowHeight - divHeight) / 2;
|
||
} else {
|
||
// If it would overflow at the bottom, position it above the cursor
|
||
if (top + divHeight > windowHeight) {
|
||
top = mouseY - divHeight;
|
||
}
|
||
}
|
||
|
||
colorCvs.style.left = `${left}px`;
|
||
colorCvs.style.top = `${top}px`;
|
||
colorCvs.colorEl = e.target;
|
||
colorCvs.currentColor = e.target.color;
|
||
colorCvs.currentHSV = e.target.hsv;
|
||
colorCvs.currentAlpha = e.target.alpha
|
||
colorCvs.draw();
|
||
e.preventDefault();
|
||
};
|
||
fillColor.addEventListener("click", colorClickHandler);
|
||
strokeColor.addEventListener("click", colorClickHandler);
|
||
// Fill and stroke colors use the same set of swatches
|
||
fillColor.addEventListener("change", (e) => {
|
||
context.swatches.unshift(fillColor.value);
|
||
if (context.swatches.length > 12) context.swatches.pop();
|
||
});
|
||
strokeColor.addEventListener("change", (e) => {
|
||
context.swatches.unshift(strokeColor.value);
|
||
if (context.swatches.length > 12) context.swatches.pop();
|
||
});
|
||
tools_scroller.appendChild(fillColor);
|
||
tools_scroller.appendChild(strokeColor);
|
||
return tools_scroller;
|
||
}
|
||
|
||
function timelineDeprecated() {
|
||
let timeline_cvs = document.createElement("canvas");
|
||
timeline_cvs.className = "timeline-deprecated";
|
||
|
||
// Start building widget hierarchy
|
||
timeline_cvs.timelinewindow = new TimelineWindow(0, 0, context)
|
||
|
||
// Load icons for show/hide layer
|
||
timeline_cvs.icons = {};
|
||
timeline_cvs.icons.volume_up_fill = new Icon("assets/volume-up-fill.svg");
|
||
timeline_cvs.icons.volume_mute = new Icon("assets/volume-mute.svg");
|
||
timeline_cvs.icons.eye_fill = new Icon("assets/eye-fill.svg");
|
||
timeline_cvs.icons.eye_slash = new Icon("assets/eye-slash.svg");
|
||
|
||
// Variable to store the last time updateTimelineCanvasSize was called
|
||
let lastResizeTime = 0;
|
||
const throttleIntervalMs = 20;
|
||
|
||
function updateTimelineCanvasSize() {
|
||
const canvasStyles = window.getComputedStyle(timeline_cvs);
|
||
|
||
timeline_cvs.width = parseInt(canvasStyles.width);
|
||
timeline_cvs.height = parseInt(canvasStyles.height);
|
||
updateLayers();
|
||
renderAll();
|
||
}
|
||
|
||
// Set up ResizeObserver to watch for changes in the canvas size
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
const currentTime = Date.now();
|
||
|
||
// Only call updateTimelineCanvasSize if enough time has passed since the last call
|
||
// This prevents error messages about a ResizeObserver loop
|
||
if (currentTime - lastResizeTime > throttleIntervalMs) {
|
||
lastResizeTime = currentTime;
|
||
updateTimelineCanvasSize();
|
||
}
|
||
});
|
||
resizeObserver.observe(timeline_cvs);
|
||
|
||
timeline_cvs.frameDragOffset = {
|
||
frames: 0,
|
||
layers: 0,
|
||
};
|
||
|
||
timeline_cvs.addEventListener("dragstart", (event) => {
|
||
event.preventDefault();
|
||
});
|
||
timeline_cvs.addEventListener("wheel", (event) => {
|
||
event.preventDefault();
|
||
const deltaX = event.deltaX * config.scrollSpeed;
|
||
const deltaY = event.deltaY * config.scrollSpeed;
|
||
|
||
let maxScroll =
|
||
context.activeObject.layers.length * layerHeight +
|
||
context.activeObject.audioTracks.length * layerHeight +
|
||
gutterHeight -
|
||
timeline_cvs.height;
|
||
|
||
timeline_cvs.offsetX = Math.max(0, timeline_cvs.offsetX + deltaX);
|
||
timeline_cvs.offsetY = Math.max(
|
||
0,
|
||
Math.min(maxScroll, timeline_cvs.offsetY + deltaY),
|
||
);
|
||
timeline_cvs.timelinewindow.offsetX = -timeline_cvs.offsetX
|
||
timeline_cvs.timelinewindow.offsetY = -timeline_cvs.offsetY
|
||
|
||
const currentTime = Date.now();
|
||
if (currentTime - lastResizeTime > throttleIntervalMs) {
|
||
lastResizeTime = currentTime;
|
||
updateLayers();
|
||
}
|
||
});
|
||
timeline_cvs.addEventListener("pointerdown", (e) => {
|
||
let mouse = getMousePos(timeline_cvs, e, true, true);
|
||
mouse.y += timeline_cvs.offsetY;
|
||
if (mouse.x > layerWidth) {
|
||
mouse.x -= layerWidth;
|
||
mouse.x += timeline_cvs.offsetX;
|
||
mouse.y -= gutterHeight;
|
||
timeline_cvs.clicked_frame = Math.floor(mouse.x / frameWidth);
|
||
context.activeObject.setFrameNum(timeline_cvs.clicked_frame);
|
||
const layerIdx = Math.floor(mouse.y / layerHeight);
|
||
if (layerIdx < context.activeObject.layers.length && layerIdx >= 0) {
|
||
const layer =
|
||
context.activeObject.layers[
|
||
context.activeObject.layers.length - layerIdx - 1
|
||
];
|
||
|
||
const frame = layer.getFrame(timeline_cvs.clicked_frame);
|
||
if (frame.exists) {
|
||
console.log(frame.keys)
|
||
if (!e.shiftKey) {
|
||
// Check if the clicked frame is already in the selection
|
||
const existingIndex = context.selectedFrames.findIndex(
|
||
(selected) =>
|
||
selected.frameNum === timeline_cvs.clicked_frame &&
|
||
selected.layer === layerIdx,
|
||
);
|
||
|
||
if (existingIndex !== -1) {
|
||
if (!e.ctrlKey) {
|
||
// Do nothing
|
||
} else {
|
||
// Remove the clicked frame from the selection
|
||
context.selectedFrames.splice(existingIndex, 1);
|
||
}
|
||
} else {
|
||
if (!e.ctrlKey) {
|
||
context.selectedFrames = []; // Reset selection
|
||
}
|
||
// Add the clicked frame to the selection
|
||
context.selectedFrames.push({
|
||
layer: layerIdx,
|
||
frameNum: timeline_cvs.clicked_frame,
|
||
});
|
||
}
|
||
} else {
|
||
const currentSelection =
|
||
context.selectedFrames[context.selectedFrames.length - 1];
|
||
|
||
const startFrame = Math.min(
|
||
currentSelection.frameNum,
|
||
timeline_cvs.clicked_frame,
|
||
);
|
||
const endFrame = Math.max(
|
||
currentSelection.frameNum,
|
||
timeline_cvs.clicked_frame,
|
||
);
|
||
|
||
const startLayer = Math.min(currentSelection.layer, layerIdx);
|
||
const endLayer = Math.max(currentSelection.layer, layerIdx);
|
||
|
||
for (let l = startLayer; l <= endLayer; l++) {
|
||
const layerToAdd =
|
||
context.activeObject.layers[
|
||
context.activeObject.layers.length - l - 1
|
||
];
|
||
|
||
for (let f = startFrame; f <= endFrame; f++) {
|
||
const frameToAdd = layerToAdd.getFrame(f);
|
||
|
||
if (
|
||
frameToAdd.exists &&
|
||
!context.selectedFrames.some(
|
||
(selected) =>
|
||
selected.frameNum === f && selected.layer === l,
|
||
)
|
||
) {
|
||
context.selectedFrames.push({
|
||
layer: l,
|
||
frameNum: f,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
timeline_cvs.draggingFrames = true;
|
||
timeline_cvs.dragFrameStart = {
|
||
frame: timeline_cvs.clicked_frame,
|
||
layer: layerIdx,
|
||
};
|
||
timeline_cvs.frameDragOffset = {
|
||
frames: 0,
|
||
layers: 0,
|
||
};
|
||
} else {
|
||
context.selectedFrames = [];
|
||
}
|
||
} else {
|
||
context.selectedFrames = [];
|
||
}
|
||
updateUI();
|
||
} else {
|
||
mouse.y -= gutterHeight;
|
||
let l = Math.floor(mouse.y / layerHeight);
|
||
if (l < context.activeObject.allLayers.length) {
|
||
let i = context.activeObject.allLayers.length - (l + 1);
|
||
mouse.y -= l * layerHeight;
|
||
if (
|
||
mouse.x > layerWidth - iconSize - 5 &&
|
||
mouse.x < layerWidth - 5 &&
|
||
mouse.y > 0.5 * (layerHeight - iconSize) &&
|
||
mouse.y < 0.5 * (layerHeight + iconSize)
|
||
) {
|
||
context.activeObject.allLayers[i].visible =
|
||
!context.activeObject.allLayers[i].visible;
|
||
updateUI();
|
||
updateMenu();
|
||
} else if (
|
||
mouse.x > layerWidth - iconSize * 2 - 10 &&
|
||
mouse.x < layerWidth - iconSize - 5 &&
|
||
mouse.y > 0.5 * (layerHeight - iconSize) &&
|
||
mouse.y < 0.5 * (layerHeight + iconSize)
|
||
) {
|
||
context.activeObject.allLayers[i].audible =
|
||
!context.activeObject.allLayers[i].audible;
|
||
updateUI();
|
||
updateMenu();
|
||
} else {
|
||
context.activeObject.currentLayer = i - context.activeObject.audioTracks.length;
|
||
}
|
||
}
|
||
}
|
||
updateLayers();
|
||
});
|
||
timeline_cvs.addEventListener("pointerup", (e) => {
|
||
let mouse = getMousePos(timeline_cvs, e, true, true);
|
||
mouse.y += timeline_cvs.offsetY;
|
||
if (mouse.x > layerWidth || timeline_cvs.draggingFrames) {
|
||
mouse.x += timeline_cvs.offsetX - layerWidth;
|
||
if (timeline_cvs.draggingFrames) {
|
||
if (
|
||
timeline_cvs.frameDragOffset.frames != 0 ||
|
||
timeline_cvs.frameDragOffset.layers != 0
|
||
) {
|
||
actions.moveFrames.create(timeline_cvs.frameDragOffset);
|
||
context.selectedFrames = [];
|
||
}
|
||
}
|
||
timeline_cvs.draggingFrames = false;
|
||
|
||
updateLayers();
|
||
updateMenu();
|
||
}
|
||
});
|
||
timeline_cvs.addEventListener("pointermove", (e) => {
|
||
let mouse = getMousePos(timeline_cvs, e, true, true);
|
||
mouse.y += timeline_cvs.offsetY;
|
||
if (mouse.x > layerWidth || timeline_cvs.draggingFrames) {
|
||
mouse.x += timeline_cvs.offsetX - layerWidth;
|
||
if (timeline_cvs.draggingFrames) {
|
||
const minFrameNum = -Math.min(
|
||
...context.selectedFrames.map((selection) => selection.frameNum),
|
||
);
|
||
const minLayer = -Math.min(
|
||
...context.selectedFrames.map((selection) => selection.layer),
|
||
);
|
||
const maxLayer =
|
||
context.activeObject.layers.length -
|
||
1 -
|
||
Math.max(
|
||
...context.selectedFrames.map((selection) => selection.layer),
|
||
);
|
||
timeline_cvs.frameDragOffset = {
|
||
frames: Math.max(
|
||
Math.floor(mouse.x / frameWidth) -
|
||
timeline_cvs.dragFrameStart.frame,
|
||
minFrameNum,
|
||
),
|
||
layers: Math.min(
|
||
Math.max(
|
||
Math.floor(mouse.y / layerHeight) -
|
||
timeline_cvs.dragFrameStart.layer,
|
||
minLayer,
|
||
),
|
||
maxLayer,
|
||
),
|
||
};
|
||
updateLayers();
|
||
}
|
||
}
|
||
});
|
||
|
||
timeline_cvs.offsetX = 0;
|
||
timeline_cvs.offsetY = 0;
|
||
updateTimelineCanvasSize();
|
||
return timeline_cvs;
|
||
}
|
||
|
||
function timeline() {
|
||
let canvas = document.createElement("canvas");
|
||
canvas.className = "timeline";
|
||
|
||
// Create TimelineWindowV2 widget
|
||
const timelineWidget = new TimelineWindowV2(0, 0, context);
|
||
|
||
// Store reference in context for zoom controls
|
||
context.timelineWidget = timelineWidget;
|
||
|
||
// Update canvas size based on container
|
||
function updateCanvasSize() {
|
||
const canvasStyles = window.getComputedStyle(canvas);
|
||
canvas.width = parseInt(canvasStyles.width);
|
||
canvas.height = parseInt(canvasStyles.height);
|
||
|
||
// Update widget dimensions
|
||
timelineWidget.width = canvas.width;
|
||
timelineWidget.height = canvas.height;
|
||
|
||
// Render
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
timelineWidget.draw(ctx);
|
||
}
|
||
|
||
// Store updateCanvasSize on the widget so zoom controls can trigger redraw
|
||
timelineWidget.requestRedraw = updateCanvasSize;
|
||
|
||
// Add custom property to store the time format toggle button
|
||
// so createPane can add it to the header
|
||
canvas.headerControls = () => {
|
||
const controls = [];
|
||
|
||
// Playback controls group
|
||
const playbackGroup = document.createElement("div");
|
||
playbackGroup.className = "playback-controls-group";
|
||
|
||
// Go to start button
|
||
const startButton = document.createElement("button");
|
||
startButton.className = "playback-btn playback-btn-start";
|
||
startButton.title = "Go to Start";
|
||
startButton.addEventListener("click", goToStart);
|
||
playbackGroup.appendChild(startButton);
|
||
|
||
// Rewind button
|
||
const rewindButton = document.createElement("button");
|
||
rewindButton.className = "playback-btn playback-btn-rewind";
|
||
rewindButton.title = "Rewind";
|
||
rewindButton.addEventListener("click", rewind);
|
||
playbackGroup.appendChild(rewindButton);
|
||
|
||
// Play/Pause button
|
||
const playPauseButton = document.createElement("button");
|
||
playPauseButton.className = context.playing ? "playback-btn playback-btn-pause" : "playback-btn playback-btn-play";
|
||
playPauseButton.title = context.playing ? "Pause" : "Play";
|
||
playPauseButton.addEventListener("click", playPause);
|
||
|
||
// Store reference so playPause() can update it
|
||
context.playPauseButton = playPauseButton;
|
||
|
||
playbackGroup.appendChild(playPauseButton);
|
||
|
||
// Fast-forward button
|
||
const ffButton = document.createElement("button");
|
||
ffButton.className = "playback-btn playback-btn-ff";
|
||
ffButton.title = "Fast Forward";
|
||
ffButton.addEventListener("click", advance);
|
||
playbackGroup.appendChild(ffButton);
|
||
|
||
// Go to end button
|
||
const endButton = document.createElement("button");
|
||
endButton.className = "playback-btn playback-btn-end";
|
||
endButton.title = "Go to End";
|
||
endButton.addEventListener("click", goToEnd);
|
||
playbackGroup.appendChild(endButton);
|
||
|
||
controls.push(playbackGroup);
|
||
|
||
// Record button (separate group)
|
||
const recordGroup = document.createElement("div");
|
||
recordGroup.className = "playback-controls-group";
|
||
|
||
const recordButton = document.createElement("button");
|
||
recordButton.className = context.isRecording ? "playback-btn playback-btn-record recording" : "playback-btn playback-btn-record";
|
||
recordButton.title = context.isRecording ? "Stop Recording" : "Record";
|
||
recordButton.addEventListener("click", toggleRecording);
|
||
recordGroup.appendChild(recordButton);
|
||
|
||
controls.push(recordGroup);
|
||
|
||
// Metronome button (only visible in measures mode)
|
||
const metronomeGroup = document.createElement("div");
|
||
metronomeGroup.className = "playback-controls-group";
|
||
|
||
// Initially hide if not in measures mode
|
||
if (timelineWidget.timelineState.timeFormat !== 'measures') {
|
||
metronomeGroup.style.display = 'none';
|
||
}
|
||
|
||
const metronomeButton = document.createElement("button");
|
||
metronomeButton.className = context.metronomeEnabled
|
||
? "playback-btn playback-btn-metronome active"
|
||
: "playback-btn playback-btn-metronome";
|
||
metronomeButton.title = context.metronomeEnabled ? "Disable Metronome" : "Enable Metronome";
|
||
|
||
// Load SVG inline for currentColor support
|
||
(async () => {
|
||
try {
|
||
const response = await fetch('./assets/metronome.svg');
|
||
const svgText = await response.text();
|
||
metronomeButton.innerHTML = svgText;
|
||
} catch (error) {
|
||
console.error('Failed to load metronome icon:', error);
|
||
}
|
||
})();
|
||
|
||
metronomeButton.addEventListener("click", async () => {
|
||
context.metronomeEnabled = !context.metronomeEnabled;
|
||
const { invoke } = window.__TAURI__.core;
|
||
try {
|
||
await invoke('set_metronome_enabled', { enabled: context.metronomeEnabled });
|
||
// Update button appearance
|
||
metronomeButton.className = context.metronomeEnabled
|
||
? "playback-btn playback-btn-metronome active"
|
||
: "playback-btn playback-btn-metronome";
|
||
metronomeButton.title = context.metronomeEnabled ? "Disable Metronome" : "Enable Metronome";
|
||
} catch (error) {
|
||
console.error('Failed to set metronome:', error);
|
||
}
|
||
});
|
||
metronomeGroup.appendChild(metronomeButton);
|
||
|
||
// Store reference for state updates and visibility toggling
|
||
context.metronomeButton = metronomeButton;
|
||
context.metronomeGroup = metronomeGroup;
|
||
|
||
controls.push(metronomeGroup);
|
||
|
||
// Time display
|
||
const timeDisplay = document.createElement("div");
|
||
timeDisplay.className = "time-display";
|
||
timeDisplay.style.cursor = "pointer";
|
||
timeDisplay.title = "Click to change time format";
|
||
|
||
// Function to update time display
|
||
const updateTimeDisplay = () => {
|
||
const currentTime = context.activeObject?.currentTime || 0;
|
||
const timeFormat = timelineWidget.timelineState.timeFormat;
|
||
const framerate = timelineWidget.timelineState.framerate;
|
||
const bpm = timelineWidget.timelineState.bpm;
|
||
const timeSignature = timelineWidget.timelineState.timeSignature;
|
||
|
||
if (timeFormat === 'frames') {
|
||
// Frames mode: show frame number and framerate
|
||
const frameNumber = Math.floor(currentTime * framerate);
|
||
|
||
timeDisplay.innerHTML = `
|
||
<div class="time-value time-frame-clickable" data-action="toggle-format">${frameNumber}</div>
|
||
<div class="time-label">FRAME</div>
|
||
<div class="time-fps-group time-fps-clickable" data-action="edit-fps">
|
||
<div class="time-value">${framerate}</div>
|
||
<div class="time-label">FPS</div>
|
||
</div>
|
||
`;
|
||
} else if (timeFormat === 'measures') {
|
||
// Measures mode: show measure.beat, BPM, and time signature
|
||
const { measure, beat } = timelineWidget.timelineState.timeToMeasure(currentTime);
|
||
|
||
timeDisplay.innerHTML = `
|
||
<div class="time-value time-frame-clickable" data-action="toggle-format">${measure}.${beat}</div>
|
||
<div class="time-label">BAR</div>
|
||
<div class="time-fps-group time-fps-clickable" data-action="edit-bpm">
|
||
<div class="time-value">${bpm}</div>
|
||
<div class="time-label">BPM</div>
|
||
</div>
|
||
<div class="time-fps-group time-fps-clickable" data-action="edit-time-signature">
|
||
<div class="time-value">${timeSignature.numerator}/${timeSignature.denominator}</div>
|
||
<div class="time-label">TIME</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
// Seconds mode: show MM:SS.mmm or HH:MM:SS.mmm
|
||
const totalSeconds = Math.floor(currentTime);
|
||
const milliseconds = Math.floor((currentTime - totalSeconds) * 1000);
|
||
const seconds = totalSeconds % 60;
|
||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
|
||
if (hours > 0) {
|
||
timeDisplay.innerHTML = `
|
||
<div class="time-value">${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(3, '0')}</div>
|
||
<div class="time-label">SEC</div>
|
||
`;
|
||
} else {
|
||
timeDisplay.innerHTML = `
|
||
<div class="time-value">${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(3, '0')}</div>
|
||
<div class="time-label">SEC</div>
|
||
`;
|
||
}
|
||
}
|
||
};
|
||
|
||
// Click handler for time display
|
||
timeDisplay.addEventListener("click", (e) => {
|
||
const target = e.target.closest('[data-action]');
|
||
|
||
if (!target) {
|
||
// Clicked outside specific elements in frames mode or anywhere in seconds mode
|
||
// Toggle format
|
||
timelineWidget.toggleTimeFormat();
|
||
updateTimeDisplay();
|
||
updateCanvasSize();
|
||
// Update metronome button visibility
|
||
if (context.metronomeGroup) {
|
||
context.metronomeGroup.style.display = timelineWidget.timelineState.timeFormat === 'measures' ? '' : 'none';
|
||
}
|
||
return;
|
||
}
|
||
|
||
const action = target.getAttribute('data-action');
|
||
|
||
if (action === 'toggle-format') {
|
||
// Clicked on frame number - toggle format
|
||
timelineWidget.toggleTimeFormat();
|
||
updateTimeDisplay();
|
||
updateCanvasSize();
|
||
// Update metronome button visibility
|
||
if (context.metronomeGroup) {
|
||
context.metronomeGroup.style.display = timelineWidget.timelineState.timeFormat === 'measures' ? '' : 'none';
|
||
}
|
||
} else if (action === 'edit-fps') {
|
||
// Clicked on FPS - show input to edit framerate
|
||
console.log('[FPS Edit] Starting FPS edit');
|
||
const currentFps = timelineWidget.timelineState.framerate;
|
||
console.log('[FPS Edit] Current FPS:', currentFps);
|
||
|
||
const newFps = prompt('Enter framerate (FPS):', currentFps);
|
||
console.log('[FPS Edit] Prompt returned:', newFps);
|
||
|
||
if (newFps !== null && !isNaN(newFps) && newFps > 0) {
|
||
const fps = parseFloat(newFps);
|
||
console.log('[FPS Edit] Parsed FPS:', fps);
|
||
|
||
console.log('[FPS Edit] Setting framerate on timeline state');
|
||
timelineWidget.timelineState.framerate = fps;
|
||
|
||
console.log('[FPS Edit] Setting frameRate on activeObject');
|
||
context.activeObject.frameRate = fps;
|
||
|
||
console.log('[FPS Edit] Updating time display');
|
||
updateTimeDisplay();
|
||
|
||
console.log('[FPS Edit] Requesting redraw');
|
||
if (timelineWidget.requestRedraw) {
|
||
timelineWidget.requestRedraw();
|
||
}
|
||
console.log('[FPS Edit] Done');
|
||
}
|
||
} else if (action === 'edit-bpm') {
|
||
// Clicked on BPM - show input to edit BPM
|
||
const currentBpm = timelineWidget.timelineState.bpm;
|
||
const newBpm = prompt('Enter BPM (Beats Per Minute):', currentBpm);
|
||
|
||
if (newBpm !== null && !isNaN(newBpm) && newBpm > 0) {
|
||
const bpm = parseFloat(newBpm);
|
||
timelineWidget.timelineState.bpm = bpm;
|
||
context.config.bpm = bpm;
|
||
updateTimeDisplay();
|
||
if (timelineWidget.requestRedraw) {
|
||
timelineWidget.requestRedraw();
|
||
}
|
||
// Notify all registered listeners of BPM change
|
||
if (context.notifyBpmChange) {
|
||
context.notifyBpmChange(bpm);
|
||
}
|
||
}
|
||
} else if (action === 'edit-time-signature') {
|
||
// Clicked on time signature - show custom dropdown with common options
|
||
const currentTimeSig = timelineWidget.timelineState.timeSignature;
|
||
const currentValue = `${currentTimeSig.numerator}/${currentTimeSig.denominator}`;
|
||
|
||
// Create a custom dropdown list
|
||
const dropdown = document.createElement('div');
|
||
dropdown.className = 'time-signature-dropdown';
|
||
dropdown.style.position = 'absolute';
|
||
dropdown.style.left = e.clientX + 'px';
|
||
dropdown.style.top = e.clientY + 'px';
|
||
dropdown.style.fontSize = '14px';
|
||
dropdown.style.backgroundColor = 'var(--background-color)';
|
||
dropdown.style.color = 'var(--label-color)';
|
||
dropdown.style.border = '1px solid var(--shadow)';
|
||
dropdown.style.borderRadius = '4px';
|
||
dropdown.style.zIndex = '10000';
|
||
dropdown.style.maxHeight = '300px';
|
||
dropdown.style.overflowY = 'auto';
|
||
dropdown.style.boxShadow = '0 4px 8px rgba(0,0,0,0.3)';
|
||
|
||
// Common time signatures
|
||
const commonTimeSigs = ['2/4', '3/4', '4/4', '5/4', '6/8', '7/8', '9/8', '12/8', 'Other...'];
|
||
|
||
commonTimeSigs.forEach(sig => {
|
||
const item = document.createElement('div');
|
||
item.textContent = sig;
|
||
item.style.padding = '8px 12px';
|
||
item.style.cursor = 'pointer';
|
||
item.style.backgroundColor = 'var(--background-color)';
|
||
item.style.color = 'var(--label-color)';
|
||
|
||
if (sig === currentValue) {
|
||
item.style.backgroundColor = 'var(--foreground-color)';
|
||
}
|
||
|
||
item.addEventListener('mouseenter', () => {
|
||
item.style.backgroundColor = 'var(--foreground-color)';
|
||
});
|
||
|
||
item.addEventListener('mouseleave', () => {
|
||
if (sig !== currentValue) {
|
||
item.style.backgroundColor = 'var(--background-color)';
|
||
}
|
||
});
|
||
|
||
item.addEventListener('click', () => {
|
||
document.body.removeChild(dropdown);
|
||
|
||
if (sig === 'Other...') {
|
||
// Show prompt for custom time signature
|
||
const newTimeSig = prompt(
|
||
'Enter time signature (e.g., "4/4", "3/4", "6/8"):',
|
||
currentValue
|
||
);
|
||
|
||
if (newTimeSig !== null) {
|
||
const match = newTimeSig.match(/^(\d+)\/(\d+)$/);
|
||
if (match) {
|
||
const numerator = parseInt(match[1]);
|
||
const denominator = parseInt(match[2]);
|
||
|
||
if (numerator > 0 && denominator > 0) {
|
||
timelineWidget.timelineState.timeSignature = { numerator, denominator };
|
||
context.config.timeSignature = { numerator, denominator };
|
||
updateTimeDisplay();
|
||
if (timelineWidget.requestRedraw) {
|
||
timelineWidget.requestRedraw();
|
||
}
|
||
}
|
||
} else {
|
||
alert('Invalid time signature format. Please use format like "4/4" or "6/8".');
|
||
}
|
||
}
|
||
} else {
|
||
// Parse the selected common time signature
|
||
const match = sig.match(/^(\d+)\/(\d+)$/);
|
||
if (match) {
|
||
const numerator = parseInt(match[1]);
|
||
const denominator = parseInt(match[2]);
|
||
timelineWidget.timelineState.timeSignature = { numerator, denominator };
|
||
context.config.timeSignature = { numerator, denominator };
|
||
updateTimeDisplay();
|
||
if (timelineWidget.requestRedraw) {
|
||
timelineWidget.requestRedraw();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
dropdown.appendChild(item);
|
||
});
|
||
|
||
document.body.appendChild(dropdown);
|
||
dropdown.focus();
|
||
|
||
// Close dropdown when clicking outside
|
||
const closeDropdown = (event) => {
|
||
if (!dropdown.contains(event.target)) {
|
||
if (document.body.contains(dropdown)) {
|
||
document.body.removeChild(dropdown);
|
||
}
|
||
document.removeEventListener('click', closeDropdown);
|
||
}
|
||
};
|
||
|
||
setTimeout(() => {
|
||
document.addEventListener('click', closeDropdown);
|
||
}, 0);
|
||
}
|
||
});
|
||
|
||
// Initial update
|
||
updateTimeDisplay();
|
||
|
||
// Store reference for updates
|
||
context.timeDisplay = timeDisplay;
|
||
context.updateTimeDisplay = updateTimeDisplay;
|
||
|
||
controls.push(timeDisplay);
|
||
|
||
return controls;
|
||
};
|
||
|
||
// Set up ResizeObserver
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
updateCanvasSize();
|
||
});
|
||
resizeObserver.observe(canvas);
|
||
|
||
// Mouse event handlers
|
||
canvas.addEventListener("pointerdown", (e) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
|
||
// Prevent default drag behavior on canvas
|
||
e.preventDefault();
|
||
|
||
// Capture pointer to ensure we get move/up events even if cursor leaves canvas
|
||
canvas.setPointerCapture(e.pointerId);
|
||
|
||
// Store event for modifier key access during clicks (for Shift-click multi-select)
|
||
timelineWidget.lastClickEvent = e;
|
||
// Also store for drag operations initially
|
||
timelineWidget.lastDragEvent = e;
|
||
|
||
timelineWidget.handleMouseEvent("mousedown", x, y);
|
||
updateCanvasSize(); // Redraw after interaction
|
||
});
|
||
|
||
canvas.addEventListener("pointermove", (e) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
|
||
// Store event for modifier key access during drag (for Shift-drag constraint)
|
||
timelineWidget.lastDragEvent = e;
|
||
|
||
timelineWidget.handleMouseEvent("mousemove", x, y);
|
||
|
||
// Update cursor based on widget's cursor property
|
||
if (timelineWidget.cursor) {
|
||
canvas.style.cursor = timelineWidget.cursor;
|
||
}
|
||
|
||
updateCanvasSize(); // Redraw after interaction
|
||
});
|
||
|
||
canvas.addEventListener("pointerup", (e) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
|
||
// Release pointer capture
|
||
canvas.releasePointerCapture(e.pointerId);
|
||
|
||
timelineWidget.handleMouseEvent("mouseup", x, y);
|
||
updateCanvasSize(); // Redraw after interaction
|
||
});
|
||
|
||
// Context menu (right-click) for deleting keyframes
|
||
canvas.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault(); // Prevent default browser context menu
|
||
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
|
||
// Store event for access to clientX/clientY for menu positioning
|
||
timelineWidget.lastEvent = e;
|
||
// Also store as click event for consistency
|
||
timelineWidget.lastClickEvent = e;
|
||
|
||
timelineWidget.handleMouseEvent("contextmenu", x, y);
|
||
updateCanvasSize(); // Redraw after interaction
|
||
});
|
||
|
||
// Add wheel event for pinch-zoom support
|
||
canvas.addEventListener("wheel", (event) => {
|
||
event.preventDefault();
|
||
|
||
// Get mouse position
|
||
const rect = canvas.getBoundingClientRect();
|
||
const mouseX = event.clientX - rect.left;
|
||
const mouseY = event.clientY - rect.top;
|
||
|
||
// Check if this is a pinch-zoom gesture (ctrlKey is set on trackpad pinch)
|
||
if (event.ctrlKey) {
|
||
// Pinch zoom - zoom in/out based on deltaY
|
||
const zoomFactor = event.deltaY > 0 ? 0.95 : 1.05;
|
||
const oldPixelsPerSecond = timelineWidget.timelineState.pixelsPerSecond;
|
||
|
||
// Adjust mouse position to account for track header offset
|
||
const timelineMouseX = mouseX - timelineWidget.trackHeaderWidth;
|
||
|
||
// Calculate the time under the mouse BEFORE zooming
|
||
const mouseTimeBeforeZoom = timelineWidget.timelineState.pixelToTime(timelineMouseX);
|
||
|
||
// Apply zoom
|
||
timelineWidget.timelineState.pixelsPerSecond *= zoomFactor;
|
||
|
||
// Clamp to reasonable range
|
||
timelineWidget.timelineState.pixelsPerSecond = Math.max(10, Math.min(10000, timelineWidget.timelineState.pixelsPerSecond));
|
||
|
||
// Adjust viewport so the time under the mouse stays in the same place
|
||
// We want: pixelToTime(timelineMouseX) == mouseTimeBeforeZoom
|
||
// pixelToTime(timelineMouseX) = (timelineMouseX / pixelsPerSecond) + viewportStartTime
|
||
// So: viewportStartTime = mouseTimeBeforeZoom - (timelineMouseX / pixelsPerSecond)
|
||
timelineWidget.timelineState.viewportStartTime = mouseTimeBeforeZoom - (timelineMouseX / timelineWidget.timelineState.pixelsPerSecond);
|
||
timelineWidget.timelineState.viewportStartTime = Math.max(0, timelineWidget.timelineState.viewportStartTime);
|
||
|
||
updateCanvasSize();
|
||
} else {
|
||
// Regular scroll - handle both horizontal and vertical scrolling everywhere
|
||
const deltaX = event.deltaX * config.scrollSpeed;
|
||
const deltaY = event.deltaY * config.scrollSpeed;
|
||
|
||
// Horizontal scroll for timeline
|
||
timelineWidget.timelineState.viewportStartTime += deltaX / timelineWidget.timelineState.pixelsPerSecond;
|
||
timelineWidget.timelineState.viewportStartTime = Math.max(0, timelineWidget.timelineState.viewportStartTime);
|
||
|
||
// Vertical scroll for tracks
|
||
timelineWidget.trackScrollOffset -= deltaY;
|
||
|
||
// Clamp scroll offset
|
||
const trackAreaHeight = canvas.height - timelineWidget.ruler.height;
|
||
const totalTracksHeight = timelineWidget.trackHierarchy.getTotalHeight();
|
||
const maxScroll = Math.min(0, trackAreaHeight - totalTracksHeight);
|
||
timelineWidget.trackScrollOffset = Math.max(maxScroll, Math.min(0, timelineWidget.trackScrollOffset));
|
||
|
||
updateCanvasSize();
|
||
}
|
||
});
|
||
|
||
updateCanvasSize();
|
||
return canvas;
|
||
}
|
||
|
||
function infopanel() {
|
||
let panel = document.createElement("div");
|
||
panel.className = "infopanel";
|
||
updateInfopanel();
|
||
return panel;
|
||
}
|
||
|
||
function outliner(object = undefined) {
|
||
let outliner = document.createElement("canvas");
|
||
outliner.className = "outliner";
|
||
if (object == undefined) {
|
||
outliner.object = root;
|
||
} else {
|
||
outliner.object = object;
|
||
}
|
||
outliner.style.cursor = "pointer";
|
||
|
||
let lastResizeTime = 0;
|
||
const throttleIntervalMs = 20;
|
||
|
||
function updateTimelineCanvasSize() {
|
||
const canvasStyles = window.getComputedStyle(outliner);
|
||
|
||
outliner.width = parseInt(canvasStyles.width);
|
||
outliner.height = parseInt(canvasStyles.height);
|
||
updateOutliner();
|
||
renderAll();
|
||
}
|
||
|
||
// Set up ResizeObserver to watch for changes in the canvas size
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
const currentTime = Date.now();
|
||
|
||
// Only call updateTimelineCanvasSize if enough time has passed since the last call
|
||
// This prevents error messages about a ResizeObserver loop
|
||
if (currentTime - lastResizeTime > throttleIntervalMs) {
|
||
lastResizeTime = currentTime;
|
||
updateTimelineCanvasSize();
|
||
}
|
||
});
|
||
resizeObserver.observe(outliner);
|
||
|
||
outliner.collapsed = {};
|
||
outliner.offsetX = 0;
|
||
outliner.offsetY = 0;
|
||
|
||
outliner.addEventListener("click", function (e) {
|
||
const mouse = getMousePos(outliner, e);
|
||
const mouseY = mouse.y; // Get the Y position of the click
|
||
const mouseX = mouse.x; // Get the X position (not used here, but can be used to check clicked area)
|
||
|
||
// Iterate again to check which object was clicked
|
||
let currentY = 20; // Starting y position
|
||
const stack = [{ object: outliner.object, indent: 0 }];
|
||
|
||
while (stack.length > 0) {
|
||
const { object, indent } = stack.pop();
|
||
|
||
// Check if the click was on this object
|
||
if (mouseY >= currentY - 20 && mouseY <= currentY) {
|
||
if (mouseX >= 0 && mouseX <= indent + 2 * triangleSize) {
|
||
// Toggle the collapsed state of the object
|
||
outliner.collapsed[object.idx] = !outliner.collapsed[object.idx];
|
||
} else {
|
||
outliner.active = object;
|
||
// Only do selection when this is pointing at the actual file
|
||
if (outliner.object==root) {
|
||
context.objectStack = []
|
||
let parent = object;
|
||
while (true) {
|
||
if (parent.parent) {
|
||
parent = parent.parent
|
||
context.objectStack.unshift(parent)
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
if (context.objectStack.length==0) {
|
||
context.objectStack.push(root)
|
||
}
|
||
context.oldselection = context.selection
|
||
context.oldshapeselection = context.shapeselection
|
||
context.selection = [object]
|
||
context.shapeselection = []
|
||
actions.select.create()
|
||
}
|
||
}
|
||
updateOutliner(); // Re-render the outliner
|
||
return;
|
||
}
|
||
|
||
// Update the Y position for the next object
|
||
currentY += 20;
|
||
|
||
// If the object is collapsed, skip it
|
||
if (outliner.collapsed[object.idx]) {
|
||
continue;
|
||
}
|
||
|
||
// If the object has layers, add them to the stack
|
||
if (object.layers) {
|
||
for (let i = object.layers.length - 1; i >= 0; i--) {
|
||
const layer = object.layers[i];
|
||
stack.push({ object: layer, indent: indent + 20 });
|
||
}
|
||
} else if (object.children) {
|
||
for (let i = object.children.length - 1; i >= 0; i--) {
|
||
const child = object.children[i];
|
||
stack.push({ object: child, indent: indent + 40 });
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
outliner.addEventListener("wheel", (event) => {
|
||
event.preventDefault();
|
||
const deltaY = event.deltaY * config.scrollSpeed;
|
||
|
||
outliner.offsetY = Math.max(0, outliner.offsetY + deltaY);
|
||
|
||
const currentTime = Date.now();
|
||
if (currentTime - lastResizeTime > throttleIntervalMs) {
|
||
lastResizeTime = currentTime;
|
||
updateOutliner();
|
||
}
|
||
});
|
||
|
||
return outliner;
|
||
}
|
||
|
||
async function startup() {
|
||
await loadConfig();
|
||
createNewFileDialog(_newFile, _open, config);
|
||
|
||
// Create start screen with callback
|
||
createStartScreen(async (options) => {
|
||
hideStartScreen();
|
||
|
||
if (options.type === 'new') {
|
||
// Create new project with selected focus
|
||
await _newFile(
|
||
options.width || 800,
|
||
options.height || 600,
|
||
options.fps || 24,
|
||
options.projectFocus
|
||
);
|
||
} else if (options.type === 'reopen' || options.type === 'recent') {
|
||
// Open existing file
|
||
await _open(options.filePath);
|
||
}
|
||
});
|
||
|
||
console.log('[startup] window.openedFiles:', window.openedFiles);
|
||
console.log('[startup] config.reopenLastSession:', config.reopenLastSession);
|
||
console.log('[startup] config.recentFiles:', config.recentFiles);
|
||
|
||
// Always update start screen data so it's ready when needed
|
||
await updateStartScreen(config);
|
||
|
||
if (!window.openedFiles?.length) {
|
||
if (config.reopenLastSession && config.recentFiles?.length) {
|
||
console.log('[startup] Reopening last session:', config.recentFiles[0]);
|
||
document.body.style.cursor = "wait"
|
||
setTimeout(()=>_open(config.recentFiles[0]), 10)
|
||
} else {
|
||
console.log('[startup] Showing start screen');
|
||
// Show start screen
|
||
showStartScreen();
|
||
}
|
||
} else {
|
||
console.log('[startup] Files already opened, skipping start screen');
|
||
}
|
||
}
|
||
|
||
startup();
|
||
|
||
// Track maximized pane state
|
||
let maximizedPane = null;
|
||
let savedPaneParent = null;
|
||
let savedRootPaneChildren = [];
|
||
let savedRootPaneClasses = null;
|
||
|
||
function toggleMaximizePane(paneDiv) {
|
||
if (maximizedPane === paneDiv) {
|
||
// Restore layout
|
||
if (savedPaneParent && savedRootPaneChildren.length > 0) {
|
||
// Remove pane from root
|
||
rootPane.removeChild(paneDiv);
|
||
|
||
// Restore all root pane children
|
||
while (rootPane.firstChild) {
|
||
rootPane.removeChild(rootPane.firstChild);
|
||
}
|
||
for (const child of savedRootPaneChildren) {
|
||
rootPane.appendChild(child);
|
||
}
|
||
|
||
// Put pane back in its original parent
|
||
savedPaneParent.appendChild(paneDiv);
|
||
|
||
// Restore root pane classes
|
||
if (savedRootPaneClasses) {
|
||
rootPane.className = savedRootPaneClasses;
|
||
}
|
||
|
||
savedPaneParent = null;
|
||
savedRootPaneChildren = [];
|
||
savedRootPaneClasses = null;
|
||
}
|
||
maximizedPane = null;
|
||
|
||
// Update button
|
||
const btn = paneDiv.querySelector('.maximize-btn');
|
||
if (btn) {
|
||
btn.innerHTML = "⛶";
|
||
btn.title = "Maximize Pane";
|
||
}
|
||
|
||
// Trigger updates
|
||
updateAll();
|
||
} else {
|
||
// Maximize pane
|
||
// Save pane's current parent
|
||
savedPaneParent = paneDiv.parentElement;
|
||
|
||
// Save all root pane children
|
||
savedRootPaneChildren = Array.from(rootPane.children);
|
||
savedRootPaneClasses = rootPane.className;
|
||
|
||
// Remove pane from its parent
|
||
savedPaneParent.removeChild(paneDiv);
|
||
|
||
// Clear root pane
|
||
while (rootPane.firstChild) {
|
||
rootPane.removeChild(rootPane.firstChild);
|
||
}
|
||
|
||
// Add only the maximized pane to root
|
||
rootPane.appendChild(paneDiv);
|
||
maximizedPane = paneDiv;
|
||
|
||
// Update button
|
||
const btn = paneDiv.querySelector('.maximize-btn');
|
||
if (btn) {
|
||
btn.innerHTML = "⛶"; // Could use different icon for restore
|
||
btn.title = "Restore Layout";
|
||
}
|
||
|
||
// Trigger updates
|
||
updateAll();
|
||
}
|
||
}
|
||
|
||
function createPaneMenu(div) {
|
||
const menuItems = ["Item 1", "Item 2", "Item 3"]; // The items for the menu
|
||
|
||
// Get the menu container (create a new div for the menu)
|
||
const popupMenu = document.createElement("div");
|
||
popupMenu.id = "popupMenu"; // Set the ID to ensure we can target it later
|
||
|
||
// Create a <ul> element to hold the list items
|
||
const ul = document.createElement("ul");
|
||
|
||
// Loop through the menuItems array and create a <li> for each item
|
||
for (let pane in panes) {
|
||
// Skip deprecated panes
|
||
if (pane === 'timelineDeprecated') {
|
||
continue;
|
||
}
|
||
|
||
const li = document.createElement("li");
|
||
// Create the <img> element for the icon
|
||
const img = document.createElement("img");
|
||
img.src = `assets/${panes[pane].name}.svg`; // Use the appropriate SVG as the source
|
||
// img.style.width = "20px"; // Set the icon size
|
||
// img.style.height = "20px"; // Set the icon size
|
||
// img.style.marginRight = "10px"; // Add space between the icon and text
|
||
|
||
// Append the image to the <li> element
|
||
li.appendChild(img);
|
||
|
||
// Set the text of the item
|
||
li.appendChild(document.createTextNode(titleCase(panes[pane].name)));
|
||
li.addEventListener("click", () => {
|
||
createPane(panes[pane], div);
|
||
updateUI();
|
||
updateLayers();
|
||
updateAll();
|
||
popupMenu.remove();
|
||
});
|
||
ul.appendChild(li); // Append the <li> to the <ul>
|
||
}
|
||
|
||
popupMenu.appendChild(ul); // Append the <ul> to the popupMenu div
|
||
document.body.appendChild(popupMenu); // Append the menu to the body
|
||
return popupMenu; // Return the created menu element
|
||
}
|
||
|
||
function createPane(paneType = undefined, div = undefined) {
|
||
if (!div) {
|
||
div = document.createElement("div");
|
||
} else {
|
||
div.textContent = "";
|
||
}
|
||
let header = document.createElement("div");
|
||
if (!paneType) {
|
||
paneType = panes.stage; // TODO: change based on type
|
||
}
|
||
let content = paneType.func();
|
||
header.className = "header";
|
||
|
||
let button = document.createElement("button");
|
||
header.appendChild(button);
|
||
let icon = document.createElement("img");
|
||
icon.className = "icon";
|
||
icon.src = `/assets/${paneType.name}.svg`;
|
||
button.appendChild(icon);
|
||
button.addEventListener("click", () => {
|
||
let popupMenu = document.getElementById("popupMenu");
|
||
|
||
// If the menu is already in the DOM, remove it
|
||
if (popupMenu) {
|
||
popupMenu.remove(); // Remove the menu from the DOM
|
||
} else {
|
||
// Create and append the new menu to the DOM
|
||
popupMenu = createPaneMenu(div);
|
||
|
||
// Position the menu intelligently to stay onscreen
|
||
const buttonRect = event.target.getBoundingClientRect();
|
||
const menuRect = popupMenu.getBoundingClientRect();
|
||
|
||
// Default: position below and to the right of the button
|
||
let left = buttonRect.left;
|
||
let top = buttonRect.bottom + window.scrollY;
|
||
|
||
// Check if menu goes off the right edge
|
||
if (left + menuRect.width > window.innerWidth) {
|
||
// Align right edge of menu with right edge of button
|
||
left = buttonRect.right - menuRect.width;
|
||
}
|
||
|
||
// Check if menu goes off the bottom edge
|
||
if (buttonRect.bottom + menuRect.height > window.innerHeight) {
|
||
// Position above the button instead
|
||
top = buttonRect.top + window.scrollY - menuRect.height;
|
||
}
|
||
|
||
// Ensure menu doesn't go off the left edge
|
||
left = Math.max(0, left);
|
||
|
||
// Ensure menu doesn't go off the top edge
|
||
top = Math.max(window.scrollY, top);
|
||
|
||
popupMenu.style.left = `${left}px`;
|
||
popupMenu.style.top = `${top}px`;
|
||
}
|
||
|
||
// Prevent the click event from propagating to the window click listener
|
||
event.stopPropagation();
|
||
});
|
||
|
||
// Add custom header controls if the content element provides them
|
||
if (content.headerControls && typeof content.headerControls === 'function') {
|
||
const controls = content.headerControls();
|
||
for (const control of controls) {
|
||
header.appendChild(control);
|
||
}
|
||
}
|
||
|
||
// Add maximize/restore button in top right
|
||
const maximizeBtn = document.createElement("button");
|
||
maximizeBtn.className = "maximize-btn";
|
||
maximizeBtn.title = "Maximize Pane";
|
||
maximizeBtn.innerHTML = "⛶"; // Maximize icon
|
||
maximizeBtn.addEventListener("click", () => {
|
||
toggleMaximizePane(div);
|
||
});
|
||
header.appendChild(maximizeBtn);
|
||
|
||
div.className = "vertical-grid pane";
|
||
div.setAttribute("data-pane-name", paneType.name);
|
||
header.style.height = "calc( 2 * var(--lineheight))";
|
||
content.style.height = "calc( 100% - 2 * var(--lineheight) )";
|
||
div.appendChild(header);
|
||
div.appendChild(content);
|
||
return div;
|
||
}
|
||
|
||
function splitPane(div, percent, horiz, newPane = undefined) {
|
||
let content = div.firstElementChild;
|
||
let div1 = document.createElement("div");
|
||
let div2 = document.createElement("div");
|
||
|
||
div1.className = "panecontainer";
|
||
div2.className = "panecontainer";
|
||
|
||
div1.appendChild(content);
|
||
if (newPane) {
|
||
div2.appendChild(newPane);
|
||
} else {
|
||
div2.appendChild(createPane());
|
||
}
|
||
div.appendChild(div1);
|
||
div.appendChild(div2);
|
||
|
||
if (horiz) {
|
||
div.className = "horizontal-grid";
|
||
} else {
|
||
div.className = "vertical-grid";
|
||
}
|
||
div.setAttribute("lb-percent", percent); // TODO: better attribute name
|
||
div.addEventListener("pointerdown", function (event) {
|
||
// Check if the clicked element is the parent itself and not a child element
|
||
if (event.target === event.currentTarget) {
|
||
if (event.button === 0) {
|
||
// Left click
|
||
event.preventDefault(); // Prevent text selection during drag
|
||
event.currentTarget.setAttribute("dragging", true);
|
||
event.currentTarget.style.userSelect = "none";
|
||
rootPane.style.userSelect = "none";
|
||
}
|
||
} else {
|
||
event.currentTarget.setAttribute("dragging", false);
|
||
}
|
||
});
|
||
div.addEventListener("contextmenu", async function (event) {
|
||
if (event.target === event.currentTarget) {
|
||
event.preventDefault(); // Prevent the default context menu from appearing
|
||
event.stopPropagation();
|
||
|
||
function createSplit(direction) {
|
||
let splitIndicator = document.createElement("div");
|
||
splitIndicator.className = "splitIndicator";
|
||
splitIndicator.style.flexDirection =
|
||
direction == "vertical" ? "column" : "row";
|
||
document.body.appendChild(splitIndicator);
|
||
splitIndicator.addEventListener("pointermove", (e) => {
|
||
const { clientX: mouseX, clientY: mouseY } = e;
|
||
const rect = splitIndicator.getBoundingClientRect();
|
||
|
||
// Create child elements and divider if not already present
|
||
let firstHalf = splitIndicator.querySelector(".first-half");
|
||
let secondHalf = splitIndicator.querySelector(".second-half");
|
||
let divider = splitIndicator.querySelector(".divider");
|
||
|
||
if (!firstHalf || !secondHalf || !divider) {
|
||
firstHalf = document.createElement("div");
|
||
secondHalf = document.createElement("div");
|
||
divider = document.createElement("div");
|
||
firstHalf.classList.add("first-half");
|
||
secondHalf.classList.add("second-half");
|
||
divider.classList.add("divider");
|
||
splitIndicator.innerHTML = ""; // Clear previous children
|
||
splitIndicator.append(firstHalf, divider, secondHalf);
|
||
}
|
||
|
||
const isVertical = direction === "vertical";
|
||
|
||
// Calculate dimensions for halves
|
||
const [first, second] = isVertical
|
||
? [mouseY - rect.top, rect.bottom - mouseY]
|
||
: [mouseX - rect.left, rect.right - mouseX];
|
||
|
||
const firstSize = `${first}px`;
|
||
const secondSize = `${second}px`;
|
||
|
||
splitIndicator.percent = isVertical
|
||
? ((mouseY - rect.top) / (rect.bottom - rect.top)) * 100
|
||
: ((mouseX - rect.left) / (rect.right - rect.left)) * 100;
|
||
|
||
// Apply styles for first and second halves
|
||
firstHalf.style[isVertical ? "height" : "width"] = firstSize;
|
||
secondHalf.style[isVertical ? "height" : "width"] = secondSize;
|
||
firstHalf.style[isVertical ? "width" : "height"] = "100%";
|
||
secondHalf.style[isVertical ? "width" : "height"] = "100%";
|
||
|
||
// Apply divider styles
|
||
divider.style.backgroundColor = "#000";
|
||
if (isVertical) {
|
||
divider.style.height = "2px";
|
||
divider.style.width = "100%";
|
||
divider.style.left = `${mouseX - rect.left}px`;
|
||
} else {
|
||
divider.style.width = "2px";
|
||
divider.style.height = "100%";
|
||
divider.style.top = `${mouseY - rect.top}px`;
|
||
}
|
||
});
|
||
splitIndicator.addEventListener("click", (e) => {
|
||
if (splitIndicator.percent) {
|
||
splitPane(
|
||
splitIndicator.targetElement,
|
||
splitIndicator.percent,
|
||
direction == "horizontal",
|
||
createPane(panes.timeline),
|
||
);
|
||
document.body.removeChild(splitIndicator);
|
||
document.removeEventListener("pointermove", splitListener);
|
||
setTimeout(updateUI, 20);
|
||
}
|
||
});
|
||
|
||
const splitListener = document.addEventListener("pointermove", (e) => {
|
||
const mouseX = e.clientX;
|
||
const mouseY = e.clientY;
|
||
|
||
// Get all elements under the mouse pointer
|
||
const elementsUnderMouse = document.querySelectorAll(":hover");
|
||
|
||
let targetElement = null;
|
||
for (let element of elementsUnderMouse) {
|
||
if (
|
||
element.matches(
|
||
".horizontal-grid > .panecontainer, .vertical-grid > .panecontainer",
|
||
)
|
||
) {
|
||
targetElement = element;
|
||
}
|
||
}
|
||
if (targetElement) {
|
||
const rect = targetElement.getBoundingClientRect();
|
||
splitIndicator.style.left = `${rect.left}px`;
|
||
splitIndicator.style.top = `${rect.top}px`;
|
||
splitIndicator.style.width = `${rect.width}px`;
|
||
splitIndicator.style.height = `${rect.height}px`;
|
||
|
||
splitIndicator.targetElement = targetElement;
|
||
}
|
||
});
|
||
}
|
||
// TODO: use icon menu items
|
||
// See https://github.com/tauri-apps/tauri/blob/dev/packages/api/src/menu/iconMenuItem.ts
|
||
|
||
// Check if children contain nested splits to determine which joins are unambiguous
|
||
const leftUpChild = div.children[0];
|
||
const rightDownChild = div.children[1];
|
||
|
||
// A child is a leaf if it's a panecontainer that directly contains another panecontainer
|
||
// A child has nested splits if it's a panecontainer that contains a grid
|
||
const leftUpHasSplit = leftUpChild &&
|
||
leftUpChild.classList.contains("panecontainer") &&
|
||
leftUpChild.firstElementChild &&
|
||
(leftUpChild.firstElementChild.classList.contains("horizontal-grid") ||
|
||
leftUpChild.firstElementChild.classList.contains("vertical-grid")) &&
|
||
leftUpChild.firstElementChild.hasAttribute("lb-percent");
|
||
|
||
const rightDownHasSplit = rightDownChild &&
|
||
rightDownChild.classList.contains("panecontainer") &&
|
||
rightDownChild.firstElementChild &&
|
||
(rightDownChild.firstElementChild.classList.contains("horizontal-grid") ||
|
||
rightDownChild.firstElementChild.classList.contains("vertical-grid")) &&
|
||
rightDownChild.firstElementChild.hasAttribute("lb-percent");
|
||
|
||
// Join Left/Up is unambiguous if we're keeping the left/up side (which may have splits)
|
||
// and removing the right/down side (which should be a simple pane)
|
||
const canJoinLeftUp = !rightDownHasSplit;
|
||
|
||
// Join Right/Down is unambiguous if we're keeping the right/down side (which may have splits)
|
||
// and removing the left/up side (which should be a simple pane)
|
||
const canJoinRightDown = !leftUpHasSplit;
|
||
|
||
const menu = await Menu.new({
|
||
items: [
|
||
{ id: "ctx_option0", text: "Area options", enabled: false },
|
||
{
|
||
id: "ctx_option1",
|
||
text: "Vertical Split",
|
||
action: () => createSplit("vertical"),
|
||
},
|
||
{
|
||
id: "ctx_option2",
|
||
text: "Horizontal Split",
|
||
action: () => createSplit("horizontal"),
|
||
},
|
||
new PredefinedMenuItem("Separator"),
|
||
{
|
||
id: "ctx_option3",
|
||
text: horiz ? "Join Left" : "Join Up",
|
||
enabled: canJoinLeftUp,
|
||
action: () => {
|
||
// Join left/up: remove the left/up pane, keep the right/down pane
|
||
const keepChild = div.children[1];
|
||
|
||
// Move all children from the kept panecontainer to the parent
|
||
const children = Array.from(keepChild.children);
|
||
|
||
// Replace the split div with just the kept child's contents
|
||
div.className = "panecontainer";
|
||
div.innerHTML = "";
|
||
children.forEach(child => {
|
||
// Recursively clear explicit sizing on grid and panecontainer elements only
|
||
function clearSizes(el) {
|
||
if (el.classList.contains("horizontal-grid") ||
|
||
el.classList.contains("vertical-grid") ||
|
||
el.classList.contains("panecontainer")) {
|
||
el.style.width = "";
|
||
el.style.height = "";
|
||
Array.from(el.children).forEach(clearSizes);
|
||
}
|
||
}
|
||
clearSizes(child);
|
||
div.appendChild(child);
|
||
});
|
||
div.removeAttribute("lb-percent");
|
||
|
||
setTimeout(() => {
|
||
updateAll();
|
||
updateUI();
|
||
updateLayers();
|
||
}, 20);
|
||
}
|
||
},
|
||
{
|
||
id: "ctx_option4",
|
||
text: horiz ? "Join Right" : "Join Down",
|
||
enabled: canJoinRightDown,
|
||
action: () => {
|
||
// Join right/down: remove the right/down pane, keep the left/up pane
|
||
const keepChild = div.children[0];
|
||
|
||
// Move all children from the kept panecontainer to the parent
|
||
const children = Array.from(keepChild.children);
|
||
|
||
// Replace the split div with just the kept child's contents
|
||
div.className = "panecontainer";
|
||
div.innerHTML = "";
|
||
children.forEach(child => {
|
||
// Recursively clear explicit sizing on grid and panecontainer elements only
|
||
function clearSizes(el) {
|
||
if (el.classList.contains("horizontal-grid") ||
|
||
el.classList.contains("vertical-grid") ||
|
||
el.classList.contains("panecontainer")) {
|
||
el.style.width = "";
|
||
el.style.height = "";
|
||
Array.from(el.children).forEach(clearSizes);
|
||
}
|
||
}
|
||
clearSizes(child);
|
||
div.appendChild(child);
|
||
});
|
||
div.removeAttribute("lb-percent");
|
||
|
||
setTimeout(() => {
|
||
updateAll();
|
||
updateUI();
|
||
updateLayers();
|
||
}, 20);
|
||
}
|
||
},
|
||
],
|
||
});
|
||
await menu.popup(new PhysicalPosition(event.clientX, event.clientY));
|
||
}
|
||
|
||
console.log("Right-click on the element");
|
||
// Your custom logic here
|
||
});
|
||
div.addEventListener("pointermove", function (event) {
|
||
// Check if the clicked element is the parent itself and not a child element
|
||
if (event.currentTarget.getAttribute("dragging") == "true") {
|
||
const frac = getMousePositionFraction(event, event.currentTarget);
|
||
div.setAttribute("lb-percent", frac * 100);
|
||
updateAll();
|
||
}
|
||
});
|
||
div.addEventListener("pointerup", (event) => {
|
||
event.currentTarget.setAttribute("dragging", false);
|
||
// event.currentTarget.style.userSelect = 'auto';
|
||
});
|
||
updateAll();
|
||
updateUI();
|
||
updateLayers();
|
||
return [div1, div2];
|
||
}
|
||
|
||
function updateAll() {
|
||
updateLayout(rootPane);
|
||
for (let element of layoutElements) {
|
||
updateLayout(element);
|
||
}
|
||
}
|
||
|
||
function updateLayout(element) {
|
||
let rect = element.getBoundingClientRect();
|
||
let percent = element.getAttribute("lb-percent");
|
||
percent ||= 50;
|
||
let children = element.children;
|
||
if (children.length != 2) return;
|
||
if (element.classList.contains("horizontal-grid")) {
|
||
children[0].style.width = `${(rect.width * percent) / 100}px`;
|
||
children[1].style.width = `${(rect.width * (100 - percent)) / 100}px`;
|
||
children[0].style.height = `${rect.height}px`;
|
||
children[1].style.height = `${rect.height}px`;
|
||
} else if (element.classList.contains("vertical-grid")) {
|
||
children[0].style.height = `${(rect.height * percent) / 100}px`;
|
||
children[1].style.height = `${(rect.height * (100 - percent)) / 100}px`;
|
||
children[0].style.width = `${rect.width}px`;
|
||
children[1].style.width = `${rect.width}px`;
|
||
}
|
||
if (children[0].getAttribute("lb-percent")) {
|
||
updateLayout(children[0]);
|
||
}
|
||
if (children[1].getAttribute("lb-percent")) {
|
||
updateLayout(children[1]);
|
||
}
|
||
}
|
||
|
||
function updateUI() {
|
||
uiDirty = true;
|
||
}
|
||
|
||
// Add updateUI and updateMenu to context so widgets can call them
|
||
context.updateUI = updateUI;
|
||
context.updateMenu = updateMenu;
|
||
|
||
async function renderUI() {
|
||
// Update video frames BEFORE drawing
|
||
if (context.activeObject) {
|
||
await updateVideoFrames(context.activeObject.currentTime);
|
||
}
|
||
|
||
for (let canvas of canvases) {
|
||
let ctx = canvas.getContext("2d");
|
||
ctx.resetTransform();
|
||
ctx.beginPath();
|
||
ctx.fillStyle = backgroundColor;
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
ctx.translate(-canvas.offsetX, -canvas.offsetY);
|
||
ctx.scale(context.zoomLevel, context.zoomLevel);
|
||
|
||
ctx.fillStyle = "white";
|
||
ctx.fillRect(0, 0, config.fileWidth, config.fileHeight);
|
||
|
||
context.ctx = ctx;
|
||
// root.draw(context);
|
||
root.draw(context)
|
||
if (context.activeObject != root) {
|
||
ctx.fillStyle = "rgba(255,255,255,0.5)";
|
||
ctx.fillRect(0, 0, config.fileWidth, config.fileHeight);
|
||
const transform = ctx.getTransform()
|
||
context.activeObject.draw(context, true);
|
||
ctx.setTransform(transform)
|
||
}
|
||
if (context.activeShape) {
|
||
context.activeShape.draw(context);
|
||
}
|
||
|
||
ctx.save()
|
||
context.activeObject.transformCanvas(ctx)
|
||
// Debug rendering
|
||
if (debugQuadtree) {
|
||
ctx.fillStyle = "rgba(255,255,255,0.5)";
|
||
ctx.fillRect(0, 0, config.fileWidth, config.fileHeight);
|
||
const ep = 2.5;
|
||
const bbox = {
|
||
x: { min: context.mousePos.x - ep, max: context.mousePos.x + ep },
|
||
y: { min: context.mousePos.y - ep, max: context.mousePos.y + ep },
|
||
};
|
||
debugCurves = [];
|
||
const currentTime = context.activeObject.currentTime || 0;
|
||
const visibleShapes = context.activeObject.activeLayer.getVisibleShapes(currentTime);
|
||
for (let shape of visibleShapes) {
|
||
for (let i of shape.quadtree.query(bbox)) {
|
||
debugCurves.push(shape.curves[i]);
|
||
}
|
||
}
|
||
|
||
}
|
||
// let i=4;
|
||
for (let curve of debugCurves) {
|
||
ctx.beginPath();
|
||
// ctx.strokeStyle = `#ff${i}${i}${i}${i}`
|
||
// i = (i+3)%10
|
||
ctx.strokeStyle = "#" + ((Math.random() * 0xffffff) << 0).toString(16);
|
||
ctx.moveTo(curve.points[0].x, curve.points[0].y);
|
||
ctx.bezierCurveTo(
|
||
curve.points[1].x,
|
||
curve.points[1].y,
|
||
curve.points[2].x,
|
||
curve.points[2].y,
|
||
curve.points[3].x,
|
||
curve.points[3].y,
|
||
);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
let bbox = curve.bbox();
|
||
ctx.rect(
|
||
bbox.x.min,
|
||
bbox.y.min,
|
||
bbox.x.max - bbox.x.min,
|
||
bbox.y.max - bbox.y.min,
|
||
);
|
||
ctx.stroke();
|
||
}
|
||
let i = 0;
|
||
for (let point of debugPoints) {
|
||
ctx.beginPath();
|
||
let j = i.toString(16).padStart(2, "0");
|
||
ctx.fillStyle = `#${j}ff${j}`;
|
||
i += 1;
|
||
i %= 255;
|
||
ctx.arc(point.x, point.y, 3, 0, 2 * Math.PI);
|
||
ctx.fill();
|
||
}
|
||
ctx.restore()
|
||
if (context.activeAction) {
|
||
actions[context.activeAction.type].render(context.activeAction, ctx);
|
||
}
|
||
}
|
||
for (let selectionRect of document.querySelectorAll(".selectionRect")) {
|
||
selectionRect.style.display = "none";
|
||
}
|
||
if (context.mode == "transform") {
|
||
if (context.selection.length > 0) {
|
||
for (let selectionRect of document.querySelectorAll(".selectionRect")) {
|
||
let bbox = undefined;
|
||
for (let item of context.selection) {
|
||
if (bbox == undefined) {
|
||
bbox = structuredClone(item.bbox());
|
||
} else {
|
||
growBoundingBox(bbox, item.bbox());
|
||
}
|
||
}
|
||
if (bbox != undefined) {
|
||
selectionRect.style.display = "block";
|
||
selectionRect.style.left = `${bbox.x.min}px`;
|
||
selectionRect.style.top = `${bbox.y.min}px`;
|
||
selectionRect.style.width = `${bbox.x.max - bbox.x.min}px`;
|
||
selectionRect.style.height = `${bbox.y.max - bbox.y.min}px`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateLayers() {
|
||
layersDirty = true;
|
||
}
|
||
|
||
function renderLayers() {
|
||
// Also trigger TimelineV2 redraw if it exists
|
||
if (context.timelineWidget?.requestRedraw) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
|
||
for (let canvas of document.querySelectorAll(".timeline-deprecated")) {
|
||
const width = canvas.width;
|
||
const height = canvas.height;
|
||
const ctx = canvas.getContext("2d");
|
||
const offsetX = canvas.offsetX;
|
||
const offsetY = canvas.offsetY;
|
||
const frameCount = (width + offsetX - layerWidth) / frameWidth;
|
||
ctx.fillStyle = backgroundColor;
|
||
ctx.fillRect(0, 0, width, height);
|
||
ctx.lineWidth = 1;
|
||
|
||
|
||
ctx.save()
|
||
ctx.translate(layerWidth, gutterHeight)
|
||
canvas.timelinewindow.width = width - layerWidth
|
||
canvas.timelinewindow.height = height - gutterHeight
|
||
canvas.timelinewindow.draw(ctx)
|
||
ctx.restore()
|
||
|
||
// Draw timeline top
|
||
ctx.save();
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.rect(layerWidth, 0, width - layerWidth, height);
|
||
ctx.clip();
|
||
ctx.translate(layerWidth - offsetX, 0);
|
||
ctx.fillStyle = labelColor;
|
||
for (
|
||
let j = Math.floor(offsetX / (5 * frameWidth)) * 5;
|
||
j < frameCount + 1;
|
||
j += 5
|
||
) {
|
||
drawCenteredText(
|
||
ctx,
|
||
j.toString(),
|
||
(j - 0.5) * frameWidth,
|
||
gutterHeight / 2,
|
||
gutterHeight,
|
||
);
|
||
}
|
||
ctx.restore();
|
||
ctx.translate(0, gutterHeight);
|
||
ctx.strokeStyle = shadow;
|
||
ctx.beginPath();
|
||
ctx.moveTo(layerWidth, 0);
|
||
ctx.lineTo(layerWidth, height);
|
||
ctx.stroke();
|
||
|
||
ctx.save();
|
||
ctx.rect(0, 0, width, height);
|
||
ctx.clip();
|
||
ctx.translate(0, -offsetY);
|
||
// Draw layer headers
|
||
let i = 0;
|
||
for (let k = context.activeObject.allLayers.length - 1; k >= 0; k--) {
|
||
let layer = context.activeObject.allLayers[k];
|
||
if (context.activeObject.activeLayer == layer) {
|
||
ctx.fillStyle = darkMode ? "#444" : "#ccc";
|
||
} else {
|
||
ctx.fillStyle = darkMode ? "#222" : "#aaa";
|
||
}
|
||
drawBorderedRect(
|
||
ctx,
|
||
0,
|
||
i * layerHeight,
|
||
layerWidth,
|
||
layerHeight,
|
||
highlight,
|
||
shadow,
|
||
);
|
||
ctx.fillStyle = darkMode ? "white" : "black";
|
||
drawHorizontallyCenteredText(
|
||
ctx,
|
||
layer.name,
|
||
5,
|
||
(i + 0.5) * layerHeight,
|
||
layerHeight * 0.4,
|
||
);
|
||
ctx.save();
|
||
const visibilityIcon = layer.visible
|
||
? canvas.icons.eye_fill
|
||
: canvas.icons.eye_slash;
|
||
visibilityIcon.render(
|
||
ctx,
|
||
layerWidth - iconSize - 5,
|
||
(i + 0.5) * layerHeight - iconSize * 0.5,
|
||
iconSize,
|
||
iconSize,
|
||
labelColor,
|
||
);
|
||
const audibilityIcon = layer.audible
|
||
? canvas.icons.volume_up_fill
|
||
: canvas.icons.volume_mute;
|
||
audibilityIcon.render(
|
||
ctx,
|
||
layerWidth - iconSize * 2 - 10,
|
||
(i + 0.5) * layerHeight - iconSize * 0.5,
|
||
iconSize,
|
||
iconSize,
|
||
labelColor,
|
||
);
|
||
ctx.restore();
|
||
|
||
// ctx.save();
|
||
// ctx.beginPath();
|
||
// ctx.rect(layerWidth, i * layerHeight, width, layerHeight);
|
||
// ctx.clip();
|
||
// ctx.translate(layerWidth - offsetX, i * layerHeight);
|
||
// // Draw empty frames
|
||
// for (let j = Math.floor(offsetX / frameWidth); j < frameCount; j++) {
|
||
// ctx.fillStyle = (j + 1) % 5 == 0 ? shade : backgroundColor;
|
||
// drawBorderedRect(
|
||
// ctx,
|
||
// j * frameWidth,
|
||
// 0,
|
||
// frameWidth,
|
||
// layerHeight,
|
||
// shadow,
|
||
// highlight,
|
||
// shadow,
|
||
// shadow,
|
||
// );
|
||
// }
|
||
// // Draw existing frames
|
||
// if (layer instanceof Layer) {
|
||
// for (let j=0; j<layer.frames.length; j++) {
|
||
// const frameInfo = layer.getFrameValue(j)
|
||
// if (frameInfo.valueAtN) {
|
||
// ctx.fillStyle = foregroundColor;
|
||
// drawBorderedRect(
|
||
// ctx,
|
||
// j * frameWidth,
|
||
// 0,
|
||
// frameWidth,
|
||
// layerHeight,
|
||
// highlight,
|
||
// shadow,
|
||
// shadow,
|
||
// shadow,
|
||
// );
|
||
// ctx.fillStyle = "#111";
|
||
// ctx.beginPath();
|
||
// ctx.arc(
|
||
// (j + 0.5) * frameWidth,
|
||
// layerHeight * 0.75,
|
||
// frameWidth * 0.25,
|
||
// 0,
|
||
// 2 * Math.PI,
|
||
// );
|
||
// ctx.fill();
|
||
// if (frameInfo.valueAtN.keyTypes.has("motion")) {
|
||
// ctx.strokeStyle = "#7a00b3";
|
||
// ctx.lineWidth = 2;
|
||
// ctx.beginPath()
|
||
// ctx.moveTo(j*frameWidth, layerHeight*0.25)
|
||
// ctx.lineTo((j+1)*frameWidth, layerHeight*0.25)
|
||
// ctx.stroke()
|
||
// }
|
||
// if (frameInfo.valueAtN.keyTypes.has("shape")) {
|
||
// ctx.strokeStyle = "#9bff9b";
|
||
// ctx.lineWidth = 2;
|
||
// ctx.beginPath()
|
||
// ctx.moveTo(j*frameWidth, layerHeight*0.35)
|
||
// ctx.lineTo((j+1)*frameWidth, layerHeight*0.35)
|
||
// ctx.stroke()
|
||
// }
|
||
// } else if (frameInfo.prev && frameInfo.next) {
|
||
// ctx.fillStyle = foregroundColor;
|
||
// drawBorderedRect(
|
||
// ctx,
|
||
// j * frameWidth,
|
||
// 0,
|
||
// frameWidth,
|
||
// layerHeight,
|
||
// highlight,
|
||
// shadow,
|
||
// backgroundColor,
|
||
// backgroundColor,
|
||
// );
|
||
// if (frameInfo.prev.keyTypes.has("motion")) {
|
||
// ctx.strokeStyle = "#7a00b3";
|
||
// ctx.lineWidth = 2;
|
||
// ctx.beginPath()
|
||
// ctx.moveTo(j*frameWidth, layerHeight*0.25)
|
||
// ctx.lineTo((j+1)*frameWidth, layerHeight*0.25)
|
||
// ctx.stroke()
|
||
// }
|
||
// if (frameInfo.prev.keyTypes.has("shape")) {
|
||
// ctx.strokeStyle = "#9bff9b";
|
||
// ctx.lineWidth = 2;
|
||
// ctx.beginPath()
|
||
// ctx.moveTo(j*frameWidth, layerHeight*0.35)
|
||
// ctx.lineTo((j+1)*frameWidth, layerHeight*0.35)
|
||
// ctx.stroke()
|
||
// }
|
||
// }
|
||
// }
|
||
// // layer.frames.forEach((frame, j) => {
|
||
// // if (!frame) return;
|
||
// // switch (frame.frameType) {
|
||
// // case "keyframe":
|
||
// // ctx.fillStyle = foregroundColor;
|
||
// // drawBorderedRect(
|
||
// // ctx,
|
||
// // j * frameWidth,
|
||
// // 0,
|
||
// // frameWidth,
|
||
// // layerHeight,
|
||
// // highlight,
|
||
// // shadow,
|
||
// // shadow,
|
||
// // shadow,
|
||
// // );
|
||
// // ctx.fillStyle = "#111";
|
||
// // ctx.beginPath();
|
||
// // ctx.arc(
|
||
// // (j + 0.5) * frameWidth,
|
||
// // layerHeight * 0.75,
|
||
// // frameWidth * 0.25,
|
||
// // 0,
|
||
// // 2 * Math.PI,
|
||
// // );
|
||
// // ctx.fill();
|
||
// // break;
|
||
// // case "normal":
|
||
// // ctx.fillStyle = foregroundColor;
|
||
// // drawBorderedRect(
|
||
// // ctx,
|
||
// // j * frameWidth,
|
||
// // 0,
|
||
// // frameWidth,
|
||
// // layerHeight,
|
||
// // highlight,
|
||
// // shadow,
|
||
// // backgroundColor,
|
||
// // backgroundColor,
|
||
// // );
|
||
// // break;
|
||
// // case "motion":
|
||
// // ctx.fillStyle = "#7a00b3";
|
||
// // ctx.fillRect(j * frameWidth, 0, frameWidth, layerHeight);
|
||
// // break;
|
||
// // case "shape":
|
||
// // ctx.fillStyle = "#9bff9b";
|
||
// // ctx.fillRect(j * frameWidth, 0, frameWidth, layerHeight);
|
||
// // break;
|
||
// // }
|
||
// // });
|
||
// } else if (layer instanceof AudioTrack) {
|
||
// // TODO: split waveform into chunks
|
||
// for (let i in layer.sounds) {
|
||
// let sound = layer.sounds[i];
|
||
// // layerTrack.appendChild(sound.img)
|
||
// ctx.drawImage(sound.img, 0, 0);
|
||
// }
|
||
// }
|
||
// // if (context.activeObject.currentFrameNum)
|
||
// ctx.restore();
|
||
i++;
|
||
}
|
||
ctx.restore();
|
||
|
||
// Draw highlighted frame
|
||
ctx.save();
|
||
ctx.translate(layerWidth - offsetX, -offsetY);
|
||
ctx.translate(
|
||
canvas.frameDragOffset.frames * frameWidth,
|
||
canvas.frameDragOffset.layers * layerHeight,
|
||
);
|
||
ctx.globalCompositeOperation = "difference";
|
||
for (let frame of context.selectedFrames) {
|
||
ctx.fillStyle = "grey";
|
||
ctx.fillRect(
|
||
frame.frameNum * frameWidth,
|
||
frame.layer * layerHeight,
|
||
frameWidth,
|
||
layerHeight,
|
||
);
|
||
}
|
||
ctx.globalCompositeOperation = "source-over";
|
||
ctx.restore();
|
||
|
||
// Draw scrubber bar
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.rect(layerWidth, -gutterHeight, width, height);
|
||
ctx.clip();
|
||
ctx.translate(layerWidth - offsetX, 0);
|
||
let frameNum = context.activeObject.currentFrameNum;
|
||
ctx.strokeStyle = scrubberColor;
|
||
ctx.beginPath();
|
||
ctx.moveTo((frameNum + 0.5) * frameWidth, 0);
|
||
ctx.lineTo((frameNum + 0.5) * frameWidth, height);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.fillStyle = scrubberColor;
|
||
ctx.fillRect(
|
||
frameNum * frameWidth,
|
||
-gutterHeight,
|
||
frameWidth,
|
||
gutterHeight,
|
||
);
|
||
ctx.fillStyle = "white";
|
||
drawCenteredText(
|
||
ctx,
|
||
(frameNum + 1).toString(),
|
||
(frameNum + 0.5) * frameWidth,
|
||
-gutterHeight / 2,
|
||
gutterHeight,
|
||
);
|
||
ctx.restore();
|
||
ctx.restore();
|
||
}
|
||
return;
|
||
for (let container of document.querySelectorAll(".layers-container")) {
|
||
let layerspanel = container.querySelectorAll(".layers")[0];
|
||
let framescontainer = container.querySelectorAll(".frames-container")[0];
|
||
layerspanel.textContent = "";
|
||
framescontainer.textContent = "";
|
||
for (let layer of context.activeObject.layers) {
|
||
let layerHeader = document.createElement("div");
|
||
layerHeader.className = "layer-header";
|
||
if (context.activeObject.activeLayer == layer) {
|
||
layerHeader.classList.add("active");
|
||
}
|
||
layerspanel.appendChild(layerHeader);
|
||
let layerName = document.createElement("div");
|
||
layerName.className = "layer-name";
|
||
layerName.contentEditable = "plaintext-only";
|
||
layerName.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
});
|
||
layerName.addEventListener("blur", (e) => {
|
||
actions.changeLayerName.create(layer, layerName.innerText);
|
||
});
|
||
layerName.innerText = layer.name;
|
||
layerHeader.appendChild(layerName);
|
||
// Visibility icon element
|
||
let visibilityIcon = document.createElement("img");
|
||
visibilityIcon.className = "visibility-icon";
|
||
visibilityIcon.src = layer.visible
|
||
? "assets/eye-fill.svg"
|
||
: "assets/eye-slash.svg";
|
||
|
||
// Toggle visibility on click
|
||
visibilityIcon.addEventListener("click", (e) => {
|
||
e.stopPropagation(); // Prevent click from bubbling to the layerHeader click listener
|
||
layer.visible = !layer.visible;
|
||
// visibilityIcon.src = layer.visible ? "assets/eye-fill.svg" : "assets/eye-slash.svg"
|
||
updateUI();
|
||
updateMenu();
|
||
updateLayers();
|
||
});
|
||
|
||
layerHeader.appendChild(visibilityIcon);
|
||
layerHeader.addEventListener("click", (e) => {
|
||
context.activeObject.currentLayer =
|
||
context.activeObject.layers.indexOf(layer);
|
||
updateLayers();
|
||
updateUI();
|
||
});
|
||
let layerTrack = document.createElement("div");
|
||
layerTrack.className = "layer-track";
|
||
if (!layer.visible) {
|
||
layerTrack.classList.add("invisible");
|
||
}
|
||
framescontainer.appendChild(layerTrack);
|
||
layerTrack.addEventListener("click", (e) => {
|
||
let mouse = getMousePos(layerTrack, e);
|
||
let frameNum = parseInt(mouse.x / 25);
|
||
context.activeObject.setFrameNum(frameNum);
|
||
updateLayers();
|
||
updateMenu();
|
||
updateUI();
|
||
updateInfopanel();
|
||
});
|
||
let highlightedFrame = false;
|
||
layer.frames.forEach((frame, i) => {
|
||
let frameEl = document.createElement("div");
|
||
frameEl.className = "frame";
|
||
frameEl.setAttribute("frameNum", i);
|
||
if (i == context.activeObject.currentFrameNum) {
|
||
frameEl.classList.add("active");
|
||
highlightedFrame = true;
|
||
}
|
||
|
||
frameEl.classList.add(frame.frameType);
|
||
layerTrack.appendChild(frameEl);
|
||
});
|
||
if (!highlightedFrame) {
|
||
let highlightObj = document.createElement("div");
|
||
let frameCount = layer.frames.length;
|
||
highlightObj.className = "frame-highlight";
|
||
highlightObj.style.left = `${(context.activeObject.currentFrameNum - frameCount) * 25}px`;
|
||
layerTrack.appendChild(highlightObj);
|
||
}
|
||
}
|
||
for (let audioTrack of context.activeObject.audioTracks) {
|
||
let layerHeader = document.createElement("div");
|
||
layerHeader.className = "layer-header";
|
||
layerHeader.classList.add("audio");
|
||
layerspanel.appendChild(layerHeader);
|
||
let layerTrack = document.createElement("div");
|
||
layerTrack.className = "layer-track";
|
||
layerTrack.classList.add("audio");
|
||
framescontainer.appendChild(layerTrack);
|
||
for (let i in audioTrack.sounds) {
|
||
let sound = audioTrack.sounds[i];
|
||
layerTrack.appendChild(sound.img);
|
||
}
|
||
let layerName = document.createElement("div");
|
||
layerName.className = "layer-name";
|
||
layerName.contentEditable = "plaintext-only";
|
||
layerName.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
});
|
||
layerName.addEventListener("blur", (e) => {
|
||
actions.changeLayerName.create(audioLayer, layerName.innerText);
|
||
});
|
||
layerName.innerText = audioTrack.name;
|
||
layerHeader.appendChild(layerName);
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateInfopanel() {
|
||
infopanelDirty = true;
|
||
}
|
||
|
||
function renderInfopanel() {
|
||
for (let panel of document.querySelectorAll(".infopanel")) {
|
||
panel.innerText = "";
|
||
let input;
|
||
let label;
|
||
let span;
|
||
let breadcrumbs = document.createElement("div");
|
||
const bctitle = document.createElement("span");
|
||
bctitle.style.cursor = "default";
|
||
bctitle.textContent = "Context: ";
|
||
breadcrumbs.appendChild(bctitle);
|
||
let crumbs = [];
|
||
for (let object of context.objectStack) {
|
||
crumbs.push({ name: object.name, object: object });
|
||
}
|
||
crumbs.forEach((crumb, index) => {
|
||
const crumbText = document.createElement("span");
|
||
crumbText.textContent = crumb.name;
|
||
breadcrumbs.appendChild(crumbText);
|
||
|
||
if (index < crumbs.length - 1) {
|
||
const separator = document.createElement("span");
|
||
separator.textContent = " > ";
|
||
separator.style.cursor = "default";
|
||
crumbText.style.cursor = "pointer";
|
||
breadcrumbs.appendChild(separator);
|
||
} else {
|
||
crumbText.style.cursor = "default";
|
||
}
|
||
});
|
||
|
||
breadcrumbs.addEventListener("click", function (event) {
|
||
const span = event.target;
|
||
|
||
// Only handle clicks on the breadcrumb text segments (not the separators)
|
||
if (span.tagName === "SPAN" && span.textContent !== " > ") {
|
||
const clickedText = span.textContent;
|
||
|
||
// Find the crumb associated with the clicked text
|
||
const crumb = crumbs.find((c) => c.name === clickedText);
|
||
if (crumb) {
|
||
const index = context.objectStack.indexOf(crumb.object);
|
||
if (index !== -1) {
|
||
// Keep only the objects up to the clicked one and add the clicked one as the last item
|
||
context.objectStack = context.objectStack.slice(0, index + 1);
|
||
updateUI();
|
||
updateLayers();
|
||
updateMenu();
|
||
updateInfopanel();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
panel.appendChild(breadcrumbs);
|
||
for (let property in tools[context.mode].properties) {
|
||
let prop = tools[context.mode].properties[property];
|
||
label = document.createElement("label");
|
||
label.className = "infopanel-field";
|
||
span = document.createElement("span");
|
||
span.className = "infopanel-label";
|
||
span.innerText = prop.label;
|
||
switch (prop.type) {
|
||
case "number":
|
||
input = document.createElement("input");
|
||
input.className = "infopanel-input";
|
||
input.type = "number";
|
||
input.disabled = prop.enabled == undefined ? false : !prop.enabled();
|
||
if (prop.value) {
|
||
input.value = prop.value.get();
|
||
} else {
|
||
input.value = getProperty(context, property);
|
||
}
|
||
if (prop.min) {
|
||
input.min = prop.min;
|
||
}
|
||
if (prop.max) {
|
||
input.max = prop.max;
|
||
}
|
||
break;
|
||
case "enum":
|
||
input = document.createElement("select");
|
||
input.className = "infopanel-input";
|
||
input.disabled = prop.enabled == undefined ? false : !prop.enabled();
|
||
let optionEl;
|
||
for (let option of prop.options) {
|
||
optionEl = document.createElement("option");
|
||
optionEl.value = option;
|
||
optionEl.innerText = option;
|
||
input.appendChild(optionEl);
|
||
}
|
||
if (prop.value) {
|
||
input.value = prop.value.get();
|
||
} else {
|
||
input.value = getProperty(context, property);
|
||
}
|
||
break;
|
||
case "boolean":
|
||
input = document.createElement("input");
|
||
input.className = "infopanel-input";
|
||
input.type = "checkbox";
|
||
input.disabled = prop.enabled == undefined ? false : !prop.enabled();
|
||
if (prop.value) {
|
||
input.checked = prop.value.get();
|
||
} else {
|
||
input.checked = getProperty(context, property);
|
||
}
|
||
break;
|
||
case "text":
|
||
input = document.createElement("input");
|
||
input.className = "infopanel-input";
|
||
input.disabled = prop.enabled == undefined ? false : !prop.enabled();
|
||
if (prop.value) {
|
||
input.value = prop.value.get();
|
||
} else {
|
||
input.value = getProperty(context, property);
|
||
}
|
||
break;
|
||
}
|
||
input.addEventListener("input", (e) => {
|
||
switch (prop.type) {
|
||
case "number":
|
||
if (!isNaN(e.target.value) && e.target.value > 0) {
|
||
if (prop.value) {
|
||
prop.value.set(parseInt(e.target.value));
|
||
} else {
|
||
setProperty(context, property, parseInt(e.target.value));
|
||
}
|
||
}
|
||
break;
|
||
case "enum":
|
||
if (prop.options.indexOf(e.target.value) >= 0) {
|
||
setProperty(context, property, e.target.value);
|
||
}
|
||
break;
|
||
case "boolean":
|
||
if (prop.value) {
|
||
prop.value.set(e.target.value);
|
||
} else {
|
||
setProperty(context, property, e.target.checked);
|
||
}
|
||
break;
|
||
case "text":
|
||
// Do nothing because this event fires for every character typed
|
||
break;
|
||
}
|
||
});
|
||
input.addEventListener("blur", (e) => {
|
||
switch (prop.type) {
|
||
case "text":
|
||
if (prop.value) {
|
||
prop.value.set(e.target.value);
|
||
} else {
|
||
setProperty(context, property, parseInt(e.target.value));
|
||
}
|
||
break;
|
||
}
|
||
});
|
||
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.target.blur();
|
||
}
|
||
});
|
||
label.appendChild(span);
|
||
label.appendChild(input);
|
||
panel.appendChild(label);
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateOutliner() {
|
||
outlinerDirty = true;
|
||
}
|
||
|
||
function renderOutliner() {
|
||
const padding = 20; // pixels
|
||
for (let outliner of document.querySelectorAll(".outliner")) {
|
||
const x = 0;
|
||
let y = padding;
|
||
const ctx = outliner.getContext("2d");
|
||
ctx.fillStyle = "white";
|
||
ctx.fillRect(0, 0, outliner.width, outliner.height);
|
||
const stack = [{ object: outliner.object, indent: 0 }];
|
||
|
||
ctx.save();
|
||
ctx.translate(0, -outliner.offsetY);
|
||
|
||
// Iterate as long as there are items in the stack
|
||
while (stack.length > 0) {
|
||
const { object, indent } = stack.pop();
|
||
|
||
// Determine if the object is collapsed and draw the corresponding triangle
|
||
const triangleX = x + indent + triangleSize; // X position for the triangle
|
||
const triangleY = y - padding / 2; // Y position for the triangle (centered vertically)
|
||
|
||
if (outliner.active === object) {
|
||
ctx.fillStyle = "red";
|
||
ctx.fillRect(0, y - padding, outliner.width, padding);
|
||
}
|
||
|
||
if (outliner.collapsed[object.idx]) {
|
||
drawRegularPolygon(ctx, triangleX, triangleY, triangleSize, 3, "black");
|
||
} else {
|
||
drawRegularPolygon(
|
||
ctx,
|
||
triangleX,
|
||
triangleY,
|
||
triangleSize,
|
||
3,
|
||
"black",
|
||
Math.PI / 2,
|
||
);
|
||
}
|
||
|
||
// Draw the current object (GraphicsObject or Layer)
|
||
const label = `(${object.constructor.name}) ${object.name}`;
|
||
ctx.fillStyle = "black";
|
||
// ctx.fillText(label, x + indent + 2*triangleSize, y);
|
||
drawHorizontallyCenteredText(
|
||
ctx,
|
||
label,
|
||
x + indent + 2 * triangleSize,
|
||
y - padding / 2,
|
||
padding * 0.75,
|
||
);
|
||
|
||
// Update the Y position for the next line
|
||
y += padding; // Space between lines (adjust as necessary)
|
||
|
||
if (outliner.collapsed[object.idx]) {
|
||
continue;
|
||
}
|
||
|
||
// If the object has layers, add them to the stack
|
||
if (object.layers) {
|
||
for (let i = object.layers.length - 1; i >= 0; i--) {
|
||
const layer = object.layers[i];
|
||
stack.push({ object: layer, indent: indent + padding });
|
||
}
|
||
} else if (object.children) {
|
||
for (let i = object.children.length - 1; i >= 0; i--) {
|
||
const child = object.children[i];
|
||
stack.push({ object: child, indent: indent + 2 * padding });
|
||
}
|
||
}
|
||
}
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
function updateMenu() {
|
||
menuDirty = true;
|
||
}
|
||
|
||
async function renderMenu() {
|
||
console.log('[renderMenu] START - root.frameRate:', root.frameRate);
|
||
let activeFrame;
|
||
let activeKeyframe;
|
||
let newFrameMenuItem;
|
||
let newKeyframeMenuItem;
|
||
let newBlankKeyframeMenuItem;
|
||
let duplicateKeyframeMenuItem;
|
||
let deleteFrameMenuItem;
|
||
|
||
// Move this
|
||
updateOutliner();
|
||
console.log('[renderMenu] After updateOutliner - root.frameRate:', root.frameRate);
|
||
|
||
let recentFilesList = [];
|
||
config.recentFiles.forEach((file) => {
|
||
recentFilesList.push({
|
||
text: file,
|
||
enabled: true,
|
||
action: () => {
|
||
document.body.style.cursor = "wait"
|
||
setTimeout(()=>_open(file),10);
|
||
},
|
||
});
|
||
});
|
||
|
||
// Legacy frame system removed - these are always false now
|
||
activeFrame = false;
|
||
activeKeyframe = false;
|
||
const appSubmenu = await Submenu.new({
|
||
text: "Lightningbeam",
|
||
items: [
|
||
{
|
||
text: "About Lightningbeam",
|
||
enabled: true,
|
||
action: about,
|
||
},
|
||
{
|
||
text: "Settings",
|
||
enabled: false,
|
||
action: () => {},
|
||
},
|
||
{
|
||
text: "Close Window",
|
||
enabled: true,
|
||
action: quit,
|
||
},
|
||
{
|
||
text: "Quit Lightningbeam",
|
||
enabled: true,
|
||
action: quit,
|
||
},
|
||
],
|
||
});
|
||
const fileSubmenu = await Submenu.new({
|
||
text: "File",
|
||
items: [
|
||
{
|
||
text: "New file...",
|
||
enabled: true,
|
||
action: newFile,
|
||
accelerator: getShortcut("new"),
|
||
},
|
||
{
|
||
text: "New Window",
|
||
enabled: true,
|
||
action: newWindow,
|
||
accelerator: getShortcut("newWindow"),
|
||
},
|
||
{
|
||
text: "Save",
|
||
enabled: true,
|
||
action: save,
|
||
accelerator: getShortcut("save"),
|
||
},
|
||
{
|
||
text: "Save As...",
|
||
enabled: true,
|
||
action: saveAs,
|
||
accelerator: getShortcut("saveAs"),
|
||
},
|
||
await Submenu.new({
|
||
text: "Open Recent",
|
||
items: recentFilesList,
|
||
}),
|
||
{
|
||
text: "Open File...",
|
||
enabled: true,
|
||
action: open,
|
||
accelerator: getShortcut("open"),
|
||
},
|
||
{
|
||
text: "Revert",
|
||
enabled: undoStack.length > lastSaveIndex,
|
||
action: revert,
|
||
},
|
||
{
|
||
text: "Import...",
|
||
enabled: true,
|
||
action: importFile,
|
||
accelerator: getShortcut("import"),
|
||
},
|
||
{
|
||
text: "Export Video...",
|
||
enabled: true,
|
||
action: render,
|
||
accelerator: getShortcut("export"),
|
||
},
|
||
{
|
||
text: "Export Audio...",
|
||
enabled: true,
|
||
action: exportAudio,
|
||
},
|
||
{
|
||
text: "Quit",
|
||
enabled: true,
|
||
action: quit,
|
||
accelerator: getShortcut("quit"),
|
||
},
|
||
],
|
||
});
|
||
|
||
const editSubmenu = await Submenu.new({
|
||
text: "Edit",
|
||
items: [
|
||
{
|
||
text:
|
||
"Undo " +
|
||
(undoStack.length > 0
|
||
? camelToWords(undoStack[undoStack.length - 1].name)
|
||
: ""),
|
||
enabled: undoStack.length > 0,
|
||
action: undo,
|
||
accelerator: getShortcut("undo"),
|
||
},
|
||
{
|
||
text:
|
||
"Redo " +
|
||
(redoStack.length > 0
|
||
? camelToWords(redoStack[redoStack.length - 1].name)
|
||
: ""),
|
||
enabled: redoStack.length > 0,
|
||
action: redo,
|
||
accelerator: getShortcut("redo"),
|
||
},
|
||
{
|
||
text: "Cut",
|
||
enabled: false,
|
||
action: () => {},
|
||
},
|
||
{
|
||
text: "Copy",
|
||
enabled:
|
||
context.selection.length > 0 || context.shapeselection.length > 0,
|
||
action: copy,
|
||
accelerator: getShortcut("copy"),
|
||
},
|
||
{
|
||
text: "Paste",
|
||
enabled: true,
|
||
action: paste,
|
||
accelerator: getShortcut("paste"),
|
||
},
|
||
{
|
||
text: "Delete",
|
||
enabled:
|
||
context.selection.length > 0 || context.shapeselection.length > 0,
|
||
action: delete_action,
|
||
accelerator: getShortcut("delete"),
|
||
},
|
||
{
|
||
text: "Select All",
|
||
enabled: true,
|
||
action: actions.selectAll.create,
|
||
accelerator: getShortcut("selectAll"),
|
||
},
|
||
{
|
||
text: "Select None",
|
||
enabled: true,
|
||
action: actions.selectNone.create,
|
||
accelerator: getShortcut("selectNone"),
|
||
},
|
||
{
|
||
text: "Preferences",
|
||
enabled: true,
|
||
action: showPreferencesDialog,
|
||
},
|
||
],
|
||
});
|
||
|
||
const modifySubmenu = await Submenu.new({
|
||
text: "Modify",
|
||
items: [
|
||
{
|
||
text: "Group",
|
||
enabled:
|
||
context.selection.length != 0 || context.shapeselection.length != 0,
|
||
action: actions.group.create,
|
||
accelerator: getShortcut("group"),
|
||
},
|
||
{
|
||
text: "Send to back",
|
||
enabled:
|
||
context.selection.length != 0 || context.shapeselection.length != 0,
|
||
action: actions.sendToBack.create,
|
||
},
|
||
{
|
||
text: "Bring to front",
|
||
enabled:
|
||
context.selection.length != 0 || context.shapeselection.length != 0,
|
||
action: actions.bringToFront.create,
|
||
},
|
||
],
|
||
});
|
||
|
||
const layerSubmenu = await Submenu.new({
|
||
text: "Layer",
|
||
items: [
|
||
{
|
||
text: "Add Layer",
|
||
enabled: true,
|
||
action: actions.addLayer.create,
|
||
accelerator: getShortcut("addLayer"),
|
||
},
|
||
{
|
||
text: "Add Video Layer",
|
||
enabled: true,
|
||
action: addVideoLayer,
|
||
},
|
||
{
|
||
text: "Add Audio Track",
|
||
enabled: true,
|
||
action: addEmptyAudioTrack,
|
||
accelerator: getShortcut("addAudioTrack")
|
||
},
|
||
{
|
||
text: "Add MIDI Track",
|
||
enabled: true,
|
||
action: addEmptyMIDITrack,
|
||
accelerator: getShortcut("addMIDITrack")
|
||
},
|
||
{
|
||
text: "Delete Layer",
|
||
enabled: context.activeObject.layers.length > 1,
|
||
action: actions.deleteLayer.create,
|
||
},
|
||
{
|
||
text: context.activeObject.activeLayer?.visible
|
||
? "Hide Layer"
|
||
: "Show Layer",
|
||
enabled: !!context.activeObject.activeLayer,
|
||
action: () => {
|
||
context.activeObject.activeLayer?.toggleVisibility();
|
||
},
|
||
},
|
||
],
|
||
});
|
||
|
||
newFrameMenuItem = {
|
||
text: "New Frame",
|
||
enabled: !activeFrame,
|
||
action: addFrame,
|
||
};
|
||
newKeyframeMenuItem = {
|
||
text: "New Keyframe",
|
||
enabled: (context.selection && context.selection.length > 0) ||
|
||
(context.shapeselection && context.shapeselection.length > 0),
|
||
accelerator: getShortcut("addKeyframe"),
|
||
action: addKeyframeAtPlayhead,
|
||
};
|
||
newBlankKeyframeMenuItem = {
|
||
text: "New Blank Keyframe",
|
||
// enabled: !activeKeyframe,
|
||
enabled: false,
|
||
accelerator: getShortcut("addBlankKeyframe"),
|
||
action: addKeyframe,
|
||
};
|
||
duplicateKeyframeMenuItem = {
|
||
text: "Duplicate Keyframe",
|
||
enabled: activeKeyframe,
|
||
action: () => {
|
||
context.activeObject.setFrameNum(context.activeObject.currentFrameNum+1)
|
||
addKeyframe()
|
||
},
|
||
};
|
||
deleteFrameMenuItem = {
|
||
text: "Delete Frame",
|
||
enabled: activeFrame,
|
||
action: deleteFrame,
|
||
};
|
||
|
||
const timelineSubmenu = await Submenu.new({
|
||
text: "Timeline",
|
||
items: [
|
||
// newFrameMenuItem,
|
||
newKeyframeMenuItem,
|
||
newBlankKeyframeMenuItem,
|
||
deleteFrameMenuItem,
|
||
duplicateKeyframeMenuItem,
|
||
{
|
||
text: "Add Keyframe at Playhead",
|
||
enabled: (context.selection && context.selection.length > 0) ||
|
||
(context.shapeselection && context.shapeselection.length > 0),
|
||
action: addKeyframeAtPlayhead,
|
||
accelerator: "K",
|
||
},
|
||
{
|
||
text: "Add Motion Tween",
|
||
enabled: activeFrame,
|
||
action: actions.addMotionTween.create,
|
||
},
|
||
{
|
||
text: "Add Shape Tween",
|
||
enabled: activeFrame,
|
||
action: actions.addShapeTween.create,
|
||
},
|
||
{
|
||
text: "Return to start",
|
||
enabled: false,
|
||
action: () => {},
|
||
},
|
||
{
|
||
text: "Play",
|
||
enabled: !context.playing,
|
||
action: playPause,
|
||
accelerator: getShortcut("playAnimation"),
|
||
},
|
||
],
|
||
});
|
||
// Build layout submenu items
|
||
const layoutMenuItems = [
|
||
{
|
||
text: "Next Layout",
|
||
enabled: true,
|
||
action: nextLayout,
|
||
accelerator: getShortcut("nextLayout"),
|
||
},
|
||
{
|
||
text: "Previous Layout",
|
||
enabled: true,
|
||
action: previousLayout,
|
||
accelerator: getShortcut("previousLayout"),
|
||
},
|
||
];
|
||
|
||
// Add separator
|
||
layoutMenuItems.push(await PredefinedMenuItem.new({ item: "Separator" }));
|
||
|
||
// Add individual layouts
|
||
for (const layoutKey of getLayoutNames()) {
|
||
const layout = getLayout(layoutKey);
|
||
const isCurrentLayout = config.currentLayout === layoutKey;
|
||
layoutMenuItems.push({
|
||
text: isCurrentLayout ? `✓ ${layout.name}` : layout.name,
|
||
enabled: true,
|
||
action: () => switchLayout(layoutKey),
|
||
});
|
||
}
|
||
|
||
const layoutSubmenu = await Submenu.new({
|
||
text: "Layout",
|
||
items: layoutMenuItems,
|
||
});
|
||
|
||
const viewSubmenu = await Submenu.new({
|
||
text: "View",
|
||
items: [
|
||
{
|
||
text: "Zoom In",
|
||
enabled: true,
|
||
action: zoomIn,
|
||
accelerator: getShortcut("zoomIn"),
|
||
},
|
||
{
|
||
text: "Zoom Out",
|
||
enabled: true,
|
||
action: zoomOut,
|
||
accelerator: getShortcut("zoomOut"),
|
||
},
|
||
{
|
||
text: "Actual Size",
|
||
enabled: context.zoomLevel != 1,
|
||
action: resetZoom,
|
||
accelerator: getShortcut("resetZoom"),
|
||
},
|
||
{
|
||
text: "Recenter View",
|
||
enabled: true,
|
||
action: recenter,
|
||
// accelerator: getShortcut("recenter"),
|
||
},
|
||
layoutSubmenu,
|
||
],
|
||
});
|
||
const helpSubmenu = await Submenu.new({
|
||
text: "Help",
|
||
items: [
|
||
{
|
||
text: "About...",
|
||
enabled: true,
|
||
action: about,
|
||
},
|
||
],
|
||
});
|
||
|
||
let items = [
|
||
fileSubmenu,
|
||
editSubmenu,
|
||
modifySubmenu,
|
||
layerSubmenu,
|
||
timelineSubmenu,
|
||
viewSubmenu,
|
||
helpSubmenu,
|
||
];
|
||
if (macOS) {
|
||
items.unshift(appSubmenu);
|
||
}
|
||
const menu = await Menu.new({
|
||
items: items,
|
||
});
|
||
console.log('[renderMenu] Before setAsWindowMenu - root.frameRate:', root.frameRate);
|
||
await (macOS ? menu.setAsAppMenu() : menu.setAsWindowMenu());
|
||
console.log('[renderMenu] END - root.frameRate:', root.frameRate);
|
||
}
|
||
updateMenu();
|
||
|
||
// Helper function to get the current track (MIDI or Audio) for node graph editing
|
||
function getCurrentTrack() {
|
||
const activeLayer = context.activeObject?.activeLayer;
|
||
if (!activeLayer || !(activeLayer instanceof AudioTrack)) {
|
||
return null;
|
||
}
|
||
if (activeLayer.audioTrackId === null) {
|
||
return null;
|
||
}
|
||
// Return both track ID and track type
|
||
return {
|
||
trackId: activeLayer.audioTrackId,
|
||
trackType: activeLayer.type // 'midi' or 'audio'
|
||
};
|
||
}
|
||
|
||
// Backwards compatibility: function to get just the MIDI track ID
|
||
function getCurrentMidiTrack() {
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo && trackInfo.trackType === 'midi') {
|
||
return trackInfo.trackId;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function nodeEditor() {
|
||
// Create container for the node editor
|
||
const container = document.createElement("div");
|
||
container.id = "node-editor-container";
|
||
|
||
// Prevent text selection during drag operations
|
||
container.addEventListener('selectstart', (e) => {
|
||
// Allow selection on input elements
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
});
|
||
container.addEventListener('mousedown', (e) => {
|
||
// Don't prevent default on inputs, textareas, or palette items (draggable)
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
||
return;
|
||
}
|
||
// Don't prevent default on palette items or their children
|
||
if (e.target.closest('.node-palette-item') || e.target.closest('.node-category-item')) {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
});
|
||
|
||
// Track editing context: null = main graph, {voiceAllocatorId, voiceAllocatorName} = editing template
|
||
let editingContext = null;
|
||
|
||
// Track palette navigation: null = showing categories, string = showing nodes in that category
|
||
let selectedCategory = null;
|
||
|
||
// Create breadcrumb/context header
|
||
const header = document.createElement("div");
|
||
header.className = "node-editor-header";
|
||
// Initial header will be updated by updateBreadcrumb() after track info is available
|
||
header.innerHTML = `
|
||
<div class="context-breadcrumb">Node Graph</div>
|
||
<button class="node-graph-clear-btn" title="Clear all nodes">Clear</button>
|
||
`;
|
||
container.appendChild(header);
|
||
|
||
// Add clear button handler
|
||
const clearBtn = header.querySelector('.node-graph-clear-btn');
|
||
clearBtn.addEventListener('click', async () => {
|
||
try {
|
||
// Get current track
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo === null) {
|
||
console.error('No track selected');
|
||
alert('Please select a track first');
|
||
return;
|
||
}
|
||
const trackId = trackInfo.trackId;
|
||
|
||
// Get the full backend graph state as JSON
|
||
const graphStateJson = await invoke('graph_get_state', { trackId });
|
||
const graphState = JSON.parse(graphStateJson);
|
||
|
||
if (!graphState.nodes || graphState.nodes.length === 0) {
|
||
return; // Nothing to clear
|
||
}
|
||
|
||
// Create and execute the action
|
||
redoStack.length = 0; // Clear redo stack
|
||
const action = {
|
||
trackId,
|
||
savedGraphJson: graphStateJson // Save the entire graph state as JSON
|
||
};
|
||
undoStack.push({ name: 'clearNodeGraph', action });
|
||
await actions.clearNodeGraph.execute(action);
|
||
updateMenu();
|
||
|
||
console.log('Cleared node graph (undoable)');
|
||
} catch (e) {
|
||
console.error('Failed to clear node graph:', e);
|
||
alert('Failed to clear node graph: ' + e);
|
||
}
|
||
});
|
||
|
||
// Create the Drawflow canvas
|
||
const editorDiv = document.createElement("div");
|
||
editorDiv.id = "drawflow";
|
||
editorDiv.style.position = "absolute";
|
||
editorDiv.style.top = "40px"; // Start below header
|
||
editorDiv.style.left = "0";
|
||
editorDiv.style.right = "0";
|
||
editorDiv.style.bottom = "0";
|
||
container.appendChild(editorDiv);
|
||
|
||
// Create node palette
|
||
const palette = document.createElement("div");
|
||
palette.className = "node-palette";
|
||
container.appendChild(palette);
|
||
|
||
// Create persistent search input
|
||
const paletteSearch = document.createElement("div");
|
||
paletteSearch.className = "palette-search";
|
||
paletteSearch.innerHTML = `
|
||
<input type="text" placeholder="Search nodes..." class="palette-search-input" value="">
|
||
<button class="palette-search-clear" style="display: none;">×</button>
|
||
`;
|
||
palette.appendChild(paletteSearch);
|
||
|
||
// Create content container that will be updated
|
||
const paletteContent = document.createElement("div");
|
||
paletteContent.className = "palette-content";
|
||
palette.appendChild(paletteContent);
|
||
|
||
// Get references to search elements
|
||
const searchInput = paletteSearch.querySelector(".palette-search-input");
|
||
const searchClearBtn = paletteSearch.querySelector(".palette-search-clear");
|
||
|
||
// Create minimap
|
||
const minimap = document.createElement("div");
|
||
minimap.className = "node-minimap";
|
||
minimap.style.display = 'none'; // Hidden by default
|
||
minimap.innerHTML = `
|
||
<canvas id="minimap-canvas"></canvas>
|
||
<div class="minimap-viewport"></div>
|
||
`;
|
||
container.appendChild(minimap);
|
||
|
||
// Category display names
|
||
const categoryNames = {
|
||
[NodeCategory.INPUT]: 'Inputs',
|
||
[NodeCategory.GENERATOR]: 'Generators',
|
||
[NodeCategory.EFFECT]: 'Effects',
|
||
[NodeCategory.UTILITY]: 'Utilities',
|
||
[NodeCategory.OUTPUT]: 'Outputs'
|
||
};
|
||
|
||
// Search state
|
||
let searchQuery = '';
|
||
|
||
// Handle search input changes
|
||
searchInput.addEventListener('input', (e) => {
|
||
searchQuery = e.target.value;
|
||
searchClearBtn.style.display = searchQuery ? 'flex' : 'none';
|
||
updatePalette();
|
||
});
|
||
|
||
// Handle search clear
|
||
searchClearBtn.addEventListener('click', () => {
|
||
searchQuery = '';
|
||
searchInput.value = '';
|
||
searchClearBtn.style.display = 'none';
|
||
searchInput.focus();
|
||
updatePalette();
|
||
});
|
||
|
||
// Function to update palette based on context and selected category
|
||
function updatePalette() {
|
||
const isTemplate = editingContext !== null;
|
||
const trackInfo = getCurrentTrack();
|
||
const isMIDI = trackInfo?.trackType === 'midi';
|
||
const isAudio = trackInfo?.trackType === 'audio';
|
||
|
||
if (selectedCategory === null && !searchQuery) {
|
||
// Show categories when no search query
|
||
const categories = getCategories().filter(category => {
|
||
// Filter categories based on context
|
||
if (isTemplate) {
|
||
// In template: show all categories
|
||
return true;
|
||
} else {
|
||
// In main graph: hide INPUT/OUTPUT categories that contain template nodes
|
||
return true; // We'll filter nodes instead
|
||
}
|
||
});
|
||
|
||
paletteContent.innerHTML = `
|
||
<h3>Node Categories</h3>
|
||
${categories.map(category => `
|
||
<div class="node-category-item" data-category="${category}">
|
||
${categoryNames[category] || category}
|
||
</div>
|
||
`).join('')}
|
||
`;
|
||
} else if (selectedCategory === null && searchQuery) {
|
||
// Show all matching nodes across all categories when searching from main panel
|
||
const allCategories = getCategories();
|
||
let allNodes = [];
|
||
|
||
allCategories.forEach(category => {
|
||
const nodesInCategory = getNodesByCategory(category);
|
||
allNodes = allNodes.concat(nodesInCategory);
|
||
});
|
||
|
||
// Filter based on context
|
||
let filteredNodes = allNodes.filter(node => {
|
||
if (isTemplate) {
|
||
// In template: hide VoiceAllocator, AudioOutput, MidiInput
|
||
return node.type !== 'VoiceAllocator' && node.type !== 'AudioOutput' && node.type !== 'MidiInput';
|
||
} else if (isMIDI) {
|
||
// MIDI track: hide AudioInput, show synth nodes
|
||
return node.type !== 'TemplateInput' && node.type !== 'TemplateOutput' && node.type !== 'AudioInput';
|
||
} else if (isAudio) {
|
||
// Audio track: hide synth/MIDI nodes, show AudioInput
|
||
const synthNodes = ['Oscillator', 'FMSynth', 'WavetableOscillator', 'SimpleSampler', 'MultiSampler', 'VoiceAllocator', 'MidiInput', 'MidiToCV'];
|
||
return node.type !== 'TemplateInput' && node.type !== 'TemplateOutput' && !synthNodes.includes(node.type);
|
||
} else {
|
||
// Fallback: hide TemplateInput/TemplateOutput
|
||
return node.type !== 'TemplateInput' && node.type !== 'TemplateOutput';
|
||
}
|
||
});
|
||
|
||
// Apply search filter
|
||
const query = searchQuery.toLowerCase();
|
||
filteredNodes = filteredNodes.filter(node => {
|
||
return node.name.toLowerCase().includes(query) ||
|
||
node.description.toLowerCase().includes(query);
|
||
});
|
||
|
||
// Function to highlight search matches in text
|
||
const highlightMatch = (text) => {
|
||
const regex = new RegExp(`(${searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||
return text.replace(regex, '<mark>$1</mark>');
|
||
};
|
||
|
||
paletteContent.innerHTML = `
|
||
<h3>Search Results</h3>
|
||
${filteredNodes.length > 0 ? filteredNodes.map(node => `
|
||
<div class="node-palette-item" data-node-type="${node.type}" draggable="true" title="${node.description}">
|
||
${highlightMatch(node.name)}
|
||
</div>
|
||
`).join('') : '<div class="no-results">No matching nodes found</div>'}
|
||
`;
|
||
} else {
|
||
// Show nodes in selected category
|
||
const nodesInCategory = getNodesByCategory(selectedCategory);
|
||
|
||
// Filter based on context
|
||
let filteredNodes = nodesInCategory.filter(node => {
|
||
if (isTemplate) {
|
||
// In template: hide VoiceAllocator, AudioOutput, MidiInput
|
||
return node.type !== 'VoiceAllocator' && node.type !== 'AudioOutput' && node.type !== 'MidiInput';
|
||
} else if (isMIDI) {
|
||
// MIDI track: hide AudioInput, show synth nodes
|
||
return node.type !== 'TemplateInput' && node.type !== 'TemplateOutput' && node.type !== 'AudioInput';
|
||
} else if (isAudio) {
|
||
// Audio track: hide synth/MIDI nodes, show AudioInput
|
||
const synthNodes = ['Oscillator', 'FMSynth', 'WavetableOscillator', 'SimpleSampler', 'MultiSampler', 'VoiceAllocator', 'MidiInput', 'MidiToCV'];
|
||
return node.type !== 'TemplateInput' && node.type !== 'TemplateOutput' && !synthNodes.includes(node.type);
|
||
} else {
|
||
// Fallback: hide TemplateInput/TemplateOutput
|
||
return node.type !== 'TemplateInput' && node.type !== 'TemplateOutput';
|
||
}
|
||
});
|
||
|
||
// Apply search filter
|
||
if (searchQuery) {
|
||
const query = searchQuery.toLowerCase();
|
||
filteredNodes = filteredNodes.filter(node => {
|
||
return node.name.toLowerCase().includes(query) ||
|
||
node.description.toLowerCase().includes(query);
|
||
});
|
||
}
|
||
|
||
// Function to highlight search matches in text
|
||
const highlightMatch = (text) => {
|
||
if (!searchQuery) return text;
|
||
const regex = new RegExp(`(${searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||
return text.replace(regex, '<mark>$1</mark>');
|
||
};
|
||
|
||
paletteContent.innerHTML = `
|
||
<div class="palette-header">
|
||
<button class="palette-back-btn">← Back</button>
|
||
<h3>${categoryNames[selectedCategory] || selectedCategory}</h3>
|
||
</div>
|
||
${filteredNodes.length > 0 ? filteredNodes.map(node => `
|
||
<div class="node-palette-item" data-node-type="${node.type}" draggable="true" title="${node.description}">
|
||
${highlightMatch(node.name)}
|
||
</div>
|
||
`).join('') : '<div class="no-results">No matching nodes found</div>'}
|
||
`;
|
||
}
|
||
}
|
||
|
||
updatePalette();
|
||
|
||
// Initialize Drawflow editor (will be set up after DOM insertion)
|
||
let editor = null;
|
||
let nodeIdCounter = 1;
|
||
|
||
// Track expanded VoiceAllocator nodes
|
||
const expandedNodes = new Set(); // Set of node IDs that are expanded
|
||
const nodeParents = new Map(); // Map of child node ID -> parent VoiceAllocator ID
|
||
|
||
// Cache node data for undo/redo (nodeId -> {nodeType, backendId, position, parameters})
|
||
const nodeDataCache = new Map();
|
||
|
||
// Track node movement for undo/redo (nodeId -> {oldX, oldY})
|
||
const nodeMoveTracker = new Map();
|
||
|
||
// Flag to prevent recording actions during undo/redo operations
|
||
let suppressActionRecording = false;
|
||
|
||
// Wait for DOM insertion
|
||
setTimeout(() => {
|
||
const drawflowDiv = container.querySelector("#drawflow");
|
||
if (!drawflowDiv) return;
|
||
|
||
editor = new Drawflow(drawflowDiv);
|
||
editor.reroute = true;
|
||
editor.reroute_fix_curvature = true;
|
||
editor.force_first_input = false;
|
||
editor.start();
|
||
|
||
// Store editor reference in context
|
||
context.nodeEditor = editor;
|
||
context.reloadNodeEditor = reloadGraph;
|
||
context.nodeEditorState = {
|
||
get suppressActionRecording() { return suppressActionRecording; },
|
||
set suppressActionRecording(value) { suppressActionRecording = value; }
|
||
};
|
||
|
||
// Initialize BPM change notification system
|
||
// This allows nodes to register callbacks to be notified when BPM changes
|
||
const bpmChangeListeners = new Set();
|
||
|
||
context.registerBpmChangeListener = (callback) => {
|
||
bpmChangeListeners.add(callback);
|
||
return () => bpmChangeListeners.delete(callback); // Return unregister function
|
||
};
|
||
|
||
context.notifyBpmChange = (newBpm) => {
|
||
console.log(`BPM changed to ${newBpm}, notifying ${bpmChangeListeners.size} listeners`);
|
||
bpmChangeListeners.forEach(callback => {
|
||
try {
|
||
callback(newBpm);
|
||
} catch (error) {
|
||
console.error('Error in BPM change listener:', error);
|
||
}
|
||
});
|
||
};
|
||
|
||
// Register a listener to update all synced Phaser nodes when BPM changes
|
||
context.registerBpmChangeListener((newBpm) => {
|
||
if (!editor) return;
|
||
|
||
const module = editor.module;
|
||
const allNodes = editor.drawflow.drawflow[module]?.data || {};
|
||
|
||
// Beat division definitions for conversion
|
||
const beatDivisions = [
|
||
{ label: '4 bars', multiplier: 16.0 },
|
||
{ label: '2 bars', multiplier: 8.0 },
|
||
{ label: '1 bar', multiplier: 4.0 },
|
||
{ label: '1/2', multiplier: 2.0 },
|
||
{ label: '1/4', multiplier: 1.0 },
|
||
{ label: '1/8', multiplier: 0.5 },
|
||
{ label: '1/16', multiplier: 0.25 },
|
||
{ label: '1/32', multiplier: 0.125 },
|
||
{ label: '1/2T', multiplier: 2.0/3.0 },
|
||
{ label: '1/4T', multiplier: 1.0/3.0 },
|
||
{ label: '1/8T', multiplier: 0.5/3.0 }
|
||
];
|
||
|
||
// Iterate through all nodes to find synced Phaser nodes
|
||
for (const [nodeId, nodeData] of Object.entries(allNodes)) {
|
||
// Check if this is a Phaser node with sync enabled
|
||
if (nodeData.name === 'Phaser' && nodeData.data.backendId !== null) {
|
||
const nodeElement = document.getElementById(`node-${nodeId}`);
|
||
if (!nodeElement) continue;
|
||
|
||
const syncCheckbox = nodeElement.querySelector(`#sync-${nodeId}`);
|
||
if (!syncCheckbox || !syncCheckbox.checked) continue;
|
||
|
||
// Get the current rate slider value (beat division index)
|
||
const rateSlider = nodeElement.querySelector(`input[data-param="0"]`); // rate is param 0
|
||
if (!rateSlider) continue;
|
||
|
||
const beatDivisionIndex = Math.min(10, Math.max(0, Math.round(parseFloat(rateSlider.value))));
|
||
const beatsPerSecond = newBpm / 60.0;
|
||
const quarterNotesPerCycle = beatDivisions[beatDivisionIndex].multiplier;
|
||
const hz = beatsPerSecond / quarterNotesPerCycle;
|
||
|
||
// Update the backend parameter
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo !== null) {
|
||
invoke("graph_set_parameter", {
|
||
trackId: trackInfo.trackId,
|
||
nodeId: nodeData.data.backendId,
|
||
paramId: 0, // rate parameter
|
||
value: hz
|
||
}).catch(err => {
|
||
console.error("Failed to update Phaser rate after BPM change:", err);
|
||
});
|
||
console.log(`Updated Phaser node ${nodeId} rate to ${hz} Hz for BPM ${newBpm}`);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Initialize minimap
|
||
const minimapCanvas = container.querySelector("#minimap-canvas");
|
||
const minimapViewport = container.querySelector(".minimap-viewport");
|
||
const minimapCtx = minimapCanvas.getContext('2d');
|
||
|
||
// Set canvas size to match container
|
||
minimapCanvas.width = 200;
|
||
minimapCanvas.height = 150;
|
||
|
||
function updateMinimap() {
|
||
if (!editor) return;
|
||
|
||
const ctx = minimapCtx;
|
||
const canvas = minimapCanvas;
|
||
|
||
// Clear canvas
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// Get all nodes
|
||
const module = editor.module;
|
||
const nodes = editor.drawflow.drawflow[module]?.data || {};
|
||
const nodeList = Object.values(nodes);
|
||
|
||
if (nodeList.length === 0) {
|
||
minimap.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
// Calculate bounding box of all nodes
|
||
let minX = Infinity, minY = Infinity;
|
||
let maxX = -Infinity, maxY = -Infinity;
|
||
|
||
nodeList.forEach(node => {
|
||
minX = Math.min(minX, node.pos_x);
|
||
minY = Math.min(minY, node.pos_y);
|
||
maxX = Math.max(maxX, node.pos_x + 160); // Approximate node width
|
||
maxY = Math.max(maxY, node.pos_y + 100); // Approximate node height
|
||
});
|
||
|
||
// Add padding
|
||
const padding = 20;
|
||
minX -= padding;
|
||
minY -= padding;
|
||
maxX += padding;
|
||
maxY += padding;
|
||
|
||
// Calculate graph dimensions
|
||
const graphWidth = maxX - minX;
|
||
const graphHeight = maxY - minY;
|
||
|
||
// Check if graph fits in viewport
|
||
const zoom = editor.zoom || 1;
|
||
const drawflowRect = drawflowDiv.getBoundingClientRect();
|
||
const viewportWidth = drawflowRect.width / zoom;
|
||
const viewportHeight = drawflowRect.height / zoom;
|
||
|
||
// Only show minimap if graph is larger than viewport
|
||
if (graphWidth <= viewportWidth && graphHeight <= viewportHeight) {
|
||
minimap.style.display = 'none';
|
||
return;
|
||
} else {
|
||
minimap.style.display = 'block';
|
||
}
|
||
|
||
// Calculate scale to fit in minimap
|
||
const scale = Math.min(canvas.width / graphWidth, canvas.height / graphHeight);
|
||
|
||
// Draw nodes
|
||
ctx.fillStyle = '#666';
|
||
nodeList.forEach(node => {
|
||
const x = (node.pos_x - minX) * scale;
|
||
const y = (node.pos_y - minY) * scale;
|
||
const width = 160 * scale;
|
||
const height = 100 * scale;
|
||
|
||
ctx.fillRect(x, y, width, height);
|
||
});
|
||
|
||
// Update viewport indicator
|
||
const canvasX = editor.canvas_x || 0;
|
||
const canvasY = editor.canvas_y || 0;
|
||
|
||
const viewportX = (-canvasX / zoom - minX) * scale;
|
||
const viewportY = (-canvasY / zoom - minY) * scale;
|
||
const viewportIndicatorWidth = (drawflowRect.width / zoom) * scale;
|
||
const viewportIndicatorHeight = (drawflowRect.height / zoom) * scale;
|
||
|
||
minimapViewport.style.left = Math.max(0, viewportX) + 'px';
|
||
minimapViewport.style.top = Math.max(0, viewportY) + 'px';
|
||
minimapViewport.style.width = viewportIndicatorWidth + 'px';
|
||
minimapViewport.style.height = viewportIndicatorHeight + 'px';
|
||
|
||
// Store scale info for click navigation
|
||
minimapCanvas.dataset.scale = scale;
|
||
minimapCanvas.dataset.minX = minX;
|
||
minimapCanvas.dataset.minY = minY;
|
||
}
|
||
|
||
// Update minimap on various events
|
||
editor.on('nodeCreated', () => setTimeout(updateMinimap, 100));
|
||
editor.on('nodeRemoved', () => setTimeout(updateMinimap, 100));
|
||
editor.on('nodeMoved', () => updateMinimap());
|
||
|
||
// Update minimap on pan/zoom
|
||
drawflowDiv.addEventListener('wheel', () => setTimeout(updateMinimap, 10));
|
||
|
||
// Store updateMinimap in context so it can be called from actions
|
||
context.updateMinimap = updateMinimap;
|
||
|
||
// Initial minimap render
|
||
setTimeout(updateMinimap, 200);
|
||
|
||
// Click-to-navigate on minimap
|
||
minimapCanvas.addEventListener('mousedown', (e) => {
|
||
const rect = minimapCanvas.getBoundingClientRect();
|
||
const clickX = e.clientX - rect.left;
|
||
const clickY = e.clientY - rect.top;
|
||
|
||
const scale = parseFloat(minimapCanvas.dataset.scale || 1);
|
||
const minX = parseFloat(minimapCanvas.dataset.minX || 0);
|
||
const minY = parseFloat(minimapCanvas.dataset.minY || 0);
|
||
|
||
// Convert click position to graph coordinates
|
||
const graphX = (clickX / scale) + minX;
|
||
const graphY = (clickY / scale) + minY;
|
||
|
||
// Center the viewport on the clicked position
|
||
const zoom = editor.zoom || 1;
|
||
const drawflowRect = drawflowDiv.getBoundingClientRect();
|
||
const viewportCenterX = drawflowRect.width / (2 * zoom);
|
||
const viewportCenterY = drawflowRect.height / (2 * zoom);
|
||
|
||
editor.canvas_x = -(graphX - viewportCenterX) * zoom;
|
||
editor.canvas_y = -(graphY - viewportCenterY) * zoom;
|
||
|
||
// Update the canvas transform
|
||
const precanvas = drawflowDiv.querySelector('.drawflow');
|
||
if (precanvas) {
|
||
precanvas.style.transform = `translate(${editor.canvas_x}px, ${editor.canvas_y}px) scale(${zoom})`;
|
||
}
|
||
|
||
updateMinimap();
|
||
});
|
||
|
||
// Add reconnection support: dragging from a connected input disconnects and starts new connection
|
||
drawflowDiv.addEventListener('mousedown', (e) => {
|
||
// Check if clicking on an input port
|
||
const inputPort = e.target.closest('.input');
|
||
|
||
if (inputPort) {
|
||
// Get the node and port information - the drawflow-node div has the id
|
||
const drawflowNode = inputPort.closest('.drawflow-node');
|
||
if (!drawflowNode) return;
|
||
|
||
const nodeId = parseInt(drawflowNode.id.replace('node-', ''));
|
||
|
||
// Access the node data directly from the current module
|
||
const moduleName = editor.module;
|
||
const node = editor.drawflow.drawflow[moduleName]?.data[nodeId];
|
||
if (!node) return;
|
||
|
||
// Get the port class (input_1, input_2, etc.)
|
||
const portClasses = Array.from(inputPort.classList);
|
||
const portClass = portClasses.find(c => c.startsWith('input_'));
|
||
if (!portClass) return;
|
||
|
||
// Check if this input has any connections
|
||
const inputConnections = node.inputs[portClass];
|
||
if (inputConnections && inputConnections.connections && inputConnections.connections.length > 0) {
|
||
// Get the first connection (inputs should only have one connection)
|
||
const connection = inputConnections.connections[0];
|
||
|
||
// Prevent default to avoid interfering with the drag
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
|
||
// Remove the connection
|
||
editor.removeSingleConnection(
|
||
connection.node,
|
||
nodeId,
|
||
connection.input,
|
||
portClass
|
||
);
|
||
|
||
// Now trigger Drawflow's connection drag from the output that was connected
|
||
// We need to simulate starting a drag from the output port
|
||
const outputNodeElement = document.getElementById(`node-${connection.node}`);
|
||
if (outputNodeElement) {
|
||
const outputPort = outputNodeElement.querySelector(`.${connection.input}`);
|
||
if (outputPort) {
|
||
// Dispatch a synthetic mousedown event on the output port
|
||
// This will trigger Drawflow's normal connection start logic
|
||
setTimeout(() => {
|
||
const rect = outputPort.getBoundingClientRect();
|
||
const syntheticEvent = new MouseEvent('mousedown', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
view: window,
|
||
clientX: rect.left + rect.width / 2,
|
||
clientY: rect.top + rect.height / 2,
|
||
button: 0
|
||
});
|
||
outputPort.dispatchEvent(syntheticEvent);
|
||
|
||
// Then immediately dispatch a mousemove to the original cursor position
|
||
// to start dragging the connection line
|
||
setTimeout(() => {
|
||
const mousemoveEvent = new MouseEvent('mousemove', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
view: window,
|
||
clientX: e.clientX,
|
||
clientY: e.clientY,
|
||
button: 0
|
||
});
|
||
document.dispatchEvent(mousemoveEvent);
|
||
}, 0);
|
||
}, 0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}, true); // Use capture phase to intercept before Drawflow
|
||
|
||
// Add trackpad/mousewheel scrolling support for panning
|
||
drawflowDiv.addEventListener('wheel', (e) => {
|
||
// Don't scroll if hovering over palette or other UI elements
|
||
if (e.target.closest('.node-palette')) {
|
||
return;
|
||
}
|
||
|
||
// Don't interfere with zoom (Ctrl+wheel)
|
||
if (e.ctrlKey) return;
|
||
|
||
// Prevent default scrolling behavior
|
||
e.preventDefault();
|
||
|
||
// Pan the canvas based on scroll direction
|
||
const deltaX = e.deltaX;
|
||
const deltaY = e.deltaY;
|
||
|
||
// Update Drawflow's canvas position
|
||
if (typeof editor.canvas_x === 'undefined') {
|
||
editor.canvas_x = 0;
|
||
}
|
||
if (typeof editor.canvas_y === 'undefined') {
|
||
editor.canvas_y = 0;
|
||
}
|
||
|
||
editor.canvas_x -= deltaX;
|
||
editor.canvas_y -= deltaY;
|
||
|
||
// Update the canvas transform
|
||
const precanvas = drawflowDiv.querySelector('.drawflow');
|
||
if (precanvas) {
|
||
const zoom = editor.zoom || 1;
|
||
precanvas.style.transform = `translate(${editor.canvas_x}px, ${editor.canvas_y}px) scale(${zoom})`;
|
||
}
|
||
}, { passive: false });
|
||
|
||
// Add palette item drag-and-drop handlers using event delegation
|
||
let draggedNodeType = null;
|
||
|
||
// Use event delegation for click on palette items, categories, and back button
|
||
palette.addEventListener("click", (e) => {
|
||
// Handle back button
|
||
const backBtn = e.target.closest(".palette-back-btn");
|
||
if (backBtn) {
|
||
selectedCategory = null;
|
||
updatePalette();
|
||
return;
|
||
}
|
||
|
||
// Handle category selection
|
||
const categoryItem = e.target.closest(".node-category-item");
|
||
if (categoryItem) {
|
||
selectedCategory = categoryItem.getAttribute("data-category");
|
||
updatePalette();
|
||
return;
|
||
}
|
||
|
||
// Handle node selection
|
||
const item = e.target.closest(".node-palette-item");
|
||
if (item) {
|
||
const nodeType = item.getAttribute("data-node-type");
|
||
|
||
// Calculate center of visible canvas viewport
|
||
const rect = drawflowDiv.getBoundingClientRect();
|
||
const canvasX = editor.canvas_x || 0;
|
||
const canvasY = editor.canvas_y || 0;
|
||
const zoom = editor.zoom || 1;
|
||
|
||
// Approximate node dimensions (nodes have min-width: 160px, typical height ~150px)
|
||
const nodeWidth = 160;
|
||
const nodeHeight = 150;
|
||
|
||
// Center position in world coordinates, offset by half node size
|
||
const centerX = (rect.width / 2 - canvasX) / zoom - nodeWidth / 2;
|
||
const centerY = (rect.height / 2 - canvasY) / zoom - nodeHeight / 2;
|
||
|
||
addNode(nodeType, centerX, centerY, null);
|
||
}
|
||
});
|
||
|
||
// Use event delegation for drag events
|
||
palette.addEventListener('dragstart', (e) => {
|
||
const item = e.target.closest(".node-palette-item");
|
||
if (item) {
|
||
draggedNodeType = item.getAttribute('data-node-type');
|
||
e.dataTransfer.effectAllowed = 'copy';
|
||
e.dataTransfer.setData('text/plain', draggedNodeType);
|
||
console.log('Drag started:', draggedNodeType);
|
||
}
|
||
});
|
||
|
||
palette.addEventListener('dragend', (e) => {
|
||
const item = e.target.closest(".node-palette-item");
|
||
if (item) {
|
||
console.log('Drag ended');
|
||
draggedNodeType = null;
|
||
}
|
||
});
|
||
|
||
// Add drop handler to drawflow canvas
|
||
drawflowDiv.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'copy';
|
||
|
||
// Check if dragging over a connection for insertion
|
||
const nodeType = e.dataTransfer.getData('text/plain') || draggedNodeType;
|
||
if (nodeType) {
|
||
const nodeDef = nodeTypes[nodeType];
|
||
if (nodeDef) {
|
||
checkConnectionInsertionDuringDrag(e, nodeDef);
|
||
}
|
||
}
|
||
});
|
||
|
||
drawflowDiv.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
|
||
// Get node type from dataTransfer instead of global variable
|
||
const nodeType = e.dataTransfer.getData('text/plain');
|
||
console.log('Drop event fired, nodeType:', nodeType);
|
||
|
||
if (!nodeType) {
|
||
console.log('No nodeType in drop data, aborting');
|
||
return;
|
||
}
|
||
|
||
// Get drop position relative to the editor
|
||
const rect = drawflowDiv.getBoundingClientRect();
|
||
|
||
// Use canvas_x and canvas_y which are set by the wheel scroll handler
|
||
const canvasX = editor.canvas_x || 0;
|
||
const canvasY = editor.canvas_y || 0;
|
||
const zoom = editor.zoom || 1;
|
||
|
||
// Approximate node dimensions (nodes have min-width: 160px, typical height ~150px)
|
||
const nodeWidth = 160;
|
||
const nodeHeight = 150;
|
||
|
||
// Calculate position accounting for canvas pan offset, centered on cursor
|
||
const x = (e.clientX - rect.left - canvasX) / zoom - nodeWidth / 2;
|
||
const y = (e.clientY - rect.top - canvasY) / zoom - nodeHeight / 2;
|
||
|
||
console.log('Position calculation:', JSON.stringify({
|
||
clientX: e.clientX,
|
||
clientY: e.clientY,
|
||
rectLeft: rect.left,
|
||
rectTop: rect.top,
|
||
canvasX,
|
||
canvasY,
|
||
zoom,
|
||
x,
|
||
y
|
||
}));
|
||
|
||
// Check if dropping into an expanded VoiceAllocator
|
||
let parentNodeId = null;
|
||
for (const expandedNodeId of expandedNodes) {
|
||
const contentsArea = document.getElementById(`voice-allocator-contents-${expandedNodeId}`);
|
||
if (contentsArea) {
|
||
const contentsRect = contentsArea.getBoundingClientRect();
|
||
if (e.clientX >= contentsRect.left && e.clientX <= contentsRect.right &&
|
||
e.clientY >= contentsRect.top && e.clientY <= contentsRect.bottom) {
|
||
parentNodeId = expandedNodeId;
|
||
console.log(`Dropping into VoiceAllocator ${expandedNodeId} at position (${x}, ${y})`);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add the node
|
||
console.log(`Adding node ${nodeType} at (${x}, ${y}) with parent ${parentNodeId}`);
|
||
const newNodeId = addNode(nodeType, x, y, parentNodeId);
|
||
|
||
// Check if we should insert into a connection
|
||
if (pendingInsertionFromDrag && newNodeId) {
|
||
console.log('Pending insertion detected, will insert node into connection');
|
||
// Defer insertion until after node is fully created
|
||
setTimeout(() => {
|
||
performConnectionInsertion(newNodeId, pendingInsertionFromDrag);
|
||
pendingInsertionFromDrag = null;
|
||
}, 100);
|
||
}
|
||
|
||
// Clear the draggedNodeType and highlights
|
||
draggedNodeType = null;
|
||
clearConnectionHighlights();
|
||
});
|
||
|
||
// Connection event handlers
|
||
editor.on("connectionCreated", (connection) => {
|
||
handleConnectionCreated(connection);
|
||
});
|
||
|
||
editor.on("connectionRemoved", (connection) => {
|
||
handleConnectionRemoved(connection);
|
||
});
|
||
|
||
// Node events
|
||
editor.on("nodeCreated", (nodeId) => {
|
||
setupNodeParameters(nodeId);
|
||
|
||
// Add double-click handler for VoiceAllocator expansion
|
||
setTimeout(() => {
|
||
const nodeElement = document.getElementById(`node-${nodeId}`);
|
||
if (nodeElement) {
|
||
nodeElement.addEventListener('dblclick', (e) => {
|
||
// Prevent double-click from bubbling to canvas
|
||
e.stopPropagation();
|
||
handleNodeDoubleClick(nodeId);
|
||
});
|
||
}
|
||
}, 50);
|
||
});
|
||
|
||
// Track which node is being dragged
|
||
let draggingNodeId = null;
|
||
|
||
// Track node drag start for undo/redo and connection insertion
|
||
drawflowDiv.addEventListener('mousedown', (e) => {
|
||
const nodeElement = e.target.closest('.drawflow-node');
|
||
if (nodeElement && !e.target.closest('.input') && !e.target.closest('.output')) {
|
||
const nodeId = parseInt(nodeElement.id.replace('node-', ''));
|
||
const node = editor.getNodeFromId(nodeId);
|
||
if (node) {
|
||
nodeMoveTracker.set(nodeId, { x: node.pos_x, y: node.pos_y });
|
||
draggingNodeId = nodeId;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Check for connection insertion while dragging existing nodes
|
||
drawflowDiv.addEventListener('mousemove', (e) => {
|
||
if (draggingNodeId !== null) {
|
||
checkConnectionInsertion(draggingNodeId);
|
||
}
|
||
});
|
||
|
||
// Node moved - resize parent VoiceAllocator and check for connection insertion
|
||
editor.on("nodeMoved", (nodeId) => {
|
||
const node = editor.getNodeFromId(nodeId);
|
||
if (node && node.data.parentNodeId) {
|
||
resizeVoiceAllocatorToFit(node.data.parentNodeId);
|
||
}
|
||
|
||
// Check if node should be inserted into a connection
|
||
checkConnectionInsertion(nodeId);
|
||
});
|
||
|
||
// Track node drag end for undo/redo and handle connection insertion
|
||
drawflowDiv.addEventListener('mouseup', (e) => {
|
||
// Check all tracked nodes for position changes and pending insertions
|
||
for (const [nodeId, oldPos] of nodeMoveTracker.entries()) {
|
||
const node = editor.getNodeFromId(nodeId);
|
||
const hasPendingInsertion = pendingNodeInsertions.has(nodeId);
|
||
|
||
if (node) {
|
||
// Check for pending insertion first
|
||
if (hasPendingInsertion) {
|
||
const insertionMatch = pendingNodeInsertions.get(nodeId);
|
||
performConnectionInsertion(nodeId, insertionMatch);
|
||
pendingNodeInsertions.delete(nodeId);
|
||
} else if (node.pos_x !== oldPos.x || node.pos_y !== oldPos.y) {
|
||
// Position changed - record action
|
||
redoStack.length = 0;
|
||
undoStack.push({
|
||
name: "graphMoveNode",
|
||
action: {
|
||
nodeId: nodeId,
|
||
oldPosition: oldPos,
|
||
newPosition: { x: node.pos_x, y: node.pos_y }
|
||
}
|
||
});
|
||
updateMenu();
|
||
}
|
||
}
|
||
}
|
||
// Clear tracker, dragging state, and highlights
|
||
nodeMoveTracker.clear();
|
||
draggingNodeId = null;
|
||
clearConnectionHighlights();
|
||
});
|
||
|
||
// Node removed - prevent deletion of template nodes
|
||
editor.on("nodeRemoved", (nodeId) => {
|
||
const nodeElement = document.getElementById(`node-${nodeId}`);
|
||
if (nodeElement && nodeElement.getAttribute('data-template-node') === 'true') {
|
||
console.warn('Cannot delete template nodes');
|
||
// TODO: Re-add the node if it was deleted
|
||
return;
|
||
}
|
||
|
||
// Get cached node data before removal
|
||
const cachedData = nodeDataCache.get(nodeId);
|
||
|
||
if (cachedData && cachedData.backendId) {
|
||
// Call backend to remove the node
|
||
invoke('graph_remove_node', {
|
||
trackId: cachedData.trackId,
|
||
nodeId: cachedData.backendId
|
||
}).catch(err => {
|
||
console.error("Failed to remove node from backend:", err);
|
||
});
|
||
|
||
// Record action for undo (don't call execute since node is already removed from frontend)
|
||
redoStack.length = 0;
|
||
undoStack.push({
|
||
name: "graphRemoveNode",
|
||
action: {
|
||
trackId: cachedData.trackId,
|
||
nodeId: nodeId,
|
||
backendId: cachedData.backendId,
|
||
nodeData: cachedData
|
||
}
|
||
});
|
||
updateMenu();
|
||
}
|
||
|
||
// Stop oscilloscope visualization if this was an Oscilloscope node
|
||
stopOscilloscopeVisualization(nodeId);
|
||
|
||
// Clean up parent-child tracking
|
||
const parentId = nodeParents.get(nodeId);
|
||
nodeParents.delete(nodeId);
|
||
|
||
// Clean up node data cache
|
||
nodeDataCache.delete(nodeId);
|
||
|
||
// Resize parent if needed
|
||
if (parentId) {
|
||
resizeVoiceAllocatorToFit(parentId);
|
||
}
|
||
});
|
||
|
||
}, 100);
|
||
|
||
// Add a node to the graph
|
||
function addNode(nodeType, x, y, parentNodeId = null) {
|
||
if (!editor) return;
|
||
|
||
const nodeDef = nodeTypes[nodeType];
|
||
if (!nodeDef) return;
|
||
|
||
const nodeId = nodeIdCounter++;
|
||
const html = nodeDef.getHTML(nodeId);
|
||
|
||
// Count inputs and outputs by type
|
||
const inputsCount = nodeDef.inputs.length;
|
||
const outputsCount = nodeDef.outputs.length;
|
||
|
||
// Add node to Drawflow
|
||
const drawflowNodeId = editor.addNode(
|
||
nodeType,
|
||
inputsCount,
|
||
outputsCount,
|
||
x,
|
||
y,
|
||
`node-${nodeType.toLowerCase()}`,
|
||
{ nodeType, backendId: null, parentNodeId: parentNodeId },
|
||
html
|
||
);
|
||
|
||
// Update all IDs in the HTML to use drawflowNodeId instead of nodeId
|
||
// This ensures parameter setup can find the correct elements
|
||
if (nodeId !== drawflowNodeId) {
|
||
setTimeout(() => {
|
||
const nodeElement = document.getElementById(`node-${drawflowNodeId}`);
|
||
if (nodeElement) {
|
||
// Update all elements with IDs containing the old nodeId
|
||
const elementsWithIds = nodeElement.querySelectorAll('[id*="-' + nodeId + '"]');
|
||
elementsWithIds.forEach(el => {
|
||
const oldId = el.id;
|
||
const newId = oldId.replace('-' + nodeId, '-' + drawflowNodeId);
|
||
el.id = newId;
|
||
console.log(`Updated element ID: ${oldId} -> ${newId}`);
|
||
});
|
||
}
|
||
}, 10);
|
||
}
|
||
|
||
// Track parent-child relationship
|
||
if (parentNodeId !== null) {
|
||
nodeParents.set(drawflowNodeId, parentNodeId);
|
||
console.log(`Node ${drawflowNodeId} (${nodeType}) is child of VoiceAllocator ${parentNodeId}`);
|
||
|
||
// Mark template nodes as non-deletable
|
||
const isTemplateNode = (nodeType === 'TemplateInput' || nodeType === 'TemplateOutput');
|
||
|
||
// Add CSS class to mark as child node
|
||
setTimeout(() => {
|
||
const nodeElement = document.getElementById(`node-${drawflowNodeId}`);
|
||
if (nodeElement) {
|
||
nodeElement.classList.add('child-node');
|
||
nodeElement.setAttribute('data-parent-node', parentNodeId);
|
||
|
||
if (isTemplateNode) {
|
||
nodeElement.classList.add('template-node');
|
||
nodeElement.setAttribute('data-template-node', 'true');
|
||
}
|
||
|
||
// Only show if parent is currently expanded
|
||
if (!expandedNodes.has(parentNodeId)) {
|
||
nodeElement.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Auto-resize parent VoiceAllocator after adding child node
|
||
resizeVoiceAllocatorToFit(parentNodeId);
|
||
}, 10);
|
||
}
|
||
|
||
// Apply port styling based on signal types
|
||
setTimeout(() => {
|
||
styleNodePorts(drawflowNodeId, nodeDef);
|
||
}, 10);
|
||
|
||
// Send command to backend
|
||
// Check editing context first (dedicated template view), then parent node (inline editing)
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo === null) {
|
||
console.error('No track selected');
|
||
alert('Please select a track first');
|
||
editor.removeNodeId(`node-${drawflowNodeId}`);
|
||
return;
|
||
}
|
||
const trackId = trackInfo.trackId;
|
||
|
||
// Determine if we're adding to a template or main graph
|
||
let commandName, commandArgs;
|
||
if (editingContext) {
|
||
// Adding to template in dedicated view
|
||
commandName = "graph_add_node_to_template";
|
||
commandArgs = {
|
||
trackId: trackId,
|
||
voiceAllocatorId: editingContext.voiceAllocatorId,
|
||
nodeType: nodeType,
|
||
x: x,
|
||
y: y
|
||
};
|
||
} else if (parentNodeId) {
|
||
// Adding to template inline (old approach, still supported for backwards compat)
|
||
commandName = "graph_add_node_to_template";
|
||
commandArgs = {
|
||
trackId: trackId,
|
||
voiceAllocatorId: editor.getNodeFromId(parentNodeId).data.backendId,
|
||
nodeType: nodeType,
|
||
x: x,
|
||
y: y
|
||
};
|
||
} else {
|
||
// Adding to main graph
|
||
commandName = "graph_add_node";
|
||
commandArgs = {
|
||
trackId: trackId,
|
||
nodeType: nodeType,
|
||
x: x,
|
||
y: y
|
||
};
|
||
}
|
||
|
||
console.log(`[DEBUG] Invoking ${commandName} with args:`, commandArgs);
|
||
|
||
// Create a promise that resolves when the GraphNodeAdded event arrives
|
||
const eventPromise = new Promise((resolve) => {
|
||
window.pendingNodeUpdate = {
|
||
drawflowNodeId,
|
||
nodeType,
|
||
resolve: (backendNodeId) => {
|
||
console.log(`[DEBUG] Event promise resolved with backend ID: ${backendNodeId}`);
|
||
resolve(backendNodeId);
|
||
}
|
||
};
|
||
});
|
||
|
||
// Wait for both the invoke response and the event
|
||
Promise.all([
|
||
invoke(commandName, commandArgs),
|
||
eventPromise
|
||
]).then(([invokeReturnedId, eventBackendId]) => {
|
||
console.log(`[DEBUG] Both returned - invoke: ${invokeReturnedId}, event: ${eventBackendId}`);
|
||
|
||
// Use the event's backend ID as it's the authoritative source
|
||
const backendNodeId = eventBackendId;
|
||
console.log(`Node ${nodeType} added with backend ID: ${backendNodeId} (parent: ${parentNodeId})`);
|
||
|
||
// Store backend node ID using Drawflow's update method
|
||
editor.updateNodeDataFromId(drawflowNodeId, { nodeType, backendId: backendNodeId, parentNodeId: parentNodeId });
|
||
|
||
console.log("Verifying stored backend ID:", editor.getNodeFromId(drawflowNodeId).data.backendId);
|
||
|
||
// Cache node data for undo/redo
|
||
const trackInfo = getCurrentTrack();
|
||
nodeDataCache.set(drawflowNodeId, {
|
||
nodeType: nodeType,
|
||
backendId: backendNodeId,
|
||
position: { x, y },
|
||
parentNodeId: parentNodeId,
|
||
trackId: trackInfo ? trackInfo.trackId : null
|
||
});
|
||
|
||
// Record action for undo (node is already added to frontend and backend)
|
||
redoStack.length = 0;
|
||
undoStack.push({
|
||
name: "graphAddNode",
|
||
action: {
|
||
trackId: getCurrentMidiTrack(),
|
||
nodeType: nodeType,
|
||
position: { x, y },
|
||
nodeId: drawflowNodeId,
|
||
backendId: backendNodeId
|
||
}
|
||
});
|
||
updateMenu();
|
||
|
||
// If this is an AudioOutput node, automatically set it as the graph output
|
||
if (nodeType === "AudioOutput") {
|
||
console.log(`Setting node ${backendNodeId} as graph output`);
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo !== null) {
|
||
invoke("graph_set_output_node", {
|
||
trackId: trackInfo.trackId,
|
||
nodeId: backendNodeId
|
||
}).then(() => {
|
||
console.log("Output node set successfully");
|
||
}).catch(err => {
|
||
console.error("Failed to set output node:", err);
|
||
});
|
||
}
|
||
}
|
||
|
||
// If this is an AutomationInput node, create timeline curve
|
||
if (nodeType === "AutomationInput" && !parentNodeId) {
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo !== null) {
|
||
const currentTrackId = trackInfo.trackId;
|
||
// Find the audio/MIDI track
|
||
const track = root.audioTracks?.find(t => t.audioTrackId === currentTrackId);
|
||
if (track) {
|
||
// Create curve parameter name: "automation.{nodeId}"
|
||
const curveName = `automation.${backendNodeId}`;
|
||
|
||
// Check if curve already exists
|
||
if (!track.animationData.curves[curveName]) {
|
||
// Create the curve with a default keyframe at time 0, value 0
|
||
const curve = track.animationData.getOrCreateCurve(curveName);
|
||
curve.addKeyframe({
|
||
time: 0,
|
||
value: 0,
|
||
interpolation: 'linear',
|
||
easeIn: { x: 0.42, y: 0 },
|
||
easeOut: { x: 0.58, y: 1 },
|
||
idx: `${Date.now()}-${Math.random()}`
|
||
});
|
||
console.log(`Initialized automation curve: ${curveName}`);
|
||
|
||
// Redraw timeline if it's open
|
||
if (context.timeline?.requestRedraw) {
|
||
context.timeline.requestRedraw();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// If this is an Oscilloscope node, start the visualization
|
||
if (nodeType === "Oscilloscope") {
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo !== null) {
|
||
const currentTrackId = trackInfo.trackId;
|
||
console.log(`Starting oscilloscope visualization for node ${drawflowNodeId} (backend ID: ${backendNodeId})`);
|
||
// Wait for DOM to update before starting visualization
|
||
setTimeout(() => {
|
||
startOscilloscopeVisualization(drawflowNodeId, currentTrackId, backendNodeId, editor);
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
// If this is a VoiceAllocator, automatically create template I/O nodes inside it
|
||
if (nodeType === "VoiceAllocator") {
|
||
setTimeout(() => {
|
||
// Get the node position
|
||
const node = editor.getNodeFromId(drawflowNodeId);
|
||
if (node) {
|
||
// Create TemplateInput on the left
|
||
addNode("TemplateInput", node.pos_x + 50, node.pos_y + 100, drawflowNodeId);
|
||
// Create TemplateOutput on the right
|
||
addNode("TemplateOutput", node.pos_x + 450, node.pos_y + 100, drawflowNodeId);
|
||
}
|
||
}, 100);
|
||
}
|
||
}).catch(err => {
|
||
console.error("Failed to add node to backend:", err);
|
||
showError("Failed to add node: " + err);
|
||
});
|
||
|
||
return drawflowNodeId;
|
||
}
|
||
|
||
// Auto-resize VoiceAllocator to fit its child nodes
|
||
function resizeVoiceAllocatorToFit(voiceAllocatorNodeId) {
|
||
if (!voiceAllocatorNodeId) return;
|
||
|
||
const parentNode = editor.getNodeFromId(voiceAllocatorNodeId);
|
||
const parentElement = document.getElementById(`node-${voiceAllocatorNodeId}`);
|
||
if (!parentNode || !parentElement) return;
|
||
|
||
// Find all child nodes
|
||
const childNodeIds = [];
|
||
for (const [childId, parentId] of nodeParents.entries()) {
|
||
if (parentId === voiceAllocatorNodeId) {
|
||
childNodeIds.push(childId);
|
||
}
|
||
}
|
||
|
||
if (childNodeIds.length === 0) return;
|
||
|
||
// Calculate bounding box of all child nodes
|
||
let minX = Infinity, minY = Infinity;
|
||
let maxX = -Infinity, maxY = -Infinity;
|
||
|
||
for (const childId of childNodeIds) {
|
||
const childNode = editor.getNodeFromId(childId);
|
||
const childElement = document.getElementById(`node-${childId}`);
|
||
if (!childNode || !childElement) continue;
|
||
|
||
const childWidth = childElement.offsetWidth || 200;
|
||
const childHeight = childElement.offsetHeight || 150;
|
||
|
||
minX = Math.min(minX, childNode.pos_x);
|
||
minY = Math.min(minY, childNode.pos_y);
|
||
maxX = Math.max(maxX, childNode.pos_x + childWidth);
|
||
maxY = Math.max(maxY, childNode.pos_y + childHeight);
|
||
}
|
||
|
||
// Add generous margin
|
||
const margin = 60;
|
||
const headerHeight = 120; // Space for VoiceAllocator header
|
||
|
||
const requiredWidth = (maxX - minX) + (margin * 2);
|
||
const requiredHeight = (maxY - minY) + (margin * 2) + headerHeight;
|
||
|
||
// Set minimum size
|
||
const finalWidth = Math.max(requiredWidth, 600);
|
||
const finalHeight = Math.max(requiredHeight, 400);
|
||
|
||
// Only resize if expanded
|
||
if (expandedNodes.has(voiceAllocatorNodeId)) {
|
||
parentElement.style.width = `${finalWidth}px`;
|
||
parentElement.style.height = `${finalHeight}px`;
|
||
parentElement.style.minWidth = `${finalWidth}px`;
|
||
parentElement.style.minHeight = `${finalHeight}px`;
|
||
|
||
console.log(`Resized VoiceAllocator ${voiceAllocatorNodeId} to ${finalWidth}x${finalHeight}`);
|
||
}
|
||
}
|
||
|
||
// Style node ports based on signal types
|
||
function styleNodePorts(nodeId, nodeDef) {
|
||
const nodeElement = document.getElementById(`node-${nodeId}`);
|
||
if (!nodeElement) return;
|
||
|
||
// Style input ports
|
||
const inputs = nodeElement.querySelectorAll(".input");
|
||
inputs.forEach((input, index) => {
|
||
if (index < nodeDef.inputs.length) {
|
||
const portDef = nodeDef.inputs[index];
|
||
// Add connector styling class directly to the input element
|
||
input.classList.add(getPortClass(portDef.type));
|
||
// Add label
|
||
const label = document.createElement("span");
|
||
label.textContent = portDef.name;
|
||
input.insertBefore(label, input.firstChild);
|
||
}
|
||
});
|
||
|
||
// Style output ports
|
||
const outputs = nodeElement.querySelectorAll(".output");
|
||
outputs.forEach((output, index) => {
|
||
if (index < nodeDef.outputs.length) {
|
||
const portDef = nodeDef.outputs[index];
|
||
// Add connector styling class directly to the output element
|
||
output.classList.add(getPortClass(portDef.type));
|
||
// Add label
|
||
const label = document.createElement("span");
|
||
label.textContent = portDef.name;
|
||
output.appendChild(label);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Setup parameter event listeners for a node
|
||
function setupNodeParameters(nodeId) {
|
||
setTimeout(() => {
|
||
const nodeElement = document.getElementById(`node-${nodeId}`);
|
||
if (!nodeElement) return;
|
||
|
||
const sliders = nodeElement.querySelectorAll('input[type="range"]');
|
||
sliders.forEach(slider => {
|
||
// Track parameter change action for undo/redo
|
||
let paramAction = null;
|
||
|
||
// Prevent node dragging when interacting with slider
|
||
slider.addEventListener("mousedown", (e) => {
|
||
e.stopPropagation();
|
||
|
||
// Initialize undo action
|
||
const paramId = parseInt(e.target.getAttribute("data-param"));
|
||
const currentValue = parseFloat(e.target.value);
|
||
const nodeData = editor.getNodeFromId(nodeId);
|
||
|
||
if (nodeData && nodeData.data.backendId !== null) {
|
||
const currentTrackId = getCurrentMidiTrack();
|
||
if (currentTrackId !== null) {
|
||
paramAction = actions.graphSetParameter.initialize(
|
||
currentTrackId,
|
||
nodeData.data.backendId,
|
||
paramId,
|
||
nodeId,
|
||
currentValue
|
||
);
|
||
}
|
||
}
|
||
});
|
||
slider.addEventListener("pointerdown", (e) => {
|
||
e.stopPropagation();
|
||
});
|
||
|
||
slider.addEventListener("input", (e) => {
|
||
const paramId = parseInt(e.target.getAttribute("data-param"));
|
||
const value = parseFloat(e.target.value);
|
||
|
||
console.log(`[setupNodeParameters] Slider input - nodeId: ${nodeId}, paramId: ${paramId}, value: ${value}`);
|
||
|
||
// Update display
|
||
const nodeData = editor.getNodeFromId(nodeId);
|
||
if (nodeData) {
|
||
const nodeDef = nodeTypes[nodeData.name];
|
||
console.log(`[setupNodeParameters] Found node type: ${nodeData.name}, parameters:`, nodeDef?.parameters);
|
||
if (nodeDef && nodeDef.parameters[paramId]) {
|
||
const param = nodeDef.parameters[paramId];
|
||
console.log(`[setupNodeParameters] Looking for span: #${param.name}-${nodeId}`);
|
||
const displaySpan = nodeElement.querySelector(`#${param.name}-${nodeId}`);
|
||
console.log(`[setupNodeParameters] Found span:`, displaySpan);
|
||
if (displaySpan) {
|
||
// Special formatting for oscilloscope trigger mode
|
||
if (param.name === 'trigger_mode') {
|
||
const modes = ['Free', 'Rising', 'Falling', 'V/oct'];
|
||
displaySpan.textContent = modes[Math.round(value)] || 'Free';
|
||
}
|
||
// Special formatting for Phaser rate in sync mode
|
||
else if (param.name === 'rate' && nodeData.name === 'Phaser') {
|
||
const syncCheckbox = nodeElement.querySelector(`#sync-${nodeId}`);
|
||
if (syncCheckbox && syncCheckbox.checked) {
|
||
const beatDivisions = [
|
||
'4 bars', '2 bars', '1 bar', '1/2', '1/4', '1/8', '1/16', '1/32', '1/2T', '1/4T', '1/8T'
|
||
];
|
||
const idx = Math.round(value);
|
||
displaySpan.textContent = beatDivisions[Math.min(10, Math.max(0, idx))];
|
||
} else {
|
||
displaySpan.textContent = value.toFixed(param.unit === 'Hz' ? 0 : 2);
|
||
}
|
||
}
|
||
else {
|
||
displaySpan.textContent = value.toFixed(param.unit === 'Hz' ? 0 : 2);
|
||
}
|
||
}
|
||
|
||
// Update oscilloscope time scale if this is a time_scale parameter
|
||
if (param.name === 'time_scale' && oscilloscopeTimeScales) {
|
||
oscilloscopeTimeScales.set(nodeId, value);
|
||
console.log(`Updated oscilloscope time scale for node ${nodeId}: ${value}ms`);
|
||
}
|
||
}
|
||
|
||
// Send to backend in real-time
|
||
if (nodeData.data.backendId !== null) {
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo !== null) {
|
||
// Convert beat divisions to Hz for Phaser rate in sync mode
|
||
let backendValue = value;
|
||
if (nodeDef && nodeDef.parameters[paramId]) {
|
||
const param = nodeDef.parameters[paramId];
|
||
if (param.name === 'rate' && nodeData.name === 'Phaser') {
|
||
const syncCheckbox = nodeElement.querySelector(`#sync-${nodeId}`);
|
||
if (syncCheckbox && syncCheckbox.checked && context.timelineWidget) {
|
||
const beatDivisions = [
|
||
{ label: '4 bars', multiplier: 16.0 },
|
||
{ label: '2 bars', multiplier: 8.0 },
|
||
{ label: '1 bar', multiplier: 4.0 },
|
||
{ label: '1/2', multiplier: 2.0 },
|
||
{ label: '1/4', multiplier: 1.0 },
|
||
{ label: '1/8', multiplier: 0.5 },
|
||
{ label: '1/16', multiplier: 0.25 },
|
||
{ label: '1/32', multiplier: 0.125 },
|
||
{ label: '1/2T', multiplier: 2.0/3.0 },
|
||
{ label: '1/4T', multiplier: 1.0/3.0 },
|
||
{ label: '1/8T', multiplier: 0.5/3.0 }
|
||
];
|
||
const idx = Math.min(10, Math.max(0, Math.round(value)));
|
||
const bpm = context.timelineWidget.timelineState.bpm;
|
||
const beatsPerSecond = bpm / 60.0;
|
||
const quarterNotesPerCycle = beatDivisions[idx].multiplier;
|
||
// Hz = how many cycles per second
|
||
backendValue = beatsPerSecond / quarterNotesPerCycle;
|
||
}
|
||
}
|
||
}
|
||
|
||
invoke("graph_set_parameter", {
|
||
trackId: trackInfo.trackId,
|
||
nodeId: nodeData.data.backendId,
|
||
paramId: paramId,
|
||
value: backendValue
|
||
}).catch(err => {
|
||
console.error("Failed to set parameter:", err);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Finalize parameter change for undo/redo when slider is released
|
||
slider.addEventListener("change", (e) => {
|
||
const newValue = parseFloat(e.target.value);
|
||
|
||
if (paramAction) {
|
||
actions.graphSetParameter.finalize(paramAction, newValue);
|
||
paramAction = null;
|
||
}
|
||
});
|
||
});
|
||
|
||
// Handle select dropdowns
|
||
const selects = nodeElement.querySelectorAll('select[data-param]');
|
||
selects.forEach(select => {
|
||
// Track parameter change action for undo/redo
|
||
let paramAction = null;
|
||
|
||
// Prevent node dragging when interacting with select
|
||
select.addEventListener("mousedown", (e) => {
|
||
e.stopPropagation();
|
||
|
||
// Initialize undo action
|
||
const paramId = parseInt(e.target.getAttribute("data-param"));
|
||
const currentValue = parseFloat(e.target.value);
|
||
const nodeData = editor.getNodeFromId(nodeId);
|
||
|
||
if (nodeData && nodeData.data.backendId !== null) {
|
||
const currentTrackId = getCurrentMidiTrack();
|
||
if (currentTrackId !== null) {
|
||
paramAction = actions.graphSetParameter.initialize(
|
||
currentTrackId,
|
||
nodeData.data.backendId,
|
||
paramId,
|
||
nodeId,
|
||
currentValue
|
||
);
|
||
}
|
||
}
|
||
});
|
||
select.addEventListener("pointerdown", (e) => {
|
||
e.stopPropagation();
|
||
});
|
||
|
||
select.addEventListener("change", (e) => {
|
||
const paramId = parseInt(e.target.getAttribute("data-param"));
|
||
const value = parseFloat(e.target.value);
|
||
|
||
console.log(`[setupNodeParameters] Select change - nodeId: ${nodeId}, paramId: ${paramId}, value: ${value}`);
|
||
|
||
// Update display span if it exists
|
||
const nodeData = editor.getNodeFromId(nodeId);
|
||
if (nodeData) {
|
||
const nodeDef = nodeTypes[nodeData.name];
|
||
if (nodeDef && nodeDef.parameters[paramId]) {
|
||
const param = nodeDef.parameters[paramId];
|
||
const displaySpan = nodeElement.querySelector(`#${param.name}-${nodeId}`);
|
||
if (displaySpan) {
|
||
// Update the span with the selected option text
|
||
displaySpan.textContent = e.target.options[e.target.selectedIndex].text;
|
||
}
|
||
}
|
||
|
||
// Send to backend
|
||
if (nodeData.data.backendId !== null) {
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo !== null) {
|
||
invoke("graph_set_parameter", {
|
||
trackId: trackInfo.trackId,
|
||
nodeId: nodeData.data.backendId,
|
||
paramId: paramId,
|
||
value: value
|
||
}).catch(err => {
|
||
console.error("Failed to set parameter:", err);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Finalize undo action
|
||
if (paramAction) {
|
||
actions.graphSetParameter.finalize(paramAction, value);
|
||
paramAction = null;
|
||
}
|
||
});
|
||
});
|
||
|
||
// Handle number inputs
|
||
const numberInputs = nodeElement.querySelectorAll('input[type="number"][data-param]');
|
||
numberInputs.forEach(numberInput => {
|
||
// Track parameter change action for undo/redo
|
||
let paramAction = null;
|
||
|
||
// Prevent node dragging when interacting with number input
|
||
numberInput.addEventListener("mousedown", (e) => {
|
||
e.stopPropagation();
|
||
|
||
// Initialize undo action
|
||
const paramId = parseInt(e.target.getAttribute("data-param"));
|
||
const currentValue = parseFloat(e.target.value);
|
||
const nodeData = editor.getNodeFromId(nodeId);
|
||
|
||
if (nodeData && nodeData.data.backendId !== null) {
|
||
const currentTrackId = getCurrentMidiTrack();
|
||
if (currentTrackId !== null) {
|
||
paramAction = actions.graphSetParameter.initialize(
|
||
currentTrackId,
|
||
nodeData.data.backendId,
|
||
paramId,
|
||
nodeId,
|
||
currentValue
|
||
);
|
||
}
|
||
}
|
||
});
|
||
numberInput.addEventListener("pointerdown", (e) => {
|
||
e.stopPropagation();
|
||
});
|
||
|
||
numberInput.addEventListener("input", (e) => {
|
||
const paramId = parseInt(e.target.getAttribute("data-param"));
|
||
let value = parseFloat(e.target.value);
|
||
|
||
// Validate and clamp to min/max
|
||
const min = parseFloat(e.target.min);
|
||
const max = parseFloat(e.target.max);
|
||
if (!isNaN(value)) {
|
||
value = Math.max(min, Math.min(max, value));
|
||
} else {
|
||
value = 0;
|
||
}
|
||
|
||
console.log(`[setupNodeParameters] Number input - nodeId: ${nodeId}, paramId: ${paramId}, value: ${value}`);
|
||
|
||
// Update corresponding slider
|
||
const slider = nodeElement.querySelector(`input[type="range"][data-param="${paramId}"]`);
|
||
if (slider) {
|
||
slider.value = value;
|
||
}
|
||
|
||
// Send to backend
|
||
const nodeData = editor.getNodeFromId(nodeId);
|
||
if (nodeData && nodeData.data.backendId !== null) {
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo !== null) {
|
||
invoke("graph_set_parameter", {
|
||
trackId: trackInfo.trackId,
|
||
nodeId: nodeData.data.backendId,
|
||
paramId: paramId,
|
||
value: value
|
||
}).catch(err => {
|
||
console.error("Failed to set parameter:", err);
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
numberInput.addEventListener("change", (e) => {
|
||
const value = parseFloat(e.target.value);
|
||
|
||
// Finalize undo action
|
||
if (paramAction) {
|
||
actions.graphSetParameter.finalize(paramAction, value);
|
||
paramAction = null;
|
||
}
|
||
});
|
||
});
|
||
|
||
// Handle checkboxes
|
||
const checkboxes = nodeElement.querySelectorAll('input[type="checkbox"][data-param]');
|
||
checkboxes.forEach(checkbox => {
|
||
checkbox.addEventListener("change", (e) => {
|
||
const paramId = parseInt(e.target.getAttribute("data-param"));
|
||
const value = e.target.checked ? 1.0 : 0.0;
|
||
|
||
console.log(`[setupNodeParameters] Checkbox change - nodeId: ${nodeId}, paramId: ${paramId}, value: ${value}`);
|
||
|
||
// Send to backend
|
||
const nodeData = editor.getNodeFromId(nodeId);
|
||
if (nodeData && nodeData.data.backendId !== null) {
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo !== null) {
|
||
invoke("graph_set_parameter", {
|
||
trackId: trackInfo.trackId,
|
||
nodeId: nodeData.data.backendId,
|
||
paramId: paramId,
|
||
value: value
|
||
}).then(() => {
|
||
console.log(`Parameter ${paramId} set to ${value}`);
|
||
}).catch(err => {
|
||
console.error("Failed to set parameter:", err);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Special handling for Phaser sync checkbox
|
||
if (checkbox.id.startsWith('sync-')) {
|
||
const rateSlider = nodeElement.querySelector(`#rate-slider-${nodeId}`);
|
||
const rateDisplay = nodeElement.querySelector(`#rate-${nodeId}`);
|
||
const rateUnit = nodeElement.querySelector(`#rate-unit-${nodeId}`);
|
||
|
||
if (rateSlider && rateDisplay && rateUnit) {
|
||
if (e.target.checked) {
|
||
// Sync mode: Use beat divisions
|
||
// Map slider 0-10 to different note divisions
|
||
// 0: 4 bars, 1: 2 bars, 2: 1 bar, 3: 1/2, 4: 1/4, 5: 1/8, 6: 1/16, 7: 1/32, 8: 1/2T, 9: 1/4T, 10: 1/8T
|
||
const beatDivisions = [
|
||
{ label: '4 bars', multiplier: 16.0 },
|
||
{ label: '2 bars', multiplier: 8.0 },
|
||
{ label: '1 bar', multiplier: 4.0 },
|
||
{ label: '1/2', multiplier: 2.0 },
|
||
{ label: '1/4', multiplier: 1.0 },
|
||
{ label: '1/8', multiplier: 0.5 },
|
||
{ label: '1/16', multiplier: 0.25 },
|
||
{ label: '1/32', multiplier: 0.125 },
|
||
{ label: '1/2T', multiplier: 2.0/3.0 },
|
||
{ label: '1/4T', multiplier: 1.0/3.0 },
|
||
{ label: '1/8T', multiplier: 0.5/3.0 }
|
||
];
|
||
|
||
rateSlider.min = '0';
|
||
rateSlider.max = '10';
|
||
rateSlider.step = '1';
|
||
const idx = Math.round(parseFloat(rateSlider.value) * 10 / 10);
|
||
rateSlider.value = Math.min(10, Math.max(0, idx));
|
||
rateDisplay.textContent = beatDivisions[parseInt(rateSlider.value)].label;
|
||
rateUnit.textContent = '';
|
||
} else {
|
||
// Free mode: Hz
|
||
rateSlider.min = '0.1';
|
||
rateSlider.max = '10.0';
|
||
rateSlider.step = '0.1';
|
||
rateDisplay.textContent = parseFloat(rateSlider.value).toFixed(1);
|
||
rateUnit.textContent = ' Hz';
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// Handle Load Sample button for SimpleSampler
|
||
const loadSampleBtn = nodeElement.querySelector(".load-sample-btn");
|
||
if (loadSampleBtn) {
|
||
loadSampleBtn.addEventListener("mousedown", (e) => e.stopPropagation());
|
||
loadSampleBtn.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||
loadSampleBtn.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
|
||
const nodeData = editor.getNodeFromId(nodeId);
|
||
if (!nodeData || nodeData.data.backendId === null) {
|
||
showError("Node not yet created on backend");
|
||
return;
|
||
}
|
||
|
||
const currentTrackId = getCurrentMidiTrack();
|
||
if (currentTrackId === null) {
|
||
showError("No MIDI track selected");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const filePath = await openFileDialog({
|
||
title: "Load Audio Sample",
|
||
filters: [{
|
||
name: "Audio Files",
|
||
extensions: audioExtensions
|
||
}]
|
||
});
|
||
|
||
if (filePath) {
|
||
await invoke("sampler_load_sample", {
|
||
trackId: currentTrackId,
|
||
nodeId: nodeData.data.backendId,
|
||
filePath: filePath
|
||
});
|
||
|
||
// Update UI to show filename
|
||
const sampleInfo = nodeElement.querySelector(`#sample-info-${nodeId}`);
|
||
if (sampleInfo) {
|
||
const filename = filePath.split('/').pop().split('\\').pop();
|
||
sampleInfo.textContent = filename;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to load sample:", err);
|
||
showError(`Failed to load sample: ${err}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Handle Add Layer button for MultiSampler
|
||
const addLayerBtn = nodeElement.querySelector(".add-layer-btn");
|
||
if (addLayerBtn) {
|
||
addLayerBtn.addEventListener("mousedown", (e) => e.stopPropagation());
|
||
addLayerBtn.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||
addLayerBtn.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
|
||
const nodeData = editor.getNodeFromId(nodeId);
|
||
if (!nodeData || nodeData.data.backendId === null) {
|
||
showError("Node not yet created on backend");
|
||
return;
|
||
}
|
||
|
||
const currentTrackId = getCurrentMidiTrack();
|
||
if (currentTrackId === null) {
|
||
showError("No MIDI track selected");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const filePath = await openFileDialog({
|
||
title: "Add Sample Layer",
|
||
filters: [{
|
||
name: "Audio Files",
|
||
extensions: audioExtensions
|
||
}]
|
||
});
|
||
|
||
if (filePath) {
|
||
// Show dialog to configure layer mapping
|
||
const layerConfig = await showLayerConfigDialog(filePath);
|
||
|
||
if (layerConfig) {
|
||
await invoke("multi_sampler_add_layer", {
|
||
trackId: currentTrackId,
|
||
nodeId: nodeData.data.backendId,
|
||
filePath: filePath,
|
||
keyMin: layerConfig.keyMin,
|
||
keyMax: layerConfig.keyMax,
|
||
rootKey: layerConfig.rootKey,
|
||
velocityMin: layerConfig.velocityMin,
|
||
velocityMax: layerConfig.velocityMax,
|
||
loopStart: layerConfig.loopStart,
|
||
loopEnd: layerConfig.loopEnd,
|
||
loopMode: layerConfig.loopMode
|
||
});
|
||
|
||
// Wait a bit for the audio thread to process the add command
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
// Refresh the layers list
|
||
await refreshSampleLayersList(nodeId);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to add layer:", err);
|
||
showError(`Failed to add layer: ${err}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Handle Import Folder button for MultiSampler
|
||
const importFolderBtn = nodeElement.querySelector(".import-folder-btn");
|
||
if (importFolderBtn) {
|
||
importFolderBtn.addEventListener("mousedown", (e) => e.stopPropagation());
|
||
importFolderBtn.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||
importFolderBtn.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
|
||
const nodeData = editor.getNodeFromId(nodeId);
|
||
if (!nodeData || nodeData.data.backendId === null) {
|
||
showError("Node not yet created on backend");
|
||
return;
|
||
}
|
||
|
||
const currentTrackId = getCurrentMidiTrack();
|
||
if (currentTrackId === null) {
|
||
showError("No MIDI track selected");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await showFolderImportDialog(currentTrackId, nodeData.data.backendId, nodeId);
|
||
} catch (err) {
|
||
console.error("Failed to import folder:", err);
|
||
showError(`Failed to import folder: ${err}`);
|
||
}
|
||
});
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
// Handle double-click on nodes (for VoiceAllocator template editing)
|
||
function handleNodeDoubleClick(nodeId) {
|
||
const node = editor.getNodeFromId(nodeId);
|
||
if (!node) return;
|
||
|
||
// Only VoiceAllocator nodes can be opened for template editing
|
||
if (node.data.nodeType !== 'VoiceAllocator') return;
|
||
|
||
// Don't allow entering templates when already editing a template
|
||
if (editingContext) {
|
||
showError("Cannot nest template editing - exit current template first");
|
||
return;
|
||
}
|
||
|
||
// Get the backend ID and node name
|
||
if (node.data.backendId === null) {
|
||
showError("VoiceAllocator not yet created on backend");
|
||
return;
|
||
}
|
||
|
||
// Enter template editing mode
|
||
const nodeName = node.name || 'VoiceAllocator';
|
||
enterTemplate(node.data.backendId, nodeName);
|
||
}
|
||
|
||
// Refresh the layers list for a MultiSampler node
|
||
async function refreshSampleLayersList(nodeId) {
|
||
const nodeData = editor.getNodeFromId(nodeId);
|
||
if (!nodeData || nodeData.data.backendId === null) {
|
||
return;
|
||
}
|
||
|
||
const currentTrackId = getCurrentMidiTrack();
|
||
if (currentTrackId === null) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const layers = await invoke("multi_sampler_get_layers", {
|
||
trackId: currentTrackId,
|
||
nodeId: nodeData.data.backendId
|
||
});
|
||
|
||
// Find the node element and query within it for the layers list
|
||
const nodeElement = document.querySelector(`#node-${nodeId}`);
|
||
if (!nodeElement) return;
|
||
|
||
const layersList = nodeElement.querySelector('[id^="sample-layers-list-"]');
|
||
const layersContainer = nodeElement.querySelector('[id^="sample-layers-container-"]');
|
||
|
||
if (!layersList) return;
|
||
|
||
// Prevent scroll events from bubbling to canvas
|
||
if (layersContainer && !layersContainer.dataset.scrollListenerAdded) {
|
||
layersContainer.addEventListener('wheel', (e) => {
|
||
e.stopPropagation();
|
||
}, { passive: false });
|
||
layersContainer.dataset.scrollListenerAdded = 'true';
|
||
}
|
||
|
||
if (layers.length === 0) {
|
||
layersList.innerHTML = '<tr><td colspan="5" class="sample-layers-empty">No layers loaded</td></tr>';
|
||
} else {
|
||
layersList.innerHTML = layers.map((layer, index) => {
|
||
const filename = layer.file_path.split('/').pop().split('\\').pop();
|
||
const keyRange = `${midiToNoteName(layer.key_min)}-${midiToNoteName(layer.key_max)}`;
|
||
const rootNote = midiToNoteName(layer.root_key);
|
||
const velRange = `${layer.velocity_min}-${layer.velocity_max}`;
|
||
|
||
return `
|
||
<tr data-index="${index}">
|
||
<td class="sample-layer-filename" title="${filename}">${filename}</td>
|
||
<td>${keyRange}</td>
|
||
<td>${rootNote}</td>
|
||
<td>${velRange}</td>
|
||
<td>
|
||
<div class="sample-layer-actions">
|
||
<button class="btn-edit-layer" data-node="${nodeId}" data-index="${index}">Edit</button>
|
||
<button class="btn-delete-layer" data-node="${nodeId}" data-index="${index}">Del</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
|
||
// Add event listeners for edit buttons
|
||
const editButtons = layersList.querySelectorAll('.btn-edit-layer');
|
||
editButtons.forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const index = parseInt(btn.dataset.index);
|
||
const layer = layers[index];
|
||
|
||
// Show edit dialog with current values
|
||
const layerConfig = await showLayerConfigDialog(layer.file_path, {
|
||
keyMin: layer.key_min,
|
||
keyMax: layer.key_max,
|
||
rootKey: layer.root_key,
|
||
velocityMin: layer.velocity_min,
|
||
velocityMax: layer.velocity_max,
|
||
loopStart: layer.loop_start,
|
||
loopEnd: layer.loop_end,
|
||
loopMode: layer.loop_mode
|
||
});
|
||
|
||
if (layerConfig) {
|
||
try {
|
||
await invoke("multi_sampler_update_layer", {
|
||
trackId: currentTrackId,
|
||
nodeId: nodeData.data.backendId,
|
||
layerIndex: index,
|
||
keyMin: layerConfig.keyMin,
|
||
keyMax: layerConfig.keyMax,
|
||
rootKey: layerConfig.rootKey,
|
||
velocityMin: layerConfig.velocityMin,
|
||
velocityMax: layerConfig.velocityMax,
|
||
loopStart: layerConfig.loopStart,
|
||
loopEnd: layerConfig.loopEnd,
|
||
loopMode: layerConfig.loopMode
|
||
});
|
||
|
||
// Refresh the list
|
||
await refreshSampleLayersList(nodeId);
|
||
} catch (err) {
|
||
console.error("Failed to update layer:", err);
|
||
showError(`Failed to update layer: ${err}`);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// Add event listeners for delete buttons
|
||
const deleteButtons = layersList.querySelectorAll('.btn-delete-layer');
|
||
deleteButtons.forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const index = parseInt(btn.dataset.index);
|
||
const layer = layers[index];
|
||
const filename = layer.file_path.split('/').pop().split('\\').pop();
|
||
|
||
if (confirm(`Delete layer "${filename}"?`)) {
|
||
try {
|
||
await invoke("multi_sampler_remove_layer", {
|
||
trackId: currentTrackId,
|
||
nodeId: nodeData.data.backendId,
|
||
layerIndex: index
|
||
});
|
||
|
||
// Refresh the list
|
||
await refreshSampleLayersList(nodeId);
|
||
} catch (err) {
|
||
console.error("Failed to remove layer:", err);
|
||
showError(`Failed to remove layer: ${err}`);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to get layers:", err);
|
||
}
|
||
}
|
||
|
||
// Push nodes away from a point using gaussian falloff
|
||
function pushNodesAway(centerX, centerY, maxDistance, excludeNodeId) {
|
||
const module = editor.module;
|
||
const allNodes = editor.drawflow.drawflow[module]?.data || {};
|
||
|
||
// Gaussian parameters
|
||
const sigma = maxDistance / 3; // Standard deviation for falloff
|
||
const maxPush = 150; // Maximum push distance at the center
|
||
|
||
for (const [id, node] of Object.entries(allNodes)) {
|
||
const nodeIdNum = parseInt(id);
|
||
if (nodeIdNum === excludeNodeId) continue;
|
||
|
||
// Calculate distance from center
|
||
const dx = node.pos_x - centerX;
|
||
const dy = node.pos_y - centerY;
|
||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||
|
||
if (distance < maxDistance && distance > 0) {
|
||
// Calculate push strength using gaussian falloff
|
||
const falloff = Math.exp(-(distance * distance) / (2 * sigma * sigma));
|
||
const pushStrength = maxPush * falloff;
|
||
|
||
// Calculate push direction (normalized)
|
||
const dirX = dx / distance;
|
||
const dirY = dy / distance;
|
||
|
||
// Calculate new position
|
||
const newX = node.pos_x + dirX * pushStrength;
|
||
const newY = node.pos_y + dirY * pushStrength;
|
||
|
||
// Update position in the data structure
|
||
node.pos_x = newX;
|
||
node.pos_y = newY;
|
||
|
||
// Update the DOM element position
|
||
const nodeElement = document.getElementById(`node-${nodeIdNum}`);
|
||
if (nodeElement) {
|
||
nodeElement.style.left = newX + 'px';
|
||
nodeElement.style.top = newY + 'px';
|
||
}
|
||
|
||
// Trigger connection redraw
|
||
editor.updateConnectionNodes(`node-${nodeIdNum}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Perform the actual connection insertion
|
||
function performConnectionInsertion(nodeId, match) {
|
||
|
||
const node = editor.getNodeFromId(nodeId);
|
||
const sourceNode = editor.getNodeFromId(match.sourceNodeId);
|
||
const targetNode = editor.getNodeFromId(match.targetNodeId);
|
||
|
||
if (!node || !sourceNode || !targetNode) {
|
||
console.error("Missing nodes for insertion");
|
||
return;
|
||
}
|
||
|
||
// Position the node between source and target
|
||
const sourceElement = document.getElementById(`node-${match.sourceNodeId}`);
|
||
const targetElement = document.getElementById(`node-${match.targetNodeId}`);
|
||
|
||
if (sourceElement && targetElement) {
|
||
const sourceRect = sourceElement.getBoundingClientRect();
|
||
const targetRect = targetElement.getBoundingClientRect();
|
||
|
||
// Calculate midpoint position
|
||
const newX = (sourceNode.pos_x + sourceRect.width + targetNode.pos_x) / 2 - 80; // Approximate node half-width
|
||
const newY = (sourceNode.pos_y + targetNode.pos_y) / 2 - 50; // Approximate node half-height
|
||
|
||
// Update node position in data structure
|
||
node.pos_x = newX;
|
||
node.pos_y = newY;
|
||
|
||
// Update the DOM element position
|
||
const nodeElement = document.getElementById(`node-${nodeId}`);
|
||
if (nodeElement) {
|
||
nodeElement.style.left = newX + 'px';
|
||
nodeElement.style.top = newY + 'px';
|
||
}
|
||
|
||
// Trigger connection redraw for this node
|
||
editor.updateConnectionNodes(`node-${nodeId}`);
|
||
|
||
// Push surrounding nodes away with gaussian falloff
|
||
pushNodesAway(newX, newY, 400, nodeId); // 400px influence radius
|
||
}
|
||
|
||
// Remove the old connection
|
||
suppressActionRecording = true;
|
||
editor.removeSingleConnection(
|
||
match.sourceNodeId,
|
||
match.targetNodeId,
|
||
match.sourceOutputClass,
|
||
match.targetInputClass
|
||
);
|
||
|
||
// Create new connections: source -> node -> target
|
||
// Connection 1: source output -> node input
|
||
setTimeout(() => {
|
||
editor.addConnection(
|
||
match.sourceNodeId,
|
||
nodeId,
|
||
match.sourceOutputClass,
|
||
`input_${match.nodeInputPort + 1}`
|
||
);
|
||
|
||
// Connection 2: node output -> target input
|
||
setTimeout(() => {
|
||
editor.addConnection(
|
||
nodeId,
|
||
match.targetNodeId,
|
||
`output_${match.nodeOutputPort + 1}`,
|
||
match.targetInputClass
|
||
);
|
||
|
||
suppressActionRecording = false;
|
||
}, 50);
|
||
}, 50);
|
||
}
|
||
|
||
// Check if cursor position during drag is near a connection
|
||
function checkConnectionInsertionDuringDrag(dragEvent, nodeDef) {
|
||
const drawflowDiv = container.querySelector("#drawflow");
|
||
if (!drawflowDiv || !editor) return;
|
||
|
||
const rect = drawflowDiv.getBoundingClientRect();
|
||
const canvasX = editor.canvas_x || 0;
|
||
const canvasY = editor.canvas_y || 0;
|
||
const zoom = editor.zoom || 1;
|
||
|
||
// Calculate cursor position in canvas coordinates
|
||
const cursorX = (dragEvent.clientX - rect.left - canvasX) / zoom;
|
||
const cursorY = (dragEvent.clientY - rect.top - canvasY) / zoom;
|
||
|
||
// Get all connections in the current module
|
||
const module = editor.module;
|
||
const allNodes = editor.drawflow.drawflow[module]?.data || {};
|
||
|
||
// Distance threshold for insertion (in pixels)
|
||
const insertionThreshold = 30;
|
||
|
||
let bestMatch = null;
|
||
let bestDistance = insertionThreshold;
|
||
|
||
// Check each connection
|
||
for (const [sourceNodeId, sourceNode] of Object.entries(allNodes)) {
|
||
for (const [outputKey, outputData] of Object.entries(sourceNode.outputs)) {
|
||
for (const connection of outputData.connections) {
|
||
const targetNodeId = connection.node;
|
||
const targetNode = allNodes[targetNodeId];
|
||
|
||
if (!targetNode) continue;
|
||
|
||
// Get source and target positions
|
||
const sourceElement = document.getElementById(`node-${sourceNodeId}`);
|
||
const targetElement = document.getElementById(`node-${targetNodeId}`);
|
||
|
||
if (!sourceElement || !targetElement) continue;
|
||
|
||
const sourceRect = sourceElement.getBoundingClientRect();
|
||
const targetRect = targetElement.getBoundingClientRect();
|
||
|
||
// Calculate output port position (right side of source node)
|
||
const sourceX = sourceNode.pos_x + sourceRect.width;
|
||
const sourceY = sourceNode.pos_y + sourceRect.height / 2;
|
||
|
||
// Calculate input port position (left side of target node)
|
||
const targetX = targetNode.pos_x;
|
||
const targetY = targetNode.pos_y + targetRect.height / 2;
|
||
|
||
// Calculate distance from cursor to connection line
|
||
const distance = distanceToLineSegment(
|
||
cursorX, cursorY,
|
||
sourceX, sourceY,
|
||
targetX, targetY
|
||
);
|
||
|
||
// Check if this is the closest connection
|
||
if (distance < bestDistance) {
|
||
// Check port compatibility
|
||
const sourcePortIndex = parseInt(outputKey.replace('output_', '')) - 1;
|
||
const targetPortIndex = parseInt(connection.output.replace('input_', '')) - 1;
|
||
|
||
const sourceDef = nodeTypes[sourceNode.name];
|
||
const targetDef = nodeTypes[targetNode.name];
|
||
|
||
if (!sourceDef || !targetDef) continue;
|
||
|
||
// Get the signal type of the connection
|
||
if (sourcePortIndex >= sourceDef.outputs.length ||
|
||
targetPortIndex >= targetDef.inputs.length) continue;
|
||
|
||
const connectionType = sourceDef.outputs[sourcePortIndex].type;
|
||
|
||
// Check if the dragged node has compatible input and output
|
||
let compatibleInputIndex = -1;
|
||
let compatibleOutputIndex = -1;
|
||
|
||
// Find first compatible input and output
|
||
for (let i = 0; i < nodeDef.inputs.length; i++) {
|
||
if (nodeDef.inputs[i].type === connectionType) {
|
||
compatibleInputIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
for (let i = 0; i < nodeDef.outputs.length; i++) {
|
||
if (nodeDef.outputs[i].type === connectionType) {
|
||
compatibleOutputIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (compatibleInputIndex !== -1 && compatibleOutputIndex !== -1) {
|
||
bestDistance = distance;
|
||
bestMatch = {
|
||
sourceNodeId: parseInt(sourceNodeId),
|
||
targetNodeId: parseInt(targetNodeId),
|
||
sourcePort: sourcePortIndex,
|
||
targetPort: targetPortIndex,
|
||
nodeInputPort: compatibleInputIndex,
|
||
nodeOutputPort: compatibleOutputIndex,
|
||
connectionType: connectionType,
|
||
sourceOutputClass: outputKey,
|
||
targetInputClass: connection.output,
|
||
insertX: cursorX,
|
||
insertY: cursorY
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// If we found a match, highlight the connection and store it
|
||
if (bestMatch) {
|
||
highlightConnectionForInsertion(bestMatch);
|
||
pendingInsertionFromDrag = bestMatch;
|
||
} else {
|
||
clearConnectionHighlights();
|
||
pendingInsertionFromDrag = null;
|
||
}
|
||
}
|
||
|
||
// Check if a node can be inserted into a connection
|
||
function checkConnectionInsertion(nodeId) {
|
||
const node = editor.getNodeFromId(nodeId);
|
||
if (!node) return;
|
||
|
||
const nodeDef = nodeTypes[node.name];
|
||
if (!nodeDef) return;
|
||
|
||
// Check if node has any connections - skip if it does
|
||
let hasConnections = false;
|
||
for (const [inputKey, inputData] of Object.entries(node.inputs)) {
|
||
if (inputData.connections && inputData.connections.length > 0) {
|
||
hasConnections = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!hasConnections) {
|
||
for (const [outputKey, outputData] of Object.entries(node.outputs)) {
|
||
if (outputData.connections && outputData.connections.length > 0) {
|
||
hasConnections = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasConnections) {
|
||
clearConnectionHighlights();
|
||
pendingNodeInsertions.delete(nodeId);
|
||
return;
|
||
}
|
||
|
||
// Get node center position
|
||
const nodeElement = document.getElementById(`node-${nodeId}`);
|
||
if (!nodeElement) return;
|
||
|
||
const nodeRect = nodeElement.getBoundingClientRect();
|
||
const nodeCenterX = node.pos_x + nodeRect.width / 2;
|
||
const nodeCenterY = node.pos_y + nodeRect.height / 2;
|
||
|
||
// Get all connections in the current module
|
||
const module = editor.module;
|
||
const allNodes = editor.drawflow.drawflow[module]?.data || {};
|
||
|
||
// Distance threshold for insertion (in pixels)
|
||
const insertionThreshold = 30;
|
||
|
||
let bestMatch = null;
|
||
let bestDistance = insertionThreshold;
|
||
|
||
// Check each connection
|
||
for (const [sourceNodeId, sourceNode] of Object.entries(allNodes)) {
|
||
if (parseInt(sourceNodeId) === nodeId) continue; // Skip the node being dragged
|
||
|
||
for (const [outputKey, outputData] of Object.entries(sourceNode.outputs)) {
|
||
for (const connection of outputData.connections) {
|
||
const targetNodeId = connection.node;
|
||
const targetNode = allNodes[targetNodeId];
|
||
|
||
if (!targetNode || parseInt(targetNodeId) === nodeId) continue;
|
||
|
||
// Get source and target positions
|
||
const sourceElement = document.getElementById(`node-${sourceNodeId}`);
|
||
const targetElement = document.getElementById(`node-${targetNodeId}`);
|
||
|
||
if (!sourceElement || !targetElement) continue;
|
||
|
||
const sourceRect = sourceElement.getBoundingClientRect();
|
||
const targetRect = targetElement.getBoundingClientRect();
|
||
|
||
// Calculate output port position (right side of source node)
|
||
const sourceX = sourceNode.pos_x + sourceRect.width;
|
||
const sourceY = sourceNode.pos_y + sourceRect.height / 2;
|
||
|
||
// Calculate input port position (left side of target node)
|
||
const targetX = targetNode.pos_x;
|
||
const targetY = targetNode.pos_y + targetRect.height / 2;
|
||
|
||
// Calculate distance from node center to connection line
|
||
const distance = distanceToLineSegment(
|
||
nodeCenterX, nodeCenterY,
|
||
sourceX, sourceY,
|
||
targetX, targetY
|
||
);
|
||
|
||
// Check if this is the closest connection
|
||
if (distance < bestDistance) {
|
||
// Check port compatibility
|
||
const sourcePortIndex = parseInt(outputKey.replace('output_', '')) - 1;
|
||
const targetPortIndex = parseInt(connection.output.replace('input_', '')) - 1;
|
||
|
||
const sourceDef = nodeTypes[sourceNode.name];
|
||
const targetDef = nodeTypes[targetNode.name];
|
||
|
||
if (!sourceDef || !targetDef) continue;
|
||
|
||
// Get the signal type of the connection
|
||
if (sourcePortIndex >= sourceDef.outputs.length ||
|
||
targetPortIndex >= targetDef.inputs.length) continue;
|
||
|
||
const connectionType = sourceDef.outputs[sourcePortIndex].type;
|
||
|
||
// Check if the dragged node has compatible input and output
|
||
let hasCompatibleInput = false;
|
||
let hasCompatibleOutput = false;
|
||
let compatibleInputIndex = -1;
|
||
let compatibleOutputIndex = -1;
|
||
|
||
// Find first compatible input and output
|
||
for (let i = 0; i < nodeDef.inputs.length; i++) {
|
||
if (nodeDef.inputs[i].type === connectionType) {
|
||
hasCompatibleInput = true;
|
||
compatibleInputIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
for (let i = 0; i < nodeDef.outputs.length; i++) {
|
||
if (nodeDef.outputs[i].type === connectionType) {
|
||
hasCompatibleOutput = true;
|
||
compatibleOutputIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (hasCompatibleInput && hasCompatibleOutput) {
|
||
bestDistance = distance;
|
||
bestMatch = {
|
||
sourceNodeId: parseInt(sourceNodeId),
|
||
targetNodeId: parseInt(targetNodeId),
|
||
sourcePort: sourcePortIndex,
|
||
targetPort: targetPortIndex,
|
||
nodeInputPort: compatibleInputIndex,
|
||
nodeOutputPort: compatibleOutputIndex,
|
||
connectionType: connectionType,
|
||
sourceOutputClass: outputKey,
|
||
targetInputClass: connection.output
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// If we found a match, highlight the connection
|
||
if (bestMatch) {
|
||
highlightConnectionForInsertion(bestMatch);
|
||
// Store the match in the Map for use on mouseup
|
||
pendingNodeInsertions.set(nodeId, bestMatch);
|
||
} else {
|
||
clearConnectionHighlights();
|
||
pendingNodeInsertions.delete(nodeId);
|
||
}
|
||
}
|
||
|
||
// Track which connection is highlighted for insertion
|
||
let highlightedConnection = null;
|
||
let highlightInterval = null;
|
||
let pendingInsertionFromDrag = null;
|
||
|
||
// Track pending insertions for existing nodes being dragged
|
||
const pendingNodeInsertions = new Map(); // nodeId -> insertion match
|
||
|
||
// Apply highlight to the tracked connection
|
||
function applyConnectionHighlight() {
|
||
if (!highlightedConnection) return;
|
||
|
||
const connectionElement = document.querySelector(
|
||
`.connection.node_in_node-${highlightedConnection.targetNodeId}.node_out_node-${highlightedConnection.sourceNodeId}`
|
||
);
|
||
|
||
if (connectionElement && !connectionElement.classList.contains('connection-insertion-highlight')) {
|
||
connectionElement.classList.add('connection-insertion-highlight');
|
||
}
|
||
}
|
||
|
||
// Highlight a connection that can receive the node
|
||
function highlightConnectionForInsertion(match) {
|
||
// Store the connection to highlight
|
||
highlightedConnection = match;
|
||
|
||
// Clear any existing interval
|
||
if (highlightInterval) {
|
||
clearInterval(highlightInterval);
|
||
}
|
||
|
||
// Apply highlight immediately
|
||
applyConnectionHighlight();
|
||
|
||
// Keep re-applying in case Drawflow redraws
|
||
highlightInterval = setInterval(applyConnectionHighlight, 50);
|
||
}
|
||
|
||
// Clear connection insertion highlights
|
||
function clearConnectionHighlights() {
|
||
// Stop the interval
|
||
if (highlightInterval) {
|
||
clearInterval(highlightInterval);
|
||
highlightInterval = null;
|
||
}
|
||
|
||
highlightedConnection = null;
|
||
|
||
// Remove all highlight classes
|
||
document.querySelectorAll('.connection-insertion-highlight').forEach(el => {
|
||
el.classList.remove('connection-insertion-highlight');
|
||
});
|
||
}
|
||
|
||
// Handle connection creation
|
||
function handleConnectionCreated(connection) {
|
||
console.log("handleConnectionCreated called:", connection);
|
||
const outputNode = editor.getNodeFromId(connection.output_id);
|
||
const inputNode = editor.getNodeFromId(connection.input_id);
|
||
|
||
console.log("Output node:", outputNode, "Input node:", inputNode);
|
||
if (!outputNode || !inputNode) {
|
||
console.log("Missing node - returning");
|
||
return;
|
||
}
|
||
|
||
console.log("Output node name:", outputNode.name, "Input node name:", inputNode.name);
|
||
const outputDef = nodeTypes[outputNode.name];
|
||
const inputDef = nodeTypes[inputNode.name];
|
||
|
||
console.log("Output def:", outputDef, "Input def:", inputDef);
|
||
if (!outputDef || !inputDef) {
|
||
console.log("Missing node definition - returning");
|
||
return;
|
||
}
|
||
|
||
// Extract port indices from connection class names
|
||
// Drawflow uses 1-based indexing, but our arrays are 0-based
|
||
const outputPort = parseInt(connection.output_class.replace("output_", "")) - 1;
|
||
const inputPort = parseInt(connection.input_class.replace("input_", "")) - 1;
|
||
|
||
console.log("Port indices (0-based) - output:", outputPort, "input:", inputPort);
|
||
console.log("Output class:", connection.output_class, "Input class:", connection.input_class);
|
||
|
||
// Validate signal types
|
||
console.log("Checking port bounds - outputPort:", outputPort, "< outputs.length:", outputDef.outputs.length, "inputPort:", inputPort, "< inputs.length:", inputDef.inputs.length);
|
||
if (outputPort < outputDef.outputs.length && inputPort < inputDef.inputs.length) {
|
||
const outputType = outputDef.outputs[outputPort].type;
|
||
const inputType = inputDef.inputs[inputPort].type;
|
||
|
||
console.log("Signal types - output:", outputType, "input:", inputType);
|
||
|
||
if (outputType !== inputType) {
|
||
console.log("TYPE MISMATCH! Removing connection");
|
||
// Type mismatch - remove connection
|
||
editor.removeSingleConnection(
|
||
connection.output_id,
|
||
connection.input_id,
|
||
connection.output_class,
|
||
connection.input_class
|
||
);
|
||
showError(`Cannot connect ${outputType} to ${inputType}`);
|
||
return;
|
||
}
|
||
|
||
console.log("Types match - proceeding with connection");
|
||
|
||
// Auto-switch Oscilloscope to V/oct trigger mode when connecting to V/oct input
|
||
if (inputNode.name === 'Oscilloscope' && inputPort === 1) {
|
||
console.log(`Auto-switching Oscilloscope node ${connection.input_id} to V/oct trigger mode`);
|
||
// Set trigger_mode parameter (id: 1) to value 3 (V/oct)
|
||
const triggerModeSlider = document.querySelector(`#node-${connection.input_id} input[data-param="1"]`);
|
||
const triggerModeSpan = document.querySelector(`#trigger_mode-${connection.input_id}`);
|
||
if (triggerModeSlider) {
|
||
triggerModeSlider.value = 3;
|
||
if (triggerModeSpan) {
|
||
triggerModeSpan.textContent = 'V/oct';
|
||
}
|
||
// Update backend parameter
|
||
if (inputNode.data.backendId !== null) {
|
||
const currentTrackId = getCurrentMidiTrack();
|
||
if (currentTrackId !== null) {
|
||
invoke("graph_set_parameter", {
|
||
trackId: currentTrackId,
|
||
nodeId: inputNode.data.backendId,
|
||
paramId: 1,
|
||
value: 3.0
|
||
}).catch(err => console.error("Failed to set V/oct trigger mode:", err));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Style the connection based on signal type
|
||
setTimeout(() => {
|
||
const connectionElement = document.querySelector(
|
||
`.connection.node_in_node-${connection.input_id}.node_out_node-${connection.output_id}`
|
||
);
|
||
if (connectionElement) {
|
||
connectionElement.classList.add(`connection-${outputType}`);
|
||
}
|
||
}, 10);
|
||
|
||
// Send to backend
|
||
console.log("Backend IDs - output:", outputNode.data.backendId, "input:", inputNode.data.backendId);
|
||
if (outputNode.data.backendId !== null && inputNode.data.backendId !== null) {
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo === null) return;
|
||
const currentTrackId = trackInfo.trackId;
|
||
|
||
// Check if we're in template editing mode (dedicated view)
|
||
if (editingContext) {
|
||
// Connecting in template view
|
||
console.log(`Connecting in template ${editingContext.voiceAllocatorId}: node ${outputNode.data.backendId} port ${outputPort} -> node ${inputNode.data.backendId} port ${inputPort}`);
|
||
invoke("graph_connect_in_template", {
|
||
trackId: currentTrackId,
|
||
voiceAllocatorId: editingContext.voiceAllocatorId,
|
||
fromNode: outputNode.data.backendId,
|
||
fromPort: outputPort,
|
||
toNode: inputNode.data.backendId,
|
||
toPort: inputPort
|
||
}).then(() => {
|
||
console.log("Template connection successful");
|
||
}).catch(err => {
|
||
console.error("Failed to connect nodes in template:", err);
|
||
showError("Template connection failed: " + err);
|
||
// Remove the connection
|
||
editor.removeSingleConnection(
|
||
connection.output_id,
|
||
connection.input_id,
|
||
connection.output_class,
|
||
connection.input_class
|
||
);
|
||
});
|
||
} else {
|
||
// Check if both nodes are inside the same VoiceAllocator (inline editing)
|
||
// Convert connection IDs to numbers to match Map keys
|
||
const outputId = parseInt(connection.output_id);
|
||
const inputId = parseInt(connection.input_id);
|
||
const outputParent = nodeParents.get(outputId);
|
||
const inputParent = nodeParents.get(inputId);
|
||
console.log(`Parent detection - output node ${outputId} parent: ${outputParent}, input node ${inputId} parent: ${inputParent}`);
|
||
|
||
if (outputParent && inputParent && outputParent === inputParent) {
|
||
// Both nodes are inside the same VoiceAllocator - connect in template (inline editing)
|
||
const parentNode = editor.getNodeFromId(outputParent);
|
||
console.log(`Connecting in VoiceAllocator template ${parentNode.data.backendId}: node ${outputNode.data.backendId} port ${outputPort} -> node ${inputNode.data.backendId} port ${inputPort}`);
|
||
invoke("graph_connect_in_template", {
|
||
trackId: currentTrackId,
|
||
voiceAllocatorId: parentNode.data.backendId,
|
||
fromNode: outputNode.data.backendId,
|
||
fromPort: outputPort,
|
||
toNode: inputNode.data.backendId,
|
||
toPort: inputPort
|
||
}).then(() => {
|
||
console.log("Template connection successful");
|
||
}).catch(err => {
|
||
console.error("Failed to connect nodes in template:", err);
|
||
showError("Template connection failed: " + err);
|
||
// Remove the connection
|
||
editor.removeSingleConnection(
|
||
connection.output_id,
|
||
connection.input_id,
|
||
connection.output_class,
|
||
connection.input_class
|
||
);
|
||
});
|
||
} else {
|
||
// Normal connection in main graph (skip if action is handling it)
|
||
console.log(`Connecting: node ${outputNode.data.backendId} port ${outputPort} -> node ${inputNode.data.backendId} port ${inputPort}`);
|
||
invoke("graph_connect", {
|
||
trackId: currentTrackId,
|
||
fromNode: outputNode.data.backendId,
|
||
fromPort: outputPort,
|
||
toNode: inputNode.data.backendId,
|
||
toPort: inputPort
|
||
}).then(async () => {
|
||
console.log("Connection successful");
|
||
|
||
// Record action for undo (only if not suppressing)
|
||
if (!suppressActionRecording) {
|
||
redoStack.length = 0;
|
||
undoStack.push({
|
||
name: "graphAddConnection",
|
||
action: {
|
||
trackId: currentTrackId,
|
||
fromNode: outputNode.data.backendId,
|
||
fromPort: outputPort,
|
||
toNode: inputNode.data.backendId,
|
||
toPort: inputPort,
|
||
// Store frontend IDs for disconnection
|
||
frontendFromId: connection.output_id,
|
||
frontendToId: connection.input_id,
|
||
fromPortClass: connection.output_class,
|
||
toPortClass: connection.input_class
|
||
}
|
||
});
|
||
}
|
||
|
||
// Auto-name AutomationInput nodes when connected
|
||
await updateAutomationName(
|
||
currentTrackId,
|
||
outputNode.data.backendId,
|
||
inputNode.data.backendId,
|
||
connection.input_class
|
||
);
|
||
|
||
updateMenu();
|
||
}).catch(err => {
|
||
console.error("Failed to connect nodes:", err);
|
||
showError("Connection failed: " + err);
|
||
// Remove the connection
|
||
editor.removeSingleConnection(
|
||
connection.output_id,
|
||
connection.input_id,
|
||
connection.output_class,
|
||
connection.input_class
|
||
);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
} else {
|
||
console.log("Port validation FAILED - ports out of bounds");
|
||
}
|
||
}
|
||
|
||
// Handle connection removal
|
||
function handleConnectionRemoved(connection) {
|
||
const outputNode = editor.getNodeFromId(connection.output_id);
|
||
const inputNode = editor.getNodeFromId(connection.input_id);
|
||
|
||
if (!outputNode || !inputNode) return;
|
||
|
||
// Drawflow uses 1-based indexing, but our arrays are 0-based
|
||
const outputPort = parseInt(connection.output_class.replace("output_", "")) - 1;
|
||
const inputPort = parseInt(connection.input_class.replace("input_", "")) - 1;
|
||
|
||
// Auto-switch Oscilloscope back to Free mode when disconnecting V/oct input
|
||
if (inputNode.name === 'Oscilloscope' && inputPort === 1) {
|
||
console.log(`Auto-switching Oscilloscope node ${connection.input_id} back to Free trigger mode`);
|
||
const triggerModeSlider = document.querySelector(`#node-${connection.input_id} input[data-param="1"]`);
|
||
const triggerModeSpan = document.querySelector(`#trigger_mode-${connection.input_id}`);
|
||
if (triggerModeSlider) {
|
||
triggerModeSlider.value = 0;
|
||
if (triggerModeSpan) {
|
||
triggerModeSpan.textContent = 'Free';
|
||
}
|
||
// Update backend parameter
|
||
if (inputNode.data.backendId !== null) {
|
||
const currentTrackId = getCurrentMidiTrack();
|
||
if (currentTrackId !== null) {
|
||
invoke("graph_set_parameter", {
|
||
trackId: currentTrackId,
|
||
nodeId: inputNode.data.backendId,
|
||
paramId: 1,
|
||
value: 0.0
|
||
}).catch(err => console.error("Failed to set Free trigger mode:", err));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Send to backend
|
||
if (outputNode.data.backendId !== null && inputNode.data.backendId !== null) {
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo !== null) {
|
||
invoke("graph_disconnect", {
|
||
trackId: trackInfo.trackId,
|
||
fromNode: outputNode.data.backendId,
|
||
fromPort: outputPort,
|
||
toNode: inputNode.data.backendId,
|
||
toPort: inputPort
|
||
}).then(() => {
|
||
// Record action for undo (only if not suppressing)
|
||
if (!suppressActionRecording) {
|
||
redoStack.length = 0;
|
||
undoStack.push({
|
||
name: "graphRemoveConnection",
|
||
action: {
|
||
trackId: trackInfo.trackId,
|
||
fromNode: outputNode.data.backendId,
|
||
fromPort: outputPort,
|
||
toNode: inputNode.data.backendId,
|
||
toPort: inputPort,
|
||
// Store frontend IDs for reconnection
|
||
frontendFromId: connection.output_id,
|
||
frontendToId: connection.input_id,
|
||
fromPortClass: connection.output_class,
|
||
toPortClass: connection.input_class
|
||
}
|
||
});
|
||
updateMenu();
|
||
}
|
||
}).catch(err => {
|
||
console.error("Failed to disconnect nodes:", err);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Show error message
|
||
function showError(message) {
|
||
const errorDiv = document.createElement("div");
|
||
errorDiv.className = "node-editor-error";
|
||
errorDiv.textContent = message;
|
||
container.appendChild(errorDiv);
|
||
|
||
setTimeout(() => {
|
||
errorDiv.remove();
|
||
}, 3000);
|
||
}
|
||
|
||
// Function to update breadcrumb display
|
||
function updateBreadcrumb() {
|
||
const breadcrumb = header.querySelector('.context-breadcrumb');
|
||
if (editingContext) {
|
||
// Determine main graph name based on track type
|
||
const trackInfo = getCurrentTrack();
|
||
const mainGraphName = trackInfo?.trackType === 'audio' ? 'Effects Graph' : 'Instrument Graph';
|
||
|
||
breadcrumb.innerHTML = `
|
||
${mainGraphName} >
|
||
<span class="template-name">${editingContext.voiceAllocatorName} Template</span>
|
||
<button class="exit-template-btn">← Exit Template</button>
|
||
`;
|
||
const exitBtn = breadcrumb.querySelector('.exit-template-btn');
|
||
exitBtn.addEventListener('click', exitTemplate);
|
||
} else {
|
||
// Not in template mode - show main graph name based on track type
|
||
const trackInfo = getCurrentTrack();
|
||
const graphName = trackInfo?.trackType === 'audio' ? 'Effects Graph' :
|
||
trackInfo?.trackType === 'midi' ? 'Instrument Graph' :
|
||
'Node Graph';
|
||
breadcrumb.textContent = graphName;
|
||
}
|
||
}
|
||
|
||
// Function to enter template editing mode
|
||
async function enterTemplate(voiceAllocatorId, voiceAllocatorName) {
|
||
editingContext = { voiceAllocatorId, voiceAllocatorName };
|
||
updateBreadcrumb();
|
||
updatePalette();
|
||
await reloadGraph();
|
||
}
|
||
|
||
// Function to exit template editing mode
|
||
async function exitTemplate() {
|
||
editingContext = null;
|
||
updateBreadcrumb();
|
||
updatePalette();
|
||
await reloadGraph();
|
||
}
|
||
|
||
// Function to reload graph from backend
|
||
async function reloadGraph() {
|
||
if (!editor) return;
|
||
|
||
const trackInfo = getCurrentTrack();
|
||
|
||
// Clear editor first
|
||
editor.clearModuleSelected();
|
||
editor.clear();
|
||
|
||
// Update UI based on track type
|
||
updateBreadcrumb();
|
||
updatePalette();
|
||
|
||
// If no track selected, just leave it cleared
|
||
if (trackInfo === null) {
|
||
console.log('No track selected, editor cleared');
|
||
return;
|
||
}
|
||
|
||
const trackId = trackInfo.trackId;
|
||
|
||
try {
|
||
// Get graph based on editing context
|
||
let graphJson;
|
||
if (editingContext) {
|
||
// Loading template graph
|
||
graphJson = await invoke('graph_get_template_state', {
|
||
trackId,
|
||
voiceAllocatorId: editingContext.voiceAllocatorId
|
||
});
|
||
} else {
|
||
// Loading main graph
|
||
graphJson = await invoke('graph_get_state', { trackId });
|
||
}
|
||
|
||
const preset = JSON.parse(graphJson);
|
||
|
||
// If graph is empty (no nodes), just leave cleared
|
||
if (!preset.nodes || preset.nodes.length === 0) {
|
||
console.log('Graph is empty, editor cleared');
|
||
return;
|
||
}
|
||
|
||
// Rebuild from preset
|
||
const nodeMap = new Map(); // Maps backend node ID to Drawflow node ID
|
||
const setupPromises = []; // Track async setup operations
|
||
|
||
// Add all nodes
|
||
for (const serializedNode of preset.nodes) {
|
||
const nodeType = serializedNode.node_type;
|
||
const nodeDef = nodeTypes[nodeType];
|
||
if (!nodeDef) continue;
|
||
|
||
// Create node HTML using the node definition's getHTML function
|
||
// Use backend node ID as the nodeId for unique element IDs
|
||
const html = nodeDef.getHTML(serializedNode.id);
|
||
|
||
// Add node to Drawflow
|
||
const drawflowId = editor.addNode(
|
||
nodeType,
|
||
nodeDef.inputs.length,
|
||
nodeDef.outputs.length,
|
||
serializedNode.position[0],
|
||
serializedNode.position[1],
|
||
nodeType,
|
||
{ nodeType, backendId: serializedNode.id, parentNodeId: null },
|
||
html,
|
||
false
|
||
);
|
||
|
||
nodeMap.set(serializedNode.id, drawflowId);
|
||
|
||
// Style ports (as Promise)
|
||
setupPromises.push(new Promise(resolve => {
|
||
setTimeout(() => {
|
||
styleNodePorts(drawflowId, nodeDef);
|
||
resolve();
|
||
}, 10);
|
||
}));
|
||
|
||
// Wire up parameter controls and set values from preset (as Promise)
|
||
setupPromises.push(new Promise(resolve => {
|
||
setTimeout(() => {
|
||
const nodeElement = container.querySelector(`#node-${drawflowId}`);
|
||
if (!nodeElement) return;
|
||
|
||
// Set parameter values from preset
|
||
nodeElement.querySelectorAll('input[type="range"]').forEach(slider => {
|
||
const paramId = parseInt(slider.dataset.param);
|
||
const value = serializedNode.parameters[paramId];
|
||
if (value !== undefined) {
|
||
slider.value = value;
|
||
// Update display span
|
||
const param = nodeDef.parameters.find(p => p.id === paramId);
|
||
const displaySpan = slider.previousElementSibling?.querySelector('span');
|
||
if (displaySpan && param) {
|
||
displaySpan.textContent = value.toFixed(param.unit === 'Hz' ? 0 : 2) + (param.unit ? ` ${param.unit}` : '');
|
||
}
|
||
}
|
||
});
|
||
|
||
// Set up event handlers for buttons
|
||
|
||
// Handle Load Sample button for SimpleSampler
|
||
const loadSampleBtn = nodeElement.querySelector(".load-sample-btn");
|
||
if (loadSampleBtn) {
|
||
loadSampleBtn.addEventListener("mousedown", (e) => e.stopPropagation());
|
||
loadSampleBtn.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||
loadSampleBtn.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
|
||
const nodeData = editor.getNodeFromId(drawflowId);
|
||
if (!nodeData || nodeData.data.backendId === null) {
|
||
showError("Node not yet created on backend");
|
||
return;
|
||
}
|
||
|
||
const currentTrackId = getCurrentMidiTrack();
|
||
if (currentTrackId === null) {
|
||
showError("No MIDI track selected");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const filePath = await openFileDialog({
|
||
title: "Load Audio Sample",
|
||
filters: [{
|
||
name: "Audio Files",
|
||
extensions: audioExtensions
|
||
}]
|
||
});
|
||
|
||
if (filePath) {
|
||
await invoke("sampler_load_sample", {
|
||
trackId: currentTrackId,
|
||
nodeId: nodeData.data.backendId,
|
||
filePath: filePath
|
||
});
|
||
|
||
// Update UI to show filename
|
||
const sampleInfo = nodeElement.querySelector(`#sample-info-${drawflowId}`);
|
||
if (sampleInfo) {
|
||
const filename = filePath.split('/').pop().split('\\').pop();
|
||
sampleInfo.textContent = filename;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to load sample:", err);
|
||
showError(`Failed to load sample: ${err}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Handle Add Layer button for MultiSampler
|
||
const addLayerBtn = nodeElement.querySelector(".add-layer-btn");
|
||
if (addLayerBtn) {
|
||
addLayerBtn.addEventListener("mousedown", (e) => e.stopPropagation());
|
||
addLayerBtn.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||
addLayerBtn.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
|
||
const nodeData = editor.getNodeFromId(drawflowId);
|
||
if (!nodeData || nodeData.data.backendId === null) {
|
||
showError("Node not yet created on backend");
|
||
return;
|
||
}
|
||
|
||
const currentTrackId = getCurrentMidiTrack();
|
||
if (currentTrackId === null) {
|
||
showError("No MIDI track selected");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const filePath = await openFileDialog({
|
||
title: "Add Sample Layer",
|
||
filters: [{
|
||
name: "Audio Files",
|
||
extensions: audioExtensions
|
||
}]
|
||
});
|
||
|
||
if (filePath) {
|
||
// Show dialog to configure layer mapping
|
||
const layerConfig = await showLayerConfigDialog(filePath);
|
||
|
||
if (layerConfig) {
|
||
await invoke("multi_sampler_add_layer", {
|
||
trackId: currentTrackId,
|
||
nodeId: nodeData.data.backendId,
|
||
filePath: filePath,
|
||
keyMin: layerConfig.keyMin,
|
||
keyMax: layerConfig.keyMax,
|
||
rootKey: layerConfig.rootKey,
|
||
velocityMin: layerConfig.velocityMin,
|
||
velocityMax: layerConfig.velocityMax,
|
||
loopStart: layerConfig.loopStart,
|
||
loopEnd: layerConfig.loopEnd,
|
||
loopMode: layerConfig.loopMode
|
||
});
|
||
|
||
// Wait a bit for the audio thread to process the add command
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
// Refresh the layers list
|
||
await refreshSampleLayersList(drawflowId);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to add layer:", err);
|
||
showError(`Failed to add layer: ${err}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
// For MultiSampler nodes, populate the layers table from preset data
|
||
if (nodeType === 'MultiSampler') {
|
||
console.log(`[reloadGraph] Found MultiSampler node ${drawflowId}, sample_data:`, serializedNode.sample_data);
|
||
if (serializedNode.sample_data) {
|
||
console.log(`[reloadGraph] sample_data.type:`, serializedNode.sample_data.type);
|
||
console.log(`[reloadGraph] sample_data keys:`, Object.keys(serializedNode.sample_data));
|
||
}
|
||
}
|
||
|
||
if (nodeType === 'MultiSampler' && serializedNode.sample_data && serializedNode.sample_data.type === 'multi_sampler') {
|
||
console.log(`[reloadGraph] Condition met for node ${drawflowId}, looking for layers list element`);
|
||
// Query for elements by prefix to avoid ID mismatch issues
|
||
const layersList = nodeElement.querySelector('[id^="sample-layers-list-"]');
|
||
const layersContainer = nodeElement.querySelector('[id^="sample-layers-container-"]');
|
||
console.log(`[reloadGraph] layersList:`, layersList);
|
||
console.log(`[reloadGraph] layersContainer:`, layersContainer);
|
||
|
||
if (layersList) {
|
||
const layers = serializedNode.sample_data.layers || [];
|
||
console.log(`[reloadGraph] Populating ${layers.length} layers for node ${drawflowId}`);
|
||
|
||
// Prevent scroll events from bubbling to canvas
|
||
if (layersContainer && !layersContainer.dataset.scrollListenerAdded) {
|
||
layersContainer.addEventListener('wheel', (e) => {
|
||
e.stopPropagation();
|
||
}, { passive: false });
|
||
layersContainer.dataset.scrollListenerAdded = 'true';
|
||
}
|
||
|
||
if (layers.length === 0) {
|
||
layersList.innerHTML = '<tr><td colspan="5" class="sample-layers-empty">No layers loaded</td></tr>';
|
||
} else {
|
||
layersList.innerHTML = layers.map((layer, index) => {
|
||
const filename = layer.file_path.split('/').pop().split('\\').pop();
|
||
const keyRange = `${midiToNoteName(layer.key_min)}-${midiToNoteName(layer.key_max)}`;
|
||
const rootNote = midiToNoteName(layer.root_key);
|
||
const velRange = `${layer.velocity_min}-${layer.velocity_max}`;
|
||
|
||
return `
|
||
<tr data-index="${index}">
|
||
<td class="sample-layer-filename" title="${filename}">${filename}</td>
|
||
<td>${keyRange}</td>
|
||
<td>${rootNote}</td>
|
||
<td>${velRange}</td>
|
||
<td>
|
||
<div class="sample-layer-actions">
|
||
<button class="btn-edit-layer" data-drawflow-node="${drawflowId}" data-index="${index}">Edit</button>
|
||
<button class="btn-delete-layer" data-drawflow-node="${drawflowId}" data-index="${index}">Del</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
|
||
// Set up event handlers for edit/delete buttons
|
||
layersList.querySelectorAll('.btn-edit-layer').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const drawflowNodeId = parseInt(btn.dataset.drawflowNode);
|
||
const layerIndex = parseInt(btn.dataset.index);
|
||
const layer = layers[layerIndex];
|
||
|
||
// Show dialog with current layer settings
|
||
const layerConfig = await showLayerConfigDialog(layer.file_path, {
|
||
keyMin: layer.key_min,
|
||
keyMax: layer.key_max,
|
||
rootKey: layer.root_key,
|
||
velocityMin: layer.velocity_min,
|
||
velocityMax: layer.velocity_max,
|
||
loopStart: layer.loop_start,
|
||
loopEnd: layer.loop_end,
|
||
loopMode: layer.loop_mode
|
||
});
|
||
|
||
if (layerConfig) {
|
||
const nodeData = editor.getNodeFromId(drawflowNodeId);
|
||
const currentTrackId = getCurrentMidiTrack();
|
||
if (nodeData && currentTrackId !== null) {
|
||
try {
|
||
await invoke("multi_sampler_update_layer", {
|
||
trackId: currentTrackId,
|
||
nodeId: nodeData.data.backendId,
|
||
layerIndex: layerIndex,
|
||
keyMin: layerConfig.keyMin,
|
||
keyMax: layerConfig.keyMax,
|
||
rootKey: layerConfig.rootKey,
|
||
velocityMin: layerConfig.velocityMin,
|
||
velocityMax: layerConfig.velocityMax,
|
||
loopStart: layerConfig.loopStart,
|
||
loopEnd: layerConfig.loopEnd,
|
||
loopMode: layerConfig.loopMode
|
||
});
|
||
await refreshSampleLayersList(drawflowNodeId);
|
||
} catch (err) {
|
||
showError(`Failed to update layer: ${err}`);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
layersList.querySelectorAll('.btn-delete-layer').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const drawflowNodeId = parseInt(btn.dataset.drawflowNode);
|
||
const layerIndex = parseInt(btn.dataset.index);
|
||
if (confirm('Delete this sample layer?')) {
|
||
const nodeData = editor.getNodeFromId(drawflowNodeId);
|
||
const currentTrackId = getCurrentMidiTrack();
|
||
if (nodeData && currentTrackId !== null) {
|
||
try {
|
||
await invoke("multi_sampler_remove_layer", {
|
||
trackId: currentTrackId,
|
||
nodeId: nodeData.data.backendId,
|
||
layerIndex: layerIndex
|
||
});
|
||
await refreshSampleLayersList(drawflowNodeId);
|
||
} catch (err) {
|
||
showError(`Failed to remove layer: ${err}`);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// For Oscilloscope nodes, start the visualization
|
||
if (nodeType === 'Oscilloscope' && serializedNode.id && trackId) {
|
||
startOscilloscopeVisualization(drawflowId, trackId, serializedNode.id, editor);
|
||
}
|
||
|
||
resolve();
|
||
}, 100);
|
||
}));
|
||
}
|
||
|
||
// Add all connections
|
||
for (const conn of preset.connections) {
|
||
const outputDrawflowId = nodeMap.get(conn.from_node);
|
||
const inputDrawflowId = nodeMap.get(conn.to_node);
|
||
|
||
if (outputDrawflowId && inputDrawflowId) {
|
||
// Drawflow uses 1-based port indexing
|
||
editor.addConnection(
|
||
outputDrawflowId,
|
||
inputDrawflowId,
|
||
`output_${conn.from_port + 1}`,
|
||
`input_${conn.to_port + 1}`
|
||
);
|
||
|
||
// Style the connection based on signal type
|
||
// We need to look up the node type and get the output port signal type
|
||
setupPromises.push(new Promise(resolve => {
|
||
setTimeout(() => {
|
||
const outputNode = editor.getNodeFromId(outputDrawflowId);
|
||
if (outputNode) {
|
||
const nodeType = outputNode.data.nodeType;
|
||
const nodeDef = nodeTypes[nodeType];
|
||
if (nodeDef && conn.from_port < nodeDef.outputs.length) {
|
||
const signalType = nodeDef.outputs[conn.from_port].type;
|
||
const connectionElement = document.querySelector(
|
||
`.connection.node_in_node-${inputDrawflowId}.node_out_node-${outputDrawflowId}`
|
||
);
|
||
if (connectionElement) {
|
||
connectionElement.classList.add(`connection-${signalType}`);
|
||
}
|
||
}
|
||
}
|
||
resolve();
|
||
}, 10);
|
||
}));
|
||
}
|
||
}
|
||
|
||
// Wait for all node setup operations to complete
|
||
await Promise.all(setupPromises);
|
||
|
||
console.log('Graph reloaded from backend');
|
||
} catch (error) {
|
||
console.error('Failed to reload graph:', error);
|
||
showError(`Failed to reload graph: ${error}`);
|
||
}
|
||
}
|
||
|
||
// Store reload function in context so it can be called from preset browser
|
||
// Wrap it to track the promise
|
||
context.reloadNodeEditor = async () => {
|
||
context.reloadGraphPromise = reloadGraph();
|
||
await context.reloadGraphPromise;
|
||
context.reloadGraphPromise = null;
|
||
};
|
||
|
||
// Store refreshSampleLayersList in context so it can be called from event handlers
|
||
context.refreshSampleLayersList = refreshSampleLayersList;
|
||
|
||
// Initial load of graph
|
||
setTimeout(() => reloadGraph(), 200);
|
||
|
||
return container;
|
||
}
|
||
|
||
function piano() {
|
||
let piano_cvs = document.createElement("canvas");
|
||
piano_cvs.className = "piano";
|
||
|
||
// Create the virtual piano widget
|
||
piano_cvs.virtualPiano = new VirtualPiano();
|
||
|
||
// Variable to store the last time updatePianoCanvasSize was called
|
||
let lastResizeTime = 0;
|
||
const throttleIntervalMs = 20;
|
||
|
||
function updatePianoCanvasSize() {
|
||
const canvasStyles = window.getComputedStyle(piano_cvs);
|
||
const width = parseInt(canvasStyles.width);
|
||
const height = parseInt(canvasStyles.height);
|
||
|
||
// Set actual size in memory (scaled for retina displays)
|
||
piano_cvs.width = width * window.devicePixelRatio;
|
||
piano_cvs.height = height * window.devicePixelRatio;
|
||
|
||
// Normalize coordinate system to use CSS pixels
|
||
const ctx = piano_cvs.getContext("2d");
|
||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||
|
||
// Render the piano
|
||
piano_cvs.virtualPiano.draw(ctx, width, height);
|
||
}
|
||
|
||
// Store references in context for global access
|
||
context.pianoWidget = piano_cvs.virtualPiano;
|
||
context.pianoCanvas = piano_cvs;
|
||
context.pianoRedraw = updatePianoCanvasSize;
|
||
|
||
const resizeObserver = new ResizeObserver((entries) => {
|
||
const currentTime = Date.now();
|
||
if (currentTime - lastResizeTime >= throttleIntervalMs) {
|
||
lastResizeTime = currentTime;
|
||
updatePianoCanvasSize();
|
||
}
|
||
});
|
||
resizeObserver.observe(piano_cvs);
|
||
|
||
// Mouse event handlers
|
||
piano_cvs.addEventListener("mousedown", (e) => {
|
||
const rect = piano_cvs.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
const width = parseInt(window.getComputedStyle(piano_cvs).width);
|
||
const height = parseInt(window.getComputedStyle(piano_cvs).height);
|
||
piano_cvs.virtualPiano.mousedown(x, y, width, height);
|
||
updatePianoCanvasSize(); // Redraw to show pressed state
|
||
});
|
||
|
||
piano_cvs.addEventListener("mousemove", (e) => {
|
||
const rect = piano_cvs.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
const width = parseInt(window.getComputedStyle(piano_cvs).width);
|
||
const height = parseInt(window.getComputedStyle(piano_cvs).height);
|
||
piano_cvs.virtualPiano.mousemove(x, y, width, height);
|
||
updatePianoCanvasSize(); // Redraw to show hover state
|
||
});
|
||
|
||
piano_cvs.addEventListener("mouseup", (e) => {
|
||
const rect = piano_cvs.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
const width = parseInt(window.getComputedStyle(piano_cvs).width);
|
||
const height = parseInt(window.getComputedStyle(piano_cvs).height);
|
||
piano_cvs.virtualPiano.mouseup(x, y, width, height);
|
||
updatePianoCanvasSize(); // Redraw to show released state
|
||
});
|
||
|
||
// Prevent text selection
|
||
piano_cvs.addEventListener("selectstart", (e) => e.preventDefault());
|
||
|
||
// Add header controls for octave and velocity
|
||
piano_cvs.headerControls = function() {
|
||
const controls = [];
|
||
|
||
// Octave control
|
||
const octaveLabel = document.createElement("span");
|
||
octaveLabel.style.marginLeft = "auto";
|
||
octaveLabel.style.marginRight = "10px";
|
||
octaveLabel.style.fontSize = "12px";
|
||
octaveLabel.textContent = `Octave: ${piano_cvs.virtualPiano.octaveOffset >= 0 ? '+' : ''}${piano_cvs.virtualPiano.octaveOffset} (Z/X)`;
|
||
|
||
// Velocity control
|
||
const velocityLabel = document.createElement("span");
|
||
velocityLabel.style.marginRight = "10px";
|
||
velocityLabel.style.fontSize = "12px";
|
||
velocityLabel.textContent = `Velocity: ${piano_cvs.virtualPiano.velocity} (C/V)`;
|
||
|
||
// Update function to refresh labels
|
||
const updateLabels = () => {
|
||
octaveLabel.textContent = `Octave: ${piano_cvs.virtualPiano.octaveOffset >= 0 ? '+' : ''}${piano_cvs.virtualPiano.octaveOffset} (Z/X)`;
|
||
velocityLabel.textContent = `Velocity: ${piano_cvs.virtualPiano.velocity} (C/V)`;
|
||
};
|
||
|
||
// Listen for keyboard events to update labels
|
||
window.addEventListener('keydown', (e) => {
|
||
if (['z', 'x', 'c', 'v'].includes(e.key.toLowerCase())) {
|
||
// Delay slightly to let the piano widget update first
|
||
setTimeout(updateLabels, 10);
|
||
}
|
||
});
|
||
|
||
controls.push(octaveLabel);
|
||
controls.push(velocityLabel);
|
||
|
||
return controls;
|
||
};
|
||
|
||
return piano_cvs;
|
||
}
|
||
|
||
function pianoRoll() {
|
||
let canvas = document.createElement("canvas");
|
||
canvas.className = "piano-roll";
|
||
|
||
// Create the piano roll editor widget
|
||
canvas.pianoRollEditor = new PianoRollEditor(0, 0, 0, 0);
|
||
|
||
function updateCanvasSize() {
|
||
const canvasStyles = window.getComputedStyle(canvas);
|
||
const width = parseInt(canvasStyles.width);
|
||
const height = parseInt(canvasStyles.height);
|
||
|
||
// Update widget dimensions
|
||
canvas.pianoRollEditor.width = width;
|
||
canvas.pianoRollEditor.height = height;
|
||
|
||
// Set actual size in memory (scaled for retina displays)
|
||
canvas.width = width * window.devicePixelRatio;
|
||
canvas.height = height * window.devicePixelRatio;
|
||
|
||
// Normalize coordinate system to use CSS pixels
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||
|
||
// Render the piano roll
|
||
canvas.pianoRollEditor.draw(ctx);
|
||
}
|
||
|
||
// Store references in context for global access and playback updates
|
||
context.pianoRollEditor = canvas.pianoRollEditor;
|
||
context.pianoRollCanvas = canvas;
|
||
context.pianoRollRedraw = updateCanvasSize;
|
||
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
updateCanvasSize();
|
||
});
|
||
resizeObserver.observe(canvas);
|
||
|
||
// Pointer event handlers (works with mouse and touch)
|
||
canvas.addEventListener("pointerdown", (e) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
canvas.pianoRollEditor.handleMouseEvent("mousedown", x, y);
|
||
updateCanvasSize();
|
||
});
|
||
|
||
canvas.addEventListener("pointermove", (e) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
canvas.pianoRollEditor.handleMouseEvent("mousemove", x, y);
|
||
|
||
// Update cursor based on widget state
|
||
if (canvas.pianoRollEditor.cursor) {
|
||
canvas.style.cursor = canvas.pianoRollEditor.cursor;
|
||
}
|
||
|
||
updateCanvasSize();
|
||
});
|
||
|
||
canvas.addEventListener("pointerup", (e) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
canvas.pianoRollEditor.handleMouseEvent("mouseup", x, y);
|
||
updateCanvasSize();
|
||
});
|
||
|
||
canvas.addEventListener("wheel", (e) => {
|
||
e.preventDefault();
|
||
canvas.pianoRollEditor.wheel(e);
|
||
updateCanvasSize();
|
||
});
|
||
|
||
// Prevent text selection
|
||
canvas.addEventListener("selectstart", (e) => e.preventDefault());
|
||
|
||
return canvas;
|
||
}
|
||
|
||
function presetBrowser() {
|
||
const container = document.createElement("div");
|
||
container.className = "preset-browser-pane";
|
||
|
||
container.innerHTML = `
|
||
<div class="preset-browser-header">
|
||
<h3>Instrument Presets</h3>
|
||
<button class="preset-btn preset-save-btn" title="Save current graph as preset">
|
||
<span>💾</span> Save Preset
|
||
</button>
|
||
</div>
|
||
<div class="preset-filter">
|
||
<input type="text" id="preset-search" placeholder="Search presets..." />
|
||
<select id="preset-tag-filter">
|
||
<option value="">All Tags</option>
|
||
</select>
|
||
</div>
|
||
<div class="preset-categories">
|
||
<div class="preset-category">
|
||
<h4>Factory Presets</h4>
|
||
<div class="preset-list" id="factory-preset-list">
|
||
<div class="preset-loading">Loading...</div>
|
||
</div>
|
||
</div>
|
||
<div class="preset-category">
|
||
<h4>User Presets</h4>
|
||
<div class="preset-list" id="user-preset-list">
|
||
<div class="preset-empty">No user presets yet</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Load presets after DOM insertion
|
||
setTimeout(async () => {
|
||
await loadPresetList(container);
|
||
|
||
// Set up save button handler
|
||
const saveBtn = container.querySelector('.preset-save-btn');
|
||
if (saveBtn) {
|
||
saveBtn.addEventListener('click', () => showSavePresetDialog(container));
|
||
}
|
||
|
||
// Set up search and filter
|
||
const searchInput = container.querySelector('#preset-search');
|
||
const tagFilter = container.querySelector('#preset-tag-filter');
|
||
|
||
if (searchInput) {
|
||
searchInput.addEventListener('input', () => filterPresets(container));
|
||
}
|
||
if (tagFilter) {
|
||
tagFilter.addEventListener('change', () => filterPresets(container));
|
||
}
|
||
}, 0);
|
||
|
||
return container;
|
||
}
|
||
|
||
async function loadPresetList(container) {
|
||
try {
|
||
const presets = await invoke('graph_list_presets');
|
||
|
||
const factoryList = container.querySelector('#factory-preset-list');
|
||
const userList = container.querySelector('#user-preset-list');
|
||
const tagFilter = container.querySelector('#preset-tag-filter');
|
||
|
||
// Collect all unique tags
|
||
const allTags = new Set();
|
||
presets.forEach(preset => {
|
||
preset.tags.forEach(tag => allTags.add(tag));
|
||
});
|
||
|
||
// Populate tag filter
|
||
if (tagFilter) {
|
||
allTags.forEach(tag => {
|
||
const option = document.createElement('option');
|
||
option.value = tag;
|
||
option.textContent = tag.charAt(0).toUpperCase() + tag.slice(1);
|
||
tagFilter.appendChild(option);
|
||
});
|
||
}
|
||
|
||
// Separate factory and user presets
|
||
const factoryPresets = presets.filter(p => p.is_factory);
|
||
const userPresets = presets.filter(p => !p.is_factory);
|
||
|
||
// Render factory presets
|
||
if (factoryList) {
|
||
if (factoryPresets.length === 0) {
|
||
factoryList.innerHTML = '<div class="preset-empty">No factory presets found</div>';
|
||
} else {
|
||
factoryList.innerHTML = factoryPresets.map(preset => createPresetItem(preset)).join('');
|
||
addPresetItemHandlers(factoryList);
|
||
}
|
||
}
|
||
|
||
// Render user presets
|
||
if (userList) {
|
||
if (userPresets.length === 0) {
|
||
userList.innerHTML = '<div class="preset-empty">No user presets yet</div>';
|
||
} else {
|
||
userList.innerHTML = userPresets.map(preset => createPresetItem(preset)).join('');
|
||
addPresetItemHandlers(userList);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load presets:', error);
|
||
const factoryList = container.querySelector('#factory-preset-list');
|
||
const userList = container.querySelector('#user-preset-list');
|
||
if (factoryList) factoryList.innerHTML = '<div class="preset-error">Failed to load presets</div>';
|
||
if (userList) userList.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
function createPresetItem(preset) {
|
||
const tags = preset.tags.map(tag => `<span class="preset-tag">${tag}</span>`).join('');
|
||
const deleteBtn = preset.is_factory ? '' : '<button class="preset-delete-btn" title="Delete preset">🗑️</button>';
|
||
|
||
return `
|
||
<div class="preset-item" data-preset-path="${preset.path}" data-preset-tags="${preset.tags.join(',')}">
|
||
<div class="preset-item-header">
|
||
<span class="preset-name">${preset.name}</span>
|
||
<button class="preset-load-btn" title="Load preset">Load</button>
|
||
${deleteBtn}
|
||
</div>
|
||
<div class="preset-details">
|
||
<div class="preset-description">${preset.description || 'No description'}</div>
|
||
<div class="preset-tags">${tags}</div>
|
||
<div class="preset-author">by ${preset.author || 'Unknown'}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function addPresetItemHandlers(listElement) {
|
||
// Toggle selection on preset item click
|
||
listElement.querySelectorAll('.preset-item').forEach(item => {
|
||
item.addEventListener('click', (e) => {
|
||
// Don't trigger if clicking buttons
|
||
if (e.target.classList.contains('preset-load-btn') ||
|
||
e.target.classList.contains('preset-delete-btn')) {
|
||
return;
|
||
}
|
||
|
||
// Toggle selection
|
||
const wasSelected = item.classList.contains('selected');
|
||
|
||
// Deselect all presets
|
||
listElement.querySelectorAll('.preset-item').forEach(i => i.classList.remove('selected'));
|
||
|
||
// Select this preset if it wasn't selected
|
||
if (!wasSelected) {
|
||
item.classList.add('selected');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Load preset on Load button click
|
||
listElement.querySelectorAll('.preset-load-btn').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const item = btn.closest('.preset-item');
|
||
const presetPath = item.dataset.presetPath;
|
||
await loadPreset(presetPath);
|
||
});
|
||
});
|
||
|
||
// Delete preset on delete button click
|
||
listElement.querySelectorAll('.preset-delete-btn').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const item = btn.closest('.preset-item');
|
||
const presetPath = item.dataset.presetPath;
|
||
const presetName = item.querySelector('.preset-name').textContent;
|
||
|
||
if (confirm(`Delete preset "${presetName}"?`)) {
|
||
try {
|
||
await invoke('graph_delete_preset', { presetPath });
|
||
// Reload preset list
|
||
const container = btn.closest('.preset-browser-pane');
|
||
await loadPresetList(container);
|
||
} catch (error) {
|
||
alert(`Failed to delete preset: ${error}`);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function loadPreset(presetPath) {
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo === null) {
|
||
alert('Please select a track first');
|
||
return;
|
||
}
|
||
const trackId = trackInfo.trackId;
|
||
|
||
try {
|
||
await invoke('graph_load_preset', {
|
||
trackId: trackId,
|
||
presetPath
|
||
});
|
||
|
||
// Refresh the node editor to show the loaded preset
|
||
await context.reloadNodeEditor?.();
|
||
|
||
console.log('Preset loaded successfully');
|
||
} catch (error) {
|
||
alert(`Failed to load preset: ${error}`);
|
||
}
|
||
}
|
||
|
||
function showSavePresetDialog(container) {
|
||
const trackInfo = getCurrentTrack();
|
||
if (trackInfo === null) {
|
||
alert('Please select a track first');
|
||
return;
|
||
}
|
||
|
||
// Create modal dialog
|
||
const dialog = document.createElement('div');
|
||
dialog.className = 'modal-overlay';
|
||
dialog.innerHTML = `
|
||
<div class="modal-dialog">
|
||
<h3>Save Preset</h3>
|
||
<form id="save-preset-form">
|
||
<div class="form-group">
|
||
<label>Preset Name</label>
|
||
<input type="text" id="preset-name" required placeholder="My Awesome Synth" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Description</label>
|
||
<textarea id="preset-description" placeholder="Describe the sound..." rows="3"></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Tags (comma-separated)</label>
|
||
<input type="text" id="preset-tags" placeholder="bass, lead, pad" />
|
||
</div>
|
||
<div class="form-actions">
|
||
<button type="button" class="btn-cancel">Cancel</button>
|
||
<button type="submit" class="btn-primary">Save</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(dialog);
|
||
|
||
// Focus name input
|
||
setTimeout(() => dialog.querySelector('#preset-name')?.focus(), 100);
|
||
|
||
// Handle cancel
|
||
dialog.querySelector('.btn-cancel').addEventListener('click', () => {
|
||
dialog.remove();
|
||
});
|
||
|
||
// Handle save
|
||
dialog.querySelector('#save-preset-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
|
||
const name = dialog.querySelector('#preset-name').value.trim();
|
||
const description = dialog.querySelector('#preset-description').value.trim();
|
||
const tagsInput = dialog.querySelector('#preset-tags').value.trim();
|
||
const tags = tagsInput ? tagsInput.split(',').map(t => t.trim()).filter(t => t) : [];
|
||
|
||
if (!name) {
|
||
alert('Please enter a preset name');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await invoke('graph_save_preset', {
|
||
trackId: trackInfo.trackId,
|
||
presetName: name,
|
||
description,
|
||
tags
|
||
});
|
||
|
||
dialog.remove();
|
||
|
||
// Reload preset list
|
||
await loadPresetList(container);
|
||
|
||
alert(`Preset "${name}" saved successfully!`);
|
||
} catch (error) {
|
||
alert(`Failed to save preset: ${error}`);
|
||
}
|
||
});
|
||
|
||
// Close on background click
|
||
dialog.addEventListener('click', (e) => {
|
||
if (e.target === dialog) {
|
||
dialog.remove();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Show preferences dialog
|
||
function showPreferencesDialog() {
|
||
const dialog = document.createElement('div');
|
||
dialog.className = 'modal-overlay';
|
||
dialog.innerHTML = `
|
||
<div class="modal-dialog preferences-dialog">
|
||
<h3>Preferences</h3>
|
||
<form id="preferences-form">
|
||
<div class="form-group">
|
||
<label>Default BPM</label>
|
||
<input type="number" id="pref-bpm" min="20" max="300" value="${config.bpm}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Default Framerate</label>
|
||
<input type="number" id="pref-framerate" min="1" max="120" value="${config.framerate}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Default File Width</label>
|
||
<input type="number" id="pref-width" min="100" max="10000" value="${config.fileWidth}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Default File Height</label>
|
||
<input type="number" id="pref-height" min="100" max="10000" value="${config.fileHeight}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Scroll Speed</label>
|
||
<input type="number" id="pref-scroll-speed" min="0.1" max="10" step="0.1" value="${config.scrollSpeed}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Audio Buffer Size (frames)</label>
|
||
<select id="pref-audio-buffer-size">
|
||
<option value="128" ${config.audioBufferSize === 128 ? 'selected' : ''}>128 (~3ms - Low latency)</option>
|
||
<option value="256" ${config.audioBufferSize === 256 ? 'selected' : ''}>256 (~6ms - Balanced)</option>
|
||
<option value="512" ${config.audioBufferSize === 512 ? 'selected' : ''}>512 (~12ms - Stable)</option>
|
||
<option value="1024" ${config.audioBufferSize === 1024 ? 'selected' : ''}>1024 (~23ms - Very stable)</option>
|
||
<option value="2048" ${config.audioBufferSize === 2048 ? 'selected' : ''}>2048 (~46ms - Low-end systems)</option>
|
||
<option value="4096" ${config.audioBufferSize === 4096 ? 'selected' : ''}>4096 (~93ms - Very low-end systems)</option>
|
||
</select>
|
||
<small style="display: block; margin-top: 4px; color: #888;">Requires app restart to take effect</small>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>
|
||
<input type="checkbox" id="pref-reopen-session" ${config.reopenLastSession ? 'checked' : ''} />
|
||
Reopen last session on startup
|
||
</label>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>
|
||
<input type="checkbox" id="pref-restore-layout" ${config.restoreLayoutFromFile ? 'checked' : ''} />
|
||
Restore layout when opening files
|
||
</label>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>
|
||
<input type="checkbox" id="pref-debug" ${config.debug ? 'checked' : ''} />
|
||
Enable debug mode
|
||
</label>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button type="button" class="btn-cancel">Cancel</button>
|
||
<button type="submit" class="btn-primary">Save</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(dialog);
|
||
|
||
// Focus first input
|
||
setTimeout(() => dialog.querySelector('#pref-bpm')?.focus(), 100);
|
||
|
||
// Handle cancel
|
||
dialog.querySelector('.btn-cancel').addEventListener('click', () => {
|
||
dialog.remove();
|
||
});
|
||
|
||
// Handle save
|
||
dialog.querySelector('#preferences-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
|
||
// Update config values
|
||
config.bpm = parseInt(dialog.querySelector('#pref-bpm').value);
|
||
config.framerate = parseInt(dialog.querySelector('#pref-framerate').value);
|
||
config.fileWidth = parseInt(dialog.querySelector('#pref-width').value);
|
||
config.fileHeight = parseInt(dialog.querySelector('#pref-height').value);
|
||
config.scrollSpeed = parseFloat(dialog.querySelector('#pref-scroll-speed').value);
|
||
config.audioBufferSize = parseInt(dialog.querySelector('#pref-audio-buffer-size').value);
|
||
config.reopenLastSession = dialog.querySelector('#pref-reopen-session').checked;
|
||
config.restoreLayoutFromFile = dialog.querySelector('#pref-restore-layout').checked;
|
||
config.debug = dialog.querySelector('#pref-debug').checked;
|
||
|
||
// Save config to localStorage
|
||
await saveConfig();
|
||
|
||
dialog.remove();
|
||
|
||
console.log('Preferences saved:', config);
|
||
});
|
||
|
||
// Close on background click
|
||
dialog.addEventListener('click', (e) => {
|
||
if (e.target === dialog) {
|
||
dialog.remove();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Helper function to convert MIDI note number to note name
|
||
function midiToNoteName(midiNote) {
|
||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||
const octave = Math.floor(midiNote / 12) - 1;
|
||
const noteName = noteNames[midiNote % 12];
|
||
return `${noteName}${octave}`;
|
||
}
|
||
|
||
// Parse note name from string (e.g., "A#3" -> 58)
|
||
function noteNameToMidi(noteName) {
|
||
const noteMap = {
|
||
'C': 0, 'C#': 1, 'Db': 1,
|
||
'D': 2, 'D#': 3, 'Eb': 3,
|
||
'E': 4,
|
||
'F': 5, 'F#': 6, 'Gb': 6,
|
||
'G': 7, 'G#': 8, 'Ab': 8,
|
||
'A': 9, 'A#': 10, 'Bb': 10,
|
||
'B': 11
|
||
};
|
||
|
||
// Match note + optional accidental + octave
|
||
const match = noteName.match(/^([A-G][#b]?)(-?\d+)$/i);
|
||
if (!match) return null;
|
||
|
||
const note = match[1].toUpperCase();
|
||
const octave = parseInt(match[2]);
|
||
|
||
if (!(note in noteMap)) return null;
|
||
|
||
return (octave + 1) * 12 + noteMap[note];
|
||
}
|
||
|
||
// Parse filename to extract note and velocity layer
|
||
function parseSampleFilename(filename) {
|
||
// Remove extension
|
||
const nameWithoutExt = filename.replace(/\.(wav|aif|aiff|flac|mp3|ogg)$/i, '');
|
||
|
||
// Try to find note patterns (e.g., A#3, Bb2, C4)
|
||
const notePattern = /([A-G][#b]?)(-?\d+)/gi;
|
||
const noteMatches = [...nameWithoutExt.matchAll(notePattern)];
|
||
|
||
if (noteMatches.length === 0) return null;
|
||
|
||
// Use the last note match (usually most reliable)
|
||
const noteMatch = noteMatches[noteMatches.length - 1];
|
||
const noteStr = noteMatch[1] + noteMatch[2];
|
||
const midiNote = noteNameToMidi(noteStr);
|
||
|
||
if (midiNote === null) return null;
|
||
|
||
// Try to find velocity indicators
|
||
// Common patterns: v1, v2, v3, pp, p, mp, mf, f, ff, fff
|
||
const velPatterns = [
|
||
{ regex: /v(\d+)/i, type: 'numeric' },
|
||
{ regex: /\b(ppp|pp|p|mp|mf|f|ff|fff)\b/i, type: 'dynamic' }
|
||
];
|
||
|
||
let velocityMarker = null;
|
||
let velocityType = null;
|
||
|
||
for (const pattern of velPatterns) {
|
||
const match = nameWithoutExt.match(pattern.regex);
|
||
if (match) {
|
||
velocityMarker = match[1];
|
||
velocityType = pattern.type;
|
||
break;
|
||
}
|
||
}
|
||
|
||
return {
|
||
note: noteStr,
|
||
midiNote,
|
||
velocityMarker,
|
||
velocityType,
|
||
filename
|
||
};
|
||
}
|
||
|
||
// Group samples by note and velocity
|
||
function groupSamples(samples) {
|
||
const groups = {};
|
||
const velocityLayers = new Set();
|
||
|
||
for (const sample of samples) {
|
||
const parsed = parseSampleFilename(sample);
|
||
if (!parsed) continue;
|
||
|
||
const key = parsed.midiNote;
|
||
if (!groups[key]) {
|
||
groups[key] = {
|
||
note: parsed.note,
|
||
midiNote: parsed.midiNote,
|
||
layers: []
|
||
};
|
||
}
|
||
|
||
groups[key].layers.push({
|
||
filename: parsed.filename,
|
||
velocityMarker: parsed.velocityMarker,
|
||
velocityType: parsed.velocityType
|
||
});
|
||
|
||
if (parsed.velocityMarker) {
|
||
velocityLayers.add(parsed.velocityMarker);
|
||
}
|
||
}
|
||
|
||
return { groups, velocityLayers: Array.from(velocityLayers).sort() };
|
||
}
|
||
|
||
// Show folder import dialog
|
||
async function showFolderImportDialog(trackId, nodeId, drawflowNodeId) {
|
||
// Select folder
|
||
const folderPath = await invoke("open_folder_dialog", {
|
||
title: "Select Sample Folder"
|
||
});
|
||
|
||
if (!folderPath) return;
|
||
|
||
// Read files from folder
|
||
const files = await invoke("read_folder_files", {
|
||
path: folderPath
|
||
});
|
||
|
||
if (!files || files.length === 0) {
|
||
alert("No audio files found in folder");
|
||
return;
|
||
}
|
||
|
||
// Parse and group samples
|
||
const { groups, velocityLayers } = groupSamples(files);
|
||
const noteGroups = Object.values(groups).sort((a, b) => a.midiNote - b.midiNote);
|
||
|
||
if (noteGroups.length === 0) {
|
||
alert("Could not detect note names in filenames");
|
||
return;
|
||
}
|
||
|
||
// Show configuration dialog
|
||
return new Promise((resolve) => {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'dialog-overlay';
|
||
|
||
const dialog = document.createElement('div');
|
||
dialog.className = 'dialog';
|
||
dialog.style.width = '600px';
|
||
dialog.style.maxWidth = '90vw';
|
||
dialog.style.maxHeight = '80vh';
|
||
dialog.style.padding = '20px';
|
||
dialog.style.backgroundColor = '#2a2a2a';
|
||
dialog.style.border = '1px solid #444';
|
||
dialog.style.borderRadius = '8px';
|
||
dialog.style.color = '#e0e0e0';
|
||
|
||
let velocityMapping = {};
|
||
|
||
// Initialize default velocity mappings
|
||
if (velocityLayers.length > 0) {
|
||
const step = Math.floor(127 / velocityLayers.length);
|
||
velocityLayers.forEach((marker, idx) => {
|
||
velocityMapping[marker] = {
|
||
min: idx * step,
|
||
max: (idx + 1) * step - 1
|
||
};
|
||
});
|
||
// Ensure last layer goes to 127
|
||
if (velocityLayers.length > 0) {
|
||
velocityMapping[velocityLayers[velocityLayers.length - 1]].max = 127;
|
||
}
|
||
}
|
||
|
||
dialog.innerHTML = `
|
||
<h3 style="margin-top: 0; margin-bottom: 15px; color: #e0e0e0;">Import Sample Folder</h3>
|
||
<div style="margin-bottom: 15px; font-size: 12px; line-height: 1.6;">
|
||
<strong>Folder:</strong> <span style="color: #888; word-break: break-all;">${folderPath}</span><br>
|
||
<strong>Found:</strong> ${noteGroups.length} notes, ${velocityLayers.length} velocity layer(s)
|
||
</div>
|
||
|
||
${velocityLayers.length > 0 ? `
|
||
<div style="margin-bottom: 15px;">
|
||
<strong style="display: block; margin-bottom: 8px;">Velocity Mapping:</strong>
|
||
<table style="width: 100%; font-size: 11px; border-collapse: collapse;">
|
||
<thead>
|
||
<tr style="background: #333;">
|
||
<th style="padding: 6px; text-align: left; border: 1px solid #444;">Marker</th>
|
||
<th style="padding: 6px; text-align: left; border: 1px solid #444;">Min Velocity</th>
|
||
<th style="padding: 6px; text-align: left; border: 1px solid #444;">Max Velocity</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="velocity-mapping-table">
|
||
${velocityLayers.map(marker => `
|
||
<tr>
|
||
<td style="padding: 6px; border: 1px solid #444;"><strong>${marker}</strong></td>
|
||
<td style="padding: 6px; border: 1px solid #444;"><input type="number" class="vel-min" data-marker="${marker}" value="${velocityMapping[marker].min}" min="0" max="127" style="width: 60px; padding: 4px; background: #1a1a1a; color: #e0e0e0; border: 1px solid #555; border-radius: 3px;"></td>
|
||
<td style="padding: 6px; border: 1px solid #444;"><input type="number" class="vel-max" data-marker="${marker}" value="${velocityMapping[marker].max}" min="0" max="127" style="width: 60px; padding: 4px; background: #1a1a1a; color: #e0e0e0; border: 1px solid #555; border-radius: 3px;"></td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
` : ''}
|
||
|
||
<div style="max-height: 300px; overflow-y: auto; margin-bottom: 15px; border: 1px solid #444; padding: 10px; font-size: 11px; background: #1a1a1a; border-radius: 4px;">
|
||
<strong style="display: block; margin-bottom: 8px;">Preview:</strong>
|
||
<ul style="margin: 0; padding-left: 20px; line-height: 1.8;">
|
||
${noteGroups.slice(0, 20).map(group => `
|
||
<li>${group.note} (MIDI ${group.midiNote}): ${group.layers.length} sample(s)
|
||
${group.layers.length <= 3 ? `<br><span style="color: #888; font-size: 10px;"> ${group.layers.map(l => l.filename).join('<br> ')}</span>` : ''}
|
||
</li>
|
||
`).join('')}
|
||
${noteGroups.length > 20 ? `<li style="color: #888;"><em>... and ${noteGroups.length - 20} more notes</em></li>` : ''}
|
||
</ul>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 15px;">
|
||
<label style="display: flex; align-items: center; cursor: pointer; user-select: none;">
|
||
<input type="checkbox" id="auto-key-ranges" checked style="margin-right: 8px;">
|
||
<span style="font-size: 12px;">Automatically set key ranges (split between adjacent notes)</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||
<button id="btn-cancel" style="padding: 8px 16px; background: #444; color: #e0e0e0; border: 1px solid #555; border-radius: 4px; cursor: pointer; font-size: 12px;">Cancel</button>
|
||
<button id="btn-import" style="padding: 8px 16px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500;">Import ${noteGroups.reduce((sum, g) => sum + g.layers.length, 0)} Samples</button>
|
||
</div>
|
||
`;
|
||
|
||
overlay.appendChild(dialog);
|
||
document.body.appendChild(overlay);
|
||
|
||
// Update velocity mapping when inputs change
|
||
const velInputs = dialog.querySelectorAll('.vel-min, .vel-max');
|
||
velInputs.forEach(input => {
|
||
input.addEventListener('input', () => {
|
||
const marker = input.dataset.marker;
|
||
const isMin = input.classList.contains('vel-min');
|
||
const value = parseInt(input.value);
|
||
|
||
if (isMin) {
|
||
velocityMapping[marker].min = value;
|
||
} else {
|
||
velocityMapping[marker].max = value;
|
||
}
|
||
});
|
||
});
|
||
|
||
dialog.querySelector('#btn-cancel').addEventListener('click', () => {
|
||
document.body.removeChild(overlay);
|
||
resolve();
|
||
});
|
||
|
||
dialog.querySelector('#btn-import').addEventListener('click', async () => {
|
||
const autoKeyRanges = dialog.querySelector('#auto-key-ranges').checked;
|
||
|
||
try {
|
||
// Build layer list
|
||
const layersToImport = [];
|
||
|
||
for (let i = 0; i < noteGroups.length; i++) {
|
||
const group = noteGroups[i];
|
||
|
||
// Calculate key range
|
||
let keyMin, keyMax;
|
||
if (autoKeyRanges) {
|
||
// Split range between adjacent notes
|
||
const prevNote = i > 0 ? noteGroups[i - 1].midiNote : 0;
|
||
const nextNote = i < noteGroups.length - 1 ? noteGroups[i + 1].midiNote : 127;
|
||
|
||
keyMin = i === 0 ? 0 : Math.ceil((prevNote + group.midiNote) / 2);
|
||
keyMax = i === noteGroups.length - 1 ? 127 : Math.floor((group.midiNote + nextNote) / 2);
|
||
} else {
|
||
keyMin = group.midiNote;
|
||
keyMax = group.midiNote;
|
||
}
|
||
|
||
// Add each velocity layer for this note
|
||
for (const layer of group.layers) {
|
||
let velMin = 0, velMax = 127;
|
||
|
||
if (layer.velocityMarker && velocityMapping[layer.velocityMarker]) {
|
||
velMin = velocityMapping[layer.velocityMarker].min;
|
||
velMax = velocityMapping[layer.velocityMarker].max;
|
||
}
|
||
|
||
layersToImport.push({
|
||
filePath: `${folderPath}/${layer.filename}`,
|
||
keyMin,
|
||
keyMax,
|
||
rootKey: group.midiNote,
|
||
velocityMin: velMin,
|
||
velocityMax: velMax
|
||
});
|
||
}
|
||
}
|
||
|
||
// Import all layers
|
||
dialog.querySelector('#btn-import').disabled = true;
|
||
dialog.querySelector('#btn-import').textContent = 'Importing...';
|
||
|
||
for (const layer of layersToImport) {
|
||
await invoke("multi_sampler_add_layer", {
|
||
trackId,
|
||
nodeId,
|
||
filePath: layer.filePath,
|
||
keyMin: layer.keyMin,
|
||
keyMax: layer.keyMax,
|
||
rootKey: layer.rootKey,
|
||
velocityMin: layer.velocityMin,
|
||
velocityMax: layer.velocityMax,
|
||
loopStart: null,
|
||
loopEnd: null,
|
||
loopMode: "Continuous"
|
||
});
|
||
}
|
||
|
||
// Refresh the layers list by re-fetching from backend
|
||
try {
|
||
const layers = await invoke("multi_sampler_get_layers", {
|
||
trackId,
|
||
nodeId
|
||
});
|
||
|
||
// Find the node element and update the layers list
|
||
const nodeElement = document.querySelector(`#node-${drawflowNodeId}`);
|
||
if (nodeElement) {
|
||
const layersList = nodeElement.querySelector('[id^="sample-layers-list-"]');
|
||
|
||
if (layersList) {
|
||
if (layers.length === 0) {
|
||
layersList.innerHTML = '<tr><td colspan="5" class="sample-layers-empty">No layers loaded</td></tr>';
|
||
} else {
|
||
layersList.innerHTML = layers.map((layer, index) => {
|
||
const filename = layer.file_path.split('/').pop().split('\\').pop();
|
||
const keyRange = `${midiToNoteName(layer.key_min)}-${midiToNoteName(layer.key_max)}`;
|
||
const rootNote = midiToNoteName(layer.root_key);
|
||
const velRange = `${layer.velocity_min}-${layer.velocity_max}`;
|
||
|
||
return `
|
||
<tr data-index="${index}">
|
||
<td class="sample-layer-filename" title="${filename}">${filename}</td>
|
||
<td>${keyRange}</td>
|
||
<td>${rootNote}</td>
|
||
<td>${velRange}</td>
|
||
<td>
|
||
<div class="sample-layer-actions">
|
||
<button class="btn-edit-layer" data-node="${drawflowNodeId}" data-index="${index}">Edit</button>
|
||
<button class="btn-delete-layer" data-node="${drawflowNodeId}" data-index="${index}">Del</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
}
|
||
}
|
||
} catch (refreshErr) {
|
||
console.error("Failed to refresh layers list:", refreshErr);
|
||
}
|
||
|
||
document.body.removeChild(overlay);
|
||
resolve();
|
||
} catch (err) {
|
||
alert(`Failed to import: ${err}`);
|
||
dialog.querySelector('#btn-import').disabled = false;
|
||
dialog.querySelector('#btn-import').textContent = 'Import';
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Show dialog to configure MultiSampler layer zones
|
||
function showLayerConfigDialog(filePath, existingConfig = null) {
|
||
return new Promise((resolve) => {
|
||
const filename = filePath.split('/').pop().split('\\').pop();
|
||
const isEdit = existingConfig !== null;
|
||
|
||
// Use existing values or defaults
|
||
const keyMin = existingConfig?.keyMin ?? 0;
|
||
const keyMax = existingConfig?.keyMax ?? 127;
|
||
const rootKey = existingConfig?.rootKey ?? 60;
|
||
const velocityMin = existingConfig?.velocityMin ?? 0;
|
||
const velocityMax = existingConfig?.velocityMax ?? 127;
|
||
const loopMode = existingConfig?.loopMode ?? 'oneshot';
|
||
const loopStart = existingConfig?.loopStart ?? null;
|
||
const loopEnd = existingConfig?.loopEnd ?? null;
|
||
|
||
// Create modal dialog
|
||
const dialog = document.createElement('div');
|
||
dialog.className = 'modal-overlay';
|
||
dialog.innerHTML = `
|
||
<div class="modal-dialog">
|
||
<h3>${isEdit ? 'Edit' : 'Configure'} Sample Layer</h3>
|
||
<p style="font-size: 12px; color: #666; margin-bottom: 16px;">
|
||
File: <strong>${filename}</strong>
|
||
</p>
|
||
<form id="layer-config-form">
|
||
<div class="form-group">
|
||
<label>Key Range</label>
|
||
<div class="form-group-inline">
|
||
<div>
|
||
<label style="font-size: 11px; color: #888;">Min</label>
|
||
<input type="number" id="key-min" min="0" max="127" value="${keyMin}" required />
|
||
<div id="key-min-name" class="form-note-name">${midiToNoteName(keyMin)}</div>
|
||
</div>
|
||
<span>-</span>
|
||
<div>
|
||
<label style="font-size: 11px; color: #888;">Max</label>
|
||
<input type="number" id="key-max" min="0" max="127" value="${keyMax}" required />
|
||
<div id="key-max-name" class="form-note-name">${midiToNoteName(keyMax)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Root Key (original pitch)</label>
|
||
<input type="number" id="root-key" min="0" max="127" value="${rootKey}" required />
|
||
<div id="root-key-name" class="form-note-name">${midiToNoteName(rootKey)}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Velocity Range</label>
|
||
<div class="form-group-inline">
|
||
<div>
|
||
<label style="font-size: 11px; color: #888;">Min</label>
|
||
<input type="number" id="velocity-min" min="0" max="127" value="${velocityMin}" required />
|
||
</div>
|
||
<span>-</span>
|
||
<div>
|
||
<label style="font-size: 11px; color: #888;">Max</label>
|
||
<input type="number" id="velocity-max" min="0" max="127" value="${velocityMax}" required />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Loop Mode</label>
|
||
<select id="loop-mode">
|
||
<option value="oneshot" ${loopMode === 'oneshot' ? 'selected' : ''}>One-Shot (play once)</option>
|
||
<option value="continuous" ${loopMode === 'continuous' ? 'selected' : ''}>Continuous (loop)</option>
|
||
</select>
|
||
<div class="form-note" style="font-size: 11px; color: #888; margin-top: 4px;">
|
||
Continuous mode will auto-detect loop points if not specified
|
||
</div>
|
||
</div>
|
||
<div id="loop-points-group" class="form-group" style="display: ${loopMode === 'continuous' ? 'block' : 'none'};">
|
||
<label>Loop Points (optional, samples)</label>
|
||
<div class="form-group-inline">
|
||
<div>
|
||
<label style="font-size: 11px; color: #888;">Start</label>
|
||
<input type="number" id="loop-start" min="0" value="${loopStart ?? ''}" placeholder="Auto" />
|
||
</div>
|
||
<span>-</span>
|
||
<div>
|
||
<label style="font-size: 11px; color: #888;">End</label>
|
||
<input type="number" id="loop-end" min="0" value="${loopEnd ?? ''}" placeholder="Auto" />
|
||
</div>
|
||
</div>
|
||
<div class="form-note" style="font-size: 11px; color: #888; margin-top: 4px;">
|
||
Leave empty to auto-detect optimal loop points
|
||
</div>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button type="button" class="btn-cancel">Cancel</button>
|
||
<button type="submit" class="btn-primary">${isEdit ? 'Update' : 'Add'} Layer</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(dialog);
|
||
|
||
// Update note names when inputs change
|
||
const keyMinInput = dialog.querySelector('#key-min');
|
||
const keyMaxInput = dialog.querySelector('#key-max');
|
||
const rootKeyInput = dialog.querySelector('#root-key');
|
||
const loopModeSelect = dialog.querySelector('#loop-mode');
|
||
const loopPointsGroup = dialog.querySelector('#loop-points-group');
|
||
|
||
const updateKeyMinName = () => {
|
||
const note = parseInt(keyMinInput.value) || 0;
|
||
dialog.querySelector('#key-min-name').textContent = midiToNoteName(note);
|
||
};
|
||
|
||
const updateKeyMaxName = () => {
|
||
const note = parseInt(keyMaxInput.value) || 127;
|
||
dialog.querySelector('#key-max-name').textContent = midiToNoteName(note);
|
||
};
|
||
|
||
const updateRootKeyName = () => {
|
||
const note = parseInt(rootKeyInput.value) || 60;
|
||
dialog.querySelector('#root-key-name').textContent = midiToNoteName(note);
|
||
};
|
||
|
||
keyMinInput.addEventListener('input', updateKeyMinName);
|
||
keyMaxInput.addEventListener('input', updateKeyMaxName);
|
||
rootKeyInput.addEventListener('input', updateRootKeyName);
|
||
|
||
// Toggle loop points visibility based on loop mode
|
||
loopModeSelect.addEventListener('change', () => {
|
||
const isContinuous = loopModeSelect.value === 'continuous';
|
||
loopPointsGroup.style.display = isContinuous ? 'block' : 'none';
|
||
});
|
||
|
||
// Focus first input
|
||
setTimeout(() => dialog.querySelector('#key-min')?.focus(), 100);
|
||
|
||
// Handle cancel
|
||
dialog.querySelector('.btn-cancel').addEventListener('click', () => {
|
||
dialog.remove();
|
||
resolve(null);
|
||
});
|
||
|
||
// Handle submit
|
||
dialog.querySelector('#layer-config-form').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
|
||
const keyMin = parseInt(keyMinInput.value);
|
||
const keyMax = parseInt(keyMaxInput.value);
|
||
const rootKey = parseInt(rootKeyInput.value);
|
||
const velocityMin = parseInt(dialog.querySelector('#velocity-min').value);
|
||
const velocityMax = parseInt(dialog.querySelector('#velocity-max').value);
|
||
const loopMode = loopModeSelect.value;
|
||
|
||
// Get loop points (null if empty)
|
||
const loopStartInput = dialog.querySelector('#loop-start');
|
||
const loopEndInput = dialog.querySelector('#loop-end');
|
||
const loopStart = loopStartInput.value ? parseInt(loopStartInput.value) : null;
|
||
const loopEnd = loopEndInput.value ? parseInt(loopEndInput.value) : null;
|
||
|
||
// Validate ranges
|
||
if (keyMin > keyMax) {
|
||
alert('Key Min must be less than or equal to Key Max');
|
||
return;
|
||
}
|
||
|
||
if (velocityMin > velocityMax) {
|
||
alert('Velocity Min must be less than or equal to Velocity Max');
|
||
return;
|
||
}
|
||
|
||
if (rootKey < keyMin || rootKey > keyMax) {
|
||
alert('Root Key must be within the key range');
|
||
return;
|
||
}
|
||
|
||
// Validate loop points if both are specified
|
||
if (loopStart !== null && loopEnd !== null && loopStart >= loopEnd) {
|
||
alert('Loop Start must be less than Loop End');
|
||
return;
|
||
}
|
||
|
||
dialog.remove();
|
||
resolve({
|
||
keyMin,
|
||
keyMax,
|
||
rootKey,
|
||
velocityMin,
|
||
velocityMax,
|
||
loopMode,
|
||
loopStart,
|
||
loopEnd
|
||
});
|
||
});
|
||
|
||
// Close on background click
|
||
dialog.addEventListener('click', (e) => {
|
||
if (e.target === dialog) {
|
||
dialog.remove();
|
||
resolve(null);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function filterPresets(container) {
|
||
const searchTerm = container.querySelector('#preset-search')?.value.toLowerCase() || '';
|
||
const selectedTag = container.querySelector('#preset-tag-filter')?.value || '';
|
||
|
||
const allItems = container.querySelectorAll('.preset-item');
|
||
|
||
allItems.forEach(item => {
|
||
const name = item.querySelector('.preset-name').textContent.toLowerCase();
|
||
const description = item.querySelector('.preset-description').textContent.toLowerCase();
|
||
const tags = item.dataset.presetTags.split(',');
|
||
|
||
const matchesSearch = !searchTerm || name.includes(searchTerm) || description.includes(searchTerm);
|
||
const matchesTag = !selectedTag || tags.includes(selectedTag);
|
||
|
||
item.style.display = (matchesSearch && matchesTag) ? 'block' : 'none';
|
||
});
|
||
}
|
||
|
||
const panes = {
|
||
stage: {
|
||
name: "stage",
|
||
func: stage,
|
||
},
|
||
toolbar: {
|
||
name: "toolbar",
|
||
func: toolbar,
|
||
},
|
||
timelineDeprecated: {
|
||
name: "timeline-deprecated",
|
||
func: timelineDeprecated,
|
||
},
|
||
timeline: {
|
||
name: "timeline",
|
||
func: timeline,
|
||
},
|
||
infopanel: {
|
||
name: "infopanel",
|
||
func: infopanel,
|
||
},
|
||
outlineer: {
|
||
name: "outliner",
|
||
func: outliner,
|
||
},
|
||
piano: {
|
||
name: "piano",
|
||
func: piano,
|
||
},
|
||
pianoRoll: {
|
||
name: "piano-roll",
|
||
func: pianoRoll,
|
||
},
|
||
nodeEditor: {
|
||
name: "node-editor",
|
||
func: nodeEditor,
|
||
},
|
||
presetBrowser: {
|
||
name: "preset-browser",
|
||
func: presetBrowser,
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Switch to a different layout
|
||
* @param {string} layoutKey - The key of the layout to switch to
|
||
*/
|
||
function switchLayout(layoutKey) {
|
||
try {
|
||
console.log(`Switching to layout: ${layoutKey}`);
|
||
|
||
// Load the layout definition
|
||
const layoutDef = loadLayoutByKeyOrName(layoutKey);
|
||
if (!layoutDef) {
|
||
console.error(`Layout not found: ${layoutKey}`);
|
||
return;
|
||
}
|
||
|
||
// Clear existing layout (except root element)
|
||
while (rootPane.firstChild) {
|
||
rootPane.removeChild(rootPane.firstChild);
|
||
}
|
||
|
||
// Clear layoutElements array
|
||
layoutElements.length = 0;
|
||
|
||
// Clear canvases array (will be repopulated when stage pane is created)
|
||
canvases.length = 0;
|
||
|
||
// Build new layout from definition directly into rootPane
|
||
buildLayout(rootPane, layoutDef, panes, createPane, splitPane);
|
||
|
||
// Update config
|
||
config.currentLayout = layoutKey;
|
||
saveConfig();
|
||
|
||
// Trigger layout update
|
||
updateAll();
|
||
updateUI();
|
||
updateLayers();
|
||
updateMenu();
|
||
|
||
// Update metronome button visibility based on timeline format
|
||
// (especially important when switching to audioDaw layout)
|
||
if (context.metronomeGroup && context.timelineWidget?.timelineState) {
|
||
const shouldShow = context.timelineWidget.timelineState.timeFormat === 'measures';
|
||
context.metronomeGroup.style.display = shouldShow ? '' : 'none';
|
||
}
|
||
|
||
console.log(`Layout switched to: ${layoutDef.name}`);
|
||
} catch (error) {
|
||
console.error(`Error switching layout:`, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Switch to the next layout in the list
|
||
*/
|
||
function nextLayout() {
|
||
const layoutKeys = getLayoutNames();
|
||
const currentIndex = layoutKeys.indexOf(config.currentLayout);
|
||
const nextIndex = (currentIndex + 1) % layoutKeys.length;
|
||
switchLayout(layoutKeys[nextIndex]);
|
||
}
|
||
|
||
/**
|
||
* Switch to the previous layout in the list
|
||
*/
|
||
function previousLayout() {
|
||
const layoutKeys = getLayoutNames();
|
||
const currentIndex = layoutKeys.indexOf(config.currentLayout);
|
||
const prevIndex = (currentIndex - 1 + layoutKeys.length) % layoutKeys.length;
|
||
switchLayout(layoutKeys[prevIndex]);
|
||
}
|
||
|
||
// Make layout functions available globally for menu actions
|
||
window.switchLayout = switchLayout;
|
||
window.nextLayout = nextLayout;
|
||
window.previousLayout = previousLayout;
|
||
|
||
function _arrayBufferToBase64(buffer) {
|
||
var binary = "";
|
||
var bytes = new Uint8Array(buffer);
|
||
var len = bytes.byteLength;
|
||
for (var i = 0; i < len; i++) {
|
||
binary += String.fromCharCode(bytes[i]);
|
||
}
|
||
return window.btoa(binary);
|
||
}
|
||
|
||
async function convertToDataURL(filePath, allowedMimeTypes) {
|
||
try {
|
||
// Read the image file as a binary file (buffer)
|
||
const binaryData = await readFile(filePath);
|
||
const mimeType = getMimeType(filePath);
|
||
if (!mimeType) {
|
||
throw new Error("Unsupported file type");
|
||
}
|
||
if (allowedMimeTypes.indexOf(mimeType) == -1) {
|
||
throw new Error(`Unsupported MIME type ${mimeType}`);
|
||
}
|
||
|
||
const base64Data = _arrayBufferToBase64(binaryData);
|
||
const dataURL = `data:${mimeType};base64,${base64Data}`;
|
||
|
||
return { dataURL, mimeType };
|
||
} catch (error) {
|
||
console.log(error);
|
||
console.error("Error reading the file:", error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Determine the MIME type based on the file extension
|
||
function getMimeType(filePath) {
|
||
const ext = filePath.split(".").pop().toLowerCase();
|
||
switch (ext) {
|
||
case "jpg":
|
||
case "jpeg":
|
||
return "image/jpeg";
|
||
case "png":
|
||
return "image/png";
|
||
case "gif":
|
||
return "image/gif";
|
||
case "bmp":
|
||
return "image/bmp";
|
||
case "webp":
|
||
return "image/webp";
|
||
case "mp3":
|
||
return "audio/mpeg";
|
||
default:
|
||
return null; // Unsupported file type
|
||
}
|
||
}
|
||
|
||
|
||
let renderInProgress = false;
|
||
let rafScheduled = false;
|
||
|
||
// FPS tracking
|
||
let lastFpsLogTime = 0;
|
||
let frameCount = 0;
|
||
let fpsHistory = [];
|
||
|
||
async function renderAll() {
|
||
rafScheduled = false;
|
||
|
||
// Skip if a render is already in progress (prevent stacking async calls)
|
||
if (renderInProgress) {
|
||
// Schedule another attempt if not already scheduled
|
||
if (!rafScheduled) {
|
||
rafScheduled = true;
|
||
requestAnimationFrame(renderAll);
|
||
}
|
||
return;
|
||
}
|
||
|
||
renderInProgress = true;
|
||
const renderStartTime = performance.now();
|
||
|
||
try {
|
||
if (uiDirty) {
|
||
await renderUI();
|
||
uiDirty = false;
|
||
}
|
||
if (layersDirty) {
|
||
renderLayers();
|
||
layersDirty = false;
|
||
}
|
||
if (outlinerDirty) {
|
||
renderOutliner();
|
||
outlinerDirty = false;
|
||
}
|
||
if (menuDirty) {
|
||
renderMenu();
|
||
menuDirty = false;
|
||
}
|
||
if (infopanelDirty) {
|
||
renderInfopanel();
|
||
infopanelDirty = false;
|
||
}
|
||
} catch (error) {
|
||
const errorMessage = error.message || error.toString(); // Use error message or string representation of the error
|
||
|
||
if (errorMessage !== lastErrorMessage) {
|
||
// A new error, log it and reset repeat count
|
||
console.error(error);
|
||
lastErrorMessage = errorMessage;
|
||
repeatCount = 1;
|
||
} else if (repeatCount === 1) {
|
||
// The error repeats for the second time, log "[Repeats]"
|
||
console.warn("[Repeats]");
|
||
repeatCount = 2;
|
||
}
|
||
} finally {
|
||
renderInProgress = false;
|
||
|
||
// FPS logging (only when playing)
|
||
if (context.playing) {
|
||
frameCount++;
|
||
const now = performance.now();
|
||
const renderTime = now - renderStartTime;
|
||
|
||
if (now - lastFpsLogTime >= 1000) {
|
||
const fps = frameCount / ((now - lastFpsLogTime) / 1000);
|
||
fpsHistory.push({ fps, renderTime });
|
||
console.log(`[FPS] ${fps.toFixed(1)} fps | Render time: ${renderTime.toFixed(1)}ms`);
|
||
frameCount = 0;
|
||
lastFpsLogTime = now;
|
||
|
||
// Keep only last 10 samples
|
||
if (fpsHistory.length > 10) {
|
||
fpsHistory.shift();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Schedule next frame if not already scheduled
|
||
if (!rafScheduled) {
|
||
rafScheduled = true;
|
||
requestAnimationFrame(renderAll);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Initialize actions module with dependencies
|
||
initializeActions({
|
||
undoStack,
|
||
redoStack,
|
||
updateMenu,
|
||
updateLayers,
|
||
updateUI,
|
||
updateVideoFrames,
|
||
updateInfopanel,
|
||
invoke,
|
||
config
|
||
});
|
||
|
||
renderAll();
|
||
|
||
if (window.openedFiles?.length>0) {
|
||
document.body.style.cursor = "wait"
|
||
setTimeout(()=>_open(window.openedFiles[0]),10)
|
||
for (let i=1; i<window.openedFiles.length; i++) {
|
||
newWindow(window.openedFiles[i])
|
||
}
|
||
}
|
||
|
||
async function addEmptyAudioTrack() {
|
||
console.log('[addEmptyAudioTrack] BEFORE - root.frameRate:', root.frameRate);
|
||
const trackName = `Audio Track ${context.activeObject.audioTracks.length + 1}`;
|
||
const trackUuid = uuidv4();
|
||
|
||
try {
|
||
// Create new AudioTrack with DAW backend
|
||
const newAudioTrack = new AudioTrack(trackUuid, trackName);
|
||
|
||
// Initialize track in backend (creates empty audio track)
|
||
await newAudioTrack.initializeTrack();
|
||
|
||
console.log('[addEmptyAudioTrack] After initializeTrack - root.frameRate:', root.frameRate);
|
||
|
||
// Add track to active object
|
||
context.activeObject.audioTracks.push(newAudioTrack);
|
||
|
||
console.log('[addEmptyAudioTrack] After push - root.frameRate:', root.frameRate);
|
||
|
||
// Select the newly created track
|
||
context.activeObject.activeLayer = newAudioTrack;
|
||
|
||
console.log('[addEmptyAudioTrack] After setting activeLayer - root.frameRate:', root.frameRate);
|
||
|
||
// Update UI
|
||
updateLayers();
|
||
if (context.timelineWidget) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
|
||
console.log('[addEmptyAudioTrack] AFTER - root.frameRate:', root.frameRate);
|
||
console.log('Empty audio track created:', trackName, 'with ID:', newAudioTrack.audioTrackId);
|
||
} catch (error) {
|
||
console.error('Failed to create empty audio track:', error);
|
||
}
|
||
}
|
||
|
||
async function addEmptyMIDITrack() {
|
||
console.log('[addEmptyMIDITrack] Creating new MIDI track');
|
||
const trackName = `MIDI Track ${context.activeObject.audioTracks.filter(t => t.type === 'midi').length + 1}`;
|
||
const trackUuid = uuidv4();
|
||
|
||
try {
|
||
// Note: MIDI tracks now use node-based instruments via instrument_graph
|
||
|
||
// Create new AudioTrack with type='midi'
|
||
const newMIDITrack = new AudioTrack(trackUuid, trackName, 'midi');
|
||
|
||
// Initialize track in backend (creates MIDI track with node graph)
|
||
await newMIDITrack.initializeTrack();
|
||
|
||
console.log('[addEmptyMIDITrack] After initializeTrack - track created with node graph');
|
||
|
||
// Add track to active object
|
||
context.activeObject.audioTracks.push(newMIDITrack);
|
||
|
||
// Select the newly created track
|
||
context.activeObject.activeLayer = newMIDITrack;
|
||
|
||
// Update UI
|
||
updateLayers();
|
||
if (context.timelineWidget) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
|
||
// Refresh node editor to show empty graph
|
||
setTimeout(() => context.reloadNodeEditor?.(), 100);
|
||
|
||
console.log('Empty MIDI track created:', trackName, 'with ID:', newMIDITrack.audioTrackId);
|
||
} catch (error) {
|
||
console.error('Failed to create empty MIDI track:', error);
|
||
}
|
||
}
|
||
|
||
async function addVideoLayer() {
|
||
console.log('[addVideoLayer] Creating new video layer');
|
||
const layerName = `Video ${context.activeObject.layers.filter(l => l.type === 'video').length + 1}`;
|
||
const layerUuid = uuidv4();
|
||
|
||
try {
|
||
// Create new VideoLayer
|
||
const newVideoLayer = new VideoLayer(layerUuid, layerName);
|
||
|
||
// Add layer to active object
|
||
context.activeObject.layers.push(newVideoLayer);
|
||
|
||
// Select the newly created layer
|
||
context.activeObject.activeLayer = newVideoLayer;
|
||
|
||
// Update UI
|
||
updateLayers();
|
||
if (context.timelineWidget) {
|
||
context.timelineWidget.requestRedraw();
|
||
}
|
||
|
||
console.log('Empty video layer created:', layerName);
|
||
} catch (error) {
|
||
console.error('Failed to create video layer:', error);
|
||
}
|
||
}
|
||
|
||
// MIDI Command Wrappers
|
||
// Note: getAvailableInstruments() removed - now using node-based instruments
|
||
|
||
async function createMIDITrack(name, instrument) {
|
||
try {
|
||
const trackId = await invoke('audio_create_track', { name, trackType: 'midi', instrument });
|
||
console.log('MIDI track created:', name, 'with instrument:', instrument, 'ID:', trackId);
|
||
return trackId;
|
||
} catch (error) {
|
||
console.error('Failed to create MIDI track:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function createMIDIClip(trackId, startTime, duration) {
|
||
try {
|
||
const clipId = await invoke('audio_create_midi_clip', { trackId, startTime, duration });
|
||
console.log('MIDI clip created on track', trackId, 'with ID:', clipId);
|
||
return clipId;
|
||
} catch (error) {
|
||
console.error('Failed to create MIDI clip:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function addMIDINote(trackId, clipId, timeOffset, note, velocity, duration) {
|
||
try {
|
||
await invoke('audio_add_midi_note', { trackId, clipId, timeOffset, note, velocity, duration });
|
||
console.log('MIDI note added:', note, 'at', timeOffset);
|
||
} catch (error) {
|
||
console.error('Failed to add MIDI note:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function loadMIDIFile(trackId, path, startTime) {
|
||
try {
|
||
const duration = await invoke('audio_load_midi_file', { trackId, path, startTime });
|
||
console.log('MIDI file loaded:', path, 'duration:', duration);
|
||
return duration;
|
||
} catch (error) {
|
||
console.error('Failed to load MIDI file:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// ========== Oscilloscope Visualization ==========
|
||
|
||
// Store oscilloscope update intervals by node ID
|
||
const oscilloscopeIntervals = new Map();
|
||
// Store oscilloscope time scales by node ID
|
||
const oscilloscopeTimeScales = new Map();
|
||
|
||
// Start oscilloscope visualization for a node
|
||
function startOscilloscopeVisualization(nodeId, trackId, backendNodeId, editorRef) {
|
||
// Clear any existing interval for this node
|
||
stopOscilloscopeVisualization(nodeId);
|
||
|
||
// Find the canvas by traversing from the node element
|
||
const nodeElement = document.getElementById(`node-${nodeId}`);
|
||
if (!nodeElement) {
|
||
console.warn(`Node element not found for node ${nodeId}`);
|
||
return;
|
||
}
|
||
|
||
const canvas = nodeElement.querySelector('canvas[id^="oscilloscope-canvas-"]');
|
||
if (!canvas) {
|
||
console.warn(`Oscilloscope canvas not found in node ${nodeId}`);
|
||
return;
|
||
}
|
||
|
||
console.log(`Found oscilloscope canvas for node ${nodeId}:`, canvas.id);
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
const width = canvas.width;
|
||
const height = canvas.height;
|
||
|
||
// Initialize time scale to default (100ms)
|
||
if (!oscilloscopeTimeScales.has(nodeId)) {
|
||
oscilloscopeTimeScales.set(nodeId, 100);
|
||
}
|
||
|
||
// Update function to fetch and draw oscilloscope data
|
||
const updateOscilloscope = async () => {
|
||
try {
|
||
// Calculate samples needed based on time scale
|
||
// Assuming 48kHz sample rate
|
||
const timeScaleMs = oscilloscopeTimeScales.get(nodeId) || 100;
|
||
const sampleRate = 48000;
|
||
const samplesNeeded = Math.floor((timeScaleMs / 1000) * sampleRate);
|
||
// Cap at 2 seconds worth of samples to avoid excessive memory usage
|
||
const sampleCount = Math.min(samplesNeeded, sampleRate * 2);
|
||
|
||
// Fetch oscilloscope data
|
||
const data = await invoke('get_oscilloscope_data', {
|
||
trackId: trackId,
|
||
nodeId: backendNodeId,
|
||
sampleCount: sampleCount
|
||
});
|
||
|
||
// Clear canvas
|
||
ctx.fillStyle = '#1a1a1a';
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
// Draw grid lines
|
||
ctx.strokeStyle = '#2a2a2a';
|
||
ctx.lineWidth = 1;
|
||
|
||
// Horizontal grid lines
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, height / 2);
|
||
ctx.lineTo(width, height / 2);
|
||
ctx.stroke();
|
||
|
||
// Draw audio waveform
|
||
if (data && data.audio && data.audio.length > 0) {
|
||
ctx.strokeStyle = '#4CAF50';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
|
||
const xStep = width / data.audio.length;
|
||
for (let i = 0; i < data.audio.length; i++) {
|
||
const x = i * xStep;
|
||
// Map sample value from [-1, 1] to canvas height
|
||
const y = height / 2 - (data.audio[i] * height / 2);
|
||
|
||
if (i === 0) {
|
||
ctx.moveTo(x, y);
|
||
} else {
|
||
ctx.lineTo(x, y);
|
||
}
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Draw CV trace in orange if present and CV input is connected
|
||
if (data && data.cv && data.cv.length > 0 && editorRef) {
|
||
// Check if CV input (port index 2 = input_3 in drawflow) is connected
|
||
const node = editorRef.getNodeFromId(nodeId);
|
||
const cvInput = node?.inputs?.input_3;
|
||
const isCvConnected = cvInput && cvInput.connections && cvInput.connections.length > 0;
|
||
|
||
if (isCvConnected) {
|
||
ctx.strokeStyle = '#FF9800'; // Orange color
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
|
||
const xStep = width / data.cv.length;
|
||
for (let i = 0; i < data.cv.length; i++) {
|
||
const x = i * xStep;
|
||
// Map CV value from [-1, 1] to canvas height
|
||
const y = height / 2 - (data.cv[i] * height / 2);
|
||
|
||
if (i === 0) {
|
||
ctx.moveTo(x, y);
|
||
} else {
|
||
ctx.lineTo(x, y);
|
||
}
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to update oscilloscope:', error);
|
||
}
|
||
};
|
||
|
||
// Initial update
|
||
updateOscilloscope();
|
||
|
||
// Update every 50ms (20 FPS)
|
||
const interval = setInterval(updateOscilloscope, 50);
|
||
oscilloscopeIntervals.set(nodeId, interval);
|
||
}
|
||
|
||
// Stop oscilloscope visualization for a node
|
||
function stopOscilloscopeVisualization(nodeId) {
|
||
const interval = oscilloscopeIntervals.get(nodeId);
|
||
if (interval) {
|
||
clearInterval(interval);
|
||
oscilloscopeIntervals.delete(nodeId);
|
||
}
|
||
}
|
||
|
||
// ========== End Oscilloscope Visualization ==========
|
||
|
||
async function testAudio() {
|
||
console.log("Starting rust")
|
||
await init();
|
||
console.log("Rust started")
|
||
const coreInterface = new CoreInterface(100, 100)
|
||
coreInterface.init()
|
||
coreInterface.play(0.0)
|
||
console.log(coreInterface)
|
||
|
||
let audioStarted = false;
|
||
const startCoreInterfaceAudio = () => {
|
||
if (!audioStarted) {
|
||
try {
|
||
coreInterface.resume_audio();
|
||
audioStarted = true;
|
||
console.log("Started CoreInterface Audio!")
|
||
} catch (err) {
|
||
console.error("Audio resume failed:", err);
|
||
}
|
||
}
|
||
|
||
// Remove the event listeners to prevent them from firing again
|
||
document.removeEventListener("click", startCoreInterfaceAudio);
|
||
document.removeEventListener("keydown", startCoreInterfaceAudio);
|
||
};
|
||
|
||
// Add event listeners for mouse click and key press
|
||
document.addEventListener("click", startCoreInterfaceAudio);
|
||
document.addEventListener("keydown", startCoreInterfaceAudio);
|
||
}
|
||
// testAudio()
|