From 6c79914ffb752dd26f1fa5c9a3f63a09ebcb4b54 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 15 Oct 2025 00:41:51 -0400 Subject: [PATCH] Work on moving things to animation curves --- src/index.html | 2 +- src/main.js | 615 ++++++++++++++++++++++++++++++++++++++++-------- src/styles.css | 1 + src/timeline.js | 494 ++++++++++++++++++++++++++++++++++++++ src/widgets.js | 111 ++++++++- 5 files changed, 1115 insertions(+), 108 deletions(-) create mode 100644 src/timeline.js diff --git a/src/index.html b/src/index.html index 6a6dc62..a9fbfdd 100644 --- a/src/index.html +++ b/src/index.html @@ -3,7 +3,7 @@ - + Lightningbeam diff --git a/src/main.js b/src/main.js index 054caa6..334edc8 100644 --- a/src/main.js +++ b/src/main.js @@ -60,7 +60,7 @@ import { shadow, } from "./styles.js"; import { Icon } from "./icon.js"; -import { AlphaSelectionBar, ColorSelectorWidget, ColorWidget, HueSelectionBar, SaturationValueSelectionGradient, TimelineWindow, Widget } from "./widgets.js"; +import { AlphaSelectionBar, ColorSelectorWidget, ColorWidget, HueSelectionBar, SaturationValueSelectionGradient, TimelineWindow, TimelineWindowV2, Widget } from "./widgets.js"; const { writeTextFile: writeTextFile, readTextFile: readTextFile, @@ -347,6 +347,7 @@ let context = { selectedFrames: [], dragDirection: undefined, zoomLevel: 1, + timelineWidget: null, // Reference to TimelineWindowV2 widget for zoom controls }; let config = { @@ -661,7 +662,7 @@ let actions = { fillImage: img, strokeShape: false, }; - let imageShape = new Shape(0, 0, ct, action.shapeUuid); + let imageShape = new Shape(0, 0, ct, imageObject.activeLayer, action.shapeUuid); imageShape.addLine(img.width, 0); imageShape.addLine(img.width, img.height); imageShape.addLine(0, img.height); @@ -1507,7 +1508,10 @@ let actions = { let serializableShapes = []; let serializableObjects = []; let bbox; - const frame = context.activeObject.currentFrame + const currentTime = context.activeObject?.currentTime || 0; + const layer = context.activeObject.activeLayer; + + // For shapes - use AnimationData system for (let shape of context.shapeselection) { serializableShapes.push(shape.idx); if (bbox == undefined) { @@ -1516,8 +1520,11 @@ let actions = { growBoundingBox(bbox, shape.bbox()); } } + + // For objects - check legacy frame system if available + const frame = context.activeObject.currentFrame; for (let object of context.selection) { - if (object.idx in frame.keys) { + if (frame && object.idx in frame.keys) { serializableObjects.push(object.idx); // TODO: rotated bbox if (bbox == undefined) { @@ -1527,6 +1534,7 @@ let actions = { } } } + context.shapeselection = []; context.selection = []; let action = { @@ -1534,8 +1542,9 @@ let actions = { objects: serializableObjects, groupUuid: uuidv4(), parent: context.activeObject.idx, - frame: frame.idx, - layer: context.activeObject.activeLayer.idx, + frame: frame?.idx, + layer: layer.idx, + currentTime: currentTime, position: { x: (bbox.x.min + bbox.x.max) / 2, y: (bbox.y.min + bbox.y.max) / 2, @@ -1548,41 +1557,62 @@ let actions = { execute: (action) => { let group = new GraphicsObject(action.groupUuid); let parent = pointerList[action.parent]; - let frame = action.frame - ? pointerList[action.frame] - : parent.currentFrame; - let layer; - if (action.layer) { - layer = pointerList[action.layer] - } else { - for (let _layer of parent.layers) { - for (let _frame of _layer.frames) { - if (_frame && (_frame.idx == frame.idx)) { - layer = _layer - } - } - } - if (layer==undefined) { - layer = parent.activeLayer - } - } + let layer = pointerList[action.layer] || parent.activeLayer; + const currentTime = action.currentTime || 0; + + // Move shapes from parent layer to group's first layer for (let shapeIdx of action.shapes) { let shape = pointerList[shapeIdx]; shape.translate(-action.position.x, -action.position.y); - group.currentFrame.addShape(shape); - frame.removeShape(shape); + + // Remove shape from parent layer's shapes array + let shapeIndex = layer.shapes.indexOf(shape); + if (shapeIndex !== -1) { + layer.shapes.splice(shapeIndex, 1); + } + + // Remove animation curves for this shape from parent layer + layer.animationData.removeCurve(`shape.${shape.idx}.exists`); + layer.animationData.removeCurve(`shape.${shape.idx}.zOrder`); + + // Add shape to group's first layer + let groupLayer = group.activeLayer; + shape.parent = groupLayer; + groupLayer.shapes.push(shape); + + // Add animation curves for this shape in group's layer + let existsCurve = new AnimationCurve(`shape.${shape.idx}.exists`); + existsCurve.addKeyframe(new Keyframe(0, 1, 'linear')); + groupLayer.animationData.setCurve(`shape.${shape.idx}.exists`, existsCurve); + + let zOrderCurve = new AnimationCurve(`shape.${shape.idx}.zOrder`); + zOrderCurve.addKeyframe(new Keyframe(0, groupLayer.shapes.length - 1, 'linear')); + groupLayer.animationData.setCurve(`shape.${shape.idx}.zOrder`, zOrderCurve); } + + // Move objects (children) to the group + // For legacy frame-based children, use frame.keys if available + let frame = action.frame ? pointerList[action.frame] : parent.currentFrame; for (let objectIdx of action.objects) { let object = pointerList[objectIdx]; - group.addObject( - object, - frame.keys[objectIdx].x - action.position.x, - frame.keys[objectIdx].y - action.position.y, - ); + if (frame && frame.keys && frame.keys[objectIdx]) { + group.addObject( + object, + frame.keys[objectIdx].x - action.position.x, + frame.keys[objectIdx].y - action.position.y, + currentTime + ); + } else { + group.addObject(object, 0, 0, currentTime); + } parent.removeChild(object); } - parent.addObject(group, action.position.x, action.position.y, frame); + + // Add group to parent using time-based API + parent.addObject(group, action.position.x, action.position.y, currentTime); context.selection = [group]; + context.activeCurve = undefined; + context.activeVertex = undefined; updateUI(); updateInfopanel(); }, @@ -1758,8 +1788,13 @@ let actions = { selection.push(child.idx); } } - for (let shape of context.activeObject.currentFrame.shapes) { - shapeselection.push(shape.idx); + // 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)) { + shapeselection.push(shape.idx); + } } let action = { selection: selection, @@ -1944,13 +1979,7 @@ function selectCurve(context, mouse) { let layer = context.activeObject?.activeLayer; if (!layer) return undefined; - for (let shape of layer.shapes) { - if (shape instanceof TempShape) continue; - - // Check if shape exists at current time - let existsValue = layer.animationData.interpolate(`shape.${shape.idx}.exists`, currentTime); - if (!existsValue || existsValue <= 0) continue; - + for (let shape of layer.getVisibleShapes(currentTime)) { if ( mouse.x > shape.boundingBox.x.min - mouseTolerance && mouse.x < shape.boundingBox.x.max + mouseTolerance && @@ -1978,8 +2007,13 @@ function selectVertex(context, mouse) { let closestDist = mouseTolerance; let closestVertex = undefined; let closestShape = undefined; - for (let shape of context.activeObject.currentFrame.shapes) { - if (shape instanceof TempShape) continue; + + // Get visible shapes from Layer using AnimationData + let currentTime = context.activeObject?.currentTime || 0; + let layer = context.activeObject?.activeLayer; + if (!layer) return undefined; + + for (let shape of layer.getVisibleShapes(currentTime)) { if ( mouse.x > shape.boundingBox.x.min - mouseTolerance && mouse.x < shape.boundingBox.x.max + mouseTolerance && @@ -2355,12 +2389,18 @@ class AnimationCurve { } interpolate(time) { - if (this.keyframes.length === 0) return null; + if (this.keyframes.length === 0) { + return null; + } const { prev, next, t } = this.getBracketingKeyframes(time); - if (!prev || !next) return null; - if (prev === next) return prev.value; + if (!prev || !next) { + return null; + } + if (prev === next) { + return prev.value; + } // Handle different interpolation types switch (prev.interpolation) { @@ -2452,6 +2492,10 @@ class AnimationData { delete this.curves[parameter]; } + setCurve(parameter, curve) { + this.curves[parameter] = curve; + } + interpolate(parameter, time) { const curve = this.curves[parameter]; if (!curve) return null; @@ -2521,7 +2565,7 @@ class Layer extends Widget { // Load shapes if present if (json.shapes) { - layer.shapes = json.shapes.map(shape => Shape.fromJSON(shape)); + layer.shapes = json.shapes.map(shape => Shape.fromJSON(shape, layer)); } // Load frames if present (old system - for backwards compatibility) @@ -2951,6 +2995,21 @@ class Layer extends Widget { }; } + // Get all shapes that exist at the given time + getVisibleShapes(time) { + const visibleShapes = []; + for (let shape of this.shapes) { + if (shape instanceof TempShape) continue; + + // Check if shape exists at current time + let existsValue = this.animationData.interpolate(`shape.${shape.idx}.exists`, time); + if (existsValue && existsValue > 0) { + visibleShapes.push(shape); + } + } + return visibleShapes; + } + draw(ctx) { // super.draw(ctx) if (!this.visible) return; @@ -3163,7 +3222,7 @@ class Layer extends Widget { strokeShape: false, sendToBack: true, }; - let shape = new Shape(regionPoints[0].x, regionPoints[0].y, cxt); + let shape = new Shape(regionPoints[0].x, regionPoints[0].y, cxt, this); shape.fromPoints(points, 1); actions.addShape.create(context.activeObject, shape, cxt); break; @@ -3652,7 +3711,7 @@ class Shape extends BaseShape { this.regionIdx = 0; this.inProgress = true; } - static fromJSON(json) { + static fromJSON(json, parent) { let fillImage = undefined; if (json.fillImage && Object.keys(json.fillImage).length !== 0) { let img = new Image(); @@ -3672,6 +3731,7 @@ class Shape extends BaseShape { fillShape: json.filled, strokeShape: json.stroked, }, + parent, json.idx, json.shapeId, ); @@ -3774,6 +3834,7 @@ class Shape extends BaseShape { this.startx, this.starty, {}, + this.parent, idx.slice(0, 8) + this.idx.slice(8), this.shapeId, ); @@ -4231,15 +4292,24 @@ class GraphicsObject extends Widget { } bbox() { let bbox; - // for (let layer of this.layers) { - // let frame = layer.getFrame(this.currentFrameNum); - // if (frame.shapes.length > 0 && bbox == undefined) { - // bbox = structuredClone(frame.shapes[0].boundingBox); - // } - // for (let shape of frame.shapes) { - // growBoundingBox(bbox, shape.boundingBox); - // } - // } + + // NEW: Include shapes from AnimationData system + let currentTime = this.currentTime || 0; + for (let layer of this.layers) { + for (let shape of layer.shapes) { + // Check if shape exists at current time + let existsValue = layer.animationData.interpolate(`shape.${shape.idx}.exists`, currentTime); + if (existsValue !== null && existsValue > 0) { + if (!bbox) { + bbox = structuredClone(shape.boundingBox); + } else { + growBoundingBox(bbox, shape.boundingBox); + } + } + } + } + + // Include children if (this.children.length > 0) { if (!bbox) { bbox = structuredClone(this.children[0].bbox()); @@ -4248,6 +4318,7 @@ class GraphicsObject extends Widget { growBoundingBox(bbox, child.bbox()); } } + if (bbox == undefined) { bbox = { x: { min: 0, max: 0 }, y: { min: 0, max: 0 } }; } @@ -4259,7 +4330,7 @@ class GraphicsObject extends Widget { bbox.y.max += this.y; return bbox; } - /* + draw(context, calculateTransform=false) { let ctx = context.ctx; ctx.save(); @@ -4279,34 +4350,142 @@ class GraphicsObject extends Widget { this.idx in context.activeAction.selection ) return; + for (let layer of this.layers) { if (context.activeObject == this && !layer.visible) continue; - let frame = layer.getFrame(this.currentFrameNum); - for (let shape of frame.shapes) { + + // Draw activeShape (shape being drawn in progress) for active layer only + if (layer === context.activeLayer && layer.activeShape) { + let cxt = {...context}; + layer.activeShape.draw(cxt); + } + + // NEW: Use AnimationData system to draw shapes + let currentTime = this.currentTime || 0; + let visibleShapes = []; + + for (let shape of layer.shapes) { + if (shape instanceof TempShape) continue; + let existsValue = layer.animationData.interpolate(`shape.${shape.idx}.exists`, currentTime); + if (existsValue !== null && existsValue > 0) { + let zOrder = layer.animationData.interpolate(`shape.${shape.idx}.zOrder`, currentTime); + visibleShapes.push({ shape, zOrder: zOrder || 0 }); + } + } + + // Sort by zOrder + visibleShapes.sort((a, b) => a.zOrder - b.zOrder); + + // Draw sorted shapes + for (let { shape } of visibleShapes) { let cxt = {...context} if (context.shapeselection.indexOf(shape) >= 0) { cxt.selected = true } shape.draw(cxt); } + + // Draw child objects using AnimationData curves for (let child of layer.children) { if (child == context.activeObject) continue; let idx = child.idx; - if (idx in frame.keys) { - child.x = frame.keys[idx].x; - child.y = frame.keys[idx].y; - child.rotation = frame.keys[idx].rotation; - child.scale_x = frame.keys[idx].scale_x; - child.scale_y = frame.keys[idx].scale_y; + + // Use AnimationData to get child's transform + let childX = layer.animationData.interpolate(`child.${idx}.x`, currentTime); + let childY = layer.animationData.interpolate(`child.${idx}.y`, currentTime); + let childRotation = layer.animationData.interpolate(`child.${idx}.rotation`, currentTime); + let childScaleX = layer.animationData.interpolate(`child.${idx}.scale_x`, currentTime); + let childScaleY = layer.animationData.interpolate(`child.${idx}.scale_y`, currentTime); + + if (childX !== null && childY !== null) { + child.x = childX; + child.y = childY; + child.rotation = childRotation || 0; + child.scale_x = childScaleX || 1; + child.scale_y = childScaleY || 1; ctx.save(); child.draw(context); - if (true) { - } ctx.restore(); } } } if (this == context.activeObject) { + // Draw selection rectangles for selected items + if (mode == "select") { + for (let item of context.selection) { + if (!item) continue; + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + let bbox = getRotatedBoundingBox(item); + ctx.rect( + bbox.x.min, + bbox.y.min, + bbox.x.max - bbox.x.min, + bbox.y.max - bbox.y.min, + ); + ctx.stroke(); + ctx.restore(); + } + // Draw drag selection rectangle + if (context.selectionRect) { + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.rect( + context.selectionRect.x1, + context.selectionRect.y1, + context.selectionRect.x2 - context.selectionRect.x1, + context.selectionRect.y2 - context.selectionRect.y1, + ); + ctx.stroke(); + ctx.restore(); + } + } else if (mode == "transform") { + let bbox = undefined; + for (let item of context.selection) { + if (bbox == undefined) { + bbox = getRotatedBoundingBox(item); + } else { + growBoundingBox(bbox, getRotatedBoundingBox(item)); + } + } + if (bbox != undefined) { + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + let xdiff = bbox.x.max - bbox.x.min; + let ydiff = bbox.y.max - bbox.y.min; + ctx.rect(bbox.x.min, bbox.y.min, xdiff, ydiff); + ctx.stroke(); + ctx.fillStyle = "#000000"; + let rectRadius = 5; + for (let i of [ + [0, 0], + [0.5, 0], + [1, 0], + [1, 0.5], + [1, 1], + [0.5, 1], + [0, 1], + [0, 0.5], + ]) { + ctx.beginPath(); + ctx.rect( + bbox.x.min + xdiff * i[0] - rectRadius, + bbox.y.min + ydiff * i[1] - rectRadius, + rectRadius * 2, + rectRadius * 2, + ); + ctx.fill(); + } + ctx.restore(); + } + } + if (context.activeCurve) { ctx.strokeStyle = "magenta"; ctx.beginPath(); @@ -4358,7 +4537,10 @@ class GraphicsObject extends Widget { ctx.fill(); ctx.restore(); } - */ + } + ctx.restore(); + } + /* draw(ctx) { super.draw(ctx) if (this==context.activeObject) { @@ -4440,6 +4622,7 @@ class GraphicsObject extends Widget { } } } + */ transformCanvas(ctx) { if (this.parent) { this.parent.transformCanvas(ctx) @@ -4510,27 +4693,53 @@ class GraphicsObject extends Widget { } super.handleMouseEvent(eventType, x, y) } - addObject(object, x = 0, y = 0, frame = undefined, layer=undefined) { - if (frame == undefined) { - frame = this.currentFrame; + addObject(object, x = 0, y = 0, time = undefined, layer=undefined) { + if (time == undefined) { + time = this.currentTime || 0; } if (layer==undefined) { layer = this.activeLayer } - // this.children.push(object); + layer.children.push(object) object.parent = this; object.x = x; object.y = y; let idx = object.idx; - frame.keys[idx] = { - x: x, - y: y, - rotation: 0, - scale_x: 1, - scale_y: 1, - goToFrame: 1, - }; + + // Add animation curves for the object's position/transform in the layer + let xCurve = new AnimationCurve(`child.${idx}.x`); + xCurve.addKeyframe(new Keyframe(time, x, 'linear')); + layer.animationData.setCurve(`child.${idx}.x`, xCurve); + + let yCurve = new AnimationCurve(`child.${idx}.y`); + yCurve.addKeyframe(new Keyframe(time, y, 'linear')); + layer.animationData.setCurve(`child.${idx}.y`, yCurve); + + let rotationCurve = new AnimationCurve(`child.${idx}.rotation`); + rotationCurve.addKeyframe(new Keyframe(time, 0, 'linear')); + layer.animationData.setCurve(`child.${idx}.rotation`, rotationCurve); + + let scaleXCurve = new AnimationCurve(`child.${idx}.scale_x`); + scaleXCurve.addKeyframe(new Keyframe(time, 1, 'linear')); + layer.animationData.setCurve(`child.${idx}.scale_x`, scaleXCurve); + + let scaleYCurve = new AnimationCurve(`child.${idx}.scale_y`); + scaleYCurve.addKeyframe(new Keyframe(time, 1, 'linear')); + layer.animationData.setCurve(`child.${idx}.scale_y`, scaleYCurve); + + // LEGACY: Also update frame.keys for backwards compatibility + let frame = this.currentFrame; + if (frame) { + frame.keys[idx] = { + x: x, + y: y, + rotation: 0, + scale_x: 1, + scale_y: 1, + goToFrame: 1, + }; + } } removeChild(childObject) { let idx = childObject.idx; @@ -5759,15 +5968,35 @@ function stage() { stage.addEventListener("wheel", (event) => { event.preventDefault(); - 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; + // 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" @@ -5912,7 +6141,7 @@ function stage() { // context.lastMouse = mouse; break; case "select": - if (context.activeObject.currentFrame.frameType != "keyframe") break; + // No longer need keyframe check with AnimationData system selection = selectVertex(context, mouse); if (selection) { context.dragging = true; @@ -5966,8 +6195,15 @@ function stage() { i-- ) { child = context.activeObject.activeLayer.children[i]; - if (!(child.idx in context.activeObject.currentFrame.keys)) - continue; + + // 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) { @@ -6245,6 +6481,15 @@ function stage() { } } 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) @@ -6412,14 +6657,31 @@ function stage() { 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) { - if (!context.activeObject.currentFrame) continue; - if (!context.activeObject.currentFrame.keys) continue; - if (!(child.idx in context.activeObject.currentFrame.keys)) continue; - context.activeObject.currentFrame.keys[child.idx].x += - mouse.x - context.lastMouse.x; - context.activeObject.currentFrame.keys[child.idx].y += - mouse.y - context.lastMouse.y; + 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')); } } } else if (context.selectionRect) { @@ -6432,9 +6694,14 @@ function stage() { context.selection.push(child); } } - for (let shape of context.activeObject.currentFrame.shapes) { - if (hitTest(regionToBbox(context.selectionRect), shape)) { - context.shapeselection.push(shape); + // 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 { @@ -6603,6 +6870,7 @@ function 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; @@ -7040,6 +7308,133 @@ function timeline() { return timeline_cvs; } +function timelineV2() { + let canvas = document.createElement("canvas"); + canvas.className = "timeline-v2"; + + // 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 toggleButton = document.createElement("button"); + toggleButton.textContent = timelineWidget.timelineState.timeFormat === 'frames' ? 'Frames' : 'Seconds'; + toggleButton.style.marginLeft = '10px'; + toggleButton.addEventListener("click", () => { + timelineWidget.toggleTimeFormat(); + toggleButton.textContent = timelineWidget.timelineState.timeFormat === 'frames' ? 'Frames' : 'Seconds'; + updateCanvasSize(); // Redraw after format change + }); + return [toggleButton]; + }; + + // 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; + + // Capture pointer to ensure we get move/up events even if cursor leaves canvas + canvas.setPointerCapture(e.pointerId); + + 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; + timelineWidget.handleMouseEvent("mousemove", x, y); + 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 + }); + + // Add wheel event for pinch-zoom support + canvas.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 oldPixelsPerSecond = timelineWidget.timelineState.pixelsPerSecond; + + // Calculate the time under the mouse BEFORE zooming + const rect = canvas.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseTimeBeforeZoom = timelineWidget.timelineState.pixelToTime(mouseX); + + // 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(mouseX) == mouseTimeBeforeZoom + // pixelToTime(mouseX) = (mouseX / pixelsPerSecond) + viewportStartTime + // So: viewportStartTime = mouseTimeBeforeZoom - (mouseX / pixelsPerSecond) + timelineWidget.timelineState.viewportStartTime = mouseTimeBeforeZoom - (mouseX / timelineWidget.timelineState.pixelsPerSecond); + timelineWidget.timelineState.viewportStartTime = Math.max(0, timelineWidget.timelineState.viewportStartTime); + + updateCanvasSize(); + } else { + // Regular scroll - horizontal scroll for timeline + const deltaX = event.deltaX * config.scrollSpeed; + + // Update viewport horizontal scroll + timelineWidget.timelineState.viewportStartTime += deltaX / timelineWidget.timelineState.pixelsPerSecond; + timelineWidget.timelineState.viewportStartTime = Math.max(0, timelineWidget.timelineState.viewportStartTime); + + updateCanvasSize(); + } + }); + + updateCanvasSize(); + return canvas; +} + function infopanel() { let panel = document.createElement("div"); panel.className = "infopanel"; @@ -7264,6 +7659,14 @@ function createPane(paneType = undefined, div = undefined) { 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); + } + } + div.className = "vertical-grid"; header.style.height = "calc( 2 * var(--lineheight))"; content.style.height = "calc( 100% - 2 * var(--lineheight) )"; @@ -7509,13 +7912,13 @@ function renderUI() { context.ctx = ctx; // root.draw(context); - root.draw(ctx) + 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.transformCanvas(ctx) - context.activeObject.draw(ctx); + context.activeObject.draw(context); ctx.setTransform(transform) } if (context.activeShape) { @@ -8670,6 +9073,10 @@ const panes = { name: "timeline", func: timeline, }, + timelineV2: { + name: "timeline-v2", + func: timelineV2, + }, infopanel: { name: "infopanel", func: infopanel, diff --git a/src/styles.css b/src/styles.css index 0871d04..143cd1a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2,6 +2,7 @@ body { width: 100%; height: 100%; overflow: hidden; + touch-action: none; /* Prevent default touch gestures including pinch-zoom */ } * { diff --git a/src/timeline.js b/src/timeline.js new file mode 100644 index 0000000..76fd1fd --- /dev/null +++ b/src/timeline.js @@ -0,0 +1,494 @@ +// Timeline V2 - New timeline implementation for AnimationData curve-based system + +/** + * TimelineState - Global state for timeline display and interaction + */ +class TimelineState { + constructor(framerate = 24) { + // Time format settings + this.timeFormat = 'frames' // 'frames' | 'seconds' | 'measures' + this.framerate = framerate + + // Zoom and viewport + this.pixelsPerSecond = 100 // Zoom level - how many pixels per second of animation + this.viewportStartTime = 0 // Horizontal scroll position (in seconds) + + // Playhead + this.currentTime = 0 // Current time (in seconds) + + // Ruler settings + this.rulerHeight = 30 // Height of time ruler in pixels + } + + /** + * Convert time (seconds) to pixel position + */ + timeToPixel(time) { + return (time - this.viewportStartTime) * this.pixelsPerSecond + } + + /** + * Convert pixel position to time (seconds) + */ + pixelToTime(pixel) { + return (pixel / this.pixelsPerSecond) + this.viewportStartTime + } + + /** + * Convert time (seconds) to frame number + */ + timeToFrame(time) { + return Math.floor(time * this.framerate) + } + + /** + * Convert frame number to time (seconds) + */ + frameToTime(frame) { + return frame / this.framerate + } + + /** + * Calculate appropriate ruler interval based on zoom level + * Returns interval in seconds that gives ~50-100px spacing + */ + getRulerInterval() { + const targetPixelSpacing = 75 // Target pixels between major ticks + const timeSpacing = targetPixelSpacing / this.pixelsPerSecond // In seconds + + // Standard interval options (in seconds) + const intervals = [ + 0.01, 0.02, 0.05, // 10ms, 20ms, 50ms + 0.1, 0.2, 0.5, // 100ms, 200ms, 500ms + 1, 2, 5, // 1s, 2s, 5s + 10, 20, 30, 60, // 10s, 20s, 30s, 1min + 120, 300, 600 // 2min, 5min, 10min + ] + + // Find closest interval + let bestInterval = intervals[0] + let bestDiff = Math.abs(timeSpacing - bestInterval) + + for (let interval of intervals) { + const diff = Math.abs(timeSpacing - interval) + if (diff < bestDiff) { + bestDiff = diff + bestInterval = interval + } + } + + return bestInterval + } + + /** + * Calculate appropriate ruler interval for frame mode + * Returns interval in frames that gives ~50-100px spacing + */ + getRulerIntervalFrames() { + const targetPixelSpacing = 75 + const pixelsPerFrame = this.pixelsPerSecond / this.framerate + const frameSpacing = targetPixelSpacing / pixelsPerFrame + + // Standard frame intervals + const intervals = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000] + + // Find closest interval + let bestInterval = intervals[0] + let bestDiff = Math.abs(frameSpacing - bestInterval) + + for (let interval of intervals) { + const diff = Math.abs(frameSpacing - interval) + if (diff < bestDiff) { + bestDiff = diff + bestInterval = interval + } + } + + return bestInterval + } + + /** + * Format time for display based on current format setting + */ + formatTime(time) { + if (this.timeFormat === 'frames') { + return `${this.timeToFrame(time)}` + } else if (this.timeFormat === 'seconds') { + const minutes = Math.floor(time / 60) + const seconds = Math.floor(time % 60) + const ms = Math.floor((time % 1) * 10) + + if (minutes > 0) { + return `${minutes}:${seconds.toString().padStart(2, '0')}` + } else { + return `${seconds}.${ms}s` + } + } + // measures format - TODO when DAW features added + return `${time.toFixed(2)}` + } + + /** + * Zoom in (increase pixelsPerSecond) + */ + zoomIn(factor = 1.5) { + this.pixelsPerSecond *= factor + // Clamp to reasonable range + this.pixelsPerSecond = Math.min(this.pixelsPerSecond, 10000) // Max zoom + } + + /** + * Zoom out (decrease pixelsPerSecond) + */ + zoomOut(factor = 1.5) { + this.pixelsPerSecond /= factor + // Clamp to reasonable range + this.pixelsPerSecond = Math.max(this.pixelsPerSecond, 10) // Min zoom + } +} + +/** + * TimeRuler - Widget for displaying time ruler with adaptive intervals + */ +class TimeRuler { + constructor(timelineState) { + this.state = timelineState + this.height = timelineState.rulerHeight + } + + /** + * Draw the time ruler + */ + draw(ctx, width) { + ctx.save() + + // Background + ctx.fillStyle = '#2a2a2a' + ctx.fillRect(0, 0, width, this.height) + + // Determine interval based on current zoom and format + let interval, isFrameMode + if (this.state.timeFormat === 'frames') { + interval = this.state.getRulerIntervalFrames() // In frames + isFrameMode = true + } else { + interval = this.state.getRulerInterval() // In seconds + isFrameMode = false + } + + // Calculate visible time range + const startTime = this.state.viewportStartTime + const endTime = this.state.pixelToTime(width) + + // Draw tick marks and labels + if (isFrameMode) { + this.drawFrameTicks(ctx, width, interval, startTime, endTime) + } else { + this.drawSecondTicks(ctx, width, interval, startTime, endTime) + } + + // Draw playhead (current time indicator) + this.drawPlayhead(ctx, width) + + ctx.restore() + } + + /** + * Draw tick marks for frame mode + */ + drawFrameTicks(ctx, width, interval, startTime, endTime) { + const startFrame = Math.floor(this.state.timeToFrame(startTime) / interval) * interval + const endFrame = Math.ceil(this.state.timeToFrame(endTime) / interval) * interval + + ctx.fillStyle = '#cccccc' + ctx.font = '11px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + + for (let frame = startFrame; frame <= endFrame; frame += interval) { + const time = this.state.frameToTime(frame) + const x = this.state.timeToPixel(time) + + if (x < 0 || x > width) continue + + // Major tick + ctx.strokeStyle = '#888888' + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(x, this.height - 10) + ctx.lineTo(x, this.height) + ctx.stroke() + + // Label + ctx.fillText(frame.toString(), x, 2) + + // Minor ticks (subdivisions) + const minorInterval = interval / 5 + if (minorInterval >= 1) { + for (let i = 1; i < 5; i++) { + const minorFrame = frame + (minorInterval * i) + const minorTime = this.state.frameToTime(minorFrame) + const minorX = this.state.timeToPixel(minorTime) + + if (minorX < 0 || minorX > width) continue + + ctx.strokeStyle = '#555555' + ctx.beginPath() + ctx.moveTo(minorX, this.height - 5) + ctx.lineTo(minorX, this.height) + ctx.stroke() + } + } + } + } + + /** + * Draw tick marks for second mode + */ + drawSecondTicks(ctx, width, interval, startTime, endTime) { + const startTick = Math.floor(startTime / interval) * interval + const endTick = Math.ceil(endTime / interval) * interval + + ctx.fillStyle = '#cccccc' + ctx.font = '11px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + + for (let time = startTick; time <= endTick; time += interval) { + const x = this.state.timeToPixel(time) + + if (x < 0 || x > width) continue + + // Major tick + ctx.strokeStyle = '#888888' + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(x, this.height - 10) + ctx.lineTo(x, this.height) + ctx.stroke() + + // Label + ctx.fillText(this.state.formatTime(time), x, 2) + + // Minor ticks (subdivisions) + const minorInterval = interval / 5 + for (let i = 1; i < 5; i++) { + const minorTime = time + (minorInterval * i) + const minorX = this.state.timeToPixel(minorTime) + + if (minorX < 0 || minorX > width) continue + + ctx.strokeStyle = '#555555' + ctx.beginPath() + ctx.moveTo(minorX, this.height - 5) + ctx.lineTo(minorX, this.height) + ctx.stroke() + } + } + } + + /** + * Draw playhead (current time indicator) + */ + drawPlayhead(ctx, width) { + const x = this.state.timeToPixel(this.state.currentTime) + + // Only draw if playhead is visible + if (x < 0 || x > width) return + + ctx.strokeStyle = '#ff0000' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(x, 0) + ctx.lineTo(x, this.height) + ctx.stroke() + + // Playhead handle (triangle at top) + ctx.fillStyle = '#ff0000' + ctx.beginPath() + ctx.moveTo(x, 0) + ctx.lineTo(x - 6, 8) + ctx.lineTo(x + 6, 8) + ctx.closePath() + ctx.fill() + } + + /** + * Hit test for playhead dragging (no longer used, kept for potential future use) + */ + hitTestPlayhead(x, y) { + const playheadX = this.state.timeToPixel(this.state.currentTime) + const distance = Math.abs(x - playheadX) + + // 10px tolerance for hitting playhead + return distance < 10 && y >= 0 && y <= this.height + } + + /** + * Handle mouse down - start dragging playhead + */ + mousedown(x, y) { + // Clicking anywhere in the ruler moves the playhead there + this.state.currentTime = this.state.pixelToTime(x) + this.state.currentTime = Math.max(0, this.state.currentTime) + this.draggingPlayhead = true + console.log("TimeRuler: Set draggingPlayhead = true, currentTime =", this.state.currentTime); + return true + } + + /** + * Handle mouse move - drag playhead + */ + mousemove(x, y) { + console.log("TimeRuler.mousemove called, draggingPlayhead =", this.draggingPlayhead); + if (this.draggingPlayhead) { + const newTime = this.state.pixelToTime(x); + this.state.currentTime = Math.max(0, newTime); + console.log("TimeRuler: Updated currentTime to", this.state.currentTime); + return true + } + return false + } + + /** + * Handle mouse up - stop dragging + */ + mouseup(x, y) { + if (this.draggingPlayhead) { + this.draggingPlayhead = false + return true + } + return false + } +} + +/** + * TrackHierarchy - Builds and manages hierarchical track structure from GraphicsObject + * Phase 2: Track hierarchy display + */ +class TrackHierarchy { + constructor() { + this.tracks = [] // Flat list of tracks for rendering + this.trackHeight = 30 // Default track height in pixels + } + + /** + * Build track list from GraphicsObject layers + * Creates a flattened list of tracks for rendering, maintaining hierarchy info + */ + buildTracks(graphicsObject) { + this.tracks = [] + + if (!graphicsObject || !graphicsObject.children) { + return + } + + // Iterate through layers (GraphicsObject.children are Layers) + for (let layer of graphicsObject.children) { + // Add layer track + const layerTrack = { + type: 'layer', + object: layer, + name: layer.name || 'Layer', + indent: 0, + collapsed: layer.collapsed || false, + visible: layer.visible !== false + } + this.tracks.push(layerTrack) + + // If layer is not collapsed, add its children + if (!layerTrack.collapsed) { + // Add child GraphicsObjects (nested groups) + if (layer.children) { + for (let child of layer.children) { + this.addObjectTrack(child, 1) + } + } + + // Add shapes + if (layer.shapes) { + for (let shape of layer.shapes) { + this.addShapeTrack(shape, 1) + } + } + } + } + } + + /** + * Recursively add object track and its children + */ + addObjectTrack(obj, indent) { + const track = { + type: 'object', + object: obj, + name: obj.name || obj.idx, + indent: indent, + collapsed: obj.trackCollapsed || false + } + this.tracks.push(track) + + // If object is not collapsed, add its children + if (!track.collapsed && obj.children) { + for (let layer of obj.children) { + // Nested object's layers + const nestedLayerTrack = { + type: 'layer', + object: layer, + name: layer.name || 'Layer', + indent: indent + 1, + collapsed: layer.collapsed || false, + visible: layer.visible !== false + } + this.tracks.push(nestedLayerTrack) + + if (!nestedLayerTrack.collapsed) { + // Add nested layer's children + if (layer.children) { + for (let child of layer.children) { + this.addObjectTrack(child, indent + 2) + } + } + if (layer.shapes) { + for (let shape of layer.shapes) { + this.addShapeTrack(shape, indent + 2) + } + } + } + } + } + } + + /** + * Add shape track + */ + addShapeTrack(shape, indent) { + const track = { + type: 'shape', + object: shape, + name: shape.constructor.name || 'Shape', + indent: indent + } + this.tracks.push(track) + } + + /** + * Calculate total height needed for all tracks + */ + getTotalHeight() { + return this.tracks.length * this.trackHeight + } + + /** + * Get track at a given Y position + */ + getTrackAtY(y) { + const trackIndex = Math.floor(y / this.trackHeight) + if (trackIndex >= 0 && trackIndex < this.tracks.length) { + return this.tracks[trackIndex] + } + return null + } +} + +export { TimelineState, TimeRuler, TrackHierarchy } diff --git a/src/widgets.js b/src/widgets.js index 1664b09..d6b9c59 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -1,5 +1,6 @@ import { backgroundColor, foregroundColor, frameWidth, highlight, layerHeight, shade, shadow } from "./styles.js"; import { clamp, drawBorderedRect, drawCheckerboardBackground, hslToRgb, hsvToRgb, rgbToHex } from "./utils.js" +import { TimelineState, TimeRuler } from "./timeline.js" function growBoundingBox(bboxa, bboxb) { bboxa.x.min = Math.min(bboxa.x.min, bboxb.x.min); @@ -512,10 +513,113 @@ class TimelineWindow extends ScrollableWindow { } } mousedown(x, y) { - + } } - + +/** + * TimelineWindowV2 - New timeline widget using AnimationData curve-based system + * Phase 1: Time ruler with zoom-adaptive intervals and playhead + */ +class TimelineWindowV2 extends Widget { + constructor(x, y, context) { + super(x, y) + this.context = context + this.width = 800 + this.height = 400 + + // Create shared timeline state (24 fps default) + this.timelineState = new TimelineState(24) + + // Create time ruler widget + this.ruler = new TimeRuler(this.timelineState) + + // Track if we're dragging playhead + this.draggingPlayhead = false + } + + draw(ctx) { + ctx.save() + + // Draw background + ctx.fillStyle = '#1e1e1e' + ctx.fillRect(0, 0, this.width, this.height) + + // Draw time ruler at top + this.ruler.draw(ctx, this.width) + + // TODO Phase 2: Draw track hierarchy below ruler + // TODO Phase 3: Draw segments + // TODO Phase 4: Draw minimized curves + + ctx.restore() + } + + mousedown(x, y) { + console.log("TimelineV2 mousedown:", x, y, "ruler height:", this.ruler.height); + // Check if clicking in ruler area + if (y <= this.ruler.height) { + // Let the ruler handle the mousedown (for playhead dragging) + const hitPlayhead = this.ruler.mousedown(x, y); + console.log("Ruler mousedown returned:", hitPlayhead); + if (hitPlayhead) { + this.draggingPlayhead = true + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + console.log("Started dragging playhead"); + return true + } + } + return false + } + + mousemove(x, y) { + if (this.draggingPlayhead) { + console.log("TimelineV2 mousemove while dragging:", x, y); + // Let the ruler handle the mousemove + this.ruler.mousemove(x, y) + + // Sync GraphicsObject currentTime with timeline playhead + if (this.context.activeObject) { + this.context.activeObject.currentTime = this.timelineState.currentTime + } + return true + } + return false + } + + mouseup(x, y) { + if (this.draggingPlayhead) { + // Let the ruler handle the mouseup + this.ruler.mouseup(x, y) + + this.draggingPlayhead = false + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + return true + } + return false + } + + // Zoom controls (can be called from keyboard shortcuts) + zoomIn() { + this.timelineState.zoomIn() + } + + zoomOut() { + this.timelineState.zoomOut() + } + + // Toggle time format + toggleTimeFormat() { + if (this.timelineState.timeFormat === 'frames') { + this.timelineState.timeFormat = 'seconds' + } else { + this.timelineState.timeFormat = 'frames' + } + } +} + export { SCROLL, Widget, @@ -527,5 +631,6 @@ export { HBox, VBox, ScrollableWindow, ScrollableWindowHeaders, - TimelineWindow + TimelineWindow, + TimelineWindowV2 }; \ No newline at end of file