From 336880bdc0ffeec08cd91106bf9e6e28a0c78ccc Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 14 May 2026 15:47:35 +1000 Subject: [PATCH] feat: add clip deletion via right-click menu and toolbar button --- .gitignore | 1 + package.json | 2 +- src/components/canvas/players/svg-player.ts | 6 +- .../components/clip/clip-component.ts | 29 ++ .../components/clip/clip-context-menu.ts | 148 ++++++++ .../components/track/track-component.ts | 3 + .../timeline/components/track/track-list.ts | 3 + src/components/timeline/timeline.ts | 13 +- src/core/edit-session.ts | 8 + src/core/inputs/controls.ts | 4 +- src/core/ui/base-toolbar.ts | 82 ++++- src/core/ui/clip-toolbar.ts | 1 + src/core/ui/media-toolbar.ts | 2 + src/core/ui/rich-caption-toolbar.ts | 2 +- src/core/ui/rich-text-toolbar.ts | 20 +- src/core/ui/svg-toolbar.ts | 1 + src/core/ui/text-to-image-toolbar.ts | 1 + src/core/ui/text-to-speech-toolbar.ts | 1 + src/core/ui/text-toolbar.ts | 1 + src/styles/index.css | 1 + src/styles/timeline/timeline.css | 73 +++- src/styles/ui/toolbar-delete.css | 128 +++++++ tests/clip-context-menu.test.ts | 328 ++++++++++++++++++ tests/clip-toolbar-merge-fields.test.ts | 2 + tests/cross-bundle-merge-fields.test.ts | 6 + tests/media-toolbar.test.ts | 6 + tests/rich-caption-toolbar.test.ts | 31 +- tests/svg-player.test.ts | 12 +- tests/svg-toolbar.test.ts | 5 +- tests/text-to-image-toolbar.test.ts | 3 + tests/toolbar-delete-button.test.ts | 253 ++++++++++++++ tests/toolbar.test.ts | 2 + 32 files changed, 1138 insertions(+), 40 deletions(-) create mode 100644 src/components/timeline/components/clip/clip-context-menu.ts create mode 100644 src/styles/ui/toolbar-delete.css create mode 100644 tests/clip-context-menu.test.ts create mode 100644 tests/toolbar-delete-button.test.ts diff --git a/.gitignore b/.gitignore index 89caf663..bba887e6 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ coverage/ .taskmaster/ .claude/ .cursor/ +.interface-design/ # Internal Shotstack files shotstack.html diff --git a/package.json b/package.json index 6f7d0d1c..3615083e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/canvas/players/svg-player.ts b/src/components/canvas/players/svg-player.ts index da5b8b51..54b027fa 100644 --- a/src/components/canvas/players/svg-player.ts +++ b/src/components/canvas/players/svg-player.ts @@ -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 | null = null; @@ -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; })(); diff --git a/src/components/timeline/components/clip/clip-component.ts b/src/components/timeline/components/clip/clip-component.ts index e912e129..9b080b8d 100644 --- a/src/components/timeline/components/clip/clip-component.ts +++ b/src/components/timeline/components/clip/clip-component.ts @@ -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; } @@ -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 = ``; + + 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 diff --git a/src/components/timeline/components/clip/clip-context-menu.ts b/src/components/timeline/components/clip/clip-context-menu.ts new file mode 100644 index 00000000..de7b1ef1 --- /dev/null +++ b/src/components/timeline/components/clip/clip-context-menu.ts @@ -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(".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 = `${TOOLBAR_ICONS.trash}DeleteDel`; + + 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 = ""; + } +} diff --git a/src/components/timeline/components/track/track-component.ts b/src/components/timeline/components/track/track-component.ts index d4a96928..0794bb8a 100644 --- a/src/components/timeline/components/track/track-component.ts +++ b/src/components/timeline/components/track/track-component.ts @@ -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) */ @@ -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); diff --git a/src/components/timeline/components/track/track-list.ts b/src/components/timeline/components/track/track-list.ts index 7ac2635b..edee3e5f 100644 --- a/src/components/timeline/components/track/track-list.ts +++ b/src/components/timeline/components/track/track-list.ts @@ -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 */ @@ -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, diff --git a/src/components/timeline/timeline.ts b/src/components/timeline/timeline.ts index 187aaa95..b606a842 100644 --- a/src/components/timeline/timeline.ts +++ b/src/components/timeline/timeline.ts @@ -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"; @@ -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 @@ -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) @@ -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 @@ -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; diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 2d241d12..f9106af0 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -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 { const track = this.tracks[trackIdx]; if (!track) return; diff --git a/src/core/inputs/controls.ts b/src/core/inputs/controls.ts index 01929e9c..3ba52441 100644 --- a/src/core/inputs/controls.ts +++ b/src/core/inputs/controls.ts @@ -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; } diff --git a/src/core/ui/base-toolbar.ts b/src/core/ui/base-toolbar.ts index ff62feb7..0683e7fe 100644 --- a/src/core/ui/base-toolbar.ts +++ b/src/core/ui/base-toolbar.ts @@ -1,10 +1,13 @@ import type { Edit } from "@core/edit-session"; +import { EditEvent } from "@core/events/edit-events"; import { makeToolbarDraggable, type ToolbarDragHandle } from "./toolbar-drag"; /** Default top offset for CSS-centered top toolbars */ const DEFAULT_TOP_OFFSET = "12px"; +export const DELETE_DISABLED_REASON = "Can't delete the only clip on the timeline"; + /** Preset font sizes used by text toolbars */ export const FONT_SIZES = [6, 8, 10, 12, 14, 16, 18, 21, 24, 28, 32, 36, 42, 48, 56, 64, 72, 96, 128]; @@ -39,7 +42,8 @@ export const TOOLBAR_ICONS = { edit: ``, chevron: ``, transition: ``, - effect: `` + effect: ``, + trash: `` }; /** @@ -53,11 +57,78 @@ export abstract class BaseToolbar { protected selectedClipIdx = -1; protected clickOutsideHandler: ((e: MouseEvent) => void) | null = null; protected dragResult: ToolbarDragHandle | null = null; + private deleteBtn: HTMLButtonElement | null = null; + private clipCountListener: (() => void) | null = null; constructor(edit: Edit) { this.edit = edit; } + /** + * Append a trash button to the right end of the toolbar. + */ + protected appendDeleteButton(): void { + if (!this.container) return; + this.buildDeleteButton(); + this.ensureClipCountSubscription(); + this.refreshDeleteState(); + } + + private buildDeleteButton(): void { + if (!this.container) return; + + this.container.querySelector(".ss-toolbar-delete-wrap")?.remove(); + + const wrapper = document.createElement("div"); + wrapper.className = "ss-toolbar-delete-wrap"; + + const divider = document.createElement("div"); + divider.className = "ss-toolbar-delete-divider"; + + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "ss-toolbar-delete-btn"; + btn.dataset["action"] = "delete-clip"; + btn.innerHTML = `${TOOLBAR_ICONS.trash}`; + + btn.addEventListener("click", e => { + e.stopPropagation(); + if (this.selectedTrackIdx < 0 || this.selectedClipIdx < 0) return; + if (!this.edit.canDeleteClip(this.selectedTrackIdx, this.selectedClipIdx)) return; + this.edit.deleteClip(this.selectedTrackIdx, this.selectedClipIdx).catch(err => { + console.warn("[shotstack-studio:base-toolbar] deleteClip failed", err); + }); + }); + + wrapper.appendChild(divider); + wrapper.appendChild(btn); + this.deleteBtn = btn; + this.container.appendChild(wrapper); + } + + /** + * Subscribe to clip-inventory events exactly once per toolbar instance. + */ + private ensureClipCountSubscription(): void { + if (this.clipCountListener) return; + this.clipCountListener = () => this.refreshDeleteState(); + this.edit.events.on(EditEvent.ClipAdded, this.clipCountListener); + this.edit.events.on(EditEvent.ClipDeleted, this.clipCountListener); + this.edit.events.on(EditEvent.ClipRestored, this.clipCountListener); + } + + /** + * Sync the trash button's disabled state with whether the selected clip is + * actually deletable. + */ + protected refreshDeleteState(): void { + if (!this.deleteBtn) return; + const hasSelection = this.selectedTrackIdx >= 0 && this.selectedClipIdx >= 0; + const deletable = hasSelection && this.edit.canDeleteClip(this.selectedTrackIdx, this.selectedClipIdx); + this.deleteBtn.disabled = !deletable; + this.deleteBtn.title = deletable ? "Delete (Del)" : DELETE_DISABLED_REASON; + } + /** * Add a drag handle to the toolbar container and enable free-form dragging. */ @@ -97,6 +168,7 @@ export abstract class BaseToolbar { this.selectedTrackIdx = trackIndex; this.selectedClipIdx = clipIndex; this.syncState(); + this.refreshDeleteState(); if (this.container) { this.container.classList.add("visible"); this.container.style.display = ""; // Clear inline style, let CSS control @@ -129,6 +201,14 @@ export abstract class BaseToolbar { this.clickOutsideHandler = null; } + if (this.clipCountListener) { + this.edit.events.off(EditEvent.ClipAdded, this.clipCountListener); + this.edit.events.off(EditEvent.ClipDeleted, this.clipCountListener); + this.edit.events.off(EditEvent.ClipRestored, this.clipCountListener); + this.clipCountListener = null; + } + this.deleteBtn = null; + this.container?.remove(); this.container = null; } diff --git a/src/core/ui/clip-toolbar.ts b/src/core/ui/clip-toolbar.ts index aac4e28d..c17e6eab 100644 --- a/src/core/ui/clip-toolbar.ts +++ b/src/core/ui/clip-toolbar.ts @@ -62,6 +62,7 @@ export class ClipToolbar extends BaseToolbar { this.setupOutsideClickHandler(); this.setupEventListeners(); this.enableDrag(); + this.appendDeleteButton(); } private setupEventListeners(): void { diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index ec60986c..8111e044 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -374,11 +374,13 @@ export class MediaToolbar extends BaseToolbar { this.setupEventListeners(); this.setupOutsideClickHandler(); this.enableDrag(); + this.appendDeleteButton(); } /** * Mount composite UI components into their placeholder elements. */ + private mountCompositeComponents(): void { // Mount opacity slider (two-phase: live preview during drag, single undo on release) const opacityMount = this.container?.querySelector("[data-opacity-slider-mount]"); diff --git a/src/core/ui/rich-caption-toolbar.ts b/src/core/ui/rich-caption-toolbar.ts index 0ce223e4..b422057c 100644 --- a/src/core/ui/rich-caption-toolbar.ts +++ b/src/core/ui/rich-caption-toolbar.ts @@ -83,6 +83,7 @@ export class RichCaptionToolbar extends RichTextToolbar { }); this.injectCaptionControls(); + this.appendDeleteButton(); } override dispose(): void { @@ -794,5 +795,4 @@ export class RichCaptionToolbar extends RichTextToolbar { } }); } - } diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index f85d1a5c..892d61d0 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -550,9 +550,7 @@ export class RichTextToolbar extends BaseToolbar { this.stylePanel.onStrokeChange(stroke => { const isDragging = this.stylePanel?.isDragging() ?? false; - const strokeValue = stroke.width > 0 - ? { width: stroke.width, color: stroke.color, opacity: stroke.opacity / 100 } - : undefined; + const strokeValue = stroke.width > 0 ? { width: stroke.width, color: stroke.color, opacity: stroke.opacity / 100 } : undefined; if (isDragging) { const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); @@ -656,13 +654,14 @@ export class RichTextToolbar extends BaseToolbar { } : undefined; // Stroke: persist if width > 0 - asset["stroke"] = finalState.stroke.width > 0 - ? { - width: finalState.stroke.width, - color: finalState.stroke.color, - opacity: finalState.stroke.opacity / 100 - } - : undefined; + asset["stroke"] = + finalState.stroke.width > 0 + ? { + width: finalState.stroke.width, + color: finalState.stroke.color, + opacity: finalState.stroke.opacity / 100 + } + : undefined; // Note: fill is handled by BackgroundColorPicker, not StylePanel } @@ -808,6 +807,7 @@ export class RichTextToolbar extends BaseToolbar { this.setupOutsideClickHandler(); this.enableDrag(); + this.appendDeleteButton(); // eslint-disable-next-line no-param-reassign -- Intentional DOM parent styling parent.style.position = "relative"; diff --git a/src/core/ui/svg-toolbar.ts b/src/core/ui/svg-toolbar.ts index 6d7cbd8c..89290b04 100644 --- a/src/core/ui/svg-toolbar.ts +++ b/src/core/ui/svg-toolbar.ts @@ -157,6 +157,7 @@ export class SvgToolbar extends BaseToolbar { this.setupOutsideClickHandler(); this.enableDrag(); + this.appendDeleteButton(); } // ─── SVG Asset Control Setup ────────────────────────────────────────────────── diff --git a/src/core/ui/text-to-image-toolbar.ts b/src/core/ui/text-to-image-toolbar.ts index 860417e8..9b78a8b9 100644 --- a/src/core/ui/text-to-image-toolbar.ts +++ b/src/core/ui/text-to-image-toolbar.ts @@ -244,6 +244,7 @@ export class TextToImageToolbar extends BaseToolbar { this.setupEventListeners(); this.setupOutsideClickHandler(); this.enableDrag(); + this.appendDeleteButton(); } private mountCompositeComponents(): void { diff --git a/src/core/ui/text-to-speech-toolbar.ts b/src/core/ui/text-to-speech-toolbar.ts index 4da39288..64bd54b4 100644 --- a/src/core/ui/text-to-speech-toolbar.ts +++ b/src/core/ui/text-to-speech-toolbar.ts @@ -272,6 +272,7 @@ export class TextToSpeechToolbar extends BaseToolbar { this.setupEventListeners(); this.setupOutsideClickHandler(); this.enableDrag(); + this.appendDeleteButton(); } private mountCompositeComponents(): void { diff --git a/src/core/ui/text-toolbar.ts b/src/core/ui/text-toolbar.ts index 44d3ca3c..5b1661c2 100644 --- a/src/core/ui/text-toolbar.ts +++ b/src/core/ui/text-toolbar.ts @@ -277,6 +277,7 @@ export class TextToolbar extends BaseToolbar { this.buildSizePopup(); this.setupEventListeners(); this.enableDrag(); + this.appendDeleteButton(); } private bindElements(): void { diff --git a/src/styles/index.css b/src/styles/index.css index 8468cb4a..5c0468aa 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -7,6 +7,7 @@ /* UI Component Styles */ @import "./ui/toolbar-drag.css"; +@import "./ui/toolbar-delete.css"; @import "./ui/scrollable-list.css"; @import "./ui/rich-text-toolbar.css"; @import "./ui/svg-toolbar.css"; diff --git a/src/styles/timeline/timeline.css b/src/styles/timeline/timeline.css index d7e33e95..b7460383 100644 --- a/src/styles/timeline/timeline.css +++ b/src/styles/timeline/timeline.css @@ -203,7 +203,9 @@ .ss-track { position: relative; border-bottom: 1px solid #f3f4f6; - transition: background 0.15s ease, height 0.2s ease; + transition: + background 0.15s ease, + height 0.2s ease; background: #ffffff; } @@ -552,6 +554,75 @@ background: rgba(0, 0, 0, 0.12); } +/* + * Inline menu trigger + */ +.ss-clip-menu-trigger { + position: absolute; + top: 4px; + right: 4px; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + background: rgba(0, 0, 0, 0.06); + border: none; + border-radius: 3px; + color: var(--clip-fg, #333); + cursor: pointer; + opacity: 0; + transition: + opacity 0.1s ease, + background 0.1s ease; + z-index: 3; +} + +.ss-clip-menu-trigger svg { + width: 12px; + height: 3px; + display: block; +} + +.ss-clip:hover .ss-clip-menu-trigger ~ .ss-clip-badge, +.ss-clip.selected .ss-clip-menu-trigger ~ .ss-clip-badge, +.ss-clip:hover .ss-clip-badge, +.ss-clip.selected .ss-clip-badge { + opacity: 0; + pointer-events: none; +} + +/* Trigger replaces the timing badge slot on hover/select */ +.ss-clip:hover .ss-clip-menu-trigger, +.ss-clip.selected .ss-clip-menu-trigger { + opacity: 1; +} + +.ss-clip-menu-trigger:hover { + background: rgba(0, 0, 0, 0.15); +} + +/* Thumbnail clips: invert overlay so the dots read against dark thumbnails */ +.ss-clip.ss-clip--thumbnails .ss-clip-menu-trigger { + background: rgba(0, 0, 0, 0.4); + color: rgba(255, 255, 255, 0.95); +} + +.ss-clip.ss-clip--thumbnails .ss-clip-menu-trigger:hover { + background: rgba(0, 0, 0, 0.6); +} + +/* Narrow clips: hide so the trigger never overlaps timing badge or label */ +.ss-clip { + container-type: inline-size; +} +@container (max-width: 90px) { + .ss-clip-menu-trigger { + display: none; + } +} + /* Playhead */ .ss-playhead { position: absolute; diff --git a/src/styles/ui/toolbar-delete.css b/src/styles/ui/toolbar-delete.css new file mode 100644 index 00000000..27e82fb5 --- /dev/null +++ b/src/styles/ui/toolbar-delete.css @@ -0,0 +1,128 @@ +/* + * Delete button shared across all top-center toolbars + */ +.ss-toolbar-delete-wrap { + display: flex; + align-items: center; + margin-left: auto; +} + +.ss-toolbar-delete-divider { + width: 1px; + height: 20px; + background: rgba(255, 255, 255, 0.1); + margin: 0 6px 0 4px; +} + +.ss-toolbar-delete-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background: transparent; + border: none; + border-radius: 6px; + color: rgba(255, 255, 255, 0.65); + cursor: pointer; + transition: all 0.15s ease; + position: relative; +} + +.ss-toolbar-delete-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.95); +} + +.ss-toolbar-delete-btn:active { + background: rgba(255, 255, 255, 0.15); +} + +.ss-toolbar-delete-btn:disabled, +.ss-toolbar-delete-btn[disabled] { + cursor: not-allowed; + color: rgba(255, 255, 255, 0.25); + background: transparent; +} + +.ss-toolbar-delete-btn svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +/* + * Right-click context menu for timeline clips. + */ +.ss-clip-context-menu { + position: fixed; + min-width: 160px; + padding: 4px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + z-index: 1000; + color: #1f2937; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 12px; + user-select: none; +} + +.ss-clip-context-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; + background: transparent; + border: none; + border-radius: 4px; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background 0.1s ease; +} + +.ss-clip-context-menu-item:hover, +.ss-clip-context-menu-item:focus-visible { + background: #f3f4f6; + outline: none; +} + +.ss-clip-context-menu-item:disabled, +.ss-clip-context-menu-item[disabled] { + color: #9ca3af; + cursor: not-allowed; + background: transparent; +} + +.ss-clip-context-menu-item:disabled:hover, +.ss-clip-context-menu-item[disabled]:hover { + background: transparent; +} + +.ss-clip-context-menu-item:disabled .ss-clip-context-menu-icon, +.ss-clip-context-menu-item[disabled] .ss-clip-context-menu-icon { + color: #d1d5db; +} + +.ss-clip-context-menu-icon { + flex-shrink: 0; + color: #6b7280; +} + +.ss-clip-context-menu-label { + flex: 1; +} + +.ss-clip-context-menu-shortcut { + font-size: 10px; + color: #9ca3af; + padding: 2px 5px; + border-radius: 3px; + background: #f3f4f6; + font-family: -apple-system, BlinkMacSystemFont, monospace; +} diff --git a/tests/clip-context-menu.test.ts b/tests/clip-context-menu.test.ts new file mode 100644 index 00000000..35d840b6 --- /dev/null +++ b/tests/clip-context-menu.test.ts @@ -0,0 +1,328 @@ +/** + * @jest-environment jsdom + */ +/* eslint-disable import/first, max-classes-per-file */ + +/** + * Right-click context menu for timeline clips. + * + * Covers: + * - Right-click on a clip element opens the menu and suppresses the native one + * - Delete item is enabled when Edit.canDeleteClip returns true + * - Delete item is disabled (with explanatory title) when canDeleteClip returns false + * - Clicking enabled Delete invokes edit.deleteClip and closes the menu + * - Escape, outside click, scroll, and resize all close the menu + * - showAt() toggles the menu when called for the same clip twice + * - showAt() switches the menu when called for a different clip + * - dispose() removes listeners and any open menu + */ + +import type { Edit } from "@core/edit-session"; + +if (typeof structuredClone === "undefined") { + global.structuredClone = (obj: unknown) => JSON.parse(JSON.stringify(obj)); +} + +jest.mock("pixi.js", () => ({})); +jest.mock("../src/components/canvas/players/player", () => ({ Player: class MockPlayer {}, PlayerType: {} })); +jest.mock("../src/core/shotstack-edit", () => ({ ShotstackEdit: class MockShotstackEdit {} })); +jest.mock("../src/core/edit-session", () => ({})); + +import { DELETE_DISABLED_REASON } from "@core/ui/base-toolbar"; +import { ClipContextMenu } from "@timeline/components/clip/clip-context-menu"; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function createMockEdit(canDelete = true) { + return { + canDeleteClip: jest.fn(() => canDelete), + deleteClip: jest.fn().mockResolvedValue(undefined) + }; +} + +function createClipEl(trackIndex: number, clipIndex: number): HTMLDivElement { + const el = document.createElement("div"); + el.className = "ss-clip"; + el.dataset["trackIndex"] = String(trackIndex); + el.dataset["clipIndex"] = String(clipIndex); + // Give it a non-zero rect so menu positioning has something real to anchor to. + Object.defineProperty(el, "getBoundingClientRect", { + value: () => ({ left: 0, top: 0, right: 100, bottom: 50, width: 100, height: 50, x: 0, y: 0, toJSON: () => ({}) }) + }); + return el; +} + +function setup(canDelete = true) { + const mockEdit = createMockEdit(canDelete); + const tracksContainer = document.createElement("div"); + tracksContainer.className = "ss-tracks"; + document.body.appendChild(tracksContainer); + + const menu = new ClipContextMenu(mockEdit as unknown as Edit, tracksContainer); + menu.mount(); + + return { + menu, + mockEdit, + tracksContainer, + getMenuEl: () => document.querySelector(".ss-clip-context-menu"), + getDeleteItem: () => document.querySelector(".ss-clip-context-menu [data-action='delete']") + }; +} + +function rightClick(target: HTMLElement, clientX = 50, clientY = 50): MouseEvent { + const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX, clientY, button: 2 }); + target.dispatchEvent(event); + return event; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +describe("ClipContextMenu", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + describe("opening via right-click", () => { + it("opens the menu when a clip element is right-clicked", () => { + const { tracksContainer, getMenuEl } = setup(); + const clip = createClipEl(0, 0); + tracksContainer.appendChild(clip); + + rightClick(clip); + + expect(getMenuEl()).toBeTruthy(); + }); + + it("suppresses the native browser menu by calling preventDefault", () => { + const { tracksContainer } = setup(); + const clip = createClipEl(0, 0); + tracksContainer.appendChild(clip); + + const event = rightClick(clip); + + expect(event.defaultPrevented).toBe(true); + }); + + it("does nothing when the right-click target is not a clip", () => { + const { tracksContainer, getMenuEl } = setup(); + const nonClip = document.createElement("div"); + tracksContainer.appendChild(nonClip); + + const event = rightClick(nonClip); + + expect(getMenuEl()).toBeNull(); + expect(event.defaultPrevented).toBe(false); + }); + + it("ignores clip elements missing the trackIndex or clipIndex dataset", () => { + const { tracksContainer, getMenuEl } = setup(); + const malformed = document.createElement("div"); + malformed.className = "ss-clip"; + // Intentionally no dataset + tracksContainer.appendChild(malformed); + + rightClick(malformed); + + expect(getMenuEl()).toBeNull(); + }); + }); + + describe("delete item state", () => { + it("renders the Delete item enabled when canDeleteClip returns true", () => { + const { tracksContainer, getDeleteItem, mockEdit } = setup(true); + const clip = createClipEl(0, 0); + tracksContainer.appendChild(clip); + + rightClick(clip); + + const item = getDeleteItem()!; + expect(item.disabled).toBe(false); + expect(mockEdit.canDeleteClip).toHaveBeenCalledWith(0, 0); + }); + + it("renders the Delete item disabled with the shared tooltip when canDeleteClip returns false", () => { + const { tracksContainer, getDeleteItem } = setup(false); + const clip = createClipEl(0, 0); + tracksContainer.appendChild(clip); + + rightClick(clip); + + const item = getDeleteItem()!; + expect(item.disabled).toBe(true); + expect(item.title).toBe(DELETE_DISABLED_REASON); + }); + + it("includes a Delete label and a Del shortcut hint", () => { + const { tracksContainer, getMenuEl } = setup(); + const clip = createClipEl(0, 0); + tracksContainer.appendChild(clip); + + rightClick(clip); + + const menuEl = getMenuEl()!; + expect(menuEl.querySelector(".ss-clip-context-menu-label")?.textContent).toBe("Delete"); + expect(menuEl.querySelector(".ss-clip-context-menu-shortcut")?.textContent).toBe("Del"); + }); + }); + + describe("clicking Delete", () => { + it("calls edit.deleteClip with the right-clicked clip's indices and closes the menu", () => { + const { tracksContainer, mockEdit, getDeleteItem, getMenuEl } = setup(true); + const clip = createClipEl(3, 7); + tracksContainer.appendChild(clip); + + rightClick(clip); + getDeleteItem()!.click(); + + expect(mockEdit.deleteClip).toHaveBeenCalledWith(3, 7); + expect(getMenuEl()).toBeNull(); + }); + + it("does not call deleteClip when the item is disabled", () => { + const { tracksContainer, mockEdit, getDeleteItem } = setup(false); + const clip = createClipEl(0, 0); + tracksContainer.appendChild(clip); + + rightClick(clip); + // Browser blocks click on disabled buttons; the click event never reaches handlers. + getDeleteItem()!.click(); + + expect(mockEdit.deleteClip).not.toHaveBeenCalled(); + }); + }); + + describe("dismissal", () => { + it("closes on Escape", () => { + const { tracksContainer, getMenuEl } = setup(); + const clip = createClipEl(0, 0); + tracksContainer.appendChild(clip); + + rightClick(clip); + expect(getMenuEl()).toBeTruthy(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + + expect(getMenuEl()).toBeNull(); + }); + + it("closes when a pointerdown lands outside the menu", () => { + const { tracksContainer, getMenuEl } = setup(); + const clip = createClipEl(0, 0); + tracksContainer.appendChild(clip); + + rightClick(clip); + expect(getMenuEl()).toBeTruthy(); + + const outside = document.createElement("div"); + document.body.appendChild(outside); + outside.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + + expect(getMenuEl()).toBeNull(); + }); + + it("stays open when a pointerdown lands inside the menu", () => { + const { tracksContainer, getMenuEl } = setup(); + const clip = createClipEl(0, 0); + tracksContainer.appendChild(clip); + + rightClick(clip); + const menuEl = getMenuEl()!; + + menuEl.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + + expect(getMenuEl()).toBeTruthy(); + }); + + it("closes on window resize", () => { + const { tracksContainer, getMenuEl } = setup(); + const clip = createClipEl(0, 0); + tracksContainer.appendChild(clip); + + rightClick(clip); + + window.dispatchEvent(new Event("resize")); + + expect(getMenuEl()).toBeNull(); + }); + + it("closes when the tracks container scrolls", () => { + const { tracksContainer, getMenuEl } = setup(); + const clip = createClipEl(0, 0); + tracksContainer.appendChild(clip); + + rightClick(clip); + + tracksContainer.dispatchEvent(new Event("scroll", { bubbles: false })); + + expect(getMenuEl()).toBeNull(); + }); + }); + + describe("showAt() programmatic API", () => { + it("opens the menu for the given clip", () => { + const { menu, getMenuEl, getDeleteItem, mockEdit } = setup(); + + menu.showAt(100, 100, 2, 4); + + expect(getMenuEl()).toBeTruthy(); + expect(getDeleteItem()).toBeTruthy(); + // Verify the right indices were used + getDeleteItem()!.click(); + expect(mockEdit.deleteClip).toHaveBeenCalledWith(2, 4); + }); + + it("toggles closed when called twice for the same clip", () => { + const { menu, getMenuEl } = setup(); + + menu.showAt(100, 100, 0, 0); + expect(getMenuEl()).toBeTruthy(); + + menu.showAt(100, 100, 0, 0); + expect(getMenuEl()).toBeNull(); + }); + + it("switches to the new clip when called for a different one", () => { + const { menu, getMenuEl, getDeleteItem, mockEdit } = setup(); + + menu.showAt(100, 100, 0, 0); + expect(getMenuEl()).toBeTruthy(); + + menu.showAt(200, 200, 1, 0); + expect(getMenuEl()).toBeTruthy(); + + getDeleteItem()!.click(); + expect(mockEdit.deleteClip).toHaveBeenLastCalledWith(1, 0); + }); + }); + + describe("dispose", () => { + it("removes any open menu", () => { + const { menu, tracksContainer, getMenuEl } = setup(); + const clip = createClipEl(0, 0); + tracksContainer.appendChild(clip); + + rightClick(clip); + expect(getMenuEl()).toBeTruthy(); + + menu.dispose(); + + expect(getMenuEl()).toBeNull(); + }); + + it("stops listening for contextmenu after dispose", () => { + const { menu, tracksContainer, getMenuEl } = setup(); + menu.dispose(); + + const clip = createClipEl(0, 0); + tracksContainer.appendChild(clip); + rightClick(clip); + + expect(getMenuEl()).toBeNull(); + }); + }); +}); diff --git a/tests/clip-toolbar-merge-fields.test.ts b/tests/clip-toolbar-merge-fields.test.ts index bc67df0b..2d9b4673 100644 --- a/tests/clip-toolbar-merge-fields.test.ts +++ b/tests/clip-toolbar-merge-fields.test.ts @@ -242,6 +242,8 @@ describe("ClipToolbar merge field integration", () => { getDocumentClip: jest.fn(() => ({ start: 0, length: 5 })), getResolvedClipById: jest.fn(() => ({ start: 0, length: 5 })), updateClipTiming: jest.fn(), + deleteClip: jest.fn(), + canDeleteClip: jest.fn(() => true), getMergeFieldForProperty: mockGetMergeFieldForProperty, mergeFields: { getAll: jest.fn(() => []), diff --git a/tests/cross-bundle-merge-fields.test.ts b/tests/cross-bundle-merge-fields.test.ts index 520fd372..4fc8c19d 100644 --- a/tests/cross-bundle-merge-fields.test.ts +++ b/tests/cross-bundle-merge-fields.test.ts @@ -89,6 +89,9 @@ function createCrossBundleEdit() { updateClipInDocument: jest.fn(), resolveClip: jest.fn(), commitClipUpdate: jest.fn(), + deleteClip: jest.fn(), + canDeleteClip: jest.fn(() => true), + events: { on: jest.fn(), off: jest.fn() }, getInternalEvents: jest.fn(() => internalEvents), getMergeFieldForProperty: jest.fn(() => null), isValueCompatibleWithClipProperty: jest.fn(() => true), @@ -120,6 +123,9 @@ function createPlainEdit() { updateClipInDocument: jest.fn(), resolveClip: jest.fn(), commitClipUpdate: jest.fn(), + deleteClip: jest.fn(), + canDeleteClip: jest.fn(() => true), + events: { on: jest.fn(), off: jest.fn() }, getInternalEvents: jest.fn(() => internalEvents), size: { width: 1920, height: 1080 } }; diff --git a/tests/media-toolbar.test.ts b/tests/media-toolbar.test.ts index 2401ce83..f8572dc8 100644 --- a/tests/media-toolbar.test.ts +++ b/tests/media-toolbar.test.ts @@ -60,6 +60,9 @@ function createMockEditSession() { updateClipInDocument: jest.fn(), resolveClip: jest.fn(), commitClipUpdate: jest.fn(), + deleteClip: jest.fn(), + canDeleteClip: jest.fn(() => true), + events: { on: jest.fn(), off: jest.fn() }, size: { width: 1920, height: 1080 } }; } @@ -97,6 +100,9 @@ function createMergeFieldMockEditSession() { updateClipInDocument: jest.fn(), resolveClip: jest.fn(), commitClipUpdate: jest.fn(), + deleteClip: jest.fn(), + canDeleteClip: jest.fn(() => true), + events: { on: jest.fn(), off: jest.fn() }, getInternalEvents: jest.fn(() => internalEvents), getMergeFieldForProperty: jest.fn((): string | null => null), removeMergeField: jest.fn().mockResolvedValue(undefined), diff --git a/tests/rich-caption-toolbar.test.ts b/tests/rich-caption-toolbar.test.ts index 8f4225b9..98147c74 100644 --- a/tests/rich-caption-toolbar.test.ts +++ b/tests/rich-caption-toolbar.test.ts @@ -110,6 +110,8 @@ function createMockEdit(overrides: Record = {}) { updateClipInDocument: jest.fn(), resolveClip: jest.fn(), commitClipUpdate: jest.fn(), + deleteClip: jest.fn(), + canDeleteClip: jest.fn(() => true), getToolbarButtons: jest.fn(() => []), getSelectedClipInfo: jest.fn(() => null), mergeFields: { @@ -345,13 +347,11 @@ describe("RichCaptionToolbar", () => { const opacitySlider = container.querySelector("[data-active-stroke-opacity]") as HTMLInputElement; expect(opacitySlider?.value).toBe("75"); }); - }); // ── User Interactions ────────────────────────────────────────────── - describe("layout controls", () => { - }); + describe("layout controls", () => {}); describe("word animation controls", () => { it("should call updateClip when animation style button is clicked", () => { @@ -363,7 +363,8 @@ describe("RichCaptionToolbar", () => { simulateClick(popBtn); expect(mockEdit.updateClip).toHaveBeenCalledWith( - 0, 0, + 0, + 0, expect.objectContaining({ asset: expect.objectContaining({ animation: expect.objectContaining({ style: "pop" }) @@ -381,7 +382,8 @@ describe("RichCaptionToolbar", () => { simulateClick(leftBtn); expect(mockEdit.updateClip).toHaveBeenCalledWith( - 0, 0, + 0, + 0, expect.objectContaining({ asset: expect.objectContaining({ animation: expect.objectContaining({ direction: "left" }) @@ -389,7 +391,6 @@ describe("RichCaptionToolbar", () => { }) ); }); - }); describe("active word controls", () => { @@ -402,7 +403,8 @@ describe("RichCaptionToolbar", () => { simulateInput(input, "#00ff00"); expect(mockEdit.updateClip).toHaveBeenCalledWith( - 0, 0, + 0, + 0, expect.objectContaining({ asset: expect.objectContaining({ active: expect.objectContaining({ @@ -422,7 +424,8 @@ describe("RichCaptionToolbar", () => { simulateInput(slider, "75"); expect(mockEdit.updateClip).toHaveBeenCalledWith( - 0, 0, + 0, + 0, expect.objectContaining({ asset: expect.objectContaining({ active: expect.objectContaining({ @@ -442,7 +445,8 @@ describe("RichCaptionToolbar", () => { simulateInput(slider, "5"); expect(mockEdit.updateClip).toHaveBeenCalledWith( - 0, 0, + 0, + 0, expect.objectContaining({ asset: expect.objectContaining({ active: expect.objectContaining({ @@ -452,7 +456,6 @@ describe("RichCaptionToolbar", () => { }) ); }); - }); // ── Source Popup ────────────────────────────────────────────────── @@ -666,7 +669,8 @@ describe("RichCaptionToolbar", () => { simulateClick(noneItem); expect(mockEdit.updateClip).toHaveBeenCalledWith( - 0, 0, + 0, + 0, expect.objectContaining({ asset: expect.objectContaining({ src: "alias://" @@ -679,7 +683,6 @@ describe("RichCaptionToolbar", () => { // ── StylePanel Override ──────────────────────────────────────────── describe("createStylePanel override", () => { - it("should keep stroke tab visible", () => { setupCaptionClip(mockEdit); toolbar.mount(container); @@ -699,7 +702,9 @@ describe("RichCaptionToolbar", () => { // After dispose, mounting a new container should work without errors const newContainer = createTestContainer(); - const { RichCaptionToolbar } = jest.requireActual("../src/core/ui/rich-caption-toolbar") as typeof import("../src/core/ui/rich-caption-toolbar"); + const { RichCaptionToolbar } = jest.requireActual( + "../src/core/ui/rich-caption-toolbar" + ) as typeof import("../src/core/ui/rich-caption-toolbar"); const newToolbar = new RichCaptionToolbar(mockEdit as never); expect(() => newToolbar.mount(newContainer)).not.toThrow(); newToolbar.dispose(); diff --git a/tests/svg-player.test.ts b/tests/svg-player.test.ts index 25ef1c9a..01b31421 100644 --- a/tests/svg-player.test.ts +++ b/tests/svg-player.test.ts @@ -332,6 +332,9 @@ describe("SvgPlayer", () => { }); describe("WASM Initialization", () => { + // Canvas 2.7.2+ handles the wasm fetch internally. The SDK only invokes + // initResvg() and lets canvas resolve the bytes (CDN by default; consumers + // can override via wasmBaseURL or wasmBinary). it("initializes resvg WASM on first load", async () => { const mockEdit = createMockEdit(); const clipConfig = createSvgClipConfig(); @@ -339,7 +342,6 @@ describe("SvgPlayer", () => { await player.load(); - expect(mockFetch).toHaveBeenCalledWith("https://unpkg.com/@resvg/resvg-wasm@2.6.2/index_bg.wasm"); expect(mockInitResvg).toHaveBeenCalled(); }); @@ -353,7 +355,6 @@ describe("SvgPlayer", () => { await player2.load(); // Should only initialize once - expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockInitResvg).toHaveBeenCalledTimes(1); }); @@ -368,7 +369,6 @@ describe("SvgPlayer", () => { await Promise.all([player1.load(), player2.load(), player3.load()]); // Should only initialize once despite concurrent requests - expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockInitResvg).toHaveBeenCalledTimes(1); }); }); @@ -466,8 +466,10 @@ describe("SvgPlayer", () => { consoleSpy.mockRestore(); }); - it("creates fallback graphic when WASM fetch fails", async () => { - mockFetch.mockRejectedValueOnce(new Error("Network error")); + it("creates fallback graphic when WASM init fails", async () => { + // Canvas's initResvg handles the fetch internally; we simulate a + // failure by making the SDK-side initResvg call reject. + mockInitResvg.mockRejectedValueOnce(new Error("Network error")); const mockEdit = createMockEdit(); const clipConfig = createSvgClipConfig(); diff --git a/tests/svg-toolbar.test.ts b/tests/svg-toolbar.test.ts index f6bb75a7..0a0821de 100644 --- a/tests/svg-toolbar.test.ts +++ b/tests/svg-toolbar.test.ts @@ -45,7 +45,10 @@ function createMockEditSession() { updateClip: jest.fn(), updateClipInDocument: jest.fn(), resolveClip: jest.fn(), - commitClipUpdate: jest.fn() + commitClipUpdate: jest.fn(), + deleteClip: jest.fn(), + canDeleteClip: jest.fn(() => true), + events: { on: jest.fn(), off: jest.fn() } }; } diff --git a/tests/text-to-image-toolbar.test.ts b/tests/text-to-image-toolbar.test.ts index 4ad19f63..c63ac679 100644 --- a/tests/text-to-image-toolbar.test.ts +++ b/tests/text-to-image-toolbar.test.ts @@ -66,6 +66,9 @@ function createMockEditSession() { updateClipInDocument: jest.fn(), resolveClip: jest.fn(), commitClipUpdate: jest.fn(), + deleteClip: jest.fn(), + canDeleteClip: jest.fn(() => true), + events: { on: jest.fn(), off: jest.fn() }, size: { width: 1920, height: 1080 } }; } diff --git a/tests/toolbar-delete-button.test.ts b/tests/toolbar-delete-button.test.ts new file mode 100644 index 00000000..29284b66 --- /dev/null +++ b/tests/toolbar-delete-button.test.ts @@ -0,0 +1,253 @@ +/** + * @jest-environment jsdom + */ +/* eslint-disable import/first, max-classes-per-file */ + +/** + * Trash button regression tests. + * + * Covers BaseToolbar.appendDeleteButton wiring across: + * - initial mount state + * - selection-driven refresh via show() + * - event-driven refresh (ClipAdded / ClipDeleted / ClipRestored) + * - click handler refusal when Edit.canDeleteClip returns false + * - listener cleanup on dispose() + * + * Uses MediaToolbar as the concrete subclass that exercises BaseToolbar's + * delete-button lifecycle. Mocks the Edit interface — these tests are about + * the toolbar's reflection of the rule, not the rule itself. + */ + +import type { Edit } from "@core/edit-session"; + +// Polyfill structuredClone for jsdom +if (typeof structuredClone === "undefined") { + global.structuredClone = (obj: unknown) => JSON.parse(JSON.stringify(obj)); +} + +jest.mock("pixi.js", () => ({})); +jest.mock("../src/components/canvas/players/player", () => ({ Player: class MockPlayer {}, PlayerType: {} })); +jest.mock("../src/core/shotstack-edit", () => ({ ShotstackEdit: class MockShotstackEdit {} })); +jest.mock("../src/core/edit-session", () => ({})); +jest.mock("@styles/inject", () => ({ injectShotstackStyles: jest.fn() })); + +import { DELETE_DISABLED_REASON } from "@core/ui/base-toolbar"; +import { MediaToolbar } from "@core/ui/media-toolbar"; +import { EditEvent } from "@core/events/edit-events"; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** A minimal event emitter that records subscriptions and supports emit/off. */ +function createMockEvents() { + const listeners = new Map void>>(); + return { + on: jest.fn((event: string, fn: (...args: unknown[]) => void) => { + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event)!.add(fn); + }), + off: jest.fn((event: string, fn: (...args: unknown[]) => void) => { + listeners.get(event)?.delete(fn); + }), + emit: (event: string, ...args: unknown[]) => { + listeners.get(event)?.forEach(fn => fn(...args)); + }, + listenerCount: (event: string) => listeners.get(event)?.size ?? 0 + }; +} + +function createMockEdit(canDelete: boolean) { + const events = createMockEvents(); + return { + events, + getClipId: jest.fn().mockReturnValue("clip-1"), + getResolvedClip: jest.fn(), + updateClip: jest.fn(), + updateClipInDocument: jest.fn(), + resolveClip: jest.fn(), + commitClipUpdate: jest.fn(), + deleteClip: jest.fn().mockResolvedValue(undefined), + canDeleteClip: jest.fn(() => canDelete), + size: { width: 1920, height: 1080 } + }; +} + +function mountToolbar(mockEdit: ReturnType): { + toolbar: MediaToolbar; + parent: HTMLDivElement; + getDeleteBtn: () => HTMLButtonElement; +} { + const toolbar = new MediaToolbar(mockEdit as unknown as Edit); + const parent = document.createElement("div"); + document.body.appendChild(parent); + toolbar.mount(parent); + return { + toolbar, + parent, + getDeleteBtn: () => parent.querySelector(".ss-toolbar-delete-btn")! + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +describe("Toolbar delete button", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + describe("initial state", () => { + it("renders the trash button at the right edge of the toolbar", () => { + const mockEdit = createMockEdit(true); + const { getDeleteBtn } = mountToolbar(mockEdit); + + const btn = getDeleteBtn(); + expect(btn).toBeTruthy(); + expect(btn.tagName).toBe("BUTTON"); + expect(btn.querySelector("svg")).toBeTruthy(); + }); + + it("is disabled before any selection has been made", () => { + const mockEdit = createMockEdit(true); + const { getDeleteBtn } = mountToolbar(mockEdit); + + expect(getDeleteBtn().disabled).toBe(true); + }); + }); + + describe("selection-driven refresh", () => { + it("enables the button when show() is called and canDeleteClip returns true", () => { + const mockEdit = createMockEdit(true); + const { toolbar, getDeleteBtn } = mountToolbar(mockEdit); + + toolbar.show(0, 0); + + expect(getDeleteBtn().disabled).toBe(false); + expect(getDeleteBtn().title).toBe("Delete (Del)"); + }); + + it("disables the button when canDeleteClip returns false for the selected clip", () => { + const mockEdit = createMockEdit(false); + const { toolbar, getDeleteBtn } = mountToolbar(mockEdit); + + toolbar.show(0, 0); + + expect(getDeleteBtn().disabled).toBe(true); + expect(getDeleteBtn().title).toBe(DELETE_DISABLED_REASON); + }); + + it("queries canDeleteClip with the selected clip indices", () => { + const mockEdit = createMockEdit(true); + const { toolbar } = mountToolbar(mockEdit); + + toolbar.show(2, 5); + + expect(mockEdit.canDeleteClip).toHaveBeenCalledWith(2, 5); + }); + }); + + describe("event-driven refresh", () => { + it("re-evaluates disabled state when ClipDeleted fires", () => { + const mockEdit = createMockEdit(true); + const { toolbar, getDeleteBtn } = mountToolbar(mockEdit); + + toolbar.show(0, 0); + expect(getDeleteBtn().disabled).toBe(false); + + // Simulate a deletion making the clip un-deletable + mockEdit.canDeleteClip.mockReturnValue(false); + mockEdit.events.emit(EditEvent.ClipDeleted, { trackIndex: 0, clipIndex: 1 }); + + expect(getDeleteBtn().disabled).toBe(true); + expect(getDeleteBtn().title).toBe(DELETE_DISABLED_REASON); + }); + + it("re-enables the button when ClipAdded restores deletability", () => { + const mockEdit = createMockEdit(false); + const { toolbar, getDeleteBtn } = mountToolbar(mockEdit); + + toolbar.show(0, 0); + expect(getDeleteBtn().disabled).toBe(true); + + mockEdit.canDeleteClip.mockReturnValue(true); + mockEdit.events.emit(EditEvent.ClipAdded, { trackIndex: 0, clipIndex: 1 }); + + expect(getDeleteBtn().disabled).toBe(false); + }); + + it("subscribes to ClipAdded, ClipDeleted, and ClipRestored", () => { + const mockEdit = createMockEdit(true); + mountToolbar(mockEdit); + + expect(mockEdit.events.on).toHaveBeenCalledWith(EditEvent.ClipAdded, expect.any(Function)); + expect(mockEdit.events.on).toHaveBeenCalledWith(EditEvent.ClipDeleted, expect.any(Function)); + expect(mockEdit.events.on).toHaveBeenCalledWith(EditEvent.ClipRestored, expect.any(Function)); + }); + + it("only subscribes once per toolbar instance", () => { + const mockEdit = createMockEdit(true); + const { toolbar } = mountToolbar(mockEdit); + + // MediaToolbar.show() doesn't rebuild the button — only re-render scenarios do. + // Simulating a re-mount path: call mount() again on the same toolbar. + toolbar.mount(document.body); + + // We expect exactly 3 subscriptions across the lifetime — one per event. + const totalCalls = mockEdit.events.on.mock.calls.length; + expect(totalCalls).toBe(3); + }); + }); + + describe("click behaviour", () => { + it("calls deleteClip with the selected indices when enabled", () => { + const mockEdit = createMockEdit(true); + const { toolbar, getDeleteBtn } = mountToolbar(mockEdit); + + toolbar.show(1, 2); + getDeleteBtn().click(); + + expect(mockEdit.deleteClip).toHaveBeenCalledWith(1, 2); + }); + + it("does not call deleteClip when canDeleteClip returns false even if the click handler fires", () => { + const mockEdit = createMockEdit(false); + const { toolbar, getDeleteBtn } = mountToolbar(mockEdit); + + toolbar.show(0, 0); + // The button is disabled — fire click directly to verify the in-handler guard + // rejects the call even if the browser-level disabled didn't (defence in depth). + getDeleteBtn().dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(mockEdit.deleteClip).not.toHaveBeenCalled(); + }); + + it("does not call deleteClip when no selection exists", () => { + const mockEdit = createMockEdit(true); + const { getDeleteBtn } = mountToolbar(mockEdit); + + // No show() — selection is -1/-1 sentinel + getDeleteBtn().dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(mockEdit.deleteClip).not.toHaveBeenCalled(); + }); + }); + + describe("cleanup", () => { + it("unsubscribes from clip-inventory events on dispose", () => { + const mockEdit = createMockEdit(true); + const { toolbar } = mountToolbar(mockEdit); + + expect(mockEdit.events.listenerCount(EditEvent.ClipAdded)).toBe(1); + expect(mockEdit.events.listenerCount(EditEvent.ClipDeleted)).toBe(1); + expect(mockEdit.events.listenerCount(EditEvent.ClipRestored)).toBe(1); + + toolbar.dispose(); + + expect(mockEdit.events.listenerCount(EditEvent.ClipAdded)).toBe(0); + expect(mockEdit.events.listenerCount(EditEvent.ClipDeleted)).toBe(0); + expect(mockEdit.events.listenerCount(EditEvent.ClipRestored)).toBe(0); + }); + }); +}); diff --git a/tests/toolbar.test.ts b/tests/toolbar.test.ts index f590dd3b..3fe96f68 100644 --- a/tests/toolbar.test.ts +++ b/tests/toolbar.test.ts @@ -159,6 +159,8 @@ function createMockEdit(overrides: Record = {}) { getFontMetadata: jest.fn(() => new Map()), getMergeFieldForProperty: jest.fn(() => null), updateClip: jest.fn(), + deleteClip: jest.fn(), + canDeleteClip: jest.fn(() => true), getToolbarButtons: jest.fn((): ToolbarButtonConfig[] => []), getSelectedClipInfo: jest.fn((): { trackIndex: number; clipIndex: number } | null => null), mergeFields: {