diff --git a/src/components/timeline/interaction/interaction-controller.ts b/src/components/timeline/interaction/interaction-controller.ts index 3d6a604..0ff70b4 100644 --- a/src/components/timeline/interaction/interaction-controller.ts +++ b/src/components/timeline/interaction/interaction-controller.ts @@ -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[] = []; @@ -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, @@ -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); } // ═══════════════════════════════════════════════════════════════════════════ @@ -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 { @@ -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 @@ -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); } @@ -187,6 +195,7 @@ 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); @@ -194,7 +203,26 @@ export class InteractionController implements TimelineInteractionRegistration { 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); @@ -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; } @@ -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; } @@ -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); @@ -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; @@ -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 { @@ -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, targetTrack: number, newTime: number): void { @@ -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); diff --git a/src/styles/timeline/timeline.css b/src/styles/timeline/timeline.css index b746038..dda0458 100644 --- a/src/styles/timeline/timeline.css +++ b/src/styles/timeline/timeline.css @@ -287,6 +287,7 @@ border-radius: 4px; cursor: grab; overflow: hidden; + touch-action: none; transition: box-shadow 0.15s ease, transform 0.1s ease; diff --git a/tests/interaction-controller.test.ts b/tests/interaction-controller.test.ts index 3d7b988..e0efb2c 100644 --- a/tests/interaction-controller.test.ts +++ b/tests/interaction-controller.test.ts @@ -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 @@ -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 @@ -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", () => { @@ -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(); + }); + }); });