feat(studio,core): reach presets and the rack from the timeline

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:
Vance Ingalls
2026-08-20 16:39:31 -07:00
co-authored by Claude Sonnet 5
parent 301cb8b51f
commit 1d5405e3e0
22 changed files with 963 additions and 82 deletions
@@ -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 };
}
@@ -28,6 +28,7 @@ import {
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
import { useSetElementAttribute } from "./timelineElementFxAttribute";
import {
useAudioGroupCarveAssignment,
useTimelineElementVisibilityEditing,
@@ -402,6 +403,18 @@ export function useTimelineEditing({
isRecordingRef,
});
const setElementFxAttribute = useSetElementAttribute({
projectIdRef,
activeCompPath,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
previewIframeRef,
pendingTimelineEditPathRef,
isRecordingRef,
});
const setAudioGroupAttribute = useSetAudioGroupAttribute({
projectIdRef,
activeCompPath,
@@ -586,6 +599,7 @@ export function useTimelineEditing({
handleToggleElementHidden,
handleAutoGroupCarveSources,
setAudioGroupAttribute,
setElementFxAttribute,
handleTimelineElementDelete,
handleTimelineElementsDelete,
handleTimelineElementSplit: handleRazorSplit,