Skip to content
Open
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
62 changes: 62 additions & 0 deletions packages/core/src/studio-api/helpers/sourceMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
91 changes: 91 additions & 0 deletions packages/studio/src/hooks/useTimelineEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
92 changes: 92 additions & 0 deletions packages/studio/src/player/components/ClipContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div
ref={menuRef}
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
style={{ left: adjustedX, top: adjustedY }}
>
<button
type="button"
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
canSplit
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
: "text-neutral-600 cursor-not-allowed"
}`}
disabled={!canSplit}
onClick={() => {
if (canSplit) {
onSplit(element, currentTime);
onClose();
}
}}
>
<span>{splitLabel}</span>
<span className="text-neutral-500 text-[10px] ml-3">S</span>
</button>

<div className="my-1 border-t border-neutral-700/60" />

<button
type="button"
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
onClick={() => {
onDelete(element);
onClose();
}}
>
<span>Delete</span>
<span className="text-neutral-500 text-[10px] ml-3">⌫</span>
</button>
</div>
);
});
Loading