Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
312 changes: 310 additions & 2 deletions editor/src/components/editor/timeline/timeline/canvas.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -79,6 +79,13 @@ class Timeline extends EventEmitter<TimelineCanvasEvents> {
#scrollY: number = 0;
#scrollbars?: Scrollbars;
#mouseWheelHandler?: (e: TPointerEventInfo<WheelEvent>) => 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<string, number> = new Map();
// Stores original pixel-left values for siblings that are being visually shifted during drag
#shiftedObjectOriginals: Map<string, number> = new Map();

// Drag Auto-scroll state
#dragAutoScrollRaf: number | null = null;
Expand Down Expand Up @@ -186,6 +193,38 @@ class Timeline extends EventEmitter<TimelineCanvasEvents> {
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) => {
Expand Down Expand Up @@ -549,7 +588,6 @@ class Timeline extends EventEmitter<TimelineCanvasEvents> {
public get trackRegions() {
return this.#trackRegions;
}

public get enableGuideRedraw() {
return this.#enableGuideRedraw;
}
Expand Down Expand Up @@ -653,6 +691,276 @@ class Timeline extends EventEmitter<TimelineCanvasEvents> {
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<string, number> {
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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();
}
});
Expand All @@ -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);
Expand Down
Loading