mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
feat(studio,core): reach presets and the rack from the timeline (#3292)
C1: the FX button in the track/group header, and its popover — the
"reach FX from the timeline" entry point, last on purpose because it
targets a group or a single clip, never "a track" (N clips = N chains
is the ill-defined thing the design doc refuses to build).
The button (TimelineFxButton.tsx): renders on group rows and on track
rows holding exactly one audio clip, reading "FX" (or "FX n" once the
target's data-fx-chain has n enabled nodes). A multi-clip ungrouped
audio track gets a pointer instead ("Group these clips to add effects
to all of them" + a Group action) rather than silently hiding the
entry point — reuses B6's exact auto-grouping write
(useAudioGroupCarveAssignment, exposed as onGroupClips) with a minted
group id (mintGroupId, exported from useFxCarveGrouping.ts).
The popover (TimelineFxPopover.tsx, components/editor/): a thin
positioner around FxPresetMenu exactly as the property panel renders
it — same audition contract (useFxAudition), same preset-apply
computation (extracted into useApplyAudioFxPreset.ts's
applyPresetToChain, now shared with propertyPanelFxSection.tsx's own
applyPreset rather than duplicated). Escape closes without
deselecting whatever is behind it; an outside pointerdown dismisses.
Footer's "+ effect"/"Open rack ›" both select the target and hand off
to the property panel (a simplification from the step doc's two
distinct behaviors — remotely toggling the rack's own internal
"adding" state isn't plumbed anywhere, and building that plumbing
would be new UI-state wiring beyond what "reuse existing selection
dispatch" asks for).
Writes, one path per target kind, neither a new persistence mechanism:
- Group: B7/B5's existing onSetAudioGroupAttributeLive/Quiet
(data-fx-chain, same as data-volume/data-hidden already do).
- Clip: a NEW onSetElementAttributeLive/Quiet pair
(timelineElementFxAttribute.ts), addressed by the TimelineElement
itself rather than the current selection. This is the one real
architectural gap the step doc's assumption didn't survive: the
property panel's onSetAttributeQuiet closes over domEditSelection,
so writing a clip that isn't already selected has no synchronous
path through it. Extracted the shared live-patch-then-persist core
(persistElementAttribute, timelineEditingHelpers.ts) out of both
this new path and the existing setAudioGroupAttribute, which the
fallow duplication gate flagged as a 66-line clone on first pass —
now a single ~50-line core parameterized by patchLive/readLive, with
each caller a ~15-line wrapper resolving its own patch target
(buildPatchTarget({domId}) for a group, buildPatchTarget(element)
for an arbitrary clip) and live-DOM lookup.
Data plumbing: HfAudioGroup.fxChain (already on the B1 model) mirrored
onto TimelineElement.audioGroupFxChain (timelineDOM.ts's groupInfoFor
cache) and TimelineTrackGroupInfo.fxChain (useTimelineTrackDerivations.ts),
alongside the existing volume/hidden mirrors.
Deferred: the property panel's own rack doesn't (yet) expose a way to
remotely force its add-menu open, so "+ effect" and "Open rack ›"
converge on the same navigation rather than the step doc's two
distinct ones. A grouped multi-clip track (some clips already carry
data-audio-group) gets neither the chain button nor the pointer —
its members' own per-clip FX buttons still work individually, and the
group's own FX button on TimelineGroupHeader covers the group level.
Gates: bun run build clean; packages/studio full suite 4286/4304 (18
pre-existing todo, up from 4276/4294 — 10 new tests, 0 regressions);
new TimelineFxPopover.test.tsx (6) + TimelineFxButton.test.tsx (4)
cover exactly-one-write-per-apply, hover-audition-reverts-on-leave,
Escape-without-deselecting, outside/inside pointerdown dismissal, and
the group-pointer's Group action; oxfmt/oxlint clean on all 22 touched
files; fallow clean (0 new dead-code/unused-export/duplication
findings — the pointer test caught during the first commit attempt).
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9ec75a485f
commit
6a92d21401
@@ -1,10 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
||||
import {
|
||||
applyPatchByTarget,
|
||||
buildPatchTarget,
|
||||
readFileContent,
|
||||
persistElementAttribute,
|
||||
type RecordEditInput,
|
||||
} from "./timelineEditingHelpers";
|
||||
import type {
|
||||
@@ -63,37 +60,21 @@ async function setAudioGroupAttribute({
|
||||
const patchTarget = buildPatchTarget({ domId: groupId });
|
||||
if (!patchTarget) return [];
|
||||
|
||||
const previousValue =
|
||||
previewIframe?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ?? null;
|
||||
patchLiveGroupAttribute(previewIframe, groupId, attr, value);
|
||||
|
||||
const before = await readFileContent(projectId, targetPath);
|
||||
if (readTagSnippetByTarget(before, patchTarget) === undefined) {
|
||||
throw new Error(`Unable to patch audio group ${groupId} in ${targetPath}`);
|
||||
}
|
||||
const operation: PatchOperation = { type: "attribute", property: attr, value };
|
||||
const patched = applyPatchByTarget(before, patchTarget, operation);
|
||||
|
||||
pendingTimelineEditPathRef.current.add(targetPath);
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
try {
|
||||
const changedPaths = await saveProjectFilesWithHistory({
|
||||
projectId,
|
||||
label,
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patched },
|
||||
readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
return changedPaths;
|
||||
} catch (error) {
|
||||
// The optimistic live write already ran; unwind it on a save failure so
|
||||
// the preview doesn't show a value that never reached disk.
|
||||
patchLiveGroupAttribute(previewIframe, groupId, attr, previousValue);
|
||||
throw error;
|
||||
}
|
||||
return persistElementAttribute({
|
||||
projectId,
|
||||
targetPath,
|
||||
patchTarget,
|
||||
attr,
|
||||
value,
|
||||
label,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
patchLive: (v) => patchLiveGroupAttribute(previewIframe, groupId, attr, v),
|
||||
readLive: () =>
|
||||
previewIframe?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
||||
import { applyPatchByTarget, findTagByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
|
||||
import {
|
||||
applyPatchByTarget,
|
||||
findTagByTarget,
|
||||
readAttributeByTarget,
|
||||
readTagSnippetByTarget,
|
||||
type PatchOperation,
|
||||
} from "../utils/sourcePatcher";
|
||||
import {
|
||||
formatTimelineAttributeNumber,
|
||||
type TimelineStackingReorderIntent,
|
||||
@@ -390,3 +396,74 @@ export async function persistTimelineBatchEdit(
|
||||
export { applyPatchByTarget, formatTimelineAttributeNumber };
|
||||
|
||||
export { patchDocumentRootDuration } from "./timelineEditingGsap";
|
||||
|
||||
export interface PersistElementAttributeInput {
|
||||
projectId: string;
|
||||
targetPath: string;
|
||||
patchTarget: PatchTarget;
|
||||
attr: string;
|
||||
value: string | null;
|
||||
label: string;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: { current: number };
|
||||
pendingTimelineEditPathRef: { current: Set<string> };
|
||||
/** Write the attribute directly on the live preview DOM node. */
|
||||
patchLive: (value: string | null) => void;
|
||||
/** Read the attribute's current value off the live preview DOM node. */
|
||||
readLive: () => string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One attribute, persisted to source and optimistically patched onto the
|
||||
* live preview, with a revert on save failure. The shared core behind
|
||||
* `setAudioGroupAttribute` (a group id addressed by its own DOM id) and
|
||||
* `useSetElementAttribute` (an arbitrary timeline clip) — same shape, only
|
||||
* how the live node is found and where the patch target resolves to differs,
|
||||
* which is exactly what `patchLive`/`readLive`/`patchTarget` parameterize.
|
||||
*/
|
||||
export async function persistElementAttribute({
|
||||
projectId,
|
||||
targetPath,
|
||||
patchTarget,
|
||||
attr,
|
||||
value,
|
||||
label,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
patchLive,
|
||||
readLive,
|
||||
}: PersistElementAttributeInput): Promise<string[]> {
|
||||
const previousValue = readLive();
|
||||
patchLive(value);
|
||||
|
||||
const before = await readFileContent(projectId, targetPath);
|
||||
if (readTagSnippetByTarget(before, patchTarget) === undefined) {
|
||||
throw new Error(`Unable to patch element in ${targetPath}`);
|
||||
}
|
||||
const operation: PatchOperation = { type: "attribute", property: attr, value };
|
||||
const patched = applyPatchByTarget(before, patchTarget, operation);
|
||||
|
||||
pendingTimelineEditPathRef.current.add(targetPath);
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
try {
|
||||
const changedPaths = await saveProjectFilesWithHistory({
|
||||
projectId,
|
||||
label,
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patched },
|
||||
readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
return changedPaths;
|
||||
} catch (error) {
|
||||
// The optimistic live write already ran; unwind it on a save failure so
|
||||
// the preview doesn't show a value that never reached disk.
|
||||
patchLive(previousValue);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* C1's clip-level FX write: persist one attribute directly on a specific
|
||||
* timeline clip, addressed by the clip itself rather than the current
|
||||
* selection — so applying a preset from the timeline FX popover doesn't
|
||||
* depend on that clip already being selected in the property panel.
|
||||
* Built on `persistElementAttribute` (`timelineEditingHelpers.ts`), the
|
||||
* shared core `setAudioGroupAttribute` also uses.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import {
|
||||
buildPatchTarget,
|
||||
findTimelineElementInIframe,
|
||||
persistElementAttribute,
|
||||
} from "./timelineEditingHelpers";
|
||||
import type {
|
||||
MutableRef,
|
||||
UseTimelineElementVisibilityEditingInput,
|
||||
} from "./timelineTrackVisibility";
|
||||
|
||||
function patchLiveElementAttribute(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
element: TimelineElement,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
activeCompPath: string | null,
|
||||
): void {
|
||||
const target = findTimelineElementInIframe(iframe, element, activeCompPath);
|
||||
if (!target) return;
|
||||
if (value === null) target.removeAttribute(attr);
|
||||
else target.setAttribute(attr, value);
|
||||
}
|
||||
|
||||
interface SetElementAttributeInput {
|
||||
projectId: string;
|
||||
activeCompPath: string | null;
|
||||
element: TimelineElement;
|
||||
attr: string;
|
||||
value: string | null;
|
||||
label: string;
|
||||
previewIframe: HTMLIFrameElement | null;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: Parameters<typeof persistElementAttribute>[0]["recordEdit"];
|
||||
domEditSaveTimestampRef: MutableRef<number>;
|
||||
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
||||
}
|
||||
|
||||
async function setElementAttribute({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
element,
|
||||
attr,
|
||||
value,
|
||||
label,
|
||||
previewIframe,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
}: SetElementAttributeInput): Promise<string[]> {
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const patchTarget = buildPatchTarget(element);
|
||||
if (!patchTarget) return [];
|
||||
|
||||
return persistElementAttribute({
|
||||
projectId,
|
||||
targetPath,
|
||||
patchTarget,
|
||||
attr,
|
||||
value,
|
||||
label,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
patchLive: (v) => patchLiveElementAttribute(previewIframe, element, attr, v, activeCompPath),
|
||||
readLive: () =>
|
||||
findTimelineElementInIframe(previewIframe, element, activeCompPath)?.getAttribute(attr) ??
|
||||
null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetElementAttribute({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
}: UseTimelineElementVisibilityEditingInput): {
|
||||
setLive: (element: TimelineElement, attr: string, value: string | null) => void;
|
||||
setQuiet: (
|
||||
element: TimelineElement,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
label: string,
|
||||
) => Promise<void>;
|
||||
} {
|
||||
const setLive = useCallback(
|
||||
(element: TimelineElement, attr: string, value: string | null) => {
|
||||
patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath);
|
||||
},
|
||||
[previewIframeRef, activeCompPath],
|
||||
);
|
||||
const setQuiet = useCallback(
|
||||
async (element: TimelineElement, attr: string, value: string | null, label: string) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
try {
|
||||
await setElementAttribute({
|
||||
projectId: pid,
|
||||
activeCompPath,
|
||||
element,
|
||||
attr,
|
||||
value,
|
||||
label,
|
||||
previewIframe: previewIframeRef.current,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Timeline] Failed to set element attribute", error);
|
||||
const message = error instanceof Error ? error.message : "Failed to update effect";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
showToast,
|
||||
projectIdRef,
|
||||
],
|
||||
);
|
||||
return { setLive, setQuiet };
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Timeline clip deletion: the marquee/multi path and the single-clip wrapper
|
||||
// the context menu uses. Extracted verbatim from useTimelineEditing.ts to keep
|
||||
// it under the studio 600-line cap, following useTimelineAssetDropOps.
|
||||
import { useCallback, type MutableRefObject, type RefObject } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
||||
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
||||
import { buildPatchTarget } from "./timelineEditingHelpers";
|
||||
import { captureDurationRollback, readFileContent } from "./timelineTimingSync";
|
||||
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
||||
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
||||
|
||||
interface UseTimelineDeleteOpsOptions {
|
||||
projectIdRef: MutableRefObject<string | null>;
|
||||
activeCompPath: string | null;
|
||||
timelineElements: TimelineElement[];
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
isRecordingRef?: MutableRefObject<boolean>;
|
||||
forceReloadSdkSession?: () => void;
|
||||
previewIframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
}
|
||||
|
||||
export function useTimelineDeleteOps({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
previewIframeRef,
|
||||
}: UseTimelineDeleteOpsOptions) {
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineElementsDelete = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (selection: TimelineElement[]) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
const [element] = selection;
|
||||
if (!element) return;
|
||||
const label =
|
||||
selection.length === 1 ? getTimelineElementLabel(element) : `${selection.length} clips`;
|
||||
|
||||
// One file per delete pass. Every element in a marquee selection lives in
|
||||
// the composition being edited, so they share a target; anything that
|
||||
// does not is dropped rather than written to the wrong file.
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const sameFile = selection.filter(
|
||||
(candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
|
||||
);
|
||||
try {
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
|
||||
// Remove every selected element before saving once. The server rewrites
|
||||
// the file per call, so `removedContent` after the last one holds them
|
||||
// all — which is what makes this a single history entry, and a single
|
||||
// undo, rather than one per clip.
|
||||
let removedContent = originalContent;
|
||||
for (const target of sameFile) {
|
||||
const patchTarget = buildPatchTarget(target);
|
||||
if (!patchTarget) {
|
||||
throw new Error(`Timeline element ${target.id} is missing a patchable target`);
|
||||
}
|
||||
|
||||
const removeResponse = await fetch(
|
||||
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
||||
body: JSON.stringify({ target: patchTarget }),
|
||||
},
|
||||
);
|
||||
if (!removeResponse.ok) {
|
||||
throw new Error(`Failed to delete ${target.id} from ${targetPath}`);
|
||||
}
|
||||
|
||||
const removeData = (await removeResponse.json()) as {
|
||||
changed?: boolean;
|
||||
content?: string;
|
||||
};
|
||||
if (typeof removeData.content === "string") removedContent = removeData.content;
|
||||
}
|
||||
// Content-driven duration: shrink the composition to the furthest
|
||||
// remaining clip end, read from the post-removal SOURCE (raw
|
||||
// data-duration), so deleting the last/longest clip removes trailing
|
||||
// empty space. Measured from the source, not the store, whose
|
||||
// durations are runtime-truncated.
|
||||
const deleteContentEnd = furthestClipEndFromSource(removedContent);
|
||||
const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd);
|
||||
// Optimistically reflect the shrunk length in the readout/seek bar,
|
||||
// rolling it back if the persist below fails (see captureDurationRollback).
|
||||
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
||||
if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
|
||||
usePlayerStore.getState().setDuration(deleteContentEnd);
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
try {
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Delete timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
// remove-element already wrote the removal, so disk holds THAT — not the
|
||||
// content read at the top. Undo still goes back to the original.
|
||||
diskContent: { [targetPath]: removedContent },
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
} catch (error) {
|
||||
rollbackDuration();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const deletedKeys = new Set(sameFile.map((te) => te.key ?? te.id));
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.setElements(timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id)));
|
||||
usePlayerStore.getState().setSelectedElementId(null);
|
||||
usePlayerStore.getState().setSelectedElementIds(new Set());
|
||||
forceReloadSdkSession?.();
|
||||
reloadPreview();
|
||||
showToast(
|
||||
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
||||
"info",
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
projectIdRef,
|
||||
recordEdit,
|
||||
showToast,
|
||||
timelineElements,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
/** Single-clip delete — the context menu and clip chrome path. */
|
||||
const handleTimelineElementDelete = useCallback(
|
||||
async (element: TimelineElement) => {
|
||||
await handleTimelineElementsDelete([element]);
|
||||
},
|
||||
[handleTimelineElementsDelete],
|
||||
);
|
||||
|
||||
return { handleTimelineElementsDelete, handleTimelineElementDelete };
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
// fallow-ignore-file complexity
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { useRazorSplit } from "./useRazorSplit";
|
||||
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
||||
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
||||
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
||||
import {
|
||||
applyTimelineStackingReorder,
|
||||
buildPatchTarget,
|
||||
patchIframeDomTiming,
|
||||
playbackStartAttributeForElement,
|
||||
persistTimelineEdit,
|
||||
@@ -28,6 +22,8 @@ import {
|
||||
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
||||
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
||||
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
||||
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
||||
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
||||
import {
|
||||
useAudioGroupCarveAssignment,
|
||||
useTimelineElementVisibilityEditing,
|
||||
@@ -38,7 +34,6 @@ import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
||||
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
||||
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
||||
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
||||
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
|
||||
type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
|
||||
stackingReorder?: TimelineStackingReorderIntent | null;
|
||||
@@ -402,6 +397,18 @@ export function useTimelineEditing({
|
||||
isRecordingRef,
|
||||
});
|
||||
|
||||
const setElementFxAttribute = useSetElementAttribute({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
});
|
||||
|
||||
const setAudioGroupAttribute = useSetAudioGroupAttribute({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
@@ -414,131 +421,19 @@ export function useTimelineEditing({
|
||||
isRecordingRef,
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineElementsDelete = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (selection: TimelineElement[]) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
const [element] = selection;
|
||||
if (!element) return;
|
||||
const label =
|
||||
selection.length === 1 ? getTimelineElementLabel(element) : `${selection.length} clips`;
|
||||
|
||||
// One file per delete pass. Every element in a marquee selection lives in
|
||||
// the composition being edited, so they share a target; anything that
|
||||
// does not is dropped rather than written to the wrong file.
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const sameFile = selection.filter(
|
||||
(candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
|
||||
);
|
||||
try {
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
|
||||
// Remove every selected element before saving once. The server rewrites
|
||||
// the file per call, so `removedContent` after the last one holds them
|
||||
// all — which is what makes this a single history entry, and a single
|
||||
// undo, rather than one per clip.
|
||||
let removedContent = originalContent;
|
||||
for (const target of sameFile) {
|
||||
const patchTarget = buildPatchTarget(target);
|
||||
if (!patchTarget) {
|
||||
throw new Error(`Timeline element ${target.id} is missing a patchable target`);
|
||||
}
|
||||
|
||||
const removeResponse = await fetch(
|
||||
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
||||
body: JSON.stringify({ target: patchTarget }),
|
||||
},
|
||||
);
|
||||
if (!removeResponse.ok) {
|
||||
throw new Error(`Failed to delete ${target.id} from ${targetPath}`);
|
||||
}
|
||||
|
||||
const removeData = (await removeResponse.json()) as {
|
||||
changed?: boolean;
|
||||
content?: string;
|
||||
};
|
||||
if (typeof removeData.content === "string") removedContent = removeData.content;
|
||||
}
|
||||
// Content-driven duration: shrink the composition to the furthest
|
||||
// remaining clip end, read from the post-removal SOURCE (raw
|
||||
// data-duration), so deleting the last/longest clip removes trailing
|
||||
// empty space. Measured from the source, not the store, whose
|
||||
// durations are runtime-truncated.
|
||||
const deleteContentEnd = furthestClipEndFromSource(removedContent);
|
||||
const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd);
|
||||
// Optimistically reflect the shrunk length in the readout/seek bar,
|
||||
// rolling it back if the persist below fails (see captureDurationRollback).
|
||||
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
||||
if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
|
||||
usePlayerStore.getState().setDuration(deleteContentEnd);
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
try {
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Delete timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
// remove-element already wrote the removal, so disk holds THAT — not the
|
||||
// content read at the top. Undo still goes back to the original.
|
||||
diskContent: { [targetPath]: removedContent },
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
} catch (error) {
|
||||
rollbackDuration();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const deletedKeys = new Set(sameFile.map((te) => te.key ?? te.id));
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.setElements(timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id)));
|
||||
usePlayerStore.getState().setSelectedElementId(null);
|
||||
usePlayerStore.getState().setSelectedElementIds(new Set());
|
||||
forceReloadSdkSession?.();
|
||||
reloadPreview();
|
||||
showToast(
|
||||
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
||||
"info",
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
showToast,
|
||||
timelineElements,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
/** Single-clip delete — the context menu and clip chrome path. */
|
||||
const handleTimelineElementDelete = useCallback(
|
||||
async (element: TimelineElement) => {
|
||||
await handleTimelineElementsDelete([element]);
|
||||
},
|
||||
[handleTimelineElementsDelete],
|
||||
);
|
||||
const { handleTimelineElementsDelete, handleTimelineElementDelete } = useTimelineDeleteOps({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
previewIframeRef,
|
||||
});
|
||||
|
||||
const { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop } =
|
||||
useTimelineAssetDropOps({
|
||||
@@ -586,6 +481,7 @@ export function useTimelineEditing({
|
||||
handleToggleElementHidden,
|
||||
handleAutoGroupCarveSources,
|
||||
setAudioGroupAttribute,
|
||||
setElementFxAttribute,
|
||||
handleTimelineElementDelete,
|
||||
handleTimelineElementsDelete,
|
||||
handleTimelineElementSplit: handleRazorSplit,
|
||||
|
||||
Reference in New Issue
Block a user