diff --git a/.husky/pre-push b/.husky/pre-push index aa17b694..e8eb1cee 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1 @@ -npx tsc --noEmit && npx jest --bail +npm run typecheck && npx jest --bail 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/clip-json.ts b/src/core/clipboard/clip-json.ts new file mode 100644 index 00000000..15866a73 --- /dev/null +++ b/src/core/clipboard/clip-json.ts @@ -0,0 +1,83 @@ +/** + * Clip JSON parse + serialise for OS-clipboard interop. + */ + +import { ClipSchema, TrackSchema, type Clip, type Track } from "@schemas"; + +function tryJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** + * Light-touch JSON recovery for common copy mistakes. + */ +export function tryParseJsonFlexible(text: string): unknown { + const trimmed = text.trim(); + if (!trimmed) return null; + + const direct = tryJson(trimmed); + if (direct !== null) return direct; + + const stripped = trimmed.replace(/^,+\s*/, "").replace(/\s*,+$/, ""); + if (stripped !== trimmed && stripped.length > 0) { + const strippedDirect = tryJson(stripped); + if (strippedDirect !== null) return strippedDirect; + } + + if (stripped.length > 0) { + const wrapped = tryJson(`[${stripped}]`); + if (wrapped !== null) return wrapped; + } + + return null; +} + +function parseObjectJson(text: string): Record | null { + const parsed = tryParseJsonFlexible(text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return parsed as Record; +} + +/** + * Try to parse text as a Clip JSON. Returns null on parse error or schema + * validation failure. Pure — no side effects. + */ +export function tryParseClipJson(text: string): Clip | null { + const obj = parseObjectJson(text); + if (!obj) return null; + const result = ClipSchema.safeParse(obj); + return result.success ? (result.data as Clip) : null; +} + +/** + * Try to parse text as one or more Track JSONs. + */ +export function tryParseTracksJson(text: string): Track[] | null { + const parsed = tryParseJsonFlexible(text); + if (parsed === null) return null; + + const candidates = Array.isArray(parsed) ? parsed : [parsed]; + if (candidates.length === 0) return null; + + const tracks: Track[] = []; + for (const item of candidates) { + const result = TrackSchema.safeParse(item); + if (!result.success) return null; + tracks.push(result.data as Track); + } + + return tracks; +} + +/** + * Serialise a clip for the OS clipboard. + */ +export function clipToJsonString(clip: Clip): string { + const exportable = structuredClone(clip); + delete (exportable as { id?: string }).id; + return JSON.stringify(exportable, null, 2); +} diff --git a/src/core/clipboard/paste-dispatcher.ts b/src/core/clipboard/paste-dispatcher.ts new file mode 100644 index 00000000..bc7e495e --- /dev/null +++ b/src/core/clipboard/paste-dispatcher.ts @@ -0,0 +1,31 @@ +import { AddTrackCommand } from "@core/commands/add-track-command"; +import type { Edit } from "@core/edit-session"; +import { ClipSchema, type Clip, type Track } 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 AddTrackCommand(action.insertionIndex, { clips: [clip] } as Track)); + 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..1a505086 --- /dev/null +++ b/src/core/clipboard/svg-clipboard.ts @@ -0,0 +1,115 @@ +/** + * SVG clipboard helpers + */ + +const LOG_PREFIX = "[shotstack-studio:svg-clipboard]"; + +const SVG_MIME = "image/svg+xml"; +const SVG_HEAD = /^\s*(?:<\?xml[^>]*\?>\s*)?(?:]*>\s*)?(?:\s*)*]/i; + +export function looksLikeSvg(text: string): boolean { + return SVG_HEAD.test(text); +} + +export async function readSvgFromClipboardItems(): Promise { + if (typeof navigator === "undefined" || !navigator.clipboard || typeof navigator.clipboard.read !== "function") { + return null; + } + + 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 (looksLikeSvg(text)) return text; + } + } + } catch (err) { + console.warn(`${LOG_PREFIX} clipboard.read() 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/clipboard/system-clipboard.ts b/src/core/clipboard/system-clipboard.ts new file mode 100644 index 00000000..429890ec --- /dev/null +++ b/src/core/clipboard/system-clipboard.ts @@ -0,0 +1,28 @@ +/** + * Generic OS clipboard read/write for plain text. + */ + +const LOG_PREFIX = "[shotstack-studio:system-clipboard]"; + +export async function readSystemClipboardText(): Promise { + if (typeof navigator === "undefined" || !navigator.clipboard || typeof navigator.clipboard.readText !== "function") { + return null; + } + try { + return await navigator.clipboard.readText(); + } catch (err) { + console.warn(`${LOG_PREFIX} readText failed`, err); + return null; + } +} + +export async function writeSystemClipboardText(text: string): Promise { + if (typeof navigator === "undefined" || !navigator.clipboard || typeof navigator.clipboard.writeText !== "function") { + return; + } + try { + await navigator.clipboard.writeText(text); + } catch (err) { + console.warn(`${LOG_PREFIX} writeText failed`, err); + } +} 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/add-track-command.ts b/src/core/commands/add-track-command.ts index d867c3a7..ffc6d497 100644 --- a/src/core/commands/add-track-command.ts +++ b/src/core/commands/add-track-command.ts @@ -1,14 +1,34 @@ import { EditEvent } from "@core/events/edit-events"; +import type { Clip, Track } from "@schemas"; import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess } from "./types"; +interface InternalClip extends Clip { + id?: string; +} + /** - * Document-only command that adds a new empty track. + * Atomic command that adds a new track (with optional clips) to the document. */ export class AddTrackCommand implements EditCommand { readonly name = "addTrack"; - constructor(private trackIdx: number) {} + private readonly trackIdx: number; + private readonly preparedTrack?: Track; + + constructor(trackIdx: number, track?: Track) { + this.trackIdx = trackIdx; + if (track) { + this.preparedTrack = { + ...track, + clips: track.clips.map(clip => { + const cloned = structuredClone(clip) as InternalClip; + if (!cloned.id) cloned.id = crypto.randomUUID(); + return cloned; + }) + }; + } + } execute(context?: CommandContext): CommandResult { if (!context) throw new Error("AddTrackCommand.execute: context is required"); @@ -16,12 +36,9 @@ export class AddTrackCommand implements EditCommand { const doc = context.getDocument(); if (!doc) throw new Error("AddTrackCommand.execute: document is required"); - // Document-only mutation - doc.addTrack(this.trackIdx); + doc.addTrack(this.trackIdx, this.preparedTrack); - // Reconciler handles track container creation and player layer updates context.resolve(); - context.updateDuration(); context.emitEvent(EditEvent.TrackAdded, { @@ -29,6 +46,15 @@ export class AddTrackCommand implements EditCommand { totalTracks: doc.getTrackCount() }); + if (this.preparedTrack) { + for (let i = 0; i < this.preparedTrack.clips.length; i += 1) { + context.emitEvent(EditEvent.ClipAdded, { + trackIndex: this.trackIdx, + clipIndex: i + }); + } + } + return CommandSuccess(); } @@ -38,14 +64,22 @@ export class AddTrackCommand implements EditCommand { const doc = context.getDocument(); if (!doc) throw new Error("AddTrackCommand.undo: document is required"); - // Document-only mutation + const trackToRemove = doc.getTrack(this.trackIdx); + const clipCount = trackToRemove?.clips.length ?? 0; + doc.removeTrack(this.trackIdx); - // Reconciler handles track container removal and player layer updates context.resolve(); - context.updateDuration(); + for (let i = clipCount - 1; i >= 0; i -= 1) { + context.emitEvent(EditEvent.ClipDeleted, { + trackIndex: this.trackIdx, + clipIndex: i + }); + } + context.emitEvent(EditEvent.TrackRemoved, { trackIndex: this.trackIdx }); + return CommandSuccess(); } diff --git a/src/core/commands/add-tracks-command.ts b/src/core/commands/add-tracks-command.ts new file mode 100644 index 00000000..2ec8939f --- /dev/null +++ b/src/core/commands/add-tracks-command.ts @@ -0,0 +1,68 @@ +import type { Track } from "@schemas"; + +import { AddTrackCommand } from "./add-track-command"; +import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types"; + +/** + * Atomic compound command that adds multiple tracks to the document. + */ +export class AddTracksCommand implements EditCommand { + readonly name = "addTracks"; + + private readonly subCommands: AddTrackCommand[]; + private executedCount = 0; + + constructor(insertionIndex: number, tracks: Track[]) { + // Reverse iteration so first source track ends up at insertionIndex. + this.subCommands = []; + for (let i = tracks.length - 1; i >= 0; i -= 1) { + this.subCommands.push(new AddTrackCommand(insertionIndex, tracks[i])); + } + } + + execute(context?: CommandContext): CommandResult { + if (!context) throw new Error("AddTracksCommand.execute: context is required"); + + try { + for (const cmd of this.subCommands) { + cmd.execute(context); + this.executedCount += 1; + } + } catch (executeError) { + // Roll back any sub-commands that succeeded before the failure. + for (let i = this.executedCount - 1; i >= 0; i -= 1) { + try { + this.subCommands[i].undo(context); + } catch (undoError) { + throw new Error( + `AddTracksCommand: execute failed at sub-command ${this.executedCount} ` + + `(${executeError instanceof Error ? executeError.message : String(executeError)}) ` + + `and rollback failed at sub-command ${i} ` + + `(${undoError instanceof Error ? undoError.message : String(undoError)}). State may be corrupted.` + ); + } + } + this.executedCount = 0; + throw executeError; + } + + return CommandSuccess(); + } + + undo(context?: CommandContext): CommandResult { + if (!context) throw new Error("AddTracksCommand.undo: context is required"); + if (this.executedCount === 0) return CommandNoop("Command was not executed"); + + // Undo in reverse order — last-inserted track is removed first. + for (let i = this.executedCount - 1; i >= 0; i -= 1) { + this.subCommands[i].undo(context); + } + this.executedCount = 0; + + return CommandSuccess(); + } + + dispose(): void { + for (const cmd of this.subCommands) cmd.dispose?.(); + } +} diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index ff478aff..2d241d12 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -2,8 +2,12 @@ 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 { tryParseClipJson, tryParseTracksJson } from "@core/clipboard/clip-json"; +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 { AddTracksCommand } from "@core/commands/add-tracks-command"; import { DeleteClipCommand } from "@core/commands/delete-clip-command"; import { DeleteTrackCommand } from "@core/commands/delete-track-command"; import { SetOutputAspectRatioCommand } from "@core/commands/set-output-aspect-ratio-command"; @@ -542,6 +546,89 @@ 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); + } + + /** Add a clip to the timeline from a Clip object or JSON string. */ + public addClipFromJson(clipOrJson: Clip | string, opts: { trackIndex?: number; start?: Seconds; length?: Seconds } = {}): Promise { + const parsed = typeof clipOrJson === "string" ? tryParseClipJson(clipOrJson) : (structuredClone(clipOrJson) as Clip); + if (!parsed) { + return Promise.reject(new Error("addClipFromJson: invalid clip JSON or schema validation failed")); + } + + delete (parsed as { id?: string }).id; + + const asset = parsed.asset as { type?: string; src?: string } | undefined; + if (asset?.type === "svg" && typeof asset.src === "string") { + asset.src = sanitiseSvg(asset.src); + } + + if (opts.start !== undefined) parsed.start = opts.start; + if (opts.length !== undefined) parsed.length = opts.length; + + const preferredTrackIdx = opts.trackIndex ?? this.selectionManager.getSelectedClipInfo()?.trackIndex ?? 0; + return insertClipWithOverlapPolicy(this, preferredTrackIdx, parsed); + } + + /** Paste one or more Track JSONs onto the timeline as new top tracks. */ + public async addTracksFromJson(tracksOrJson: Track[] | Track | string, opts: { start?: Seconds } = {}): Promise { + let tracks: Track[]; + if (typeof tracksOrJson === "string") { + const parsed = tryParseTracksJson(tracksOrJson); + if (!parsed) throw new Error("addTracksFromJson: invalid tracks JSON or schema validation failed"); + tracks = parsed; + } else if (Array.isArray(tracksOrJson)) { + tracks = tracksOrJson; + } else { + tracks = [tracksOrJson]; + } + + if (tracks.length === 0) throw new Error("addTracksFromJson: no tracks to paste"); + + let minStart = Infinity; + for (const track of tracks) { + for (const clip of track.clips) { + const s = typeof clip.start === "number" ? clip.start : 0; + if (s < minStart) minStart = s; + } + } + if (!Number.isFinite(minStart)) minStart = 0; + + const anchor = (opts.start ?? this.playbackTime) as number; + const offset = anchor - minStart; + + const prepared: Track[] = tracks.map(track => ({ + ...track, + clips: track.clips.map(clip => { + const cloned = structuredClone(clip) as Clip; + delete (cloned as { id?: string }).id; + const asset = cloned.asset as { type?: string; src?: string } | undefined; + if (asset?.type === "svg" && typeof asset.src === "string") { + asset.src = sanitiseSvg(asset.src); + } + const original = typeof cloned.start === "number" ? cloned.start : 0; + cloned.start = (original + offset) as Seconds; + return cloned; + }) + })); + + await this.executeCommand(new AddTracksCommand(0, prepared)); + } + 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 @@ -776,12 +863,8 @@ export class Edit { public async addTrack(trackIdx: number, track: Track): Promise { TrackSchema.parse(track); - const command = new AddTrackCommand(trackIdx); - await this.executeCommand(command); - - for (const clip of track.clips) { - await this.addClip(trackIdx, clip); - } + // Single atomic command — track + all its clips in one undo step. + await this.executeCommand(new AddTrackCommand(trackIdx, track)); // Auto-link caption clips with unresolved alias sources await this.autoLinkCaptionSources(trackIdx, track.clips); @@ -1736,8 +1819,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..01929e9c 100644 --- a/src/core/inputs/controls.ts +++ b/src/core/inputs/controls.ts @@ -1,11 +1,15 @@ +import { tryParseClipJson, tryParseTracksJson } from "@core/clipboard/clip-json"; +import { readSvgFromClipboardItems, looksLikeSvg } from "@core/clipboard/svg-clipboard"; +import { readSystemClipboardText } from "@core/clipboard/system-clipboard"; import { Edit } from "@core/edit-session"; -import { sec } from "@core/timing/types"; +import { sec, type Seconds } from "@core/timing/types"; export class Controls { private edit: Edit; 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 +180,7 @@ export class Controls { case "KeyV": { if (event.metaKey || event.ctrlKey) { event.preventDefault(); - this.edit.pasteClip(); + this.handlePaste(); } break; } @@ -186,6 +190,60 @@ export class Controls { } }; + private handlePaste(): void { + if (this.pendingPaste) return; + this.pendingPaste = this.dispatchPaste().finally(() => { + this.pendingPaste = null; + }); + } + + /** Resolve Ctrl/Cmd+V across all paste sources. */ + private async dispatchPaste(): Promise { + const svgFromMime = await readSvgFromClipboardItems(); + if (svgFromMime) { + await this.tryAddSvgClip(svgFromMime); + return; + } + + const text = await readSystemClipboardText(); + if (text) { + const clip = tryParseClipJson(text); + if (clip) { + try { + await this.edit.addClipFromJson(clip, { start: this.edit.playbackTime as Seconds }); + } catch (err) { + console.warn("[shotstack-studio:controls] clip JSON paste failed", err); + } + return; + } + + const tracks = tryParseTracksJson(text); + if (tracks) { + try { + await this.edit.addTracksFromJson(tracks, { start: this.edit.playbackTime as Seconds }); + } catch (err) { + console.warn("[shotstack-studio:controls] tracks JSON paste failed", err); + } + return; + } + + if (looksLikeSvg(text)) { + await this.tryAddSvgClip(text); + return; + } + } + + this.edit.pasteClip(); + } + + private async tryAddSvgClip(svg: string): Promise { + 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..222e06ef 100644 --- a/src/core/selection-manager.ts +++ b/src/core/selection-manager.ts @@ -4,8 +4,11 @@ */ import type { Player } from "@canvas/players/player"; +import { clipToJsonString } from "@core/clipboard/clip-json"; +import { insertClipWithOverlapPolicy } from "@core/clipboard/paste-dispatcher"; +import { writeSystemClipboardText } from "@core/clipboard/system-clipboard"; import { EditEvent } from "@core/events/edit-events"; -import type { ResolvedClip } from "@core/schemas"; +import type { ResolvedClip, Clip } from "@core/schemas"; import { stripInternalProperties } from "@core/shared/clip-utils"; import type { Seconds } from "@core/timing/types"; @@ -125,33 +128,32 @@ export class SelectionManager { // ─── Clipboard ──────────────────────────────────────────────────────────── /** - * Copy a clip to the internal clipboard. + * Copy a clip to the clipboard. */ copyClip(trackIdx: number, clipIdx: number): void { const clip = this.edit.getResolvedClip(trackIdx, clipIdx); - if (clip) { - this.copiedClip = { - trackIndex: trackIdx, - clipConfiguration: structuredClone(clip) - }; - this.edit.getInternalEvents().emit(EditEvent.ClipCopied, { trackIndex: trackIdx, clipIndex: clipIdx }); - } + if (!clip) return; + + this.copiedClip = { + trackIndex: trackIdx, + clipConfiguration: structuredClone(clip) + }; + this.edit.getInternalEvents().emit(EditEvent.ClipCopied, { trackIndex: trackIdx, clipIndex: clipIdx }); + writeSystemClipboardText(clipToJsonString(clip as unknown as Clip)).catch(() => undefined); } /** * 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/clip-json.test.ts b/tests/clip-json.test.ts new file mode 100644 index 00000000..5827c6c2 --- /dev/null +++ b/tests/clip-json.test.ts @@ -0,0 +1,214 @@ +/** + * Clip JSON parse + serialise tests. + */ + +import { clipToJsonString, tryParseClipJson, tryParseJsonFlexible, tryParseTracksJson } from "@core/clipboard/clip-json"; +import type { Clip } from "@schemas"; + +describe("tryParseClipJson", () => { + const validClipJson = JSON.stringify({ + asset: { type: "image", src: "https://example.com/x.jpg" }, + start: 0, + length: 5, + fit: "contain" + }); + + it("returns a parsed clip when input is valid clip JSON", () => { + const clip = tryParseClipJson(validClipJson); + expect(clip).not.toBeNull(); + expect(clip?.asset?.type).toBe("image"); + expect(clip?.start).toBe(0); + expect(clip?.length).toBe(5); + }); + + it("returns null for invalid JSON syntax", () => { + expect(tryParseClipJson("{ not valid json")).toBeNull(); + }); + + it("returns null for JSON that is a primitive", () => { + expect(tryParseClipJson("42")).toBeNull(); + expect(tryParseClipJson('"a string"')).toBeNull(); + expect(tryParseClipJson("null")).toBeNull(); + }); + + it("returns null for JSON arrays (not clip-shaped)", () => { + expect(tryParseClipJson("[1, 2, 3]")).toBeNull(); + }); + + it("returns null for an object without required clip fields", () => { + expect(tryParseClipJson('{"foo": "bar"}')).toBeNull(); + }); + + it("returns null for an object with bad clip field types", () => { + expect(tryParseClipJson('{"asset": {"type": "image"}, "start": "not a number", "length": 5}')).toBeNull(); + }); + + it("returns null for an unknown asset.type value", () => { + expect( + tryParseClipJson( + JSON.stringify({ + asset: { type: "definitely-not-a-real-type", src: "x" }, + start: 0, + length: 5 + }) + ) + ).toBeNull(); + }); + + it("does not match SVG markup masquerading as clipboard text", () => { + expect(tryParseClipJson("")).toBeNull(); + }); +}); + +describe("clipToJsonString", () => { + it("serialises a clip and strips the id field", () => { + const clip = { + id: "should-be-stripped", + asset: { type: "image" as const, src: "https://example.com/x.jpg" }, + start: 0, + length: 5 + } as unknown as Clip; + + const json = clipToJsonString(clip); + expect(json).toContain('"asset"'); + expect(json).toContain('"start"'); + expect(json).not.toContain("should-be-stripped"); + }); + + it("does not mutate the input clip", () => { + const clip = { + id: "keep-me", + asset: { type: "image" as const, src: "https://example.com/x.jpg" }, + start: 0, + length: 5 + } as unknown as Clip; + + clipToJsonString(clip); + expect((clip as { id?: string }).id).toBe("keep-me"); + }); + + it("produces a string that round-trips through tryParseClipJson", () => { + const original = { + asset: { type: "image" as const, src: "https://example.com/x.jpg" }, + start: 1, + length: 4 + } as unknown as Clip; + + const json = clipToJsonString(original); + const parsed = tryParseClipJson(json); + + expect(parsed?.start).toBe(1); + expect(parsed?.length).toBe(4); + expect(parsed?.asset?.type).toBe("image"); + }); +}); + +describe("tryParseTracksJson", () => { + const oneClip = { asset: { type: "image", src: "https://example.com/x.jpg" }, start: 0, length: 5 }; + const otherClip = { asset: { type: "image", src: "https://example.com/y.jpg" }, start: 5, length: 3 }; + + it("parses a single-track object as a one-element array", () => { + const track = tryParseTracksJson(JSON.stringify({ clips: [oneClip] })); + expect(track).not.toBeNull(); + expect(track).toHaveLength(1); + expect(track?.[0].clips).toHaveLength(1); + }); + + it("parses an array of tracks", () => { + const tracks = tryParseTracksJson(JSON.stringify([{ clips: [oneClip] }, { clips: [otherClip] }])); + expect(tracks).toHaveLength(2); + }); + + it("auto-wraps a comma-separated track fragment in [...] before parsing", () => { + // User copied a chunk of `tracks: [..., ...]` content including the comma. + const fragment = `${JSON.stringify({ clips: [oneClip] })},${JSON.stringify({ clips: [otherClip] })}`; + const tracks = tryParseTracksJson(fragment); + expect(tracks).toHaveLength(2); + }); + + it("recovers from a trailing comma after the last track", () => { + const fragment = `${JSON.stringify({ clips: [oneClip] })},${JSON.stringify({ clips: [otherClip] })},`; + const tracks = tryParseTracksJson(fragment); + expect(tracks).toHaveLength(2); + }); + + it("recovers from a leading comma before the first track", () => { + const fragment = `,${JSON.stringify({ clips: [oneClip] })},${JSON.stringify({ clips: [otherClip] })}`; + const tracks = tryParseTracksJson(fragment); + expect(tracks).toHaveLength(2); + }); + + it("recovers from leading whitespace in the copied chunk", () => { + const fragment = ` ${JSON.stringify({ clips: [oneClip] })}`; + const tracks = tryParseTracksJson(fragment); + expect(tracks).toHaveLength(1); + }); + + it("returns null for clip-shaped JSON (no clips wrapper)", () => { + expect(tryParseTracksJson(JSON.stringify(oneClip))).toBeNull(); + }); + + it("returns null for an empty clips array (TrackSchema requires min 1)", () => { + expect(tryParseTracksJson('{"clips":[]}')).toBeNull(); + }); + + it("returns null for an empty top-level array", () => { + expect(tryParseTracksJson("[]")).toBeNull(); + }); + + it("returns null when any track in the array fails to validate", () => { + const fragment = `${JSON.stringify({ clips: [oneClip] })},{"foo":"bar"}`; + expect(tryParseTracksJson(fragment)).toBeNull(); + }); + + it("returns null for invalid JSON that is also invalid when wrapped", () => { + expect(tryParseTracksJson("not json at all")).toBeNull(); + }); +}); + +describe("tryParseJsonFlexible", () => { + it("parses well-formed JSON directly", () => { + expect(tryParseJsonFlexible('{"a":1}')).toEqual({ a: 1 }); + expect(tryParseJsonFlexible("[1,2,3]")).toEqual([1, 2, 3]); + }); + + it("trims leading and trailing whitespace", () => { + expect(tryParseJsonFlexible(' {"a":1}\n')).toEqual({ a: 1 }); + }); + + it("strips a single trailing comma", () => { + expect(tryParseJsonFlexible('{"a":1},')).toEqual({ a: 1 }); + }); + + it("strips a single leading comma", () => { + expect(tryParseJsonFlexible(',{"a":1}')).toEqual({ a: 1 }); + }); + + it("strips multiple trailing commas", () => { + expect(tryParseJsonFlexible('{"a":1},,,')).toEqual({ a: 1 }); + }); + + it("wraps a comma-separated fragment in [...] to recover an array", () => { + expect(tryParseJsonFlexible('{"a":1},{"b":2}')).toEqual([{ a: 1 }, { b: 2 }]); + }); + + it("strips trailing comma AND wraps in [...] (the user's case)", () => { + expect(tryParseJsonFlexible('{"a":1},{"b":2},')).toEqual([{ a: 1 }, { b: 2 }]); + }); + + it("returns null for empty or whitespace-only input", () => { + expect(tryParseJsonFlexible("")).toBeNull(); + expect(tryParseJsonFlexible(" ")).toBeNull(); + }); + + it("returns null for completely non-JSON text", () => { + expect(tryParseJsonFlexible("this is just prose")).toBeNull(); + }); + + it("does NOT relax JSON syntax inside the payload (no JSON5)", () => { + // Trailing comma INSIDE an object — outer-level recovery can't help. + expect(tryParseJsonFlexible('{"a":1,}')).toBeNull(); + // Single quotes — not relaxed. + expect(tryParseJsonFlexible("{'a':1}")).toBeNull(); + }); +}); diff --git a/tests/controls-paste.test.ts b/tests/controls-paste.test.ts new file mode 100644 index 00000000..94ce135e --- /dev/null +++ b/tests/controls-paste.test.ts @@ -0,0 +1,241 @@ +/** + * Controls Paste-Dispatch Tests + * + * Verifies the Ctrl/Cmd+V priority ladder in Controls.dispatchPaste: + * 1. SVG MIME blob → addSvgClip + * 2. Clip JSON in OS text → addClipFromJson + * 3. SVG markup in OS text → addSvgClip + * 4. Internal copiedClip fallback → pasteClip + */ + +import { Controls } from "@core/inputs/controls"; +import type { Edit } from "@core/edit-session"; + +jest.mock("@core/clipboard/svg-clipboard", () => ({ + readSvgFromClipboardItems: jest.fn(), + looksLikeSvg: jest.fn((text: string) => /^\s* ({ + readSystemClipboardText: jest.fn() +})); + +jest.mock("@core/clipboard/clip-json", () => ({ + tryParseClipJson: jest.fn(), + tryParseTracksJson: jest.fn() +})); + +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { readSvgFromClipboardItems: mockReadMime } = require("@core/clipboard/svg-clipboard") as { readSvgFromClipboardItems: jest.Mock }; +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { readSystemClipboardText: mockReadText } = require("@core/clipboard/system-clipboard") as { readSystemClipboardText: jest.Mock }; +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { tryParseClipJson: mockParseClipJson, tryParseTracksJson: mockParseTracksJson } = require("@core/clipboard/clip-json") as { + tryParseClipJson: jest.Mock; + tryParseTracksJson: jest.Mock; +}; + +interface ControlsInternals { + dispatchPaste(): Promise; + handlePaste(): void; + pendingPaste: Promise | null; +} + +function createMockEdit(): Edit & { + addSvgClip: jest.Mock; + addClipFromJson: jest.Mock; + addTracksFromJson: jest.Mock; + pasteClip: jest.Mock; + playbackTime: number; +} { + return { + playbackTime: 0, + addSvgClip: jest.fn().mockResolvedValue(undefined), + addClipFromJson: jest.fn().mockResolvedValue(undefined), + addTracksFromJson: jest.fn().mockResolvedValue(undefined), + pasteClip: jest.fn().mockResolvedValue(undefined) + } as unknown as Edit & { + addSvgClip: jest.Mock; + addClipFromJson: jest.Mock; + addTracksFromJson: jest.Mock; + pasteClip: jest.Mock; + playbackTime: number; + }; +} + +beforeEach(() => { + mockReadMime.mockReset(); + mockReadText.mockReset(); + mockParseClipJson.mockReset(); + mockParseTracksJson.mockReset(); +}); + +describe("dispatchPaste — priority ladder", () => { + it("addSvgClip wins when an svg+xml MIME blob is in the clipboard", async () => { + const svg = ''; + mockReadMime.mockResolvedValueOnce(svg); + const edit = createMockEdit(); + const controls = new Controls(edit) as unknown as ControlsInternals; + + await controls.dispatchPaste(); + + expect(edit.addSvgClip).toHaveBeenCalledWith(svg); + expect(mockReadText).not.toHaveBeenCalled(); + expect(edit.addClipFromJson).not.toHaveBeenCalled(); + expect(edit.pasteClip).not.toHaveBeenCalled(); + }); + + it("addClipFromJson wins when text is parseable clip JSON (and no MIME SVG)", async () => { + const clipObj = { asset: { type: "image", src: "x" }, start: 0, length: 5 }; + mockReadMime.mockResolvedValueOnce(null); + mockReadText.mockResolvedValueOnce(JSON.stringify(clipObj)); + mockParseClipJson.mockReturnValueOnce(clipObj); + const edit = createMockEdit(); + edit.playbackTime = 7; + const controls = new Controls(edit) as unknown as ControlsInternals; + + await controls.dispatchPaste(); + + expect(edit.addClipFromJson).toHaveBeenCalledWith(clipObj, { start: 7 }); + expect(edit.addSvgClip).not.toHaveBeenCalled(); + expect(edit.pasteClip).not.toHaveBeenCalled(); + }); + + it("addTracksFromJson wins when text is parseable tracks JSON (and not clip-shaped)", async () => { + const tracksObj = [{ clips: [{ asset: { type: "image", src: "x" }, start: 0, length: 5 }] }]; + mockReadMime.mockResolvedValueOnce(null); + mockReadText.mockResolvedValueOnce(JSON.stringify(tracksObj)); + mockParseClipJson.mockReturnValueOnce(null); + mockParseTracksJson.mockReturnValueOnce(tracksObj); + const edit = createMockEdit(); + edit.playbackTime = 12; + const controls = new Controls(edit) as unknown as ControlsInternals; + + await controls.dispatchPaste(); + + expect(edit.addTracksFromJson).toHaveBeenCalledWith(tracksObj, { start: 12 }); + expect(edit.addClipFromJson).not.toHaveBeenCalled(); + expect(edit.addSvgClip).not.toHaveBeenCalled(); + expect(edit.pasteClip).not.toHaveBeenCalled(); + }); + + it("addSvgClip with raw text wins when text is SVG and not parseable as clip or tracks JSON", async () => { + const svg = ""; + mockReadMime.mockResolvedValueOnce(null); + mockReadText.mockResolvedValueOnce(svg); + mockParseClipJson.mockReturnValueOnce(null); + mockParseTracksJson.mockReturnValueOnce(null); + const edit = createMockEdit(); + const controls = new Controls(edit) as unknown as ControlsInternals; + + await controls.dispatchPaste(); + + expect(edit.addSvgClip).toHaveBeenCalledWith(svg); + expect(edit.addClipFromJson).not.toHaveBeenCalled(); + expect(edit.addTracksFromJson).not.toHaveBeenCalled(); + expect(edit.pasteClip).not.toHaveBeenCalled(); + }); + + it("falls back to pasteClip when text is neither JSON-parseable nor SVG", async () => { + mockReadMime.mockResolvedValueOnce(null); + mockReadText.mockResolvedValueOnce("hello world"); + mockParseClipJson.mockReturnValueOnce(null); + mockParseTracksJson.mockReturnValueOnce(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(); + expect(edit.addClipFromJson).not.toHaveBeenCalled(); + expect(edit.addTracksFromJson).not.toHaveBeenCalled(); + }); + + it("does NOT fall back to pasteClip when addTracksFromJson throws — failure is logged only", async () => { + const tracksObj = [{ clips: [{ asset: { type: "image", src: "x" }, start: 0, length: 5 }] }]; + mockReadMime.mockResolvedValueOnce(null); + mockReadText.mockResolvedValueOnce("[]"); + mockParseClipJson.mockReturnValueOnce(null); + mockParseTracksJson.mockReturnValueOnce(tracksObj); + const edit = createMockEdit(); + edit.addTracksFromJson.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(); + + expect(edit.pasteClip).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("tracks JSON paste failed"), expect.any(Error)); + consoleSpy.mockRestore(); + }); + + it("falls back to pasteClip when there is no OS clipboard content at all", async () => { + mockReadMime.mockResolvedValueOnce(null); + mockReadText.mockResolvedValueOnce(null); + const edit = createMockEdit(); + const controls = new Controls(edit) as unknown as ControlsInternals; + + await controls.dispatchPaste(); + + expect(edit.pasteClip).toHaveBeenCalledTimes(1); + }); + + it("does NOT fall back to pasteClip when addClipFromJson throws — failure is logged only", async () => { + const clipObj = { asset: { type: "image", src: "x" }, start: 0, length: 5 }; + mockReadMime.mockResolvedValueOnce(null); + mockReadText.mockResolvedValueOnce("{}"); + mockParseClipJson.mockReturnValueOnce(clipObj); + const edit = createMockEdit(); + edit.addClipFromJson.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(); + + expect(edit.pasteClip).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("clip JSON paste failed"), expect.any(Error)); + consoleSpy.mockRestore(); + }); +}); + +describe("handlePaste — concurrency gate", () => { + it("ignores additional invocations while a paste is in flight", async () => { + let resolveFirst: (() => void) | undefined; + mockReadMime.mockImplementationOnce( + () => + new Promise(resolve => { + resolveFirst = () => resolve(null); + }) + ); + + const edit = createMockEdit(); + const controls = new Controls(edit) as unknown as ControlsInternals; + + controls.handlePaste(); + controls.handlePaste(); + controls.handlePaste(); + + expect(mockReadMime).toHaveBeenCalledTimes(1); + + resolveFirst?.(); + await controls.pendingPaste; + + mockReadMime.mockResolvedValueOnce(null); + mockReadText.mockResolvedValueOnce(null); + controls.handlePaste(); + expect(mockReadMime).toHaveBeenCalledTimes(2); + }); + + it("clears pendingPaste after the dispatch resolves", async () => { + mockReadMime.mockResolvedValueOnce(null); + mockReadText.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..f382c817 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,27 @@ 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 }; + }), + readSvgFromClipboardItems: jest.fn().mockResolvedValue(null), + looksLikeSvg: jest.fn((text: string) => /^\s* ({ + readSystemClipboardText: jest.fn().mockResolvedValue(null), + writeSystemClipboardText: jest.fn().mockResolvedValue(undefined) +})); + // Mock pixi-filters jest.mock("pixi-filters", () => ({ AdjustmentFilter: jest.fn().mockImplementation(() => ({})), @@ -243,6 +264,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. */ @@ -656,6 +681,22 @@ describe("Edit Clip Operations", () => { expect(edit.hasCopiedClip()).toBe(true); }); + it("copyClip mirrors the clip to the OS clipboard as JSON", async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const { writeSystemClipboardText } = require("@core/clipboard/system-clipboard") as { writeSystemClipboardText: jest.Mock }; + writeSystemClipboardText.mockClear(); + + // Index 1 = the video clip added in the inner beforeEach (index 0 is the image from the outer beforeEach). + edit.copyClip(0, 1); + + expect(writeSystemClipboardText).toHaveBeenCalledTimes(1); + const written = writeSystemClipboardText.mock.calls[0][0] as string; + const parsed = JSON.parse(written); + expect(parsed.asset.type).toBe("video"); + // id must be stripped — pasted clip will get a fresh one. + expect(parsed.id).toBeUndefined(); + }); + it("copyClip emits clip:copied event", async () => { emitSpy.mockClear(); @@ -693,6 +734,409 @@ 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