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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .husky/pre-push
Original file line number Diff line number Diff line change
@@ -1 +1 @@
npx tsc --noEmit && npx jest --bail
npm run typecheck && npx jest --bail
63 changes: 51 additions & 12 deletions src/components/timeline/interaction/interaction-calculations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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 };
}
}
Expand Down Expand Up @@ -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 };
}
83 changes: 83 additions & 0 deletions src/core/clipboard/clip-json.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null {
const parsed = tryParseJsonFlexible(text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
}

/**
* 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);
}
31 changes: 31 additions & 0 deletions src/core/clipboard/paste-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
115 changes: 115 additions & 0 deletions src/core/clipboard/svg-clipboard.ts
Original file line number Diff line number Diff line change
@@ -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*)?(?:<!DOCTYPE[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*<svg[\s/>]/i;

export function looksLikeSvg(text: string): boolean {
return SVG_HEAD.test(text);
}

export async function readSvgFromClipboardItems(): Promise<string | null> {
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 };
}
28 changes: 28 additions & 0 deletions src/core/clipboard/system-clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Generic OS clipboard read/write for plain text.
*/

const LOG_PREFIX = "[shotstack-studio:system-clipboard]";

export async function readSystemClipboardText(): Promise<string | null> {
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<void> {
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);
}
}
6 changes: 6 additions & 0 deletions src/core/commands/add-clip-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading