From 32dd0bb834760633afe45899f39f64cbcf569073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 22:41:01 -0400 Subject: [PATCH] feat(studio): add split clip feature with timeline context menu and hotkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add splitElementInHtml to core source mutation helpers — clones an element at the split time, adjusts data-start/data-duration/data-media-start for both halves, and inserts the clone after the original. Wire through: split-element API endpoint, handleTimelineElementSplit in useTimelineEditing, clip context menu (right-click → Split at Xs), toolbar split button, and S keyboard shortcut. Edge cases: locked/implicit clips blocked, media trim offset adjusted by playback rate, unique ID generation with collision avoidance, undo via edit history. --- .../src/studio-api/helpers/sourceMutation.ts | 62 +++++++++++++ .../studio/src/hooks/useTimelineEditing.ts | 91 ++++++++++++++++++ .../src/player/components/ClipContextMenu.tsx | 92 +++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 packages/studio/src/player/components/ClipContextMenu.tsx diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index 6e934b2e5..8cdc5ae05 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -212,3 +212,65 @@ export function probeElementInSource(source: string, target: SourceMutationTarge const el = findTargetElement(document, target); return el != null && isHTMLElement(el); } + +export interface SplitElementResult { + html: string; + matched: boolean; + newId: string | null; +} + +export function splitElementInHtml( + source: string, + target: SourceMutationTarget, + splitTime: number, + newId: string, +): SplitElementResult { + const { document, wrappedFragment } = parseSourceDocument(source); + const el = findTargetElement(document, target); + if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null }; + + const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0; + const duration = parseFloat(el.getAttribute("data-duration") ?? "0") || 0; + if (duration <= 0 || splitTime <= start || splitTime >= start + duration) { + return { html: source, matched: false, newId: null }; + } + + const firstDuration = splitTime - start; + const secondDuration = duration - firstDuration; + + const clone = el.cloneNode(true) as HTMLElement; + clone.setAttribute("id", newId); + clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000)); + clone.setAttribute("data-duration", String(Math.round(secondDuration * 1000) / 1000)); + + // Adjust media trim offset for the second half + const playbackStartAttr = el.hasAttribute("data-playback-start") + ? "data-playback-start" + : el.hasAttribute("data-media-start") + ? "data-media-start" + : null; + if (playbackStartAttr) { + const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0; + const rate = parseFloat(el.getAttribute("data-playback-rate") ?? "1") || 1; + clone.setAttribute( + playbackStartAttr, + String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000), + ); + } + + // Trim the original element's duration + el.setAttribute("data-duration", String(Math.round(firstDuration * 1000) / 1000)); + + // Insert clone after original + if (el.nextSibling) { + el.parentElement!.insertBefore(clone, el.nextSibling); + } else { + el.parentElement!.appendChild(clone); + } + + return { + html: wrappedFragment ? document.body.innerHTML || "" : document.toString(), + matched: true, + newId, + }; +} diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 86e76f9f9..e9045b960 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -466,10 +466,101 @@ export function useTimelineEditing({ [showToast], ); + const handleTimelineElementSplit = useCallback( + async (element: TimelineElement, splitTime: number) => { + const pid = projectIdRef.current; + if (!pid) return; + + if ( + element.timelineLocked || + element.timingSource === "implicit" || + !element.duration || + !Number.isFinite(element.duration) + ) { + showToast("This clip cannot be split.", "error"); + return; + } + + if (splitTime <= element.start || splitTime >= element.start + element.duration) { + showToast("Playhead must be inside the clip to split.", "error"); + return; + } + + const patchTarget = buildPatchTarget(element); + if (!patchTarget) { + showToast("Clip is missing a patchable target.", "error"); + return; + } + + const targetPath = element.sourceFile || activeCompPath || "index.html"; + try { + const originalContent = await readFileContent(pid, targetPath); + const existingIds = collectHtmlIds(originalContent); + const baseId = element.domId || "clip"; + let newId = `${baseId}-split`; + let suffix = 2; + while (existingIds.includes(newId)) { + newId = `${baseId}-split-${suffix++}`; + } + + const response = await fetch( + `/api/projects/${pid}/file-mutations/split-element/${encodeURIComponent(targetPath)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target: patchTarget, splitTime, newId }), + }, + ); + if (!response.ok) { + throw new Error("Split request failed"); + } + + const data = (await response.json()) as { + ok?: boolean; + changed?: boolean; + content?: string; + }; + if (!data.ok || !data.changed) { + showToast("Failed to split clip — playhead may be outside the clip.", "error"); + return; + } + + const patchedContent = typeof data.content === "string" ? data.content : originalContent; + + domEditSaveTimestampRef.current = Date.now(); + await saveProjectFilesWithHistory({ + projectId: pid, + label: "Split timeline clip", + kind: "timeline", + files: { [targetPath]: patchedContent }, + readFile: async () => originalContent, + writeFile: writeProjectFile, + recordEdit, + }); + + reloadPreview(); + const label = getTimelineElementLabel(element); + showToast(`Split ${label} at ${splitTime.toFixed(2)}s`, "info"); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to split timeline clip"; + showToast(message, "error"); + } + }, + [ + activeCompPath, + recordEdit, + showToast, + writeProjectFile, + domEditSaveTimestampRef, + reloadPreview, + ], + ); + return { handleTimelineElementMove, handleTimelineElementResize, handleTimelineElementDelete, + handleTimelineElementSplit, handleTimelineAssetDrop, handleTimelineFileDrop, handleBlockedTimelineEdit, diff --git a/packages/studio/src/player/components/ClipContextMenu.tsx b/packages/studio/src/player/components/ClipContextMenu.tsx new file mode 100644 index 000000000..4caf1a065 --- /dev/null +++ b/packages/studio/src/player/components/ClipContextMenu.tsx @@ -0,0 +1,92 @@ +import { memo, useCallback, useEffect, useRef } from "react"; +import type { TimelineElement } from "../store/playerStore"; + +interface ClipContextMenuProps { + x: number; + y: number; + element: TimelineElement; + currentTime: number; + onClose: () => void; + onSplit: (element: TimelineElement, splitTime: number) => void; + onDelete: (element: TimelineElement) => void; +} + +export const ClipContextMenu = memo(function ClipContextMenu({ + x, + y, + element, + currentTime, + onClose, + onSplit, + onDelete, +}: ClipContextMenuProps) { + const menuRef = useRef(null); + + const dismiss = useCallback( + (e: MouseEvent | KeyboardEvent) => { + if (e instanceof KeyboardEvent && e.key !== "Escape") return; + if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return; + onClose(); + }, + [onClose], + ); + + useEffect(() => { + document.addEventListener("mousedown", dismiss); + document.addEventListener("keydown", dismiss); + return () => { + document.removeEventListener("mousedown", dismiss); + document.removeEventListener("keydown", dismiss); + }; + }, [dismiss]); + + const adjustedX = Math.min(x, window.innerWidth - 200); + const adjustedY = Math.min(y, window.innerHeight - 200); + + const canSplit = currentTime > element.start && currentTime < element.start + element.duration; + + const splitLabel = canSplit + ? `Split at ${currentTime.toFixed(2)}s` + : "Split (move playhead inside clip)"; + + return ( +
+ + +
+ + +
+ ); +});