From af4cf249e09dffaa93951f811e833492f0b88679 Mon Sep 17 00:00:00 2001 From: xo-o Date: Fri, 20 Feb 2026 19:51:53 -0500 Subject: [PATCH 1/5] implement placeholder timeline --- .../editor/timeline/timeline/canvas.ts | 124 ++++++++++++- .../timeline/handlers/drag-handler.ts | 14 +- .../timeline/handlers/modify-handler.ts | 168 +++++++++++------- .../timeline/timeline/handlers/selection.ts | 37 +++- 4 files changed, 261 insertions(+), 82 deletions(-) diff --git a/editor/src/components/editor/timeline/timeline/canvas.ts b/editor/src/components/editor/timeline/timeline/canvas.ts index 4c6d6c07..1f3d7acf 100644 --- a/editor/src/components/editor/timeline/timeline/canvas.ts +++ b/editor/src/components/editor/timeline/timeline/canvas.ts @@ -1,4 +1,4 @@ -import { Canvas, Rect, type FabricObject, ActiveSelection } from "fabric"; +import { Canvas, Rect, type FabricObject, ActiveSelection, util } from "fabric"; import { Track } from "./track"; import { Text, @@ -79,6 +79,8 @@ class Timeline extends EventEmitter { #scrollY: number = 0; #scrollbars?: Scrollbars; #mouseWheelHandler?: (e: TPointerEventInfo) => void; + #dragPlaceholder: Rect | null = null; + #primaryDragTarget: FabricObject | null = null; // Drag Auto-scroll state #dragAutoScrollRaf: number | null = null; @@ -186,6 +188,34 @@ class Timeline extends EventEmitter { this.#lastPointer = { x: pointer.clientX, y: pointer.clientY }; this.#startDragAutoScroll(); } + + const target = options.target || this.canvas.findTarget(options.e); + if ( + target && + (target.type === "activeSelection" || (target as any)._objects) + ) { + const selection = target as any; + const pointer = this.canvas.getPointer(options.e); + + const matrix = selection.calcTransformMatrix(true); + const invertedMatrix = util.invertTransform(matrix); + const localPointer = util.transformPoint(pointer, invertedMatrix); + + // Find which object in selection contains the pointer + this.#primaryDragTarget = selection._objects.find((obj: any) => { + // Children in ActiveSelection/Group are usually centered + const w = obj.getScaledWidth(); + const h = obj.getScaledHeight(); + return ( + localPointer.x >= obj.left && + localPointer.x <= obj.left + w && + localPointer.y >= obj.top && + localPointer.y <= obj.top + h + ); + }); + } else { + this.#primaryDragTarget = target || null; + } }); this.canvas.on("mouse:move", (options) => { @@ -549,7 +579,6 @@ class Timeline extends EventEmitter { public get trackRegions() { return this.#trackRegions; } - public get enableGuideRedraw() { return this.#enableGuideRedraw; } @@ -653,6 +682,97 @@ class Timeline extends EventEmitter { this.canvas.requestRenderAll(); } + public get primaryDragTarget() { + return this.#primaryDragTarget; + } + + public get dragPlaceholder() { + return this.#dragPlaceholder; + } + + public updateDragPlaceholder(target: FabricObject) { + if (!this.#dragPlaceholder) { + this.#dragPlaceholder = new Rect({ + fill: "rgba(254, 249, 195, 0.4)", + stroke: "#facc15", + strokeWidth: 2, + strokeDashArray: [5, 5], + rx: 4, + ry: 4, + selectable: false, + evented: false, + visible: false, + }); + this.canvas.add(this.#dragPlaceholder); + // Ensure it stays behind clips but above tracks + this.canvas.sendObjectToBack(this.#dragPlaceholder); + } + + let left = target.left || 0; + let top = target.top || 0; + let width = target.width || 0; + let height = target.height || 0; + + // If it's a multi-selection, we only show placeholder for the primary target + if (target.type === "activeSelection" || (target as any)._objects) { + const selection = target as any; + const primaryTarget = + this.#primaryDragTarget && + selection._objects.includes(this.#primaryDragTarget) + ? this.#primaryDragTarget + : selection._objects[0]; + + if (primaryTarget) { + // Calculate absolute position of the sub-object + // In ActiveSelection, children coordinates are relative to the selection center. + const matrix = selection.calcTransformMatrix(true); + + // Sub-targets in selection are always centered (originX/Y: center) by Fabric + const point = { x: primaryTarget.left, y: primaryTarget.top }; + const absPoint = util.transformPoint(point, matrix); + // Scale the sub-object dimensions by selection scales + const finalWidth = + primaryTarget.getScaledWidth() * (selection.scaleX || 1); + const finalHeight = + primaryTarget.getScaledHeight() * (selection.scaleY || 1); + + width = finalWidth; + height = finalHeight; + left = absPoint.x; + top = absPoint.y - finalHeight / 2; + } + } + + // Determine the snap track + const track = this.getTrackAt(top + height / 2); + const snapTop = track ? track.top : top; + + this.#dragPlaceholder.set({ + left, + top: snapTop, + width, + height, + visible: true, + }); + this.canvas.sendObjectToBack(this.#dragPlaceholder); + // Re-ensure tracks are really at the back + this.#trackObjects.forEach((t) => this.canvas.sendObjectToBack(t)); + + this.canvas.requestRenderAll(); + } + + public removeDragPlaceholder() { + if (this.#dragPlaceholder) { + this.canvas.remove(this.#dragPlaceholder); + this.#dragPlaceholder = null; + this.canvas.requestRenderAll(); + } + } + + public clearPrimaryDragTarget() { + this.#primaryDragTarget = null; + } + public clear() { this.#tracks = []; // Reset tracks this.#clipsMap = {}; // Reset clips diff --git a/editor/src/components/editor/timeline/timeline/handlers/drag-handler.ts b/editor/src/components/editor/timeline/timeline/handlers/drag-handler.ts index 903277e5..65553878 100644 --- a/editor/src/components/editor/timeline/timeline/handlers/drag-handler.ts +++ b/editor/src/components/editor/timeline/timeline/handlers/drag-handler.ts @@ -1,17 +1,19 @@ -import { type FabricObject } from 'fabric'; -import type Timeline from '../canvas'; +import { type FabricObject } from "fabric"; +import type Timeline from "../canvas"; import { getLineGuideStops, getObjectSnappingEdges, getGuides, drawGuides, clearAuxiliaryObjects, -} from '../guidelines/utils'; +} from "../guidelines/utils"; export function handleDragging(timeline: Timeline, options: any) { const target = options.target as FabricObject; if (!target) return; + timeline.updateDragPlaceholder(target); + // --- Snapping Guidelines --- const allObjects = timeline.canvas.getObjects(); const targetRect = target.getBoundingRect(); @@ -34,8 +36,8 @@ export function handleDragging(timeline: Timeline, options: any) { } guides.forEach((lineGuide) => { - if (lineGuide.orientation === 'V') { - target.set('left', lineGuide.lineGuide + lineGuide.offset); + if (lineGuide.orientation === "V") { + target.set("left", lineGuide.lineGuide + lineGuide.offset); target.setCoords(); } }); @@ -56,7 +58,7 @@ export function handleDragging(timeline: Timeline, options: any) { timeline.clearSeparatorHighlights(); if (potentialSeparator) { - potentialSeparator.highlight.set('fill', 'white'); + potentialSeparator.highlight.set("fill", "white"); timeline.setActiveSeparatorIndex(potentialSeparator.index); } else { timeline.setActiveSeparatorIndex(null); diff --git a/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts b/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts index dd940908..21268ba9 100644 --- a/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts +++ b/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts @@ -1,13 +1,13 @@ -import { type FabricObject } from 'fabric'; -import type Timeline from '../canvas'; -import { clearAuxiliaryObjects } from '../guidelines/utils'; -import { generateUUID } from '@/utils/id'; +import { type FabricObject, util } from "fabric"; +import type Timeline from "../canvas"; +import { clearAuxiliaryObjects } from "../guidelines/utils"; +import { generateUUID } from "@/utils/id"; import { type ITimelineTrack, MICROSECONDS_PER_SECOND, type TrackType, -} from '@/types/timeline'; -import { TIMELINE_CONSTANTS } from '../../timeline-constants'; +} from "@/types/timeline"; +import { TIMELINE_CONSTANTS } from "../../timeline-constants"; /** * Helper to safely update the local clips map in Timeline to reflect the new visual state @@ -16,7 +16,7 @@ import { TIMELINE_CONSTANTS } from '../../timeline-constants'; function updateClipTimeLocally( timeline: Timeline, clipId: string, - newDisplayFrom: number + newDisplayFrom: number, ) { const clip = timeline.clipsMap[clipId]; if (!clip) return; @@ -40,6 +40,51 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { clearAuxiliaryObjects(timeline.canvas, timeline.canvas.getObjects()); const targetAny = target as any; + // --- Snap target to placeholder position before any other logic --- + const placeholder = timeline.dragPlaceholder; + if (placeholder && placeholder.visible) { + const placeholderLeft = placeholder.left || 0; + const placeholderTop = placeholder.top || 0; + + if ( + targetAny.type === "activeselection" || + target.type === "activeselection" + ) { + const selection = target as any; + const pdt = timeline.primaryDragTarget as any; + const primaryTarget = pdt + ? selection._objects?.includes(pdt) + ? pdt + : pdt.elementId + ? selection._objects?.find( + (obj: any) => obj.elementId === pdt.elementId, + ) || selection._objects?.[0] + : selection._objects?.[0] + : selection._objects?.[0]; + + if (primaryTarget) { + const matrix = selection.calcTransformMatrix(true); + const point = { x: primaryTarget.left, y: primaryTarget.top }; + const absPoint = util.transformPoint(point, matrix); + + const deltaX = placeholderLeft - absPoint.x; + const deltaY = placeholderTop - absPoint.y; + + target.set({ + left: (target.left || 0) + deltaX, + top: (target.top || 0) + deltaY, + }); + target.setCoords(); + } + } else { + // Single clip: snap directly to placeholder + target.set({ + left: placeholderLeft, + top: placeholderTop, + }); + target.setCoords(); + } + } // --------------------------------------------------------- // 1. Handle Drop on Separator (Single Clip Only - Reverted Multi-clip) @@ -47,11 +92,13 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { if (timeline.activeSeparatorIndex !== null) { // If it's an active selection, we skip separator logic to avoid issues (as requested to remove 3) if ( - targetAny.type === 'activeSelection' || - target.type === 'activeSelection' + targetAny.type === "activeselection" || + target.type === "activeselection" ) { timeline.clearSeparatorHighlights(); timeline.setActiveSeparatorIndex(null); + timeline.removeDragPlaceholder(); + timeline.clearPrimaryDragTarget(); timeline.canvas.requestRenderAll(); return; } @@ -62,7 +109,7 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { const tracks = timeline.tracks; const currentTrackIndex = tracks.findIndex((t) => - t.clipIds.includes(clipId) + t.clipIds.includes(clipId), ); // Restore original Logic for Single Clip Separator Drop (plus the negative time fix) @@ -83,19 +130,19 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { if (left < 0) left = 0; let newDisplayFrom = Math.round( (left / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * timeline.timeScale)) * - MICROSECONDS_PER_SECOND + MICROSECONDS_PER_SECOND, ); if (newDisplayFrom < 0) newDisplayFrom = 0; updateClipTimeLocally(timeline, clipId, newDisplayFrom); - let newTrackType: TrackType = 'Video'; - if (clip.type === 'Audio') newTrackType = 'Audio'; - else if (clip.type === 'Text' || clip.type === 'Caption') - newTrackType = 'Text'; - else if (clip.type === 'Effect') newTrackType = 'Effect'; - else if (clip.type === 'Video' || clip.type === 'Image') - newTrackType = 'Video'; + let newTrackType: TrackType = "Video"; + if (clip.type === "Audio") newTrackType = "Audio"; + else if (clip.type === "Text" || clip.type === "Caption") + newTrackType = "Text"; + else if (clip.type === "Effect") newTrackType = "Effect"; + else if (clip.type === "Video" || clip.type === "Image") + newTrackType = "Video"; const newTrackId = generateUUID(); const newTrack: ITimelineTrack = { @@ -129,11 +176,11 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { timeline.setTracksInternal(newTracksList); timeline.render(); - timeline.emit('timeline:updated', { tracks: newTracksList }); + timeline.emit("timeline:updated", { tracks: newTracksList }); // Also emit clip modification to save time? // Yes - timeline.emit('clip:modified', { + timeline.emit("clip:modified", { clipId, displayFrom: newDisplayFrom, duration: clip.duration, // use current duration @@ -141,6 +188,8 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { timeline.clearSeparatorHighlights(); timeline.setActiveSeparatorIndex(null); + timeline.removeDragPlaceholder(); + timeline.clearPrimaryDragTarget(); return; } } @@ -149,11 +198,13 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { // 2. Handle Multi-selection Move (General) // --------------------------------------------------------- if ( - targetAny.type === 'activeSelection' || - target.type === 'activeSelection' + targetAny.type === "activeselection" || + target.type === "activeselection" ) { timeline.clearSeparatorHighlights(); timeline.setActiveSeparatorIndex(null); + timeline.removeDragPlaceholder(); + timeline.clearPrimaryDragTarget(); timeline.canvas.requestRenderAll(); return; } @@ -175,11 +226,11 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { const proposedStart = Math.round( (left / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * timeline.timeScale)) * - MICROSECONDS_PER_SECOND + MICROSECONDS_PER_SECOND, ); const proposedDuration = Math.round( (width / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * timeline.timeScale)) * - MICROSECONDS_PER_SECOND + MICROSECONDS_PER_SECOND, ); const proposedEnd = proposedStart + proposedDuration; @@ -211,15 +262,15 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { updateClipTimeLocally(timeline, clipId, proposedStart); const clipInfo = timeline.clipsMap[clipId]; - let newTrackType: TrackType = 'Video'; + let newTrackType: TrackType = "Video"; if (targetTrack) newTrackType = targetTrack.type; else if (clipInfo) { - if (clipInfo.type === 'Audio') newTrackType = 'Audio'; - else if (clipInfo.type === 'Text' || clipInfo.type === 'Caption') - newTrackType = 'Text'; - else if (clipInfo.type === 'Effect') newTrackType = 'Effect'; - else if (clipInfo.type === 'Video' || clipInfo.type === 'Image') - newTrackType = 'Video'; + if (clipInfo.type === "Audio") newTrackType = "Audio"; + else if (clipInfo.type === "Text" || clipInfo.type === "Caption") + newTrackType = "Text"; + else if (clipInfo.type === "Effect") newTrackType = "Effect"; + else if (clipInfo.type === "Video" || clipInfo.type === "Image") + newTrackType = "Video"; } const newTrackId = generateUUID(); @@ -240,7 +291,7 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { .filter((t: ITimelineTrack) => t.clipIds.length > 0); const targetTrackIndex = newTracksList.findIndex( - (t) => t.id === trackRegion.id + (t) => t.id === trackRegion.id, ); const insertIndex = @@ -250,10 +301,10 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { timeline.setTracksInternal(newTracksList); timeline.render(); - timeline.emit('timeline:updated', { tracks: newTracksList }); + timeline.emit("timeline:updated", { tracks: newTracksList }); const trim = targetAny.trim; - timeline.emit('clip:modified', { + timeline.emit("clip:modified", { clipId, displayFrom: proposedStart, duration: proposedDuration, @@ -261,9 +312,9 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { }); } else { // --- NO OVERLAP: MOVE --- - target.set('top', trackRegion.top); + target.set("top", trackRegion.top); target.setCoords(); - timeline.emit('clip:movedToTrack', { + timeline.emit("clip:movedToTrack", { clipId: clipId, trackId: trackRegion.id, }); @@ -284,16 +335,16 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { TIMELINE_CONSTANTS.PIXELS_PER_SECOND * timeline.timeScale; - target.set('left', originalLeft); + target.set("left", originalLeft); const tracks = timeline.tracks; const originalTrack = tracks.find((t) => t.clipIds.includes(clipId)); if (originalTrack) { const originalRegion = timeline.trackRegions.find( - (r) => r.id === originalTrack.id + (r) => r.id === originalTrack.id, ); if (originalRegion) { - target.set('top', originalRegion.top); + target.set("top", originalRegion.top); } } target.setCoords(); @@ -303,6 +354,8 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { timeline.clearSeparatorHighlights(); timeline.setActiveSeparatorIndex(null); + timeline.removeDragPlaceholder(); + timeline.clearPrimaryDragTarget(); timeline.canvas.requestRenderAll(); } @@ -314,31 +367,10 @@ export function handleClipModification(timeline: Timeline, options: any) { const targetAny = target as any; - if (targetAny.type === 'activeSelection' && targetAny._objects) { - const clips: Array<{ clipId: string; displayFrom: number }> = []; - - for (const obj of targetAny._objects) { - const objAny = obj as any; - if (!objAny.elementId) continue; - - const left = (obj.left || 0) + (target.left || 0); - - let displayFrom = Math.round( - (left / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * timeline.timeScale)) * - MICROSECONDS_PER_SECOND - ); - - if (displayFrom < 0) displayFrom = 0; - - clips.push({ - clipId: objAny.elementId, - displayFrom, - }); - } - - if (clips.length > 0) { - timeline.emit('clips:modified', { clips }); - } + if (targetAny.type === "activeselection" && targetAny._objects) { + // Skip: clips:modified is deferred to selection:cleared + // so that the store only updates once the user deselects the group. + return; } else { const clipId = targetAny.elementId; if (!clipId) return; @@ -348,25 +380,25 @@ export function handleClipModification(timeline: Timeline, options: any) { if (left < 0) { left = 0; - target.set('left', 0); + target.set("left", 0); target.setCoords(); } let displayFrom = Math.round( (left / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * timeline.timeScale)) * - MICROSECONDS_PER_SECOND + MICROSECONDS_PER_SECOND, ); if (displayFrom < 0) displayFrom = 0; const duration = Math.round( (width / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * timeline.timeScale)) * - MICROSECONDS_PER_SECOND + MICROSECONDS_PER_SECOND, ); const trim = targetAny.trim; - timeline.emit('clip:modified', { + timeline.emit("clip:modified", { clipId, displayFrom, duration, diff --git a/editor/src/components/editor/timeline/timeline/handlers/selection.ts b/editor/src/components/editor/timeline/timeline/handlers/selection.ts index c7364487..28b825df 100644 --- a/editor/src/components/editor/timeline/timeline/handlers/selection.ts +++ b/editor/src/components/editor/timeline/timeline/handlers/selection.ts @@ -1,14 +1,16 @@ -import { ActiveSelection } from 'fabric'; -import type Timeline from '../canvas'; +import { ActiveSelection } from "fabric"; +import type Timeline from "../canvas"; +import { TIMELINE_CONSTANTS } from "../../timeline-constants"; +import { MICROSECONDS_PER_SECOND } from "@/types/timeline"; export function handleSelectionCreate(timeline: Timeline, e: any) { const activeSelection = timeline.canvas.getActiveObject(); if (activeSelection instanceof ActiveSelection) { activeSelection.set({ - borderColor: 'rgba(255, 255, 255, 0.5)', + borderColor: "rgba(255, 255, 255, 0.5)", hasControls: false, - hoverCursor: 'default', + hoverCursor: "default", padding: 0, borderScaleFactor: 1, }); @@ -34,9 +36,9 @@ export function handleSelectionUpdate(timeline: Timeline, e: any) { if (activeSelection instanceof ActiveSelection) { activeSelection.set({ - borderColor: 'transparent', + borderColor: "transparent", hasControls: false, - hoverCursor: 'default', + hoverCursor: "default", }); } @@ -60,9 +62,32 @@ export function handleSelectionUpdate(timeline: Timeline, e: any) { export function handleSelectionClear(timeline: Timeline, e: any) { const { deselected } = e; if (deselected) { + const clips: Array<{ clipId: string; displayFrom: number }> = []; + deselected.forEach((obj: any) => { if ((obj as any).setSelected) (obj as any).setSelected(false); + + // Emit deferred clips:modified from standalone positions + if (obj.elementId) { + let left = obj.left || 0; + if (left < 0) left = 0; + + let displayFrom = Math.round( + (left / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * timeline.timeScale)) * + MICROSECONDS_PER_SECOND, + ); + if (displayFrom < 0) displayFrom = 0; + + clips.push({ + clipId: obj.elementId, + displayFrom, + }); + } }); + + if (clips.length > 0) { + timeline.emit("clips:modified", { clips }); + } } timeline.emitSelectionChange(); } From a55025f209d81cfcdb525ab479cca8c7b5896d41 Mon Sep 17 00:00:00 2001 From: xo-o Date: Sat, 21 Feb 2026 00:45:22 -0500 Subject: [PATCH 2/5] update placeholder --- .../editor/timeline/timeline/canvas.ts | 132 ++++++++++++++++-- .../timeline/handlers/modify-handler.ts | 94 +++++++++++-- .../timeline/timeline/handlers/selection.ts | 108 +++++++++++++- 3 files changed, 306 insertions(+), 28 deletions(-) diff --git a/editor/src/components/editor/timeline/timeline/canvas.ts b/editor/src/components/editor/timeline/timeline/canvas.ts index 1f3d7acf..96938ab6 100644 --- a/editor/src/components/editor/timeline/timeline/canvas.ts +++ b/editor/src/components/editor/timeline/timeline/canvas.ts @@ -80,6 +80,7 @@ class Timeline extends EventEmitter { #scrollbars?: Scrollbars; #mouseWheelHandler?: (e: TPointerEventInfo) => void; #dragPlaceholder: Rect | null = null; + #extraDragPlaceholders: Rect[] = []; #primaryDragTarget: FabricObject | null = null; // Drag Auto-scroll state @@ -190,6 +191,10 @@ class Timeline extends EventEmitter { } const target = options.target || this.canvas.findTarget(options.e); + if (target) { + (target as any)._originalLeft = target.left; + (target as any)._originalTop = target.top; + } if ( target && (target.type === "activeSelection" || (target as any)._objects) @@ -708,6 +713,9 @@ class Timeline extends EventEmitter { this.canvas.sendObjectToBack(this.#dragPlaceholder); } + // Hide extra placeholders by default, we'll show them if needed + this.#extraDragPlaceholders.forEach((p) => p.set({ visible: false })); + let left = target.left || 0; let top = target.top || 0; let width = target.width || 0; @@ -723,6 +731,11 @@ class Timeline extends EventEmitter { : selection._objects[0]; if (primaryTarget) { + const primaryClipId = (primaryTarget as any).elementId; + const sourceTrack = this.#tracks.find((t) => + t.clipIds.includes(primaryClipId), + ); + // Calculate absolute position of the sub-object // In ActiveSelection, children coordinates are relative to the selection center. const matrix = selection.calcTransformMatrix(true); @@ -740,20 +753,113 @@ class Timeline extends EventEmitter { height = finalHeight; left = absPoint.x; top = absPoint.y - finalHeight / 2; + + // Determine the snap track based on primary target + const track = this.getTrackAt(top + height / 2); + const snapTop = track ? track.top : top; + const snapDiffY = snapTop - top; + + this.#dragPlaceholder.set({ + left, + top: snapTop, + width, + height, + visible: true, + }); + + // Now handle extra placeholders for clips on the same track as primary + if (sourceTrack) { + const sameTrackClips = selection._objects.filter((obj: any) => { + if (obj === primaryTarget) return false; + return sourceTrack.clipIds.includes(obj.elementId); + }); + + sameTrackClips.forEach((extraTarget: any, index: number) => { + let p = this.#extraDragPlaceholders[index]; + if (!p) { + p = new Rect({ + fill: "rgba(254, 249, 195, 0.4)", + stroke: "#facc15", + strokeWidth: 2, + strokeDashArray: [5, 5], + rx: 4, + ry: 4, + selectable: false, + evented: false, + visible: false, + }); + this.#extraDragPlaceholders.push(p); + this.canvas.add(p); + } + + const ePoint = { x: extraTarget.left, y: extraTarget.top }; + const eAbsPoint = util.transformPoint(ePoint, matrix); + const eWidth = + extraTarget.getScaledWidth() * (selection.scaleX || 1); + const eHeight = + extraTarget.getScaledHeight() * (selection.scaleY || 1); + const eLeft = eAbsPoint.x; + const eTop = eAbsPoint.y - eHeight / 2; + + p.set({ + left: eLeft, + top: eTop + snapDiffY, + width: eWidth, + height: eHeight, + visible: true, + }); + this.canvas.sendObjectToBack(p); + }); + } } - } + } else { + // Single clip: snap directly to placeholder + const track = this.getTrackAt(top + height / 2); + const snapTop = track ? track.top : top; - // Determine the snap track - const track = this.getTrackAt(top + height / 2); - const snapTop = track ? track.top : top; + let finalLeft = left; + + if (track) { + const trackClips = + this.#tracks.find((t) => t.id === track.id)?.clipIds || []; + const targetClipId = (target as any).elementId; + + for (const clipId of trackClips) { + if (clipId === targetClipId) continue; + const clipObj = this.#clipObjects.get(clipId); + if (!clipObj) continue; + + const clipLeft = clipObj.left || 0; + const clipWidth = clipObj.getScaledWidth(); + const clipRight = clipLeft + clipWidth; + + const targetWidth = target.getScaledWidth(); + const targetRight = left + targetWidth; + + // Horizontal overlap check + if (targetRight > clipLeft && left < clipRight) { + const targetCenter = left + targetWidth / 2; + const clipCenter = clipLeft + clipWidth / 2; + + if (targetCenter < clipCenter) { + finalLeft = clipLeft - targetWidth; + } else { + finalLeft = clipRight; + } + break; + } + } + } + + this.#dragPlaceholder.set({ + left: finalLeft, + top: snapTop, + width, + height, + visible: true, + }); + } - this.#dragPlaceholder.set({ - left, - top: snapTop, - width, - height, - visible: true, - }); this.canvas.sendObjectToBack(this.#dragPlaceholder); // Re-ensure tracks are really at the back this.#trackObjects.forEach((t) => this.canvas.sendObjectToBack(t)); @@ -765,8 +871,10 @@ class Timeline extends EventEmitter { if (this.#dragPlaceholder) { this.canvas.remove(this.#dragPlaceholder); this.#dragPlaceholder = null; - this.canvas.requestRenderAll(); } + this.#extraDragPlaceholders.forEach((p) => this.canvas.remove(p)); + this.#extraDragPlaceholders = []; + this.canvas.requestRenderAll(); } public clearPrimaryDragTarget() { diff --git a/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts b/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts index 21268ba9..7d2c968f 100644 --- a/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts +++ b/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts @@ -36,11 +36,14 @@ function updateClipTimeLocally( export function handleTrackRelocation(timeline: Timeline, options: any) { const target = options.target as FabricObject | undefined; if (!target) return; + const allClips = timeline.canvas + .getObjects() + .filter((obj: any) => obj.elementId); clearAuxiliaryObjects(timeline.canvas, timeline.canvas.getObjects()); const targetAny = target as any; - // --- Snap target to placeholder position before any other logic --- + const placeholder = timeline.dragPlaceholder; if (placeholder && placeholder.visible) { const placeholderLeft = placeholder.left || 0; @@ -51,16 +54,18 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { target.type === "activeselection" ) { const selection = target as any; + const selectedObjects = selection._objects || []; + const pdt = timeline.primaryDragTarget as any; const primaryTarget = pdt - ? selection._objects?.includes(pdt) + ? selectedObjects?.includes(pdt) ? pdt : pdt.elementId - ? selection._objects?.find( + ? selectedObjects?.find( (obj: any) => obj.elementId === pdt.elementId, - ) || selection._objects?.[0] - : selection._objects?.[0] - : selection._objects?.[0]; + ) || selectedObjects?.[0] + : selectedObjects?.[0] + : selectedObjects?.[0]; if (primaryTarget) { const matrix = selection.calcTransformMatrix(true); @@ -69,6 +74,77 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { const deltaX = placeholderLeft - absPoint.x; const deltaY = placeholderTop - absPoint.y; + const simulated = selectedObjects.map((obj: any) => { + const newLeft = (-obj.left || 0) + deltaX; + const width = (obj.width || 0) * (obj.scaleX || 1); + return { + elementId: obj.elementId, + left: newLeft, + right: newLeft + width, + }; + }); + + const nonSelectedClips = allClips + .filter( + (clip: any) => + !selectedObjects.some( + (sel: any) => sel.elementId === clip.elementId, + ), + ) + .map((clip: any) => { + const track = timeline.tracks.find((t) => + t.clipIds.includes(clip.elementId), + ); + return { clip, trackId: track?.id }; + }); + + let shouldCancel = false; + + for (const sim of simulated) { + const objInSelection = selectedObjects.find( + (o: any) => o.elementId === sim.elementId, + ); + const simTop = + placeholderTop + + (objInSelection.top - (primaryTarget.top || 0)) * + (selection.scaleY || 1); + const simHeight = + (objInSelection.height || 0) * + (objInSelection.scaleY || 1) * + (selection.scaleY || 1); + const targetTrack = timeline.getTrackAt(simTop + simHeight / 2); + + if (!targetTrack) continue; + + for (const { clip, trackId } of nonSelectedClips) { + if (trackId !== targetTrack.id) continue; + + const clipLeft = clip.left || 0; + const clipWidth = (clip.width || 0) * (clip.scaleX || 1); + const clipRight = clipLeft + clipWidth; + const overlaps = sim.right > clipLeft && sim.left < clipRight; + + if (overlaps) { + shouldCancel = true; + break; + } + } + if (shouldCancel) break; + } + + if (shouldCancel) { + target.set({ + left: (target as any)._originalLeft, + top: (target as any)._originalTop, + }); + target.setCoords(); + timeline.clearSeparatorHighlights(); + timeline.setActiveSeparatorIndex(null); + timeline.removeDragPlaceholder(); + timeline.clearPrimaryDragTarget(); + timeline.canvas.requestRenderAll(); + return; + } target.set({ left: (target.left || 0) + deltaX, @@ -78,10 +154,7 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { } } else { // Single clip: snap directly to placeholder - target.set({ - left: placeholderLeft, - top: placeholderTop, - }); + target.set({ left: placeholderLeft, top: placeholderTop }); target.setCoords(); } } @@ -201,6 +274,7 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { targetAny.type === "activeselection" || target.type === "activeselection" ) { + // Track changes and position updates are deferred to selection:cleared timeline.clearSeparatorHighlights(); timeline.setActiveSeparatorIndex(null); timeline.removeDragPlaceholder(); diff --git a/editor/src/components/editor/timeline/timeline/handlers/selection.ts b/editor/src/components/editor/timeline/timeline/handlers/selection.ts index 28b825df..4b8f7d3a 100644 --- a/editor/src/components/editor/timeline/timeline/handlers/selection.ts +++ b/editor/src/components/editor/timeline/timeline/handlers/selection.ts @@ -1,7 +1,12 @@ import { ActiveSelection } from "fabric"; import type Timeline from "../canvas"; import { TIMELINE_CONSTANTS } from "../../timeline-constants"; -import { MICROSECONDS_PER_SECOND } from "@/types/timeline"; +import { + MICROSECONDS_PER_SECOND, + type ITimelineTrack, + type TrackType, +} from "@/types/timeline"; +import { generateUUID } from "@/utils/id"; export function handleSelectionCreate(timeline: Timeline, e: any) { const activeSelection = timeline.canvas.getActiveObject(); @@ -62,13 +67,52 @@ export function handleSelectionUpdate(timeline: Timeline, e: any) { export function handleSelectionClear(timeline: Timeline, e: any) { const { deselected } = e; if (deselected) { + const trackMoves: Array<{ clipId: string; targetIdx: number }> = []; const clips: Array<{ clipId: string; displayFrom: number }> = []; + // Phase 1: Collect all changes (no emissions — track layout stays stable) deselected.forEach((obj: any) => { if ((obj as any).setSelected) (obj as any).setSelected(false); - // Emit deferred clips:modified from standalone positions if (obj.elementId) { + // Detect track change from standalone position + const clipHeight = obj.getScaledHeight?.() || obj.height || 0; + const clipCenterY = (obj.top || 0) + clipHeight / 2; + const trackRegion = timeline.getTrackAt(clipCenterY); + + let targetIdx = -1; + if (trackRegion) { + targetIdx = timeline.tracks.findIndex((t) => t.id === trackRegion.id); + } else if (timeline.trackRegions.length > 0) { + const lastRegion = + timeline.trackRegions[timeline.trackRegions.length - 1]; + if (clipCenterY > lastRegion.bottom) { + // Below last track: calculate virtual track slot + const dist = clipCenterY - lastRegion.bottom; + const GAP = TIMELINE_CONSTANTS.TRACK_SPACING; + const DEFAULT_H = 60; // Estimated height for new slots + targetIdx = + timeline.tracks.length + Math.floor(dist / (DEFAULT_H + GAP)); + } + } + + if (targetIdx !== -1) { + const currentTrack = timeline.tracks.find((t) => + t.clipIds.includes(obj.elementId), + ); + const currentIdx = currentTrack + ? timeline.tracks.indexOf(currentTrack) + : -1; + + if (currentIdx !== targetIdx) { + trackMoves.push({ + clipId: obj.elementId, + targetIdx, + }); + } + } + + // Collect position update let left = obj.left || 0; if (left < 0) left = 0; @@ -78,13 +122,65 @@ export function handleSelectionClear(timeline: Timeline, e: any) { ); if (displayFrom < 0) displayFrom = 0; - clips.push({ - clipId: obj.elementId, - displayFrom, - }); + clips.push({ clipId: obj.elementId, displayFrom }); } }); + // Phase 2: Apply track moves atomically via timeline:updated + if (trackMoves.length > 0) { + let newTracks = timeline.tracks.map((t) => ({ + ...t, + clipIds: [...t.clipIds], + })); + + // Ensure we have enough tracks for the max targetIdx + const maxTargetIdx = Math.max(...trackMoves.map((m) => m.targetIdx)); + while (newTracks.length <= maxTargetIdx) { + newTracks.push({ + id: generateUUID(), + type: "Video", + name: "Video Track", + clipIds: [], + muted: false, + }); + } + + for (const { clipId, targetIdx } of trackMoves) { + // Remove from current tracks + for (const track of newTracks) { + track.clipIds = track.clipIds.filter((id) => id !== clipId); + } + + // Add to new track and update its type based on the first clip that lands there + const targetTrack = newTracks[targetIdx]; + if (targetTrack) { + targetTrack.clipIds.push(clipId); + + const clip = timeline.clipsMap[clipId]; + if (clip) { + let newType: TrackType = "Video"; + if (clip.type === "Audio") newType = "Audio"; + else if (clip.type === "Text" || clip.type === "Caption") + newType = "Text"; + else if (clip.type === "Effect") newType = "Effect"; + else if (clip.type === "Video" || clip.type === "Image") + newType = "Video"; + + targetTrack.type = newType; + targetTrack.name = `${newType} Track`; + } + } + } + + // Remove empty tracks + newTracks = newTracks.filter((t) => t.clipIds.length > 0); + + timeline.setTracksInternal(newTracks); + timeline.render(); + timeline.emit("timeline:updated", { tracks: newTracks }); + } + + // Phase 3: Emit position updates if (clips.length > 0) { timeline.emit("clips:modified", { clips }); } From 18cc17866eeeced9b1c7e8f355c5741ce94ac02e Mon Sep 17 00:00:00 2001 From: xo-o Date: Sat, 21 Feb 2026 14:50:33 -0500 Subject: [PATCH 3/5] Update simulated position --- .../timeline/timeline/handlers/modify-handler.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts b/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts index 7d2c968f..3cf99dd5 100644 --- a/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts +++ b/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts @@ -75,8 +75,14 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { const deltaX = placeholderLeft - absPoint.x; const deltaY = placeholderTop - absPoint.y; const simulated = selectedObjects.map((obj: any) => { - const newLeft = (-obj.left || 0) + deltaX; - const width = (obj.width || 0) * (obj.scaleX || 1); + const objAbsPoint = util.transformPoint( + { x: obj.left, y: obj.top }, + matrix, + ); + let newLeft = objAbsPoint.x + deltaX; + if (newLeft < 0) newLeft = 0; + const width = + (obj.width || 0) * (obj.scaleX || 1) * (selection.scaleX || 1); return { elementId: obj.elementId, left: newLeft, From eb3155ad42def63a46b5ddb8e5eeac392b4f8ab1 Mon Sep 17 00:00:00 2001 From: xo-o Date: Sat, 21 Feb 2026 15:18:42 -0500 Subject: [PATCH 4/5] update logic placeholder --- .../timeline/handlers/modify-handler.ts | 26 +++++++++++++++++++ .../timeline/timeline/handlers/selection.ts | 14 ++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts b/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts index 3cf99dd5..7d28b448 100644 --- a/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts +++ b/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts @@ -8,6 +8,7 @@ import { type TrackType, } from "@/types/timeline"; import { TIMELINE_CONSTANTS } from "../../timeline-constants"; +import { handleDragend, handleSelectionClear } from "./selection"; /** * Helper to safely update the local clips map in Timeline to reflect the new visual state @@ -79,13 +80,19 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { { x: obj.left, y: obj.top }, matrix, ); + let newLeft = objAbsPoint.x + deltaX; if (newLeft < 0) newLeft = 0; + + const newTop = objAbsPoint.y + deltaY; + const width = (obj.width || 0) * (obj.scaleX || 1) * (selection.scaleX || 1); + return { elementId: obj.elementId, left: newLeft, + top: newTop, right: newLeft + width, }; }); @@ -157,6 +164,25 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { top: (target.top || 0) + deltaY, }); target.setCoords(); + simulated.forEach((sim: any) => { + const obj = selectedObjects.find( + (o: any) => o.elementId === sim.elementId, + ); + if (!obj) return; + + obj.set({ + left: sim.left, + top: sim.top, + }); + + obj.setCoords(); + }); + + handleDragend(timeline, { + deselected: selectedObjects, + }); + timeline.canvas.discardActiveObject(); + timeline.canvas.requestRenderAll(); } } else { // Single clip: snap directly to placeholder diff --git a/editor/src/components/editor/timeline/timeline/handlers/selection.ts b/editor/src/components/editor/timeline/timeline/handlers/selection.ts index 4b8f7d3a..5c4c6f19 100644 --- a/editor/src/components/editor/timeline/timeline/handlers/selection.ts +++ b/editor/src/components/editor/timeline/timeline/handlers/selection.ts @@ -65,17 +65,19 @@ export function handleSelectionUpdate(timeline: Timeline, e: any) { } export function handleSelectionClear(timeline: Timeline, e: any) { + timeline.emitSelectionChange(); +} + +export function handleDragend(timeline: Timeline, e: any) { const { deselected } = e; if (deselected) { const trackMoves: Array<{ clipId: string; targetIdx: number }> = []; const clips: Array<{ clipId: string; displayFrom: number }> = []; - // Phase 1: Collect all changes (no emissions — track layout stays stable) deselected.forEach((obj: any) => { if ((obj as any).setSelected) (obj as any).setSelected(false); if (obj.elementId) { - // Detect track change from standalone position const clipHeight = obj.getScaledHeight?.() || obj.height || 0; const clipCenterY = (obj.top || 0) + clipHeight / 2; const trackRegion = timeline.getTrackAt(clipCenterY); @@ -112,7 +114,6 @@ export function handleSelectionClear(timeline: Timeline, e: any) { } } - // Collect position update let left = obj.left || 0; if (left < 0) left = 0; @@ -120,20 +121,19 @@ export function handleSelectionClear(timeline: Timeline, e: any) { (left / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * timeline.timeScale)) * MICROSECONDS_PER_SECOND, ); + console.log("displayFrom", displayFrom); if (displayFrom < 0) displayFrom = 0; clips.push({ clipId: obj.elementId, displayFrom }); } }); - // Phase 2: Apply track moves atomically via timeline:updated if (trackMoves.length > 0) { let newTracks = timeline.tracks.map((t) => ({ ...t, clipIds: [...t.clipIds], })); - // Ensure we have enough tracks for the max targetIdx const maxTargetIdx = Math.max(...trackMoves.map((m) => m.targetIdx)); while (newTracks.length <= maxTargetIdx) { newTracks.push({ @@ -146,12 +146,10 @@ export function handleSelectionClear(timeline: Timeline, e: any) { } for (const { clipId, targetIdx } of trackMoves) { - // Remove from current tracks for (const track of newTracks) { track.clipIds = track.clipIds.filter((id) => id !== clipId); } - // Add to new track and update its type based on the first clip that lands there const targetTrack = newTracks[targetIdx]; if (targetTrack) { targetTrack.clipIds.push(clipId); @@ -172,7 +170,6 @@ export function handleSelectionClear(timeline: Timeline, e: any) { } } - // Remove empty tracks newTracks = newTracks.filter((t) => t.clipIds.length > 0); timeline.setTracksInternal(newTracks); @@ -180,7 +177,6 @@ export function handleSelectionClear(timeline: Timeline, e: any) { timeline.emit("timeline:updated", { tracks: newTracks }); } - // Phase 3: Emit position updates if (clips.length > 0) { timeline.emit("clips:modified", { clips }); } From dd8632c8813ffca51b5f03437e652d7e73cf6c07 Mon Sep 17 00:00:00 2001 From: xo-o Date: Mon, 23 Feb 2026 15:49:24 -0500 Subject: [PATCH 5/5] update placeholder --- .../editor/timeline/timeline/canvas.ts | 122 ++++- .../timeline/handlers/modify-handler.ts | 437 +++++++++++++----- 2 files changed, 424 insertions(+), 135 deletions(-) diff --git a/editor/src/components/editor/timeline/timeline/canvas.ts b/editor/src/components/editor/timeline/timeline/canvas.ts index 96938ab6..db5765d0 100644 --- a/editor/src/components/editor/timeline/timeline/canvas.ts +++ b/editor/src/components/editor/timeline/timeline/canvas.ts @@ -82,6 +82,10 @@ class Timeline extends EventEmitter { #dragPlaceholder: Rect | null = null; #extraDragPlaceholders: Rect[] = []; #primaryDragTarget: FabricObject | null = null; + // Stores clipId → new pixel-left for clips that need to shift right on drop (single-clip push-to-fit) + #pendingClipShifts: Map = new Map(); + // Stores original pixel-left values for siblings that are being visually shifted during drag + #shiftedObjectOriginals: Map = new Map(); // Drag Auto-scroll state #dragAutoScrollRaf: number | null = null; @@ -817,36 +821,83 @@ class Timeline extends EventEmitter { const track = this.getTrackAt(top + height / 2); const snapTop = track ? track.top : top; + // Revert any siblings that were visually shifted in the previous frame + this.#revertShiftedObjects(); + + // Reset pending shifts each frame + this.#pendingClipShifts.clear(); + let finalLeft = left; if (track) { const trackClips = this.#tracks.find((t) => t.id === track.id)?.clipIds || []; const targetClipId = (target as any).elementId; + const targetWidth = target.getScaledWidth(); + + // 1. Collect sibling clip objects sorted left→right using their ORIGINAL/REAL positions + const siblings = trackClips + .filter((id) => id !== targetClipId) + .map((id) => { + const obj = this.#clipObjects.get(id); + if (!obj) return null; + return { id, left: obj.left || 0, width: obj.getScaledWidth() }; + }) + .filter(Boolean) as { id: string; left: number; width: number }[]; + siblings.sort((a, b) => a.left - b.left); + + // 2. Find insertion index 'k' based on center-to-center heuristic + // D wants to land between siblings[k-1] and siblings[k] + let k = siblings.length; + const dCenter = left + targetWidth / 2; + for (let i = 0; i < siblings.length; i++) { + const s = siblings[i]; + const sCenter = s.left + s.width / 2; + if (dCenter < sCenter) { + k = i; + break; + } + } - for (const clipId of trackClips) { - if (clipId === targetClipId) continue; - const clipObj = this.#clipObjects.get(clipId); - if (!clipObj) continue; - - const clipLeft = clipObj.left || 0; - const clipWidth = clipObj.getScaledWidth(); - const clipRight = clipLeft + clipWidth; - - const targetWidth = target.getScaledWidth(); - const targetRight = left + targetWidth; - - // Horizontal overlap check - if (targetRight > clipLeft && left < clipRight) { - const targetCenter = left + targetWidth / 2; - const clipCenter = clipLeft + clipWidth / 2; + // 3. Determine bounds for the chosen slot + const leftLimit = + k > 0 ? siblings[k - 1].left + siblings[k - 1].width : 0; + const rightLimit = k < siblings.length ? siblings[k].left : Infinity; + const gapSize = rightLimit - leftLimit; + + // 4. Calculate finalLeft and pushAmount + let pushAmount = 0; + if (targetWidth <= gapSize) { + // Fits: allow free movement within the gap + finalLeft = Math.max( + leftLimit, + Math.min(left, rightLimit - targetWidth), + ); + pushAmount = 0; + } else { + // Doesn't fit: snap placeholder to start of gap and push everything after by shortfall. + // This avoids the "moving with it" behavior because the shift is constant for this slot. + finalLeft = leftLimit; + pushAmount = targetWidth - gapSize; + } - if (targetCenter < clipCenter) { - finalLeft = clipLeft - targetWidth; - } else { - finalLeft = clipRight; + // 5. Apply shifts and record for drop + if (pushAmount > 0) { + for (let i = k; i < siblings.length; i++) { + const s = siblings[i]; + const newLeft = s.left + pushAmount; + this.#pendingClipShifts.set(s.id, newLeft); + + // Live visual shift: move the Fabric object so user sees it during drag + const obj = this.#clipObjects.get(s.id); + if (obj) { + // Store original if not yet stored this drag + if (!this.#shiftedObjectOriginals.has(s.id)) { + this.#shiftedObjectOriginals.set(s.id, s.left); + } + obj.set("left", newLeft); + obj.setCoords(); } - break; } } } @@ -868,6 +919,9 @@ class Timeline extends EventEmitter { } public removeDragPlaceholder() { + // Revert any live-shifted siblings back to their original positions + this.#revertShiftedObjects(); + if (this.#dragPlaceholder) { this.canvas.remove(this.#dragPlaceholder); this.#dragPlaceholder = null; @@ -877,10 +931,36 @@ class Timeline extends EventEmitter { this.canvas.requestRenderAll(); } + /** Restores all Fabric objects that were live-shifted during a drag back to their stored originals. */ + #revertShiftedObjects() { + for (const [id, originalLeft] of this.#shiftedObjectOriginals) { + const obj = this.#clipObjects.get(id); + if (obj) { + obj.set("left", originalLeft); + obj.setCoords(); + } + } + this.#shiftedObjectOriginals.clear(); + } + public clearPrimaryDragTarget() { this.#primaryDragTarget = null; } + public getPendingShifts(): Map { + return this.#pendingClipShifts; + } + + /** Clears the originals map WITHOUT reverting — call this after a committed drop so + * removeDragPlaceholder doesn't undo positions already set by render(). */ + public clearShiftedOriginals() { + this.#shiftedObjectOriginals.clear(); + } + + public clearPendingShifts() { + this.#pendingClipShifts.clear(); + } + public clear() { this.#tracks = []; // Reset tracks this.#clipsMap = {}; // Reset clips diff --git a/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts b/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts index 7d28b448..2098674b 100644 --- a/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts +++ b/editor/src/components/editor/timeline/timeline/handlers/modify-handler.ts @@ -6,6 +6,7 @@ import { type ITimelineTrack, MICROSECONDS_PER_SECOND, type TrackType, + type IClip, } from "@/types/timeline"; import { TIMELINE_CONSTANTS } from "../../timeline-constants"; import { handleDragend, handleSelectionClear } from "./selection"; @@ -46,6 +47,231 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { const targetAny = target as any; const placeholder = timeline.dragPlaceholder; + // Always clear pending shifts so they don't linger between drops + const pendingShifts = timeline.getPendingShifts(); + + // --------------------------------------------------------- + // 1. Handle Drop on Separator (Prioritized) + // --------------------------------------------------------- + if (timeline.activeSeparatorIndex !== null) { + // --- MULTI-CLIP SEPARATOR DROP --- + if ( + targetAny.type === "activeselection" || + target.type === "activeselection" + ) { + const selection = target as any; + const selectedObjects = selection._objects || []; + const separatorIndex = timeline.activeSeparatorIndex; + const placeholderLeft = placeholder?.left || 0; + + // 1. Calculate deltas for positioning + const pdt = timeline.primaryDragTarget as any; + const primaryTarget = pdt || selectedObjects[0]; + const matrix = selection.calcTransformMatrix(true); + const primaryAbsPoint = util.transformPoint( + { x: primaryTarget.left, y: primaryTarget.top }, + matrix, + ); + const deltaX = placeholderLeft - primaryAbsPoint.x; + + // 2. Map and group objects by their ORIGINAL track to preserve vertical layout + const clipsToMove: { + id: string; + sourceTrackIdx: number; + displayFrom: number; + clip: IClip; + }[] = []; + + selectedObjects.forEach((obj: any) => { + const clipId = obj.elementId; + const clip = timeline.clipsMap[clipId]; + if (!clip) return; + + const sourceTrackIdx = timeline.tracks.findIndex((t) => + t.clipIds.includes(clipId), + ); + const objAbsPoint = util.transformPoint( + { x: obj.left, y: obj.top }, + matrix, + ); + let newX = objAbsPoint.x + deltaX; + if (newX < 0) newX = 0; + + const displayFrom = Math.round( + (newX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * timeline.timeScale)) * + MICROSECONDS_PER_SECOND, + ); + + clipsToMove.push({ id: clipId, sourceTrackIdx, displayFrom, clip }); + }); + + // Sort source tracks to find the relative row offsets + const uniqueSourceTrackIndices = [ + ...new Set(clipsToMove.map((c) => c.sourceTrackIdx)), + ].sort((a, b) => a - b); + + // 3. Create a map of sourceTrackIdx -> newTrackId + const newTracksList = [...timeline.tracks]; + const sourceToNewTrackId = new Map(); + const newTracksToInsert: ITimelineTrack[] = []; + + uniqueSourceTrackIndices.forEach((sourceIdx, relativeRow) => { + const newTrackId = generateUUID(); + const firstClipIdInRow = clipsToMove.find( + (c) => c.sourceTrackIdx === sourceIdx, + )?.id; + const clip = firstClipIdInRow + ? timeline.clipsMap[firstClipIdInRow] + : null; + + let newTrackType: TrackType = "Video"; + if (clip) { + if (clip.type === "Audio") newTrackType = "Audio"; + else if (clip.type === "Text" || clip.type === "Caption") + newTrackType = "Text"; + else if (clip.type === "Effect") newTrackType = "Effect"; + } + + const newTrack: ITimelineTrack = { + id: newTrackId, + type: newTrackType, + name: `${newTrackType} Track`, + clipIds: [], + muted: false, + }; + newTracksToInsert.push(newTrack); + sourceToNewTrackId.set(sourceIdx, newTrackId); + }); + + // Insert the batch of new tracks at separatorIndex + newTracksList.splice(separatorIndex, 0, ...newTracksToInsert); + + // 4. Update track assignments and positions + clipsToMove.forEach(({ id, sourceTrackIdx, displayFrom }) => { + // Remove from current tracks + newTracksList.forEach((t) => { + t.clipIds = t.clipIds.filter((cid) => cid !== id); + }); + + // Add to new track + const newTrackId = sourceToNewTrackId.get(sourceTrackIdx); + const targetTrack = newTracksList.find((t) => t.id === newTrackId); + if (targetTrack) { + targetTrack.clipIds.push(id); + } + + updateClipTimeLocally(timeline, id, displayFrom); + }); + + // Cleanup empty tracks and finalize + const filteredTracks = newTracksList.filter((t) => t.clipIds.length > 0); + timeline.setTracksInternal(filteredTracks); + timeline.render(); + timeline.emit("timeline:updated", { tracks: filteredTracks }); + + // Emit clips:modified for the move + timeline.emit("clips:modified", { + clips: clipsToMove.map((c) => ({ + clipId: c.id, + displayFrom: c.displayFrom, + })), + }); + + timeline.clearSeparatorHighlights(); + timeline.setActiveSeparatorIndex(null); + timeline.removeDragPlaceholder(); + timeline.clearPrimaryDragTarget(); + timeline.canvas.discardActiveObject(); + timeline.canvas.requestRenderAll(); + return; + } + + if (targetAny.elementId) { + const index = timeline.activeSeparatorIndex; + const clipId = targetAny.elementId; + + const tracks = timeline.tracks; + const currentTrackIndex = tracks.findIndex((t) => + t.clipIds.includes(clipId), + ); + + if (currentTrackIndex === -1) { + } + + const clip = timeline.clipsMap[clipId]; + if (!clip) { + timeline.clearSeparatorHighlights(); + timeline.setActiveSeparatorIndex(null); + return; + } + + // Calculate new time (clamped) + let left = target.left || 0; + if (left < 0) left = 0; + let newDisplayFrom = Math.round( + (left / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * timeline.timeScale)) * + MICROSECONDS_PER_SECOND, + ); + if (newDisplayFrom < 0) newDisplayFrom = 0; + + updateClipTimeLocally(timeline, clipId, newDisplayFrom); + + let newTrackType: TrackType = "Video"; + if (clip.type === "Audio") newTrackType = "Audio"; + else if (clip.type === "Text" || clip.type === "Caption") + newTrackType = "Text"; + else if (clip.type === "Effect") newTrackType = "Effect"; + else if (clip.type === "Video" || clip.type === "Image") + newTrackType = "Video"; + + const newTrackId = generateUUID(); + const newTrack: ITimelineTrack = { + id: newTrackId, + type: newTrackType, + name: `${newTrackType} Track`, + clipIds: [clipId], + muted: false, + }; + + const affectedTrackIds = new Set(); + const currentTrack = tracks.find((t) => t.clipIds.includes(clipId)); + if (currentTrack) affectedTrackIds.add(currentTrack.id); + + const newTracksList = tracks + .map((t) => { + if (affectedTrackIds.has(t.id)) { + return { + ...t, + clipIds: t.clipIds.filter((id) => id !== clipId), + }; + } + return t; + }) + .filter((t) => t.clipIds.length > 0); + + newTracksList.splice(index, 0, newTrack); + + timeline.setTracksInternal(newTracksList); + timeline.render(); + timeline.emit("timeline:updated", { tracks: newTracksList }); + + timeline.emit("clip:modified", { + clipId, + displayFrom: newDisplayFrom, + duration: clip.duration, + }); + + timeline.clearSeparatorHighlights(); + timeline.setActiveSeparatorIndex(null); + timeline.removeDragPlaceholder(); + timeline.clearPrimaryDragTarget(); + return; + } + } + + // --------------------------------------------------------- + // 3. Placeholder snap (Normal Track drops) + // --------------------------------------------------------- if (placeholder && placeholder.visible) { const placeholderLeft = placeholder.left || 0; const placeholderTop = placeholder.top || 0; @@ -191,114 +417,6 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { } } - // --------------------------------------------------------- - // 1. Handle Drop on Separator (Single Clip Only - Reverted Multi-clip) - // --------------------------------------------------------- - if (timeline.activeSeparatorIndex !== null) { - // If it's an active selection, we skip separator logic to avoid issues (as requested to remove 3) - if ( - targetAny.type === "activeselection" || - target.type === "activeselection" - ) { - timeline.clearSeparatorHighlights(); - timeline.setActiveSeparatorIndex(null); - timeline.removeDragPlaceholder(); - timeline.clearPrimaryDragTarget(); - timeline.canvas.requestRenderAll(); - return; - } - - if (targetAny.elementId) { - const index = timeline.activeSeparatorIndex; - const clipId = targetAny.elementId; - - const tracks = timeline.tracks; - const currentTrackIndex = tracks.findIndex((t) => - t.clipIds.includes(clipId), - ); - - // Restore original Logic for Single Clip Separator Drop (plus the negative time fix) - if (currentTrackIndex === -1) { - // Look up via map if not found in tracks array directly? - // Actually, let's use the same logic as before but with the local time update - } - - const clip = timeline.clipsMap[clipId]; - if (!clip) { - timeline.clearSeparatorHighlights(); - timeline.setActiveSeparatorIndex(null); - return; - } - - // Calculate new time (clamped) - let left = target.left || 0; - if (left < 0) left = 0; - let newDisplayFrom = Math.round( - (left / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * timeline.timeScale)) * - MICROSECONDS_PER_SECOND, - ); - if (newDisplayFrom < 0) newDisplayFrom = 0; - - updateClipTimeLocally(timeline, clipId, newDisplayFrom); - - let newTrackType: TrackType = "Video"; - if (clip.type === "Audio") newTrackType = "Audio"; - else if (clip.type === "Text" || clip.type === "Caption") - newTrackType = "Text"; - else if (clip.type === "Effect") newTrackType = "Effect"; - else if (clip.type === "Video" || clip.type === "Image") - newTrackType = "Video"; - - const newTrackId = generateUUID(); - const newTrack: ITimelineTrack = { - id: newTrackId, - type: newTrackType, - name: `${newTrackType} Track`, - clipIds: [clipId], - muted: false, - }; - - // Remove from old track - // If the clip was in a track, we need to remove it. - // But unlike overlap, here we have precise index target. - const affectedTrackIds = new Set(); - const currentTrack = tracks.find((t) => t.clipIds.includes(clipId)); - if (currentTrack) affectedTrackIds.add(currentTrack.id); - - const newTracksList = tracks - .map((t) => { - if (affectedTrackIds.has(t.id)) { - return { - ...t, - clipIds: t.clipIds.filter((id) => id !== clipId), - }; - } - return t; - }) - .filter((t) => t.clipIds.length > 0); - - newTracksList.splice(index, 0, newTrack); - - timeline.setTracksInternal(newTracksList); - timeline.render(); - timeline.emit("timeline:updated", { tracks: newTracksList }); - - // Also emit clip modification to save time? - // Yes - timeline.emit("clip:modified", { - clipId, - displayFrom: newDisplayFrom, - duration: clip.duration, // use current duration - }); - - timeline.clearSeparatorHighlights(); - timeline.setActiveSeparatorIndex(null); - timeline.removeDragPlaceholder(); - timeline.clearPrimaryDragTarget(); - return; - } - } - // --------------------------------------------------------- // 2. Handle Multi-selection Move (General) // --------------------------------------------------------- @@ -325,8 +443,13 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { const clipId = targetAny.elementId; if (clipId) { - let left = target.left || 0; - if (left < 0) left = 0; // Visual clamp + // Use the placeholder's computed left if available (already accounts for snap) + const placeholder = timeline.dragPlaceholder; + let left = + placeholder && placeholder.visible + ? placeholder.left || 0 + : Math.max(0, target.left || 0); + if (left < 0) left = 0; const width = target.width || 0; @@ -341,6 +464,95 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { const proposedEnd = proposedStart + proposedDuration; const targetTrack = timeline.tracks.find((t) => t.id === trackRegion.id); + + // ------------------------------------------------------- + // Push-to-fit: if there are pending shifts recorded by the + // placeholder, it means the dragged clip should land at + // `left` and all blocking clips to the right should shift. + // ------------------------------------------------------- + if (pendingShifts.size > 0) { + // Verify all shifts belong to this track (safety check) + const trackClipIds = new Set(targetTrack?.clipIds ?? []); + const allShiftsOnThisTrack = [...pendingShifts.keys()].every((id) => + trackClipIds.has(id), + ); + + if (allShiftsOnThisTrack) { + // 1. Apply and emit each shifted sibling individually + for (const [shiftedClipId, newPixelLeft] of pendingShifts) { + const shiftedClip = timeline.clipsMap[shiftedClipId]; + if (!shiftedClip) continue; + + const newDisplayFrom = Math.round( + (newPixelLeft / + (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * timeline.timeScale)) * + MICROSECONDS_PER_SECOND, + ); + + updateClipTimeLocally(timeline, shiftedClipId, newDisplayFrom); + // Emit individually — same as any other clip move, include existing trim + timeline.emit("clip:modified", { + clipId: shiftedClipId, + displayFrom: newDisplayFrom, + duration: shiftedClip.duration, + trim: shiftedClip.trim, + }); + } + + // 2. Place the dragged clip at the placeholder position + updateClipTimeLocally(timeline, clipId, proposedStart); + const trim = targetAny.trim; + timeline.emit("clip:modified", { + clipId, + displayFrom: proposedStart, + duration: proposedDuration, + trim, + }); + + // 3. Move clip to target track if it changed tracks + target.set("top", trackRegion.top); + target.setCoords(); + + const originalTrack = timeline.tracks.find((t) => + t.clipIds.includes(clipId), + ); + if (!originalTrack || originalTrack.id !== trackRegion.id) { + const updatedTracks = timeline.tracks + .map((t) => { + if (t.id === originalTrack?.id) { + return { + ...t, + clipIds: t.clipIds.filter((id) => id !== clipId), + }; + } + if (t.id === trackRegion.id) { + return { ...t, clipIds: [...t.clipIds, clipId] }; + } + return t; + }) + .filter((t) => t.clipIds.length > 0); + timeline.setTracksInternal(updatedTracks); + timeline.emit("timeline:updated", { tracks: updatedTracks }); + } + + // 4. Re-render immediately so siblings visually move to their new positions + timeline.render(); + + timeline.clearPendingShifts(); + // Clear originals WITHOUT reverting — render() already committed the new positions + timeline.clearShiftedOriginals(); + timeline.clearSeparatorHighlights(); + timeline.setActiveSeparatorIndex(null); + timeline.removeDragPlaceholder(); + timeline.clearPrimaryDragTarget(); + timeline.canvas.requestRenderAll(); + return; + } + } + + // ------------------------------------------------------- + // Normal overlap check (no pending push-shifts) + // ------------------------------------------------------- let hasOverlap = false; if (targetTrack) { @@ -349,13 +561,9 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { const otherClip = timeline.clipsMap[otherClipId]; if (!otherClip) continue; - // Check for actual overlap: clips overlap if one starts before the other ends - // and ends after the other starts const otherStart = otherClip.display.from; const otherEnd = otherClip.display.to; - // Two clips overlap if: - // proposedStart < otherEnd AND proposedEnd > otherStart if (proposedStart < otherEnd && proposedEnd > otherStart) { hasOverlap = true; break; @@ -458,6 +666,7 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { } } + timeline.clearPendingShifts(); timeline.clearSeparatorHighlights(); timeline.setActiveSeparatorIndex(null); timeline.removeDragPlaceholder();