From fa3a5723fcd525c46f719bd26934f5d1be474cec Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 29 Apr 2026 13:38:08 +1000 Subject: [PATCH 1/3] feat: support SVG clipboard paste with overlap-aware placement --- .../interaction/interaction-calculations.ts | 63 ++++-- src/core/clipboard/paste-dispatcher.ts | 31 +++ src/core/clipboard/svg-clipboard.ts | 120 ++++++++++++ src/core/commands/add-clip-command.ts | 6 + .../create-track-and-add-clip-command.ts | 75 ++++++++ src/core/edit-session.ts | 23 ++- src/core/inputs/controls.ts | 33 +++- src/core/selection-manager.ts | 9 +- tests/controls-paste.test.ts | 138 +++++++++++++ tests/edit-clip-operations.test.ts | 172 ++++++++++++++++- tests/edit-commands.test.ts | 106 ++++++++++ tests/interaction-calculations.test.ts | 78 +++++++- tests/svg-clipboard.test.ts | 181 ++++++++++++++++++ 13 files changed, 1013 insertions(+), 22 deletions(-) create mode 100644 src/core/clipboard/paste-dispatcher.ts create mode 100644 src/core/clipboard/svg-clipboard.ts create mode 100644 src/core/commands/create-track-and-add-clip-command.ts create mode 100644 tests/controls-paste.test.ts create mode 100644 tests/svg-clipboard.test.ts diff --git a/src/components/timeline/interaction/interaction-calculations.ts b/src/components/timeline/interaction/interaction-calculations.ts index de92bf26..ba43a0dd 100644 --- a/src/components/timeline/interaction/interaction-calculations.ts +++ b/src/components/timeline/interaction/interaction-calculations.ts @@ -210,6 +210,20 @@ export function findNearestSnapPoint(input: ApplySnapInput): Seconds | null { // ─── Collision Detection ─────────────────────────────────────────────────── +/** + * Calculate the overlap duration between two time ranges. + * @param start1 Start time of first range + * @param end1 End time of first range + * @param start2 Start time of second range + * @param end2 End time of second range + * @returns The duration of overlap, or 0 if no overlap + */ +export function calculateOverlap(start1: number, end1: number, start2: number, end2: number): number { + const overlapStart = Math.max(start1, start2); + const overlapEnd = Math.min(end1, end2); + return Math.max(0, overlapEnd - overlapStart); +} + export function getTrackClipsExcluding(track: TrackState, excludeClip: ClipRef): ClipState[] { return track.clips .filter(c => !(c.trackIndex === excludeClip.trackIndex && c.clipIndex === excludeClip.clipIndex)) @@ -228,7 +242,7 @@ export function findOverlappingClip( const clipStart = clip.config.start; const clipEnd = clipStart + clip.config.length; - if (desiredStart < clipEnd && desiredEnd > clipStart) { + if (calculateOverlap(desiredStart, desiredEnd, clipStart, clipEnd) > 0) { return { clip, index: i }; } } @@ -420,18 +434,43 @@ export function determineDropAction(input: DetermineDropActionInput): DropAction return determineNormalMove(startTime, newTime, originalTrack, dragTarget.trackIndex, pushOffset); } -// ─── Utility Functions ───────────────────────────────────────────────────── +// ─── Paste Placement ───────────────────────────────────────────────────── + +/** Minimal time-range shape — accepts anything with start/length numbers. */ +export interface ClipTimeRange { + readonly start: number; + readonly length: number; +} + +export type PasteAction = + | { readonly type: "place"; readonly trackIndex: number } + | { readonly type: "insert-track"; readonly insertionIndex: number }; + +export interface ResolvePastePlacementInput { + readonly preferredTrackIndex: number; + readonly preferredTrackClips: readonly ClipTimeRange[] | undefined; + readonly desiredStart: number; + readonly desiredLength: number; +} /** - * Calculate the overlap duration between two time ranges. - * @param start1 Start time of first range - * @param end1 End time of first range - * @param start2 Start time of second range - * @param end2 End time of second range - * @returns The duration of overlap, or 0 if no overlap + * Decide where a pasted clip should land. + * + * Policy: if the preferred track has any clip overlapping the desired time + * range, return an `insert-track` action targeting the top of the timeline + * (index 0). Otherwise place on the preferred track. + * + * Pure function — performs no mutations. The caller dispatches commands + * based on the returned action, mirroring the `determineDropAction` pattern. */ -export function calculateOverlap(start1: number, end1: number, start2: number, end2: number): number { - const overlapStart = Math.max(start1, start2); - const overlapEnd = Math.min(end1, end2); - return Math.max(0, overlapEnd - overlapStart); +export function resolvePastePlacement(input: ResolvePastePlacementInput): PasteAction { + const { preferredTrackIndex, preferredTrackClips, desiredStart, desiredLength } = input; + + if (preferredTrackClips && preferredTrackClips.length > 0) { + const desiredEnd = desiredStart + desiredLength; + const overlaps = preferredTrackClips.some(c => calculateOverlap(desiredStart, desiredEnd, c.start, c.start + c.length) > 0); + if (overlaps) return { type: "insert-track", insertionIndex: 0 }; + } + + return { type: "place", trackIndex: preferredTrackIndex }; } diff --git a/src/core/clipboard/paste-dispatcher.ts b/src/core/clipboard/paste-dispatcher.ts new file mode 100644 index 00000000..34c1d8bb --- /dev/null +++ b/src/core/clipboard/paste-dispatcher.ts @@ -0,0 +1,31 @@ +import { CreateTrackAndAddClipCommand } from "@core/commands/create-track-and-add-clip-command"; +import type { Edit } from "@core/edit-session"; +import { ClipSchema, type Clip, type ResolvedClip } from "@schemas"; +import { resolvePastePlacement } from "@timeline/interaction/interaction-calculations"; + +/** + * Insert a clip at `preferredTrackIdx`, falling back to a new top track if the + * clip's time range would overlap an existing clip on the preferred track. + * @internal + */ +export async function insertClipWithOverlapPolicy(edit: Edit, preferredTrackIdx: number, clip: Clip): Promise { + const desiredStart = typeof clip.start === "number" ? clip.start : 0; + const desiredLength = typeof clip.length === "number" ? clip.length : 0; + const tracks = edit.getTracks(); + const track = tracks[preferredTrackIdx]; + + const action = resolvePastePlacement({ + preferredTrackIndex: preferredTrackIdx, + preferredTrackClips: track?.map(p => ({ start: p.getStart(), length: p.getEnd() - p.getStart() })), + desiredStart, + desiredLength + }); + + if (action.type === "insert-track") { + ClipSchema.parse(clip); + await edit.executeEditCommand(new CreateTrackAndAddClipCommand(action.insertionIndex, clip as unknown as ResolvedClip)); + return; + } + + await edit.addClip(action.trackIndex, clip); +} diff --git a/src/core/clipboard/svg-clipboard.ts b/src/core/clipboard/svg-clipboard.ts new file mode 100644 index 00000000..2739ef95 --- /dev/null +++ b/src/core/clipboard/svg-clipboard.ts @@ -0,0 +1,120 @@ +/** + * Read SVG markup from the system clipboard, sanitise it, and parse intrinsic size. + */ + +const LOG_PREFIX = "[shotstack-studio:svg-clipboard]"; + +const SVG_MIME = "image/svg+xml"; +const SVG_HEAD = /^\s*(?:<\?xml[^>]*\?>\s*)?(?:]*>\s*)?(?:\s*)*]/i; + +export async function readSvgFromClipboard(): Promise { + if (typeof navigator === "undefined" || !navigator.clipboard) return null; + + if (typeof navigator.clipboard.read === "function") { + try { + const items = await navigator.clipboard.read(); + for (const item of items) { + if (item.types.includes(SVG_MIME)) { + const blob = await item.getType(SVG_MIME); + const text = await blob.text(); + if (SVG_HEAD.test(text)) return text; + } + } + } catch (error) { + console.warn(`${LOG_PREFIX} clipboard.read() failed, falling back to readText`, error); + } + } + + if (typeof navigator.clipboard.readText === "function") { + try { + const text = await navigator.clipboard.readText(); + if (SVG_HEAD.test(text)) return text; + } catch (err) { + console.warn(`${LOG_PREFIX} clipboard.readText() failed`, err); + } + } + + return null; +} + +const DANGEROUS_TAGS = ["script", "foreignObject"] as const; +const EVENT_HANDLER_ATTR = /^on/i; +const JS_URL = /^\s*javascript:/i; + +/** + * Sanitise SVG markup before it enters the edit. + */ +export function sanitiseSvg(markup: string): string { + if (typeof DOMParser === "undefined" || typeof XMLSerializer === "undefined") { + throw new Error(`${LOG_PREFIX} sanitiseSvg requires DOMParser/XMLSerializer; call this only in a browser context`); + } + + const doc = new DOMParser().parseFromString(markup, "image/svg+xml"); + if (doc.querySelector("parsererror")) { + console.warn(`${LOG_PREFIX} sanitiseSvg: parser error, returning input unchanged`); + return markup; + } + + for (const tag of DANGEROUS_TAGS) { + doc.querySelectorAll(tag).forEach(el => el.remove()); + } + + doc.querySelectorAll("*").forEach(el => { + for (const attr of Array.from(el.attributes)) { + if (EVENT_HANDLER_ATTR.test(attr.name)) { + el.removeAttribute(attr.name); + } else if ((attr.name === "href" || attr.name === "xlink:href") && JS_URL.test(attr.value)) { + el.removeAttribute(attr.name); + } + } + }); + + return new XMLSerializer().serializeToString(doc); +} + +export interface SvgIntrinsicSize { + width?: number; + height?: number; +} + +/** + * Pull width/height from an SVG, falling back to viewBox dimensions. + */ +export function parseSvgIntrinsicSize(markup: string): SvgIntrinsicSize { + if (typeof DOMParser === "undefined") return {}; + + const doc = new DOMParser().parseFromString(markup, "image/svg+xml"); + if (doc.querySelector("parsererror")) return {}; + + const svgEl = doc.querySelector("svg"); + if (!svgEl) return {}; + + const parseLen = (val: string | null): number | undefined => { + if (!val) return undefined; + const num = parseFloat(val); + return Number.isFinite(num) && num > 0 ? num : undefined; + }; + + const explicitWidth = parseLen(svgEl.getAttribute("width")); + const explicitHeight = parseLen(svgEl.getAttribute("height")); + if (explicitWidth !== undefined && explicitHeight !== undefined) { + return { width: explicitWidth, height: explicitHeight }; + } + + const viewBox = svgEl.getAttribute("viewBox"); + if (viewBox) { + const parts = viewBox + .trim() + .split(/[\s,]+/) + .map(Number); + if (parts.length === 4 && parts.every(Number.isFinite)) { + const [, , vbW, vbH] = parts; + return { + width: explicitWidth ?? (vbW > 0 ? vbW : undefined), + height: explicitHeight ?? (vbH > 0 ? vbH : undefined) + }; + } + } + + return { width: explicitWidth, height: explicitHeight }; +} diff --git a/src/core/commands/add-clip-command.ts b/src/core/commands/add-clip-command.ts index 3c679cab..57268ff2 100644 --- a/src/core/commands/add-clip-command.ts +++ b/src/core/commands/add-clip-command.ts @@ -77,6 +77,12 @@ export class AddClipCommand implements EditCommand { this.convertedReferences = convertAliasReferencesToValues(document, context.getEditState(), clipAlias, skipIndices); } + const selectedClip = context.getSelectedClip(); + if (selectedClip && selectedClip.clipId === this.addedClipId) { + context.setSelectedClip(null); + context.emitEvent(EditEvent.SelectionCleared); + } + // Document mutation only - reconciler disposes the Player context.documentRemoveClip(this.trackIdx, clipIndex); diff --git a/src/core/commands/create-track-and-add-clip-command.ts b/src/core/commands/create-track-and-add-clip-command.ts new file mode 100644 index 00000000..cb7481bd --- /dev/null +++ b/src/core/commands/create-track-and-add-clip-command.ts @@ -0,0 +1,75 @@ +import { EditEvent } from "@core/events/edit-events"; +import type { ResolvedClip } from "@schemas"; + +import { AddClipCommand } from "./add-clip-command"; +import { AddTrackCommand } from "./add-track-command"; +import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types"; + +/** + * Compound command that creates a new track and adds a clip to it atomically. + */ +export class CreateTrackAndAddClipCommand implements EditCommand { + readonly name = "createTrackAndAddClip"; + + private addTrackCommand: AddTrackCommand; + private addClipCommand: AddClipCommand; + private wasExecuted = false; + + constructor( + private readonly insertionIndex: number, + clip: ResolvedClip + ) { + this.addTrackCommand = new AddTrackCommand(insertionIndex); + this.addClipCommand = new AddClipCommand(insertionIndex, clip); + } + + async execute(context?: CommandContext): Promise { + if (!context) throw new Error("CreateTrackAndAddClipCommand.execute: context is required"); + + let addTrackExecuted = false; + + try { + this.addTrackCommand.execute(context); + addTrackExecuted = true; + + this.addClipCommand.execute(context); + this.wasExecuted = true; + } catch (executeError) { + // Partial rollback if the track was created but the clip failed to add. + if (addTrackExecuted && !this.wasExecuted) { + try { + this.addTrackCommand.undo(context); + } catch (undoError) { + throw new Error( + `CreateTrackAndAddClipCommand: execute failed (${executeError instanceof Error ? executeError.message : String(executeError)}) ` + + `and rollback also failed (${undoError instanceof Error ? undoError.message : String(undoError)}). State may be corrupted.` + ); + } + } + throw executeError; + } + + return CommandSuccess(); + } + + async undo(context?: CommandContext): Promise { + if (!context) throw new Error("CreateTrackAndAddClipCommand.undo: context is required"); + if (!this.wasExecuted) return CommandNoop("Command was not executed"); + + // Reverse order: remove the clip first, then the now-empty track. + this.addClipCommand.undo(context); + this.addTrackCommand.undo(context); + this.wasExecuted = false; + + context.emitEvent(EditEvent.TrackRemoved, { + trackIndex: this.insertionIndex + }); + + return CommandSuccess(); + } + + dispose(): void { + this.addTrackCommand.dispose?.(); + this.addClipCommand.dispose?.(); + } +} diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index ff478aff..cdb8a606 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -2,6 +2,8 @@ import { type Player, PlayerType } from "@canvas/players/player"; import { PlayerFactory } from "@canvas/players/player-factory"; import type { Canvas } from "@canvas/shotstack-canvas"; // TODO: Consolidate commands - many have overlapping concerns and could be unified +import { insertClipWithOverlapPolicy } from "@core/clipboard/paste-dispatcher"; +import { parseSvgIntrinsicSize, sanitiseSvg } from "@core/clipboard/svg-clipboard"; import { AddClipCommand } from "@core/commands/add-clip-command"; import { AddTrackCommand } from "@core/commands/add-track-command"; import { DeleteClipCommand } from "@core/commands/delete-clip-command"; @@ -542,6 +544,23 @@ export class Edit { return this.executeCommand(command); } + public addSvgClip(svgMarkup: string, opts: { trackIndex?: number; start?: Seconds; length?: Seconds } = {}): Promise { + const sanitised = sanitiseSvg(svgMarkup); + const { width, height } = parseSvgIntrinsicSize(sanitised); + const preferredTrackIdx = opts.trackIndex ?? this.selectionManager.getSelectedClipInfo()?.trackIndex ?? 0; + + const clip: Clip = { + asset: { type: "svg", src: sanitised }, + start: opts.start ?? (this.playbackTime as Seconds), + length: opts.length ?? sec(5), + fit: "contain", + ...(width !== undefined ? { width } : {}), + ...(height !== undefined ? { height } : {}) + }; + + return insertClipWithOverlapPolicy(this, preferredTrackIdx, clip); + } + public getClip(trackIdx: number, clipIdx: number): Clip | null { // Return from Player array for position-based ordering (matches Player behavior) // Cast to Clip since clipConfiguration is ResolvedClip internally but compatible at runtime @@ -1736,8 +1755,8 @@ export class Edit { * Paste the copied clip at the current playhead position. * @internal */ - public pasteClip(): void { - this.selectionManager.pasteClip(); + public pasteClip(): Promise { + return this.selectionManager.pasteClip(); } /** diff --git a/src/core/inputs/controls.ts b/src/core/inputs/controls.ts index bacf70a0..02917930 100644 --- a/src/core/inputs/controls.ts +++ b/src/core/inputs/controls.ts @@ -1,3 +1,4 @@ +import { readSvgFromClipboard } from "@core/clipboard/svg-clipboard"; import { Edit } from "@core/edit-session"; import { sec } from "@core/timing/types"; @@ -6,6 +7,7 @@ export class Controls { private seekDistance: number = 0.05; // 50ms in seconds private seekDistanceLarge: number = 0.5; // 500ms in seconds private frameTime: number = 1 / 60; // ~16.67ms in seconds + private pendingPaste: Promise | null = null; constructor(edit: Edit) { this.edit = edit; @@ -176,7 +178,7 @@ export class Controls { case "KeyV": { if (event.metaKey || event.ctrlKey) { event.preventDefault(); - this.edit.pasteClip(); + this.handlePaste(); } break; } @@ -186,6 +188,35 @@ export class Controls { } }; + private handlePaste(): void { + if (this.pendingPaste) return; + this.pendingPaste = this.dispatchPaste().finally(() => { + this.pendingPaste = null; + }); + } + + private async dispatchPaste(): Promise { + let svg: string | null = null; + try { + svg = await readSvgFromClipboard(); + } catch (err) { + console.warn("[shotstack-studio:controls] clipboard read failed, using internal clipboard", err); + this.edit.pasteClip(); + return; + } + + if (!svg) { + this.edit.pasteClip(); + return; + } + + try { + await this.edit.addSvgClip(svg); + } catch (err) { + console.warn("[shotstack-studio:controls] SVG paste failed", err); + } + } + private handleKeyUp = (event: KeyboardEvent): void => { if (this.shouldIgnoreKeyboardEvent(event)) { return; diff --git a/src/core/selection-manager.ts b/src/core/selection-manager.ts index 5489b4cb..a98a4d00 100644 --- a/src/core/selection-manager.ts +++ b/src/core/selection-manager.ts @@ -4,6 +4,7 @@ */ import type { Player } from "@canvas/players/player"; +import { insertClipWithOverlapPolicy } from "@core/clipboard/paste-dispatcher"; import { EditEvent } from "@core/events/edit-events"; import type { ResolvedClip } from "@core/schemas"; import { stripInternalProperties } from "@core/shared/clip-utils"; @@ -141,17 +142,15 @@ export class SelectionManager { /** * Paste the copied clip at the current playhead position. */ - pasteClip(): void { - if (!this.copiedClip) return; + pasteClip(): Promise { + if (!this.copiedClip) return Promise.resolve(); const pastedClip = structuredClone(this.copiedClip.clipConfiguration); pastedClip.start = this.edit.playbackTime as Seconds; - // Remove ID so document generates a new one (otherwise reconciler - // would see duplicate IDs and update instead of create) delete (pastedClip as { id?: string }).id; - this.edit.addClip(this.copiedClip.trackIndex, pastedClip); + return insertClipWithOverlapPolicy(this.edit, this.copiedClip.trackIndex, pastedClip); } /** diff --git a/tests/controls-paste.test.ts b/tests/controls-paste.test.ts new file mode 100644 index 00000000..019d75a8 --- /dev/null +++ b/tests/controls-paste.test.ts @@ -0,0 +1,138 @@ +/** + * Controls Paste-Dispatch Tests + * + * Verifies the Ctrl/Cmd+V branching logic in Controls.handlePaste: + * - SVG present in clipboard → edit.addSvgClip(svg) + * - No SVG → edit.pasteClip() + * - Clipboard read failure → falls back to edit.pasteClip() + * - Concurrency gate: rapid paste invocations don't stack + */ + +import { Controls } from "@core/inputs/controls"; +import type { Edit } from "@core/edit-session"; + +// Mock the clipboard module so each test controls what readSvgFromClipboard returns +// without needing the system clipboard or DOMParser. +jest.mock("@core/clipboard/svg-clipboard", () => ({ + readSvgFromClipboard: jest.fn() +})); + +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { readSvgFromClipboard: mockReadSvg } = require("@core/clipboard/svg-clipboard") as { readSvgFromClipboard: jest.Mock }; + +interface ControlsInternals { + dispatchPaste(): Promise; + handlePaste(): void; + pendingPaste: Promise | null; +} + +function createMockEdit(): Edit & { addSvgClip: jest.Mock; pasteClip: jest.Mock } { + return { + addSvgClip: jest.fn().mockResolvedValue(undefined), + pasteClip: jest.fn().mockResolvedValue(undefined) + } as unknown as Edit & { addSvgClip: jest.Mock; pasteClip: jest.Mock }; +} + +beforeEach(() => { + mockReadSvg.mockReset(); +}); + +describe("Controls.dispatchPaste — branch selection", () => { + it("calls edit.addSvgClip when the clipboard contains SVG markup", async () => { + const svg = ''; + mockReadSvg.mockResolvedValueOnce(svg); + const edit = createMockEdit(); + const controls = new Controls(edit) as unknown as ControlsInternals; + + await controls.dispatchPaste(); + + expect(edit.addSvgClip).toHaveBeenCalledWith(svg); + expect(edit.pasteClip).not.toHaveBeenCalled(); + }); + + it("falls back to edit.pasteClip when the clipboard has no SVG", async () => { + mockReadSvg.mockResolvedValueOnce(null); + const edit = createMockEdit(); + const controls = new Controls(edit) as unknown as ControlsInternals; + + await controls.dispatchPaste(); + + expect(edit.pasteClip).toHaveBeenCalledTimes(1); + expect(edit.addSvgClip).not.toHaveBeenCalled(); + }); + + it("falls back to edit.pasteClip when the clipboard read throws", async () => { + mockReadSvg.mockRejectedValueOnce(new Error("clipboard denied")); + const edit = createMockEdit(); + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); + const controls = new Controls(edit) as unknown as ControlsInternals; + + await controls.dispatchPaste(); + + expect(edit.pasteClip).toHaveBeenCalledTimes(1); + expect(edit.addSvgClip).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("clipboard read failed"), expect.any(Error)); + consoleSpy.mockRestore(); + }); + + it("does NOT fall back to pasteClip when addSvgClip throws — the failure is logged only", async () => { + const svg = ''; + mockReadSvg.mockResolvedValueOnce(svg); + const edit = createMockEdit(); + edit.addSvgClip.mockRejectedValueOnce(new Error("schema validation failed")); + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); + const controls = new Controls(edit) as unknown as ControlsInternals; + + await controls.dispatchPaste(); + + // pasteClip must not fire — that would silently paste content the user + // didn't ask for (a stale internal-clipboard clip from earlier). + expect(edit.pasteClip).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("SVG paste failed"), expect.any(Error)); + consoleSpy.mockRestore(); + }); +}); + +describe("Controls.handlePaste — concurrency gate", () => { + it("ignores additional paste invocations while one is in flight", async () => { + // Build a controlled promise so the first paste stays pending until we resolve it. + let resolveFirst: ((value: string | null) => void) | undefined; + mockReadSvg.mockImplementationOnce( + () => + new Promise(resolve => { + resolveFirst = resolve; + }) + ); + + const edit = createMockEdit(); + const controls = new Controls(edit) as unknown as ControlsInternals; + + // Three rapid invocations — only the first should start a real read. + controls.handlePaste(); + controls.handlePaste(); + controls.handlePaste(); + + expect(mockReadSvg).toHaveBeenCalledTimes(1); + + // Resolve the in-flight paste so the gate clears. + resolveFirst?.(null); + await controls.pendingPaste; + + // After the gate clears, a new invocation can run. + mockReadSvg.mockResolvedValueOnce(null); + controls.handlePaste(); + expect(mockReadSvg).toHaveBeenCalledTimes(2); + }); + + it("clears the pendingPaste field after the dispatch resolves", async () => { + mockReadSvg.mockResolvedValueOnce(null); + const edit = createMockEdit(); + const controls = new Controls(edit) as unknown as ControlsInternals; + + controls.handlePaste(); + expect(controls.pendingPaste).not.toBeNull(); + + await controls.pendingPaste; + expect(controls.pendingPaste).toBeNull(); + }); +}); diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index b5e18ebc..d776f3a1 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -1,7 +1,7 @@ /** * Edit Class Clip Operations Tests * - * Tests clip CRUD operations: addClip, deleteClip, updateClip + * Tests clip CRUD operations: addClip, deleteClip, updateClip, addSvgClip * These are the core editing operations that modify timeline content. */ @@ -11,6 +11,20 @@ import type { EventEmitter } from "@core/events/event-emitter"; import type { Clip, ResolvedClip } from "@schemas"; import { ms, sec } from "@core/timing/types"; +// Stub the DOM-dependent svg-clipboard helpers — sanitisation is unit-tested +// in svg-clipboard.test.ts. Here we verify addSvgClip's orchestration: clip +// shape, fit default, dispatch through insertClipWithOverlapPolicy. +// (jest.mock is hoisted by ts-jest, so placement after imports is fine.) +jest.mock("@core/clipboard/svg-clipboard", () => ({ + sanitiseSvg: jest.fn((markup: string) => markup.replace(//gi, "").replace(/\son\w+="[^"]*"/gi, "")), + parseSvgIntrinsicSize: jest.fn((markup: string) => { + const w = markup.match(/\bwidth="(\d+)/i); + const h = markup.match(/\bheight="(\d+)/i); + return { width: w ? Number(w[1]) : undefined, height: h ? Number(h[1]) : undefined }; + }), + readSvgFromClipboard: jest.fn().mockResolvedValue(null) +})); + // Mock pixi-filters jest.mock("pixi-filters", () => ({ AdjustmentFilter: jest.fn().mockImplementation(() => ({})), @@ -243,6 +257,10 @@ jest.mock("@canvas/players/caption-player", () => ({ CaptionPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Caption)) })); +jest.mock("@canvas/players/svg-player", () => ({ + SvgPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Svg)) +})); + /** * Helper to access private Edit state for testing. */ @@ -693,6 +711,158 @@ describe("Edit Clip Operations", () => { const { tracks: after } = getEditState(edit); expect(after[0].length).toBe(countBefore); }); + + it("pasteClip lands on a new top track when paste would overlap on the source track", async () => { + edit.copyClip(0, 0); + edit.playbackTime = sec(2); // overlaps with the video clip at 0-5 + + const { tracks: before } = getEditState(edit); + const trackCountBefore = before.length; + const sourceCountBefore = before[0].length; + + await edit.pasteClip(); + + const { tracks: after } = getEditState(edit); + // New track inserted at the top (index 0); original tracks shift down. + expect(after.length).toBe(trackCountBefore + 1); + // New top track holds only the pasted clip. + expect(after[0].length).toBe(1); + // Original track is now at index 1, untouched. + expect(after[1].length).toBe(sourceCountBefore); + }); + + it("undoing a paste that created a new track reverses both as one atomic step", async () => { + edit.copyClip(0, 0); + edit.playbackTime = sec(2); // forces overlap → insert-track path + + const { tracks: beforePaste } = getEditState(edit); + const trackCountBeforePaste = beforePaste.length; + + await edit.pasteClip(); + expect(getEditState(edit).tracks.length).toBe(trackCountBeforePaste + 1); + + // Single undo should reverse both the track creation and the clip add. + await edit.undo(); + + const { tracks: afterUndo } = getEditState(edit); + expect(afterUndo.length).toBe(trackCountBeforePaste); + expect(afterUndo[0].length).toBe(beforePaste[0].length); + }); + + it("undoing a paste clears the selection if the pasted clip was selected", async () => { + edit.copyClip(0, 0); + edit.playbackTime = sec(5); // non-overlap path; pastes onto same track + await edit.pasteClip(); + + const pastedIdx = getEditState(edit).tracks[0].length - 1; + edit.selectClip(0, pastedIdx); + expect(edit.isClipSelected(0, pastedIdx)).toBe(true); + + await edit.undo(); + + // Without the fix, selection would still reference the disposed player + // and the canvas selection handles would linger over the gone clip. + expect(edit.getSelectedClipInfo()).toBeNull(); + }); + + it("pasteClip stays on the source track when there is no overlap", async () => { + edit.copyClip(0, 0); + edit.playbackTime = sec(5); // touches end of original; non-overlapping + + const { tracks: before } = getEditState(edit); + const trackCountBefore = before.length; + const sourceCountBefore = before[0].length; + + await edit.pasteClip(); + + const { tracks: after } = getEditState(edit); + expect(after.length).toBe(trackCountBefore); + expect(after[0].length).toBe(sourceCountBefore + 1); + }); + }); + + describe("addSvgClip()", () => { + const ICON_SVG = ''; + + it("inserts an svg-typed clip at the playhead with fit:contain by default", async () => { + edit.playbackTime = sec(3); + await edit.addSvgClip(ICON_SVG); + + const clip = edit.getClip(0, edit.getTracks()[0].length - 1); + expect(clip?.asset?.type).toBe("svg"); + expect(clip?.fit).toBe("contain"); + expect(clip?.start).toBe(3); + expect(clip?.length).toBe(5); + }); + + it("populates clip width/height from the SVG's intrinsic dimensions", async () => { + await edit.addSvgClip(ICON_SVG); + + const clip = edit.getClip(0, edit.getTracks()[0].length - 1); + expect(clip?.width).toBe(100); + expect(clip?.height).toBe(100); + }); + + it("strips '; + await edit.addSvgClip(dirty); + + const clip = edit.getClip(0, edit.getTracks()[0].length - 1); + const src = (clip?.asset as { src?: string } | undefined)?.src ?? ""; + expect(src).not.toMatch(/'; + const clean = sanitiseSvg(dirty); + expect(clean).not.toMatch(/' }, + start: 0, + length: 3, + fit: "contain" as const + }; + + await edit.addClipFromJson(svgClipJson as unknown as Parameters[0]); + + const clip = edit.getClip(0, edit.getTracks()[0].length - 1); + const src = (clip?.asset as { src?: string } | undefined)?.src ?? ""; + expect(src).not.toMatch(/