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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ coverage/
.taskmaster/
.claude/
.cursor/
.interface-design/

# Internal Shotstack files
shotstack.html
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
},
"dependencies": {
"@shotstack/schemas": "1.10.9",
"@shotstack/shotstack-canvas": "^2.4.3",
"@shotstack/shotstack-canvas": "^2.7.2",
"howler": "^2.2.4",
"mediabunny": "^1.11.2",
"opentype.js": "^1.3.4",
Expand Down
6 changes: 1 addition & 5 deletions src/components/canvas/players/svg-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import * as pixi from "pixi.js";

import { createPlaceholderGraphic } from "./placeholder-graphic";

const RESVG_WASM_URL = "https://unpkg.com/@resvg/resvg-wasm@2.6.2/index_bg.wasm";

export class SvgPlayer extends Player {
private static resvgInitialized: boolean = false;
private static resvgInitPromise: Promise<void> | null = null;
Expand All @@ -33,9 +31,7 @@ export class SvgPlayer extends Player {
}

SvgPlayer.resvgInitPromise = (async () => {
const response = await fetch(RESVG_WASM_URL);
const wasmBytes = await response.arrayBuffer();
await initResvg(wasmBytes);
await initResvg();
SvgPlayer.resvgInitialized = true;
})();

Expand Down
29 changes: 29 additions & 0 deletions src/components/timeline/components/clip/clip-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface ClipComponentOptions {
attachedLuma?: LumaRef;
/** Callback when mask badge is clicked - passes the CONTENT clip indices */
onMaskClick?: (contentTrackIndex: number, contentClipIndex: number) => void;
/** Callback when the inline ellipsis trigger is clicked. Passes anchor coords for menu positioning. */
onMenuClick?: (trackIndex: number, clipIndex: number, anchorX: number, anchorY: number) => void;
/** Pre-computed AI asset numbers (map of clip ID to number) */
aiAssetNumbers: Map<string, number>;
}
Expand Down Expand Up @@ -83,10 +85,37 @@ export class ClipComponent {
rightHandle.className = "ss-clip-resize-handle right";
this.element.appendChild(rightHandle);

// Inline ellipsis menu trigger
this.buildMenuTrigger();

// Set up interaction handlers
this.setupInteraction();
}

private buildMenuTrigger(): void {
const { onMenuClick } = this.options;
if (!onMenuClick) return;

const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "ss-clip-menu-trigger";
trigger.setAttribute("aria-label", "Clip menu");
trigger.innerHTML = `<svg viewBox="0 0 16 4" fill="currentColor" aria-hidden="true"><circle cx="2" cy="2" r="1.5"/><circle cx="8" cy="2" r="1.5"/><circle cx="14" cy="2" r="1.5"/></svg>`;

trigger.addEventListener("pointerdown", e => {
// Stop the clip's pointerdown from running drag/select logic
e.stopPropagation();
});
trigger.addEventListener("click", e => {
e.stopPropagation();
if (!this.currentState) return;
const rect = trigger.getBoundingClientRect();
onMenuClick(this.currentState.trackIndex, this.currentState.clipIndex, rect.left, rect.bottom);
});

this.element.appendChild(trigger);
}

private setupInteraction(): void {
this.element.addEventListener("pointerdown", e => {
// Check if clicking on resize handle
Expand Down
148 changes: 148 additions & 0 deletions src/components/timeline/components/clip/clip-context-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type { Edit } from "@core/edit-session";
import { DELETE_DISABLED_REASON, TOOLBAR_ICONS } from "@core/ui/base-toolbar";

interface OpenState {
menu: HTMLElement;
trackIndex: number;
clipIndex: number;
}

/**
* Right-click context menu for timeline clips.
*/
export class ClipContextMenu {
private open: OpenState | null = null;
private readonly handleContextMenu: (e: MouseEvent) => void;
private readonly handleDocumentPointerDown: (e: PointerEvent) => void;
private readonly handleKeyDown: (e: KeyboardEvent) => void;
private readonly handleScroll: () => void;
private readonly handleResize: () => void;

constructor(
private readonly edit: Edit,
private readonly tracksContainer: HTMLElement
) {
this.handleContextMenu = this.onContextMenu.bind(this);
this.handleDocumentPointerDown = this.onDocumentPointerDown.bind(this);
this.handleKeyDown = this.onKeyDown.bind(this);
this.handleScroll = () => this.hide();
this.handleResize = () => this.hide();
}

mount(): void {
this.tracksContainer.addEventListener("contextmenu", this.handleContextMenu);
}

dispose(): void {
this.tracksContainer.removeEventListener("contextmenu", this.handleContextMenu);
this.hide();
}

/**
* Open the menu programmatically near the given screen coordinates.
*/
showAt(clientX: number, clientY: number, trackIndex: number, clipIndex: number): void {
if (this.open && this.open.trackIndex === trackIndex && this.open.clipIndex === clipIndex) {
this.hide();
return;
}
this.show(clientX, clientY, trackIndex, clipIndex);
}

private onContextMenu(e: MouseEvent): void {
const target = e.target as HTMLElement;
const clipEl = target.closest<HTMLElement>(".ss-clip");
if (!clipEl) return;

const trackIndexAttr = clipEl.dataset["trackIndex"];
const clipIndexAttr = clipEl.dataset["clipIndex"];
if (trackIndexAttr === undefined || clipIndexAttr === undefined) return;

e.preventDefault();
this.show(e.clientX, e.clientY, parseInt(trackIndexAttr, 10), parseInt(clipIndexAttr, 10));
}

private show(clientX: number, clientY: number, trackIndex: number, clipIndex: number): void {
this.hide();

const canDelete = this.edit.canDeleteClip(trackIndex, clipIndex);

const menu = document.createElement("div");
menu.className = "ss-clip-context-menu";
menu.setAttribute("role", "menu");

const deleteBtn = document.createElement("button");
deleteBtn.type = "button";
deleteBtn.className = "ss-clip-context-menu-item ss-clip-context-menu-item--danger";
deleteBtn.setAttribute("role", "menuitem");
deleteBtn.dataset["action"] = "delete";
deleteBtn.disabled = !canDelete;
if (!canDelete) deleteBtn.title = DELETE_DISABLED_REASON;
deleteBtn.innerHTML = `<svg class="ss-clip-context-menu-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${TOOLBAR_ICONS.trash}</svg><span class="ss-clip-context-menu-label">Delete</span><span class="ss-clip-context-menu-shortcut">Del</span>`;

deleteBtn.addEventListener("click", () => {
this.hide();
this.edit.deleteClip(trackIndex, clipIndex).catch(err => {
console.warn("[shotstack-studio:clip-context-menu] deleteClip failed", err);
});
});

menu.appendChild(deleteBtn);
document.body.appendChild(menu);
this.open = { menu, trackIndex, clipIndex };

this.positionWithinViewport(clientX, clientY);

document.addEventListener("pointerdown", this.handleDocumentPointerDown);
document.addEventListener("keydown", this.handleKeyDown);
this.tracksContainer.addEventListener("scroll", this.handleScroll, true);
window.addEventListener("resize", this.handleResize);
}

private hide(): void {
if (!this.open) return;
this.open.menu.remove();
this.open = null;
document.removeEventListener("pointerdown", this.handleDocumentPointerDown);
document.removeEventListener("keydown", this.handleKeyDown);
this.tracksContainer.removeEventListener("scroll", this.handleScroll, true);
window.removeEventListener("resize", this.handleResize);
}

private onDocumentPointerDown(e: PointerEvent): void {
if (!this.open) return;
if (!this.open.menu.contains(e.target as Node)) {
this.hide();
}
}

private onKeyDown(e: KeyboardEvent): void {
if (e.key === "Escape") {
e.preventDefault();
this.hide();
}
}

/**
* Place the menu near the cursor, clamped to the viewport so it never
* spills off-screen near the right or bottom edge.
*/
private positionWithinViewport(clientX: number, clientY: number): void {
if (!this.open) return;
const { menu } = this.open;
menu.style.left = "0px";
menu.style.top = "0px";
menu.style.visibility = "hidden";

const { offsetWidth, offsetHeight } = menu;
const margin = 4;
const maxX = window.innerWidth - offsetWidth - margin;
const maxY = window.innerHeight - offsetHeight - margin;
const x = Math.max(margin, Math.min(clientX, maxX));
const y = Math.max(margin, Math.min(clientY, maxY));

menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.visibility = "";
}
}
3 changes: 3 additions & 0 deletions src/components/timeline/components/track/track-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface TrackComponentOptions {
findAttachedLuma?: (trackIndex: number, clipIndex: number) => { trackIndex: number; clipIndex: number } | null;
/** Callback when mask badge is clicked on a content clip */
onMaskClick?: (contentTrackIndex: number, contentClipIndex: number) => void;
/** Callback when the inline ellipsis trigger on a clip is clicked */
onMenuClick?: (trackIndex: number, clipIndex: number, anchorX: number, anchorY: number) => void;
/** Check if attached luma is currently visible for editing */
isLumaVisibleForEditing?: (contentTrackIndex: number, contentClipIndex: number) => boolean;
/** Find the content clip that a luma is attached to via timing match (pure function) */
Expand Down Expand Up @@ -180,6 +182,7 @@ export class TrackComponent {
getClipError: this.options.getClipError,
attachedLuma: attachedLuma ?? undefined,
onMaskClick: this.options.onMaskClick,
onMenuClick: this.options.onMenuClick,
aiAssetNumbers: this.options.aiAssetNumbers
});
this.clipComponents.set(clipState.id, clipComponent);
Expand Down
3 changes: 3 additions & 0 deletions src/components/timeline/components/track/track-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface TrackListOptions {
findAttachedLuma?: (trackIndex: number, clipIndex: number) => { trackIndex: number; clipIndex: number } | null;
/** Callback when mask badge is clicked on a content clip */
onMaskClick?: (contentTrackIndex: number, contentClipIndex: number) => void;
/** Callback when the inline ellipsis trigger on a clip is clicked */
onMenuClick?: (trackIndex: number, clipIndex: number, anchorX: number, anchorY: number) => void;
/** Check if attached luma is currently visible for editing */
isLumaVisibleForEditing?: (contentTrackIndex: number, contentClipIndex: number) => boolean;
/** Find the content clip that a luma is attached to via timing match */
Expand Down Expand Up @@ -86,6 +88,7 @@ export class TrackListComponent {
hasAttachedLuma: this.options.hasAttachedLuma,
findAttachedLuma: this.options.findAttachedLuma,
onMaskClick: this.options.onMaskClick,
onMenuClick: this.options.onMenuClick,
isLumaVisibleForEditing: this.options.isLumaVisibleForEditing,
findContentForLuma: this.options.findContentForLuma,
aiAssetNumbers: this.options.aiAssetNumbers,
Expand Down
13 changes: 12 additions & 1 deletion src/components/timeline/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { type Seconds, sec } from "@core/timing/types";
import { injectShotstackStyles } from "@styles/inject";
import { DEFAULT_PIXELS_PER_SECOND, TIMELINE_PADDING, type ClipRenderer, type ClipInfo } from "@timeline/timeline.types";

import { ClipContextMenu } from "./components/clip/clip-context-menu";
import { PlayheadComponent } from "./components/playhead/playhead-component";
import { RulerComponent } from "./components/ruler/ruler-component";
import { ToolbarComponent } from "./components/toolbar/toolbar-component";
Expand Down Expand Up @@ -46,6 +47,7 @@ export class Timeline {
private playheadGhost: HTMLElement | null = null;
private feedbackLayer: HTMLElement | null = null;
private interactionController: InteractionController | null = null;
private clipContextMenu: ClipContextMenu | null = null;
private resizeHandle: TimelineResizeHandle | null = null;

// Hybrid render loop state
Expand Down Expand Up @@ -420,7 +422,10 @@ export class Timeline {
this.stateManager.isLumaVisibleForEditing(contentTrackIndex, contentClipIndex),
findContentForLuma: (lumaTrack, lumaClip) => this.stateManager.findContentForLuma(lumaTrack, lumaClip),
aiAssetNumbers: this.computeAiAssetNumbers(),
onHeightChange: () => this.requestRender()
onHeightChange: () => this.requestRender(),
onMenuClick: (trackIndex, clipIndex, anchorX, anchorY) => {
this.clipContextMenu?.showAt(anchorX, anchorY, trackIndex, clipIndex);
}
});

// Set up scroll sync (also sync playhead)
Expand Down Expand Up @@ -462,6 +467,9 @@ export class Timeline {
});
this.interactionController.mount();

this.clipContextMenu = new ClipContextMenu(this.edit, this.trackList.element);
this.clipContextMenu.mount();

this.stateManager.setInteractionQuery({
isDragging: (t, c) => this.interactionController?.isDragging(t, c) ?? false,
isResizing: (t, c) => this.interactionController?.isResizing(t, c) ?? false
Expand All @@ -475,6 +483,9 @@ export class Timeline {
this.interactionController?.dispose();
this.interactionController = null;

this.clipContextMenu?.dispose();
this.clipContextMenu = null;

this.toolbar?.dispose();
this.toolbar = null;

Expand Down
8 changes: 8 additions & 0 deletions src/core/edit-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,14 @@ export class Edit {
return clip.asset;
}

/**
* Whether the clip at `(trackIdx, clipIdx)` can be deleted.
*/
public canDeleteClip(trackIdx: number, clipIdx: number): boolean {
if (this.document.getClipCount() <= 1) return false;
return this.document.getClip(trackIdx, clipIdx) !== null;
}

public async deleteClip(trackIdx: number, clipIdx: number): Promise<void> {
const track = this.tracks[trackIdx];
if (!track) return;
Expand Down
4 changes: 3 additions & 1 deletion src/core/inputs/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ export class Controls {
const selected = this.edit.getSelectedClipInfo();
if (selected) {
event.preventDefault();
this.edit.deleteClip(selected.trackIndex, selected.clipIndex);
this.edit.deleteClip(selected.trackIndex, selected.clipIndex).catch(err => {
console.warn("[shotstack-studio:controls] deleteClip failed", err);
});
}
break;
}
Expand Down
Loading
Loading