Skip to content
Merged
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
74 changes: 69 additions & 5 deletions src/components/timeline/interaction/interaction-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export interface TimelineInteractionRegistration {
/** Controller for timeline interactions (drag, resize, selection) */
export class InteractionController implements TimelineInteractionRegistration {
private state: InteractionState = IDLE_STATE;
private activePointerId = -1;
private readonly config: ResolvedConfig;
private snapPoints: SnapPoint[] = [];

Expand All @@ -108,6 +109,7 @@ export class InteractionController implements TimelineInteractionRegistration {
private readonly handlePointerDown: (e: PointerEvent) => void;
private readonly handlePointerMove: (e: PointerEvent) => void;
private readonly handlePointerUp: (e: PointerEvent) => void;
private readonly handlePointerCancel: (e: PointerEvent) => void;

constructor(
private readonly edit: Edit,
Expand All @@ -124,6 +126,7 @@ export class InteractionController implements TimelineInteractionRegistration {
this.handlePointerDown = this.onPointerDown.bind(this);
this.handlePointerMove = this.onPointerMove.bind(this);
this.handlePointerUp = this.onPointerUp.bind(this);
this.handlePointerCancel = this.onPointerCancel.bind(this);
}

// ═══════════════════════════════════════════════════════════════════════════
Expand All @@ -134,6 +137,7 @@ export class InteractionController implements TimelineInteractionRegistration {
this.tracksContainer.addEventListener("pointerdown", this.handlePointerDown);
document.addEventListener("pointermove", this.handlePointerMove);
document.addEventListener("pointerup", this.handlePointerUp);
document.addEventListener("pointercancel", this.handlePointerCancel);
}

public update(_deltaTime: number): void {
Expand All @@ -146,6 +150,9 @@ export class InteractionController implements TimelineInteractionRegistration {
}

private onPointerDown(e: PointerEvent): void {
if (e.button !== 0) return;
if (this.state.type !== "idle") this.cancelInteraction();

const target = e.target as HTMLElement;

// Find clip element
Expand Down Expand Up @@ -174,6 +181,7 @@ export class InteractionController implements TimelineInteractionRegistration {
const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex);
if (!clip) return;

this.beginPointerInteraction(e);
this.state = createPendingState({ x: e.clientX, y: e.clientY }, clipRef, clip.config.start);
}

Expand All @@ -187,14 +195,34 @@ export class InteractionController implements TimelineInteractionRegistration {
) as HTMLElement | null;
if (!clipElement) return;

this.beginPointerInteraction(e);
this.state = createResizingState(clipRef, clipElement, edge, clip.config.start, clip.config.length);

this.buildSnapPointsForClip(clipRef);

e.preventDefault();
}

/** Track the pointer driving this interaction and capture it so its pointerup cannot be lost */
private beginPointerInteraction(e: PointerEvent): void {
this.activePointerId = e.pointerId;
try {
this.tracksContainer.setPointerCapture(e.pointerId);
} catch {
// pointer already released
}
}

private onPointerMove(e: PointerEvent): void {
if (this.state.type === "idle" || e.pointerId !== this.activePointerId) return;

// Primary button no longer held: the pointerup was lost (native menu, app switch) - cancel
// eslint-disable-next-line no-bitwise -- PointerEvent.buttons is a bitmask
if ((e.buttons & 1) === 0) {
this.cancelInteraction();
return;
}

switch (this.state.type) {
case "pending":
this.handlePendingMove(e, this.state);
Expand Down Expand Up @@ -223,7 +251,7 @@ export class InteractionController implements TimelineInteractionRegistration {
const { clipRef } = state;
const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex);
if (!clip) {
this.state = IDLE_STATE;
this.setIdle();
return;
}

Expand All @@ -232,7 +260,7 @@ export class InteractionController implements TimelineInteractionRegistration {
`[data-track-index="${clipRef.trackIndex}"][data-clip-index="${clipRef.clipIndex}"]`
) as HTMLElement | null;
if (!clipElement) {
this.state = IDLE_STATE;
this.setIdle();
return;
}

Expand Down Expand Up @@ -484,10 +512,12 @@ export class InteractionController implements TimelineInteractionRegistration {
}

private onPointerUp(e: PointerEvent): void {
if (this.state.type === "idle" || e.pointerId !== this.activePointerId || e.button !== 0) return;

switch (this.state.type) {
case "pending":
// Was just a click, selection already handled
this.state = IDLE_STATE;
this.setIdle();
break;
case "dragging":
this.completeDrag(e, this.state);
Expand All @@ -500,6 +530,37 @@ export class InteractionController implements TimelineInteractionRegistration {
}
}

private onPointerCancel(e: PointerEvent): void {
if (this.state.type === "idle" || e.pointerId !== this.activePointerId) return;
this.cancelInteraction();
}

/** Abandon the current interaction without executing any command */
private cancelInteraction(): void {
if (this.state.type === "dragging") this.cancelDrag(this.state);
else if (this.state.type === "resizing") this.cancelResize(this.state);
this.setIdle();
}

private cancelDrag(state: DraggingState): void {
restoreClipElementStyles(state.clipElement, state.originalStyles);
state.ghost.remove();
this.feedbackElements = clearAllFeedback(this.feedbackElements, state.clipElement);
}

private cancelResize(state: ResizingState): void {
const { clipElement, originalStart, originalLength } = state;
clipElement.style.setProperty("--clip-start", String(originalStart));
clipElement.style.setProperty("--clip-length", String(originalLength));
hideSnapLine(this.feedbackElements.snapLine);
hideDragTimeTooltip(this.feedbackElements.dragTimeTooltip);
}

private setIdle(): void {
this.state = IDLE_STATE;
this.activePointerId = -1;
}

private completeDrag(_e: PointerEvent, state: DraggingState): void {
const { clipRef, clipElement, ghost, originalStyles, dragTarget, collisionResult, altKeyHeld, startTime, originalTrack } = state;

Expand Down Expand Up @@ -532,7 +593,7 @@ export class InteractionController implements TimelineInteractionRegistration {
// 4. Cleanup
ghost.remove();
this.feedbackElements = clearAllFeedback(this.feedbackElements, clipElement);
this.state = IDLE_STATE;
this.setIdle();
}

private executeDropAction(state: DraggingState, action: DropAction, targetClip: ClipState | null, existingLumaRef: ClipRef | null): void {
Expand Down Expand Up @@ -769,7 +830,7 @@ export class InteractionController implements TimelineInteractionRegistration {
// Cleanup
hideSnapLine(this.feedbackElements.snapLine);
hideDragTimeTooltip(this.feedbackElements.dragTimeTooltip);
this.state = IDLE_STATE;
this.setIdle();
}

private moveLumaWithContent(lumaPlayer: ReturnType<TimelineStateManager["getAttachedLumaPlayer"]>, targetTrack: number, newTime: number): void {
Expand Down Expand Up @@ -859,9 +920,12 @@ export class InteractionController implements TimelineInteractionRegistration {
}

public dispose(): void {
this.cancelInteraction();

this.tracksContainer.removeEventListener("pointerdown", this.handlePointerDown);
document.removeEventListener("pointermove", this.handlePointerMove);
document.removeEventListener("pointerup", this.handlePointerUp);
document.removeEventListener("pointercancel", this.handlePointerCancel);

// Dispose all feedback elements (stateless, idempotent)
disposeFeedbackElements(this.feedbackElements);
Expand Down
1 change: 1 addition & 0 deletions src/styles/timeline/timeline.css
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@
border-radius: 4px;
cursor: grab;
overflow: hidden;
touch-action: none;
transition:
box-shadow 0.15s ease,
transform 0.1s ease;
Expand Down
147 changes: 145 additions & 2 deletions tests/interaction-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@ function createPointerEvent(
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
button?: number;
buttons?: number;
pointerId?: number;
} = {}
): Event {
// Create a custom event with pointer event properties
Expand All @@ -230,14 +233,18 @@ function createPointerEvent(
cancelable: true
});

// Add pointer/mouse event properties
// Add pointer/mouse event properties (primary button held by default, released on pointerup/cancel)
const defaultButtons = type === "pointerup" || type === "pointercancel" ? 0 : 1;
Object.defineProperties(event, {
clientX: { value: options.clientX ?? 0, writable: false },
clientY: { value: options.clientY ?? 0, writable: false },
altKey: { value: options.altKey ?? false, writable: false },
shiftKey: { value: options.shiftKey ?? false, writable: false },
ctrlKey: { value: options.ctrlKey ?? false, writable: false },
metaKey: { value: options.metaKey ?? false, writable: false }
metaKey: { value: options.metaKey ?? false, writable: false },
button: { value: options.button ?? 0, writable: false },
buttons: { value: options.buttons ?? defaultButtons, writable: false },
pointerId: { value: options.pointerId ?? 1, writable: false }
});

// Override target if provided
Expand Down Expand Up @@ -781,6 +788,7 @@ describe("InteractionController", () => {
expect(tracksRemoveSpy).toHaveBeenCalledWith("pointerdown", expect.any(Function));
expect(docRemoveSpy).toHaveBeenCalledWith("pointermove", expect.any(Function));
expect(docRemoveSpy).toHaveBeenCalledWith("pointerup", expect.any(Function));
expect(docRemoveSpy).toHaveBeenCalledWith("pointercancel", expect.any(Function));
});

it("removes feedback elements on dispose", () => {
Expand Down Expand Up @@ -1051,4 +1059,139 @@ describe("InteractionController", () => {
expect(controller.isDragging(0, 0)).toBe(true);
});
});

// ─── Interrupted Interaction Tests ───────────────────────────────────────

describe("interrupted interactions", () => {
const createController = () => {
controller = new InteractionController(
mockEdit as never,
mockStateManager as never,
mockTrackList as never,
mockDOM.tracksContainer,
mockDOM.feedbackLayer
);
controller.mount();
};

const startDrag = (clipElement: HTMLElement) => {
clipElement.dispatchEvent(createPointerEvent("pointerdown", { clientX: 50, clientY: 20 }));
document.dispatchEvent(createPointerEvent("pointermove", { clientX: 100, clientY: 20 }));
};

it("cancels drag on pointercancel, restoring the clip without executing commands", () => {
createController();
const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement;

startDrag(clipElement);
expect(controller.isDragging(0, 0)).toBe(true);
expect(clipElement.style.position).toBe("fixed");

document.dispatchEvent(createPointerEvent("pointercancel", { clientX: 100, clientY: 20 }));

expect(controller.isDragging(0, 0)).toBe(false);
expect(clipElement.style.position).toBe("");
expect(mockDOM.feedbackLayer.querySelector(".ss-drag-ghost")).toBeNull();
expect(mockEdit.executeEditCommand).not.toHaveBeenCalled();
});

it("cancels drag when a move arrives with no button held (lost pointerup)", () => {
createController();
const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement;

startDrag(clipElement);
expect(controller.isDragging(0, 0)).toBe(true);

document.dispatchEvent(createPointerEvent("pointermove", { clientX: 300, clientY: 20, buttons: 0 }));

expect(controller.isDragging(0, 0)).toBe(false);
expect(clipElement.style.position).toBe("");
expect(mockEdit.executeEditCommand).not.toHaveBeenCalled();
});

it("ignores non-primary-button pointerdown", () => {
createController();
const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement;

clipElement.dispatchEvent(createPointerEvent("pointerdown", { clientX: 50, clientY: 20, button: 2, buttons: 2 }));
document.dispatchEvent(createPointerEvent("pointermove", { clientX: 100, clientY: 20, buttons: 2 }));

expect(controller.isDragging(0, 0)).toBe(false);
expect(clipElement.style.position).toBe("");
});

it("keeps dragging when a non-primary button is released mid-drag", () => {
createController();
const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement;

startDrag(clipElement);
document.dispatchEvent(createPointerEvent("pointerup", { clientX: 100, clientY: 20, button: 2, buttons: 1 }));
expect(controller.isDragging(0, 0)).toBe(true);

document.dispatchEvent(createPointerEvent("pointerup", { clientX: 100, clientY: 20 }));
expect(controller.isDragging(0, 0)).toBe(false);
});

it("ignores moves and ups from other pointers during a drag", () => {
createController();
const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement;

startDrag(clipElement);
const leftBefore = clipElement.style.left;

document.dispatchEvent(createPointerEvent("pointermove", { clientX: 500, clientY: 20, pointerId: 7 }));
expect(clipElement.style.left).toBe(leftBefore);

document.dispatchEvent(createPointerEvent("pointerup", { clientX: 500, clientY: 20, pointerId: 7 }));
expect(controller.isDragging(0, 0)).toBe(true);
});

it("cancels a stale drag cleanly when a new pointerdown arrives", () => {
createController();
const clips = mockDOM.tracksContainer.querySelectorAll(".ss-clip");
const firstClip = clips[0] as HTMLElement;
const secondClip = clips[1] as HTMLElement;

startDrag(firstClip);
expect(firstClip.style.position).toBe("fixed");

// Pointerup was lost; user presses again on another clip
secondClip.dispatchEvent(createPointerEvent("pointerdown", { clientX: 250, clientY: 20 }));

expect(firstClip.style.position).toBe("");
expect(mockDOM.feedbackLayer.querySelector(".ss-drag-ghost")).toBeNull();
expect(controller.isDragging(0, 0)).toBe(false);
expect(mockEdit.executeEditCommand).not.toHaveBeenCalled();
});

it("cancels resize on pointercancel, restoring clip timing vars", () => {
createController();
const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement;
const handle = clipElement.querySelector(".ss-clip-resize-handle.right") as HTMLElement;

handle.dispatchEvent(createPointerEvent("pointerdown", { clientX: 195, clientY: 20 }));
document.dispatchEvent(createPointerEvent("pointermove", { clientX: 300, clientY: 20 }));
expect(controller.isResizing(0, 0)).toBe(true);
expect(clipElement.style.getPropertyValue("--clip-length")).not.toBe("2");

document.dispatchEvent(createPointerEvent("pointercancel", {}));

expect(controller.isResizing(0, 0)).toBe(false);
expect(clipElement.style.getPropertyValue("--clip-length")).toBe("2");
expect(mockEdit.executeEditCommand).not.toHaveBeenCalled();
});

it("restores an in-flight drag when disposed mid-drag", () => {
createController();
const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement;

startDrag(clipElement);
expect(clipElement.style.position).toBe("fixed");

controller.dispose();

expect(clipElement.style.position).toBe("");
expect(mockDOM.feedbackLayer.querySelector(".ss-drag-ghost")).toBeNull();
});
});
});
Loading