diff --git a/editor/src/components/editor/timeline/timeline/canvas.ts b/editor/src/components/editor/timeline/timeline/canvas.ts index 4c6d6c07..db5765d0 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,13 @@ class Timeline extends EventEmitter { #scrollY: number = 0; #scrollbars?: Scrollbars; #mouseWheelHandler?: (e: TPointerEventInfo) => void; + #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; @@ -186,6 +193,38 @@ 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 as any)._originalLeft = target.left; + (target as any)._originalTop = target.top; + } + 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 +588,6 @@ class Timeline extends EventEmitter { public get trackRegions() { return this.#trackRegions; } - public get enableGuideRedraw() { return this.#enableGuideRedraw; } @@ -653,6 +691,276 @@ 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); + } + + // 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; + 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) { + 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); + + // 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 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; + + // 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; + } + } + + // 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; + } + + // 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(); + } + } + } + } + + this.#dragPlaceholder.set({ + left: finalLeft, + 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() { + // Revert any live-shifted siblings back to their original positions + this.#revertShiftedObjects(); + + if (this.#dragPlaceholder) { + this.canvas.remove(this.#dragPlaceholder); + this.#dragPlaceholder = null; + } + this.#extraDragPlaceholders.forEach((p) => this.canvas.remove(p)); + this.#extraDragPlaceholders = []; + 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/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..2098674b 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,15 @@ -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'; + type IClip, +} 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 @@ -16,7 +18,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; @@ -36,22 +38,150 @@ 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; + const placeholder = timeline.dragPlaceholder; + // Always clear pending shifts so they don't linger between drops + const pendingShifts = timeline.getPendingShifts(); + // --------------------------------------------------------- - // 1. Handle Drop on Separator (Single Clip Only - Reverted Multi-clip) + // 1. Handle Drop on Separator (Prioritized) // --------------------------------------------------------- if (timeline.activeSeparatorIndex !== null) { - // If it's an active selection, we skip separator logic to avoid issues (as requested to remove 3) + // --- MULTI-CLIP SEPARATOR DROP --- if ( - targetAny.type === 'activeSelection' || - target.type === 'activeSelection' + 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; } @@ -62,13 +192,10 @@ 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) 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]; @@ -83,19 +210,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 = { @@ -106,9 +233,6 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { 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); @@ -129,31 +253,182 @@ 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 + 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; + + if ( + targetAny.type === "activeselection" || + target.type === "activeselection" + ) { + const selection = target as any; + const selectedObjects = selection._objects || []; + + const pdt = timeline.primaryDragTarget as any; + const primaryTarget = pdt + ? selectedObjects?.includes(pdt) + ? pdt + : pdt.elementId + ? selectedObjects?.find( + (obj: any) => obj.elementId === pdt.elementId, + ) || selectedObjects?.[0] + : selectedObjects?.[0] + : selectedObjects?.[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; + const simulated = selectedObjects.map((obj: any) => { + const objAbsPoint = util.transformPoint( + { 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, + }; + }); + + 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, + 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 + target.set({ left: placeholderLeft, top: placeholderTop }); + target.setCoords(); + } + } + // --------------------------------------------------------- // 2. Handle Multi-selection Move (General) // --------------------------------------------------------- if ( - targetAny.type === 'activeSelection' || - target.type === 'activeSelection' + targetAny.type === "activeselection" || + target.type === "activeselection" ) { + // Track changes and position updates are deferred to selection:cleared timeline.clearSeparatorHighlights(); timeline.setActiveSeparatorIndex(null); + timeline.removeDragPlaceholder(); + timeline.clearPrimaryDragTarget(); timeline.canvas.requestRenderAll(); return; } @@ -168,22 +443,116 @@ 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; 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; 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) { @@ -192,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; @@ -211,15 +576,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 +605,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 +615,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 +626,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 +649,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(); @@ -301,8 +666,11 @@ export function handleTrackRelocation(timeline: Timeline, options: any) { } } + timeline.clearPendingShifts(); timeline.clearSeparatorHighlights(); timeline.setActiveSeparatorIndex(null); + timeline.removeDragPlaceholder(); + timeline.clearPrimaryDragTarget(); timeline.canvas.requestRenderAll(); } @@ -314,31 +682,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 +695,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..5c4c6f19 100644 --- a/editor/src/components/editor/timeline/timeline/handlers/selection.ts +++ b/editor/src/components/editor/timeline/timeline/handlers/selection.ts @@ -1,14 +1,21 @@ -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, + type ITimelineTrack, + type TrackType, +} from "@/types/timeline"; +import { generateUUID } from "@/utils/id"; 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 +41,9 @@ export function handleSelectionUpdate(timeline: Timeline, e: any) { if (activeSelection instanceof ActiveSelection) { activeSelection.set({ - borderColor: 'transparent', + borderColor: "transparent", hasControls: false, - hoverCursor: 'default', + hoverCursor: "default", }); } @@ -58,11 +65,121 @@ 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 }> = []; + deselected.forEach((obj: any) => { if ((obj as any).setSelected) (obj as any).setSelected(false); + + if (obj.elementId) { + 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, + }); + } + } + + 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, + ); + console.log("displayFrom", displayFrom); + if (displayFrom < 0) displayFrom = 0; + + clips.push({ clipId: obj.elementId, displayFrom }); + } }); + + if (trackMoves.length > 0) { + let newTracks = timeline.tracks.map((t) => ({ + ...t, + clipIds: [...t.clipIds], + })); + + 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) { + for (const track of newTracks) { + track.clipIds = track.clipIds.filter((id) => id !== clipId); + } + + 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`; + } + } + } + + newTracks = newTracks.filter((t) => t.clipIds.length > 0); + + timeline.setTracksInternal(newTracks); + timeline.render(); + timeline.emit("timeline:updated", { tracks: newTracks }); + } + + if (clips.length > 0) { + timeline.emit("clips:modified", { clips }); + } } timeline.emitSelectionChange(); }