Files
hyperframes/packages/studio/src/hooks/useTimelineDeleteOps.ts
T
Vance IngallsandClaude Sonnet 5 6a92d21401 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>
2026-08-21 12:01:34 -07:00

171 lines
7.0 KiB
TypeScript

// 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 };
}