diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index edb539fc53..6a33c90254 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -41,6 +41,9 @@ "packages/studio/src/components/nle/TimelineEditorNotice.tsx", // Zoom hook extracted for downstream razor-blade PRs (#1330, #1331). "packages/studio/src/player/components/useTimelineZoom.ts", + // Cached O(1) GSAP target lookup, replacing O(n²) inline checks. + // Consumers migrate in a follow-up once useDomGeometryCommits adopts it. + "packages/studio/src/hooks/gsapTargetCache.ts", // Preview helper consumed dynamically from the studio iframe bridge. "packages/studio/src/hooks/gsapRuntimePreview.ts", ], @@ -151,6 +154,19 @@ "file": "packages/studio/src/player/components/timelineCallbacks.ts", "exports": ["*"], }, + // gsapTargetCache: cached O(1) GSAP target lookup, consumed by + // useDomEditCommits and intended to replace the local copy in + // useDomGeometryCommits once callers migrate. + { + "file": "packages/studio/src/hooks/gsapTargetCache.ts", + "exports": ["isElementGsapTargeted"], + }, + // Re-exports from useDomEditCommits: barrel-style re-exports + // consumed by downstream studio code. + { + "file": "packages/studio/src/hooks/useDomEditCommits.ts", + "exports": ["GSAP_CSS_FALLBACK_BLOCKED_MESSAGE", "PersistDomEditOperations"], + }, { "file": "packages/studio/src/utils/timelineElementSplit.ts", "exports": ["buildPatchTarget", "readFileContent"], diff --git a/packages/core/package.json b/packages/core/package.json index a540193cfe..7e438730ba 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,6 +35,10 @@ "types": "./src/compiler/index.ts" }, "./runtime": "./dist/hyperframe.runtime.iife.js", + "./runtime/clipTree": { + "import": "./src/runtime/clipTree.ts", + "types": "./src/runtime/clipTree.ts" + }, "./runtime/lottie-readiness": { "import": "./src/lottieReadiness.ts", "types": "./src/lottieReadiness.ts" diff --git a/packages/core/src/runtime/clipTree.ts b/packages/core/src/runtime/clipTree.ts new file mode 100644 index 0000000000..0bf87c66d1 --- /dev/null +++ b/packages/core/src/runtime/clipTree.ts @@ -0,0 +1,126 @@ +/** + * window.__clipTree — hierarchical clip tree for Studio. + * + * Maps every timed element to a node so Studio can derive parent/child + * relationships for inline timeline expansion. Read-only: timing edits go + * through the host's normal save → reloadPreview path, not a DOM patch here. + * + * ponytail: intentionally minimal. Node carries only what consumers read + * (id/parentId/children) plus the backing element. Add fields (label, kind, + * absolute start) when a caller needs them. + */ + +import type { RuntimeTimelineLike } from "./types"; + +export interface ClipNode { + readonly id: string; + readonly element: Element; + readonly parentId: string | null; + readonly children: readonly ClipNode[]; +} + +export interface ClipTree { + readonly roots: readonly ClipNode[]; +} + +// Mutable shape used only while building; the public ClipTree exposes it as +// readonly so Studio consumers can't accidentally mutate the live tree. +type MutableClipNode = { + id: string; + element: Element; + parentId: string | null; + children: MutableClipNode[]; +}; + +const DECORATIVE_TAGS = new Set(["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"]); + +interface StartResolverLike { + resolveStartForElement: (element: Element, fallback?: number) => number; +} + +function parseNum(value: string | null): number | null { + if (value == null) return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +function durationFromTimeline( + el: Element, + registry: Record, +): number | null { + const compId = el.getAttribute("data-composition-id"); + if (!compId) return null; + const d = Number(registry[compId]?.duration?.()); + return Number.isFinite(d) && d > 0 ? d : null; +} + +function durationFromMedia(el: Element): number | null { + if (!(el instanceof HTMLMediaElement) || !Number.isFinite(el.duration)) return null; + const mediaStart = + parseNum(el.getAttribute("data-playback-start")) ?? + parseNum(el.getAttribute("data-media-start")) ?? + 0; + return el.duration > mediaStart ? el.duration - mediaStart : null; +} + +// Used only to filter out zero-duration (decorative) elements at build time. +function resolveDuration( + el: Element, + timelineRegistry: Record, + rootDuration: number, + absoluteStart: number, +): number { + const attr = parseNum(el.getAttribute("data-duration")); + if (attr != null && attr > 0) return attr; + return ( + durationFromTimeline(el, timelineRegistry) ?? + durationFromMedia(el) ?? + Math.max(0, rootDuration - absoluteStart) + ); +} + +function linkParentChild(elementToNode: Map): void { + for (const [el, node] of elementToNode) { + let cursor = el.parentElement; + while (cursor) { + const parentNode = elementToNode.get(cursor); + if (parentNode) { + node.parentId = parentNode.id; + parentNode.children.push(node); + break; + } + cursor = cursor.parentElement; + } + } +} + +export function createClipTree(params: { + startResolver: StartResolverLike; + timelineRegistry: Record; + rootDuration: number; +}): ClipTree { + const { startResolver, timelineRegistry, rootDuration } = params; + const elementToNode = new Map(); + + const root = document.querySelector("[data-composition-id]"); + let ordinal = 0; + + for (const el of document.querySelectorAll("[data-start]")) { + if (el === root || DECORATIVE_TAGS.has(el.tagName)) continue; + const absoluteStart = startResolver.resolveStartForElement(el, 0); + if (resolveDuration(el, timelineRegistry, rootDuration, absoluteStart) <= 0) continue; + const node: MutableClipNode = { + id: (el as HTMLElement).id || `__clip-${ordinal++}`, + element: el, + parentId: null, + children: [], + }; + elementToNode.set(el, node); + } + + linkParentChild(elementToNode); + + return { + roots: Array.from(elementToNode.values()).filter((n) => n.parentId === null), + }; +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index d7cd75d4df..8ee49183c1 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -20,6 +20,7 @@ import { createRuntimePlayer } from "./player"; import { createRuntimeState } from "./state"; import { collectRuntimeTimelinePayload } from "./timeline"; import { createRuntimeStartTimeResolver } from "./startResolver"; +import { createClipTree } from "./clipTree"; import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader"; import { applyCaptionOverrides } from "./captionOverrides"; import { TransportClock } from "./clock"; @@ -1560,6 +1561,18 @@ export function initSandboxRuntimeModular(): void { }); }; + // Signature the live __clipTree was built from; rebuild only when the set of + // timed elements changes (e.g. a sub-composition finishes loading), not every + // transport tick. A plain count misses same-count swaps (one sub-comp unloads + // as another loads), so the signature keys on id+tag in document order. + let clipTreeSignature = ""; + const computeClipTreeSignature = (): string => { + let sig = ""; + for (const el of document.querySelectorAll("[data-start]")) { + sig += `${el.id}:${el.tagName}|`; + } + return sig; + }; const postTimeline = () => { sanitizeCompositionDurationAttributes(); applyCompositionSizing(); @@ -1580,6 +1593,23 @@ export function initSandboxRuntimeModular(): void { canonicalFps: state.canonicalFps, }); window.__clipManifest = payload; + + const currentSignature = computeClipTreeSignature(); + if (!window.__clipTree || clipTreeSignature !== currentSignature) { + const runtimeWindow = window as Window & { + __timelines?: Record; + }; + window.__clipTree = createClipTree({ + startResolver: createRuntimeStartTimeResolver({ + timelineRegistry: runtimeWindow.__timelines ?? {}, + includeAuthoredTimingAttrs: true, + }), + timelineRegistry: runtimeWindow.__timelines ?? {}, + rootDuration: payload.durationInFrames / state.canonicalFps, + }); + clipTreeSignature = currentSignature; + } + postRuntimeMessage(payload); scheduleRootStageLayoutDiagnostics(); }; diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index 439f9c9067..c6a306068d 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -1,6 +1,7 @@ import type { RuntimeTimelineMessage, RuntimeTimelineLike } from "./types"; import type { HyperframePickerApi } from "../inline-scripts/pickerApi"; import type { PlayerAPI } from "../core.types"; +import type { ClipTree } from "./clipTree"; type ThreeClockLike = { elapsedTime: number; @@ -29,6 +30,7 @@ declare global { __timelines: Record; __player?: PlayerAPI; __clipManifest?: RuntimeTimelineMessage; + __clipTree?: ClipTree; __playerReady?: boolean; __renderReady?: boolean; __hfRuntimeTeardown?: (() => void) | null; diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 07d7da5205..4d2887881f 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef, useState } from "react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; import { Eye, Layers, Move, X } from "../../icons/SystemIcons"; import { useStudioShellContext } from "../../contexts/StudioContext"; import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits"; @@ -111,6 +111,29 @@ export const PropertyPanel = memo(function PropertyPanel({ const cacheElementKey = element?.id ?? element?.selector ?? ""; const cacheEntry = usePlayerStore((s) => s.keyframeCache.get(cacheElementKey)); + const iframeRef = previewIframeRef ?? { current: null }; + const gsapAnimIdForMemo = element + ? (gsapAnimations?.find((a: { keyframes?: unknown }) => a.keyframes)?.id ?? + gsapAnimations?.[0]?.id ?? + null) + : null; + const gsapRuntimeValues = useMemo( + () => + element + ? readGsapRuntimeValuesForPanel(gsapAnimIdForMemo, gsapAnimations, element, iframeRef) + : null, + // eslint-disable-next-line react-hooks/exhaustive-deps -- iframeRef is stable; currentTime drives re-reads during playback + [gsapAnimIdForMemo, gsapAnimations, element, currentTime], + ); + const gsapBorderRadius = useMemo( + () => + element + ? readGsapBorderRadiusForPanel(gsapRuntimeValues, gsapAnimations, element, iframeRef) + : null, + // eslint-disable-next-line react-hooks/exhaustive-deps + [gsapRuntimeValues, gsapAnimations, element, currentTime], + ); + if (!element) { return (
@@ -194,21 +217,6 @@ export const PropertyPanel = memo(function PropertyPanel({ return gsapAnimId ?? ""; }; - // Read ALL GSAP-interpolated values at the current seek time. - const gsapRuntimeValues = readGsapRuntimeValuesForPanel( - gsapAnimId, - gsapAnimations, - element, - previewIframeRef ?? { current: null }, - ); - - const gsapBorderRadius = readGsapBorderRadiusForPanel( - gsapRuntimeValues, - gsapAnimations, - element, - previewIframeRef ?? { current: null }, - ); - const displayX = gsapRuntimeValues?.x ?? manualOffset.x; const displayY = gsapRuntimeValues?.y ?? manualOffset.y; const displayW = gsapRuntimeValues?.width ?? resolvedWidth; diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 6a67ab97f7..29a94d818a 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -73,7 +73,7 @@ export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag( export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag( env, ["VITE_STUDIO_ENABLE_RAZOR_TOOL", "VITE_STUDIO_RAZOR_TOOL_ENABLED"], - false, + true, ); // When disabled (the default), drag/resize/rotate commits always take the CSS diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 0a221f9a0c..8f67172b61 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -10,10 +10,13 @@ import { import { useMountEffect } from "../../hooks/useMountEffect"; import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player"; import type { TimelineElement } from "../../player"; +import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; import { NLEPreview } from "./NLEPreview"; import { CompositionBreadcrumb } from "./CompositionBreadcrumb"; import { usePreviewBlockDrop } from "./usePreviewBlockDrop"; import { useCompositionStack } from "./useCompositionStack"; +import { useTimelineEditContext } from "../../contexts/TimelineEditContext"; +import { trackStudioExpandedClipEdit } from "../../telemetry/events"; import { TIMELINE_TOGGLE_SHORTCUT_LABEL, getTimelineToggleTitle, @@ -58,6 +61,7 @@ interface NLELayoutProps { blockName: string, position: { left: number; top: number }, ) => Promise | void; + onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectTimelineElement?: (element: TimelineElement | null) => void; /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ onCompIdToSrcChange?: (map: Map) => void; @@ -103,6 +107,7 @@ export const NLELayout = memo(function NLELayout({ onAssetDrop, onBlockDrop, onPreviewBlockDrop, + onBlockedEditAttempt, onSelectTimelineElement, onCompIdToSrcChange, timelineVisible, @@ -175,6 +180,7 @@ export const NLELayout = memo(function NLELayout({ const handleDrillDown = useCallback( (element: TimelineElement) => { if (!element.compositionSrc) return; + usePlayerStore.getState().setSelectedElementId(null); // Check compIdToSrc map first; then scan iframe DOM; then fall through to drillDown const compId = element.id; let resolvedPath = compIdToSrc.get(compId); @@ -202,6 +208,73 @@ export const NLELayout = memo(function NLELayout({ [compIdToSrc, drillDown, iframeRef_], ); + // Move/resize/split come from the timeline edit context, not props — the + // wrappers below intercept expanded clips and must call the *real* handlers. + // (Delete is a direct prop; it stays that way.) + const { onMoveElement, onResizeElement, onSplitElement } = useTimelineEditContext(); + + // An expanded sub-comp child reaches the normal edit handlers in its own + // local coordinates: addressed by its real DOM id, with timeline time rebased + // onto the sub-comp it lives in. The handlers then save + reloadPreview exactly + // as they do for top-level clips — no separate live-DOM path. + const toLocalElement = useCallback( + (element: TimelineElement, basis: number): TimelineElement => ({ + ...element, + id: element.domId ?? element.id, + start: element.start - basis, + }), + [], + ); + + const handleMoveElement = useCallback( + (element: TimelineElement, updates: Pick) => { + const basis = element.expandedParentStart; + if (basis === undefined) return onMoveElement?.(element, updates); + trackStudioExpandedClipEdit({ action: "move" }); + onMoveElement?.(toLocalElement(element, basis), { + ...updates, + start: Math.max(0, updates.start - basis), + }); + }, + [onMoveElement, toLocalElement], + ); + + const handleResizeElement = useCallback( + ( + element: TimelineElement, + updates: Pick, + ) => { + const basis = element.expandedParentStart; + if (basis === undefined) return onResizeElement?.(element, updates); + trackStudioExpandedClipEdit({ action: "resize" }); + onResizeElement?.(toLocalElement(element, basis), { + ...updates, + start: Math.max(0, updates.start - basis), + }); + }, + [onResizeElement, toLocalElement], + ); + + const handleDeleteElement = useCallback( + (element: TimelineElement) => { + const basis = element.expandedParentStart; + if (basis === undefined) return onDeleteElement?.(element); + trackStudioExpandedClipEdit({ action: "delete" }); + return onDeleteElement?.(toLocalElement(element, basis)); + }, + [onDeleteElement, toLocalElement], + ); + + const handleSplitElement = useCallback( + (element: TimelineElement, splitTime: number) => { + const basis = element.expandedParentStart; + if (basis === undefined) return onSplitElement?.(element, splitTime); + trackStudioExpandedClipEdit({ action: "split" }); + return onSplitElement?.(toLocalElement(element, basis), Math.max(0, splitTime - basis)); + }, + [onSplitElement, toLocalElement], + ); + // Composition ID → file path map from raw index.html const compIdToSrcRef = useRef(compIdToSrc); compIdToSrcRef.current = compIdToSrc; @@ -356,6 +429,17 @@ export const NLELayout = memo(function NLELayout({
{ + const el = iframeRef.current?.parentElement ?? iframeRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const inside = + e.clientX >= rect.left && + e.clientX <= rect.right && + e.clientY >= rect.top && + e.clientY <= rect.bottom; + if (!inside) onSelectTimelineElement?.(null); + }} onDragOver={handlePreviewDragOver} onDragLeave={handlePreviewDragLeave} onDrop={handlePreviewDrop} @@ -429,9 +513,13 @@ export const NLELayout = memo(function NLELayout({ onDrillDown={handleDrillDown} renderClipContent={renderClipContent} onFileDrop={onFileDrop} - onDeleteElement={onDeleteElement} + onDeleteElement={handleDeleteElement} onAssetDrop={onAssetDrop} onBlockDrop={onBlockDrop} + onMoveElement={handleMoveElement} + onResizeElement={handleResizeElement} + onBlockedEditAttempt={onBlockedEditAttempt} + onSplitElement={handleSplitElement} onSelectElement={onSelectTimelineElement} />
diff --git a/packages/studio/src/hooks/gsapScriptCommitHelpers.ts b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts index 4e8da55b45..d84c698608 100644 --- a/packages/studio/src/hooks/gsapScriptCommitHelpers.ts +++ b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts @@ -37,6 +37,13 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +export function formatFieldsSuffix(rawFields: unknown): string { + const fields = Array.isArray(rawFields) + ? rawFields.filter((f): f is string => typeof f === "string") + : []; + return fields.length > 0 ? ` (${fields.join(", ")})` : ""; +} + export async function readJsonResponseBody(res: Response): Promise { const contentType = res.headers.get("content-type") ?? ""; if (!contentType.includes("application/json")) { @@ -55,14 +62,10 @@ function formatGsapMutationHttpErrorMessage(statusCode: number, body: unknown): export function formatGsapMutationRejectionToast(error: GsapMutationHttpError): string { const body = error.responseBody; if (isRecord(body)) { - const fields = Array.isArray(body.fields) - ? body.fields.filter((field): field is string => typeof field === "string") - : []; - const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : ""; return `Couldn't save animation: ${formatGsapMutationHttpErrorMessage( error.statusCode, body, - )}${suffix}`; + )}${formatFieldsSuffix(body.fields)}`; } return `Couldn't save animation: ${error.message}`; } diff --git a/packages/studio/src/hooks/gsapTargetCache.ts b/packages/studio/src/hooks/gsapTargetCache.ts new file mode 100644 index 0000000000..68d2c3bc2d --- /dev/null +++ b/packages/studio/src/hooks/gsapTargetCache.ts @@ -0,0 +1,65 @@ +import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability"; + +type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> }; + +let _gsapCachedTimelines: Record | undefined; +let _gsapTargetIds: Set | undefined; +let _gsapTargetNodes: WeakSet | undefined; + +function addTargetsFromTimeline(tl: TimelineLike, ids: Set, nodes: WeakSet): void { + const children = tl.getChildren?.(true); + if (!children) return; + for (const child of children) { + const targets = child.targets?.(); + if (!targets) continue; + for (const t of targets) { + nodes.add(t); + if (t.id) ids.add(t.id); + } + } +} + +function collectGsapTargets(timelines: Record): { + ids: Set; + nodes: WeakSet; +} { + const ids = new Set(); + const nodes = new WeakSet(); + for (const tl of Object.values(timelines)) { + if (!tl) continue; + try { + addTargetsFromTimeline(tl, ids, nodes); + } catch { + /* teardown race */ + } + } + return { ids, nodes }; +} + +function readTimelines(iframe: HTMLIFrameElement | null): Record | undefined { + if (!iframe?.contentWindow) return undefined; + try { + return (iframe.contentWindow as Window & { __timelines?: Record }) + .__timelines; + } catch { + return undefined; + } +} + +export function isElementGsapTargeted( + iframe: HTMLIFrameElement | null, + element: HTMLElement, +): boolean { + if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false; + const timelines = readTimelines(iframe); + if (!timelines) return false; + + if (timelines !== _gsapCachedTimelines) { + const cache = collectGsapTargets(timelines); + _gsapTargetIds = cache.ids; + _gsapTargetNodes = cache.nodes; + _gsapCachedTimelines = timelines; + } + + return _gsapTargetNodes!.has(element) || !!(element.id && _gsapTargetIds!.has(element.id)); +} diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 526c05c41f..c62b9f20cd 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -136,6 +136,7 @@ interface HotkeyCallbacks { onToggleRecording?: () => void; leftSidebarRef: React.RefObject; domEditSelectionRef: React.MutableRefObject; + showToast: (message: string, tone?: "error" | "info") => void; } function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): boolean { @@ -205,6 +206,14 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks void cb.handleTimelineElementSplit(el, currentTime); return; } + // Expanded sub-comp children carry a qualified `sourceFile#id` selection + // that isn't in the raw `elements` list, so the s-key can't resolve them. + // Nudge toward the razor tool instead of failing silently. + if (!el && selectedElementId.includes("#")) { + event.preventDefault(); + cb.showToast("Use the razor tool (B) to split clips inside a sub-composition", "info"); + return; + } } } @@ -376,6 +385,7 @@ export function useAppHotkeys({ onToggleRecording, leftSidebarRef, domEditSelectionRef, + showToast, }; // ── Keydown dispatch ── diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 0129cd761f..55460128eb 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -14,6 +14,7 @@ import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit"; import { useDomEditTextCommits } from "./useDomEditTextCommits"; import { useDomGeometryCommits } from "./useDomGeometryCommits"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; +import { formatFieldsSuffix } from "./gsapScriptCommitHelpers"; // ── Helpers ── @@ -31,14 +32,7 @@ async function readErrorResponseBody( function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string { if (!body?.error) return "Couldn't save edit"; - // Pre-existing clone of the GSAP save-error formatter (gsapScriptCommitHelpers); - // surfaced here by this PR's adjacent edits, not introduced by it. - // fallow-ignore-next-line code-duplication - const fields = Array.isArray(body.fields) - ? body.fields.filter((field): field is string => typeof field === "string") - : []; - const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : ""; - return `Couldn't save edit: ${body.error}${suffix}`; + return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`; } interface RecordEditInput { diff --git a/packages/studio/src/hooks/useDomGeometryCommits.ts b/packages/studio/src/hooks/useDomGeometryCommits.ts index be8e03c4a9..1997b11ed6 100644 --- a/packages/studio/src/hooks/useDomGeometryCommits.ts +++ b/packages/studio/src/hooks/useDomGeometryCommits.ts @@ -1,5 +1,4 @@ import { useCallback } from "react"; -import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability"; import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing"; import { applyStudioPathOffset, @@ -19,45 +18,11 @@ import { } from "../components/editor/manualEditsDomPatches"; import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay"; import type { PatchOperation } from "../utils/sourcePatcher"; +import { isElementGsapTargeted } from "./gsapTargetCache"; export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE = "This element is GSAP-animated — dragging via CSS would corrupt keyframes"; -// ── Helpers ── - -type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> }; - -// fallow-ignore-next-line complexity -function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean { - // When the GSAP drag intercept is disabled for debugging, treat every - // element as un-targeted so commits take the plain CSS persist path. - if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false; - if (!iframe?.contentWindow) return false; - let timelines: Record | undefined; - try { - timelines = (iframe.contentWindow as Window & { __timelines?: Record }) - .__timelines; - } catch { - return false; - } - if (!timelines) return false; - const id = element.id; - for (const tl of Object.values(timelines)) { - if (!tl?.getChildren) continue; - try { - for (const child of tl.getChildren(true)) { - if (!child.targets) continue; - for (const t of child.targets()) { - if (t === element || (id && t.id === id)) return true; - } - } - } catch { - continue; - } - } - return false; -} - // ── Hook ── interface UseDomGeometryCommitsParams { diff --git a/packages/studio/src/hooks/useRazorSplit.ts b/packages/studio/src/hooks/useRazorSplit.ts index 99870a02b2..0bf0fb2a9f 100644 --- a/packages/studio/src/hooks/useRazorSplit.ts +++ b/packages/studio/src/hooks/useRazorSplit.ts @@ -3,6 +3,7 @@ import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers"; +import { trackStudioRazorSplit } from "../telemetry/events"; import { canSplitElement, buildPatchTarget, @@ -196,6 +197,7 @@ export function useRazorSplit({ }); reloadPreview(); + trackStudioRazorSplit({ mode: "single", count: 1 }); showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info"); if (skippedSelectors?.length) { showToast( @@ -277,6 +279,7 @@ export function useRazorSplit({ }); reloadPreview(); + trackStudioRazorSplit({ mode: "all", count: splitCount }); showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info"); } catch (error) { const message = error instanceof Error ? error.message : "Failed to split clips"; diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 347a6907b4..b63589912d 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -3,6 +3,7 @@ import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis"; import { isMusicTrack } from "../../utils/timelineInspector"; import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements"; import { useMountEffect } from "../../hooks/useMountEffect"; import { EditPopover } from "./EditModal"; import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme"; @@ -28,7 +29,10 @@ import { shouldShowTimelineShortcutHint, } from "./timelineLayout"; import type { TimelineDropCallbacks } from "./timelineCallbacks"; -import { useTimelineEditContext } from "../../contexts/TimelineEditContext"; +import { + useResolvedTimelineEditCallbacks, + type TimelineEditOverrides, +} from "./useResolvedTimelineEditCallbacks"; // Re-export pure utilities so existing imports from "./Timeline" still resolve. export { @@ -45,7 +49,7 @@ export { getDefaultDroppedTrack, } from "./timelineLayout"; -interface TimelineProps extends TimelineDropCallbacks { +interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides { onSeek?: (time: number) => void; onDrillDown?: (element: TimelineElement) => void; renderClipContent?: ( @@ -67,6 +71,10 @@ export const Timeline = memo(function Timeline({ onAssetDrop, onBlockDrop, onDeleteElement: _onDeleteElement, + onMoveElement: onMoveElementOverride, + onResizeElement: onResizeElementOverride, + onBlockedEditAttempt: onBlockedEditAttemptOverride, + onSplitElement: onSplitElementOverride, onSelectElement, theme: themeOverrides, }: TimelineProps = {}) { @@ -80,14 +88,18 @@ export const Timeline = memo(function Timeline({ onDeleteAllKeyframes, onChangeKeyframeEase, onMoveKeyframe, - } = useTimelineEditContext(); + } = useResolvedTimelineEditCallbacks({ + onMoveElement: onMoveElementOverride, + onResizeElement: onResizeElementOverride, + onBlockedEditAttempt: onBlockedEditAttemptOverride, + onSplitElement: onSplitElementOverride, + }); const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]); useMusicBeatAnalysis(); - const elements = usePlayerStore((s) => s.elements); + const rawElements = usePlayerStore((s) => s.elements); + const expandedElements = useExpandedTimelineElements(); const beatAnalysis = usePlayerStore((s) => s.beatAnalysis); const musicElement = usePlayerStore((s) => s.elements.find(isMusicTrack) ?? null); - - // Merge user edits + remap beats from audio-file → composition coordinates. const beatEdits = usePlayerStore((s) => s.beatEdits); const adjustedBeatAnalysis = useMemo( () => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits), @@ -176,21 +188,21 @@ export const Timeline = memo(function Timeline({ const effectiveDuration = useMemo(() => { const safeDur = Number.isFinite(duration) ? duration : 0; - if (elements.length === 0) return safeDur; - const maxEnd = Math.max(...elements.map((el) => el.start + el.duration)); + if (rawElements.length === 0) return safeDur; + const maxEnd = Math.max(...rawElements.map((el) => el.start + el.duration)); const result = Math.max(safeDur, maxEnd); return Number.isFinite(result) ? result : safeDur; - }, [elements, duration]); + }, [rawElements, duration]); const tracks = useMemo(() => { - const map = new Map(); - for (const el of elements) { + const map = new Map(); + for (const el of expandedElements) { const list = map.get(el.track) ?? []; list.push(el); map.set(el.track, list); } return Array.from(map.entries()).sort(([a], [b]) => a - b); - }, [elements]); + }, [expandedElements]); const trackStyles = useMemo(() => { const map = new Map(); @@ -247,8 +259,9 @@ export const Timeline = memo(function Timeline({ const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); const selectedElement = useMemo( - () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null, - [elements, selectedElementId], + () => + expandedElements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null, + [expandedElements, selectedElementId], ); const selectedElementRef = useRef(selectedElement); selectedElementRef.current = selectedElement; @@ -283,7 +296,7 @@ export const Timeline = memo(function Timeline({ effectiveDuration, pps, timelineReady, - elementsLength: elements.length, + elementsLength: expandedElements.length, setZoomMode, setManualZoomPercent, onSeek, @@ -332,7 +345,7 @@ export const Timeline = memo(function Timeline({ useEffect(() => { syncShortcutHintVisibility(); - }, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]); + }, [syncShortcutHintVisibility, timelineReady, expandedElements.length, totalH]); const getPreviewElement = useCallback( (element: TimelineElement): TimelineElement => { @@ -362,7 +375,7 @@ export const Timeline = memo(function Timeline({ onBlockDrop, }); - if (!timelineReady || elements.length === 0) { + if (!timelineReady || expandedElements.length === 0) { return ( { - const el = elements.find((x) => (x.key ?? x.id) === elId); + const el = expandedElements.find((x) => (x.key ?? x.id) === elId); if (el) { setSelectedElementId(elId); onSelectElement?.(el); diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index 07b2e0d373..914b2da2ef 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -102,7 +102,7 @@ export const TimelineClip = memo(function TimelineClip({ onDoubleClick={onDoubleClick} onContextMenu={onContextMenu} > - {/* Left accent stripe */} + {/* Left accent stripe — wider + brighter for expanded sub-comp children */}