diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 6be062963..ed17525c1 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -1,12 +1,4 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type MutableRefObject, - type PointerEvent as ReactPointerEvent, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from "react"; import { PropertyPanel } from "./editor/PropertyPanel"; import { LayersPanel } from "./editor/LayersPanel"; import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel"; @@ -39,9 +31,7 @@ import { } from "./studioColorGradingScope"; import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes"; import { timelineKeysForSelections, type ToggleHiddenHandler } from "../utils/studioHelpers"; - -const MIN_INSPECTOR_SPLIT_PERCENT = 20; -const MAX_INSPECTOR_SPLIT_PERCENT = 75; +import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize"; export interface StudioRightPanelProps { designPanelActive: boolean; @@ -115,6 +105,7 @@ export function StudioRightPanel({ handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, + handleDomAttributesCommit, handleDomPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, @@ -184,13 +175,13 @@ export function StudioRightPanel({ coalesceKey: activeCompPath ? `slideshow-notes:${activeCompPath}` : "slideshow-notes", }); - const [layersPanePercent, setLayersPanePercent] = useState(40); - const splitContainerRef = useRef(null); - const splitDragRef = useRef<{ - startY: number; - startPercent: number; - height: number; - } | null>(null); + const { + layersPanePercent, + splitContainerRef, + handleInspectorSplitResizeStart, + handleInspectorSplitResizeMove, + handleInspectorSplitResizeEnd, + } = useInspectorSplitResize(); const backgroundRemovalAbortRef = useRef(null); useEffect( @@ -231,35 +222,6 @@ export function StudioRightPanel({ toggleRightInspectorPane(pane); }; - const handleInspectorSplitResizeStart = useCallback( - (event: ReactPointerEvent) => { - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - const height = splitContainerRef.current?.getBoundingClientRect().height ?? 0; - splitDragRef.current = { - startY: event.clientY, - startPercent: layersPanePercent, - height, - }; - }, - [layersPanePercent], - ); - - const handleInspectorSplitResizeMove = useCallback((event: ReactPointerEvent) => { - const drag = splitDragRef.current; - if (!drag || drag.height <= 0) return; - const deltaPercent = ((event.clientY - drag.startY) / drag.height) * 100; - const next = Math.min( - MAX_INSPECTOR_SPLIT_PERCENT, - Math.max(MIN_INSPECTOR_SPLIT_PERCENT, drag.startPercent + deltaPercent), - ); - setLayersPanePercent(next); - }, []); - - const handleInspectorSplitResizeEnd = useCallback(() => { - splitDragRef.current = null; - }, []); - const handleApplyColorGradingScope = useCallback( async (scope: ColorGradingScope, value: string | null) => applyColorGradingScopeUpdate({ @@ -375,6 +337,7 @@ export function StudioRightPanel({ onUngroup={handleUngroupSelection} onSetStyle={handleDomStyleCommit} onSetAttribute={handleDomAttributeCommit} + onSetAttributes={handleDomAttributesCommit} onSetAttributeLive={handleDomAttributeLiveCommit} onApplyColorGradingScope={handleApplyColorGradingScope} onSetHtmlAttribute={handleDomHtmlAttributeCommit} diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index fe914267d..ac90cfba7 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -77,6 +77,7 @@ export function PropertyPanelFlat({ onUngroup, onSetStyle, onSetAttribute, + onSetAttributes, onSetAttributeLive, onApplyColorGradingScope, onSetHtmlAttribute, @@ -145,6 +146,7 @@ export function PropertyPanelFlat({ | "onUngroup" | "onSetStyle" | "onSetAttribute" + | "onSetAttributes" | "onSetAttributeLive" | "onApplyColorGradingScope" | "onSetHtmlAttribute" @@ -440,6 +442,7 @@ export function PropertyPanelFlat({ multipleTimelines={gsapMultipleTimelines} unsupportedTimelinePattern={gsapUnsupportedTimelinePattern} onSetAttribute={onSetAttribute} + onSetAttributes={onSetAttributes} {...(gsapEffectHandlers ?? EMPTY_GSAP_EFFECT_HANDLERS)} /> ), diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx index 2f5a47357..60eb2458a 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx @@ -101,6 +101,59 @@ describe("FlatTimingRow", () => { expect(onSetAttribute).toHaveBeenCalledWith("start", "10.00"); act(() => root.unmount()); }); + + it("pins an inferred range through ONE atomic onSetAttributes call when provided, instead of two sequential onSetAttribute calls", async () => { + const onSetAttribute = vi.fn(); + const onSetAttributes = vi.fn().mockResolvedValue(undefined); + const element = baseElement({ dataAttributes: { start: "0", duration: "0" } }); + const { host, root } = renderInto( + , + ); + // Range is inferred (start=2, duration=3) — editing Start alone must pin + // the WHOLE range (both attrs), not just data-start. + const startInput = host.querySelectorAll("input")[0]; + if (!startInput) throw new Error("expected a Start input"); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + await act(async () => { + setter.call(startInput, "5s"); + startInput.dispatchEvent(new Event("input", { bubbles: true })); + startInput.dispatchEvent(new Event("focusout", { bubbles: true })); + await Promise.resolve(); + }); + expect(onSetAttributes).toHaveBeenCalledTimes(1); + expect(onSetAttributes).toHaveBeenCalledWith(element, { start: "5.00", duration: "3.00" }); + expect(onSetAttribute).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("falls back to two sequential onSetAttribute calls to pin an inferred range when onSetAttributes is not provided", async () => { + const onSetAttribute = vi.fn().mockResolvedValue(undefined); + const element = baseElement({ dataAttributes: { start: "0", duration: "0" } }); + const { host, root } = renderInto( + , + ); + const startInput = host.querySelectorAll("input")[0]; + if (!startInput) throw new Error("expected a Start input"); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + await act(async () => { + setter.call(startInput, "5s"); + startInput.dispatchEvent(new Event("input", { bubbles: true })); + startInput.dispatchEvent(new Event("focusout", { bubbles: true })); + await Promise.resolve(); + }); + expect(onSetAttribute).toHaveBeenNthCalledWith(1, "start", "5.00"); + expect(onSetAttribute).toHaveBeenNthCalledWith(2, "duration", "3.00"); + act(() => root.unmount()); + }); }); describe("FlatMotionSection", () => { diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx index 83c17cf6b..f457979c6 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx @@ -13,10 +13,17 @@ export function FlatTimingRow({ element, animations = [], onSetAttribute, + onSetAttributes, }: { element: DomEditSelection; animations?: GsapAnimation[]; onSetAttribute: (attr: string, value: string) => void | Promise; + /** Commits start+duration together in ONE atomic persist call, bound to + * THIS render's `element` explicitly — not whatever is "currently" + * selected by the time the call resolves. Falls back to two sequential + * `onSetAttribute` calls (with the same non-atomicity/misdirection risk + * documented below) when the caller doesn't wire it up. */ + onSetAttributes?: (selection: DomEditSelection, attrs: Record) => Promise; }) { const { start, duration, inferred: derived } = deriveElementTiming(element, animations); const end = start + duration; @@ -25,10 +32,19 @@ export function FlatTimingRow({ // WHOLE displayed range: writing only data-duration flips inference off and // drops start to data-start-or-0 (the clip silently shifts), and writing only // data-start is ignored while duration is still inferred (the edit looks - // dead). Pin both attributes, sequentially, so the display never jumps. + // dead). Pin both attributes in ONE atomic commit bound to THIS element — + // two sequential `onSetAttribute` calls would each resolve `domEditSelection` + // fresh from current hook state, so a selection change between the two + // awaits could misdirect the second write at the newly-selected element, and + // a failure of just the second call would leave the pair half-applied. const pinRange = async (nextStart: number, nextDuration: number) => { - await onSetAttribute("start", nextStart.toFixed(2)); - await onSetAttribute("duration", nextDuration.toFixed(2)); + const attrs = { start: nextStart.toFixed(2), duration: nextDuration.toFixed(2) }; + if (onSetAttributes) { + await onSetAttributes(element, attrs); + return; + } + await onSetAttribute("start", attrs.start); + await onSetAttribute("duration", attrs.duration); }; const commitStart = (nextValue: string) => { @@ -92,6 +108,7 @@ export function FlatMotionSection({ multipleTimelines, unsupportedTimelinePattern, onSetAttribute, + onSetAttributes, onAddAnimation, ...callbacks }: { @@ -102,6 +119,7 @@ export function FlatMotionSection({ multipleTimelines?: boolean; unsupportedTimelinePattern?: boolean; onSetAttribute: (attr: string, value: string) => void | Promise; + onSetAttributes?: (selection: DomEditSelection, attrs: Record) => Promise; onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; } & GsapAnimationEditCallbacks) { const [addMenuOpen, setAddMenuOpen] = useState(false); @@ -109,7 +127,12 @@ export function FlatMotionSection({ return (
{showTiming && ( - + )} {showEffects && ( <> diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index fabb286a3..7e4483436 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -553,6 +553,39 @@ describe("FlatSlider", () => { expect(track.hasPointerCapture(1)).toBe(false); act(() => root.unmount()); }); + + it("a native pointercancel during a drag reverts to the pre-drag value, instead of leaving the last dragged-to position committed", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }), + }); + act(() => { + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 65, pointerId: 1 }), + ); + }); + expect(onCommit).toHaveBeenLastCalledWith(65); + act(() => { + track.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerId: 1 })); + }); + expect(onCommit).toHaveBeenLastCalledWith(10); + expect(track.getAttribute("aria-valuenow")).toBe("10"); + expect(track.hasPointerCapture(1)).toBe(false); + act(() => root.unmount()); + }); }); describe("FlatSlider — Grade extensions", () => { diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index 86205546c..d8b1752d7 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -441,11 +441,12 @@ export function FlatSlider({ commitDraft(stepped); }} onPointerCancel={(e) => { - draggingRef.current = false; - if (e.currentTarget.hasPointerCapture(e.pointerId)) { - explicitReleaseRef.current = true; - e.currentTarget.releasePointerCapture(e.pointerId); - } + // A native pointercancel means the platform aborted the gesture (a + // scroll/touch takeover, pen leaving range, etc.) — that must cancel + // the drag the same way Escape/right-click do (revert to the + // pre-drag value), not just stop dragging and leave whatever + // intermediate position the pointer last reached committed. + cancelDrag(e.currentTarget); }} onLostPointerCapture={() => { if (explicitReleaseRef.current) { diff --git a/packages/studio/src/components/editor/propertyPanelTypes.ts b/packages/studio/src/components/editor/propertyPanelTypes.ts index ba862349f..12b3cb136 100644 --- a/packages/studio/src/components/editor/propertyPanelTypes.ts +++ b/packages/studio/src/components/editor/propertyPanelTypes.ts @@ -33,6 +33,12 @@ export interface PropertyPanelProps { onUngroup?: () => void; onSetStyle: (prop: string, value: string) => void | Promise; onSetAttribute: (attr: string, value: string) => void | Promise; + /** Commits several data-* attributes on the SAME element in ONE atomic + * persist call — e.g. a pinned timing range's start+duration together, so + * a selection change or a partial failure mid-commit can't misdirect one + * of the two writes or leave them half-applied. Falls back to sequential + * `onSetAttribute` calls where omitted. */ + onSetAttributes?: (selection: DomEditSelection, attrs: Record) => Promise; onSetAttributeLive: ( attr: string, value: string | null, diff --git a/packages/studio/src/components/editor/useColorGradingController.ts b/packages/studio/src/components/editor/useColorGradingController.ts index 19921f9f1..d35d60c10 100644 --- a/packages/studio/src/components/editor/useColorGradingController.ts +++ b/packages/studio/src/components/editor/useColorGradingController.ts @@ -195,6 +195,10 @@ export function useColorGradingController({ const persistTimerRef = useRef | null>(null); const pendingPersistValueRef = useRef(undefined); const pendingPersistGradingRef = useRef(null); + // Identity the pending edit was made FOR, snapshotted at schedule time — + // read by a global flush instead of identityKeyRef.current, which may no + // longer describe this edit's element by the time the flush runs. + const pendingPersistIdentityRef = useRef(null); // The last grading value actually confirmed saved — distinct from `grading` // (the optimistic value shown immediately on commit). A rejected persist // reverts to this instead of leaving the UI permanently showing a value @@ -270,6 +274,7 @@ export function useColorGradingController({ const value = pendingPersistValueRef.current; pendingPersistValueRef.current = undefined; pendingPersistGradingRef.current = null; + pendingPersistIdentityRef.current = null; trackStudioPendingEdit(onSetAttributeLive(COLOR_GRADING_DATA_KEY, value)); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- identityKey is the intended trigger; see comment above @@ -411,8 +416,13 @@ export function useColorGradingController({ if (pendingPersistValueRef.current === undefined) return undefined; const value = pendingPersistValueRef.current; const attemptedGrading = pendingPersistGradingRef.current ?? latestGradingRef.current; + // Snapshotted at schedule time, not identityKeyRef.current read fresh + // here — an async flush could otherwise tag the attempt with whatever + // identity is "current" by then, not the one this edit was made for. + const attemptIdentityKey = pendingPersistIdentityRef.current ?? identityKeyRef.current; pendingPersistValueRef.current = undefined; pendingPersistGradingRef.current = null; + pendingPersistIdentityRef.current = null; // A flush cancels the pending debounce timer above, so this becomes the // one-and-only in-flight attempt for this element — bump the version so // it still registers as "the latest attempt" against the same guard a @@ -424,7 +434,7 @@ export function useColorGradingController({ return persistColorGradingValue( value, attemptedGrading, - identityKeyRef.current, + attemptIdentityKey, isLatestAttempt, onSetAttributeLive, ); @@ -513,6 +523,7 @@ export function useColorGradingController({ ? serializeHfColorGrading(nextGrading) : null; pendingPersistGradingRef.current = nextGrading; + pendingPersistIdentityRef.current = identityKeyRef.current; // Captured now (edit time), not read fresh inside the timer — the // timer fires 350ms later and may run after selection has already // moved on, at which point identityKeyRef.current would no longer @@ -527,6 +538,7 @@ export function useColorGradingController({ const attemptedGrading = pendingPersistGradingRef.current ?? nextGrading; pendingPersistValueRef.current = undefined; pendingPersistGradingRef.current = null; + pendingPersistIdentityRef.current = null; persistTimerRef.current = null; void persistColorGradingValue( value ?? null, diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index bd1593efe..37d461608 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -16,6 +16,7 @@ export interface DomEditActionsValue extends Pick< | "handleDomAttributeCommit" | "handleDomAttributeLiveCommit" | "handleDomHtmlAttributeCommit" + | "handleDomAttributesCommit" | "handleDomPathOffsetCommit" | "handleDomGroupPathOffsetCommit" | "handleDomZIndexReorderCommit" @@ -138,6 +139,7 @@ export function DomEditProvider({ handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, + handleDomAttributesCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomZIndexReorderCommit, @@ -224,6 +226,7 @@ export function DomEditProvider({ handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, + handleDomAttributesCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomZIndexReorderCommit, @@ -291,6 +294,7 @@ export function DomEditProvider({ handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, + handleDomAttributesCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomZIndexReorderCommit, diff --git a/packages/studio/src/hooks/timelineTrackVisibility.ts b/packages/studio/src/hooks/timelineTrackVisibility.ts index db3c9f143..f305226c9 100644 --- a/packages/studio/src/hooks/timelineTrackVisibility.ts +++ b/packages/studio/src/hooks/timelineTrackVisibility.ts @@ -63,7 +63,7 @@ interface UseTimelineTrackVisibilityEditingInput extends Omit< interface UseTimelineElementVisibilityEditingInput extends Omit< ToggleTimelineElementHiddenInput, - "projectId" | "elementKey" | "hidden" | "previewIframe" + "projectId" | "elementKey" | "hidden" | "previewIframe" | "timelineElements" > { projectIdRef: ReadonlyRef; previewIframeRef: ReadonlyRef; @@ -322,7 +322,6 @@ export function useTimelineTrackVisibilityEditing({ export function useTimelineElementVisibilityEditing({ projectIdRef, activeCompPath, - timelineElements, showToast, writeProjectFile, recordEdit, @@ -335,6 +334,15 @@ export function useTimelineElementVisibilityEditing({ elementKey: string | readonly string[], hidden: boolean, ) => Promise { + // Resolve against the EXPANDED rows, not the raw store list — a nested + // sub-composition child has no entry of its own in the raw list (only its + // host does), so an elementKey for such a child (the + // `sourceFile#domId`-shaped virtual key `resolveTimelineIdForSelection` + // falls back to) would never match anything there and Hide All would + // silently no-op for it. The expanded list synthesizes a real, patchable + // TimelineElement (with matching key/domId/sourceFile) for each visible + // child whenever its host is currently expanded. + const expandedElements = useExpandedTimelineElements(); return useCallback( async (elementKey: string | readonly string[], hidden: boolean) => { if (isRecordingRef?.current) { @@ -347,7 +355,7 @@ export function useTimelineElementVisibilityEditing({ await toggleTimelineElementHidden({ projectId: pid, activeCompPath, - timelineElements, + timelineElements: expandedElements, elementKey, hidden, previewIframe: previewIframeRef.current, @@ -366,7 +374,7 @@ export function useTimelineElementVisibilityEditing({ }, [ activeCompPath, - timelineElements, + expandedElements, previewIframeRef, writeProjectFile, recordEdit, diff --git a/packages/studio/src/hooks/useDomEditAttributeCommits.ts b/packages/studio/src/hooks/useDomEditAttributeCommits.ts index 12aa8ff0a..0c3e7a39c 100644 --- a/packages/studio/src/hooks/useDomEditAttributeCommits.ts +++ b/packages/studio/src/hooks/useDomEditAttributeCommits.ts @@ -58,6 +58,25 @@ interface CapturedAttributeElement { previousValue: string | null; } +interface CapturedMultiAttributeElement { + element: HTMLElement; + previousValues: Map; +} + +function captureMultiAttributeElement( + doc: Document | null | undefined, + selection: DomEditSelection, + activeCompPath: string | null, + fullAttrs: string[], +): CapturedMultiAttributeElement | null { + const el = findPreviewAttributeElement(doc, selection, activeCompPath); + if (!el) return null; + const previousValues = new Map( + fullAttrs.map((fullAttr) => [fullAttr, el.getAttribute(fullAttr)]), + ); + return { element: el, previousValues }; +} + function captureAttributeElement( doc: Document | null | undefined, selection: DomEditSelection, @@ -142,6 +161,105 @@ export function useDomEditAttributeCommits({ ], ); + // Commits several data-* attributes on the SAME element in ONE persist call + // — needed when two attributes together describe a single logical value + // (e.g. a pinned timing range's start+duration): committing them through two + // separate sequential `commitDataAttribute` calls leaves a window where the + // second call resolves `domEditSelection` fresh from current hook state, so + // a selection change between the two awaits would misdirect it at the + // NEWLY selected element instead of the one being edited, and a failure of + // just the second call would leave the two attributes in an inconsistent + // half-applied state. Bundling them into one `PatchOperation[]` against an + // explicit, caller-supplied `selection` (not the "current" one) closes both + // gaps — matching `onCommitAnimatedProperties`'s same-shaped fix for GSAP + // property batches. + const commitDataAttributes = useCallback( + async ( + selection: DomEditSelection, + attrs: Record, + options: DataAttributeCommitOptions, + ) => { + const iframe = previewIframeRef.current; + const entries = Object.entries(attrs).map(([attr, value]) => ({ + attr, + fullAttr: resolveFullAttrName(attr, true), + value, + })); + const commitKey = `${options.coalescePrefix}:${entries + .map((entry) => entry.attr) + .sort() + .join(",")}:${getDomEditTargetKey(selection)}`; + const isLatestCommit = bumpDomEditCommitMapVersion( + domAttributeCommitVersionRef.current, + commitKey, + ); + const ops: PatchOperation[] = entries.map((entry) => ({ + type: "attribute", + property: entry.attr, + value: entry.value, + })); + let captured: CapturedMultiAttributeElement | null = null; + + await runDomEditCommit({ + capture: () => { + captured = captureMultiAttributeElement( + iframe?.contentDocument, + selection, + activeCompPath, + entries.map((entry) => entry.fullAttr), + ); + }, + apply: () => { + if (!captured) return; + for (const entry of entries) { + const nextValue = entry.value === null || entry.value === "" ? null : entry.value; + setOrRemovePreviewAttribute(captured.element, entry.fullAttr, nextValue); + } + }, + persist: () => + persistDomEditOperations(selection, ops, { + label: options.label, + coalesceKey: commitKey, + skipRefresh: options.skipRefresh, + }), + shouldRevert: () => isLatestCommit(), + revert: () => { + if (!captured) return; + for (const entry of entries) { + setOrRemovePreviewAttribute( + captured.element, + entry.fullAttr, + captured.previousValues.get(entry.fullAttr) ?? null, + ); + } + }, + onError: (error) => reportDomEditPersistFailure(selection, ops, error, showToast), + shouldResync: () => isLatestCommit() && !!options.refreshAfter, + resync: () => refreshDomEditSelectionFromPreview(selection), + onSettled: options.onSettled, + }); + }, + [ + activeCompPath, + persistDomEditOperations, + refreshDomEditSelectionFromPreview, + showToast, + previewIframeRef, + ], + ); + + const handleDomAttributesCommit = useCallback( + async (selection: DomEditSelection, attrs: Record) => { + await commitDataAttributes(selection, attrs, { + label: "Edit timing", + coalescePrefix: "attrs", + skipRefresh: false, + refreshAfter: true, + }); + }, + [commitDataAttributes], + ); + const handleDomAttributeCommit = useCallback( async (attr: string, value: string) => { await commitDataAttribute(attr, value, { @@ -226,5 +344,6 @@ export function useDomEditAttributeCommits({ handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, + handleDomAttributesCommit, }; } diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 8af3729b1..6f5fb56dd 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -523,6 +523,7 @@ export function useDomEditCommits({ handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, + handleDomAttributesCommit, handleDomTextCommit, commitDomTextFields, handleDomTextFieldStyleCommit, @@ -585,6 +586,7 @@ export function useDomEditCommits({ handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, + handleDomAttributesCommit, handleDomTextCommit, commitDomTextFields, handleDomTextFieldStyleCommit, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index fa1b8a9ce..ee1a3ea52 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -229,6 +229,7 @@ export function useDomEditSession({ handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, + handleDomAttributesCommit, handleDomTextCommit, handleDomTextFieldStyleCommit, handleDomAddTextField, @@ -529,6 +530,7 @@ export function useDomEditSession({ handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, + handleDomAttributesCommit, handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit, handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit, handleDomZIndexReorderCommit, diff --git a/packages/studio/src/hooks/useDomEditTextCommits.ts b/packages/studio/src/hooks/useDomEditTextCommits.ts index cbce28912..711135627 100644 --- a/packages/studio/src/hooks/useDomEditTextCommits.ts +++ b/packages/studio/src/hooks/useDomEditTextCommits.ts @@ -145,15 +145,19 @@ export function useDomEditTextCommits({ const domTextCommitVersionRef = useRef(0); const domStyleCommitVersionRef = useRef(new Map()); - const { handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit } = - useDomEditAttributeCommits({ - activeCompPath, - previewIframeRef, - showToast, - domEditSelection, - refreshDomEditSelectionFromPreview, - persistDomEditOperations, - }); + const { + handleDomAttributeCommit, + handleDomAttributeLiveCommit, + handleDomHtmlAttributeCommit, + handleDomAttributesCommit, + } = useDomEditAttributeCommits({ + activeCompPath, + previewIframeRef, + showToast, + domEditSelection, + refreshDomEditSelectionFromPreview, + persistDomEditOperations, + }); const handleDomStyleCommit = useCallback( async (property: string, value: string) => { @@ -471,6 +475,7 @@ export function useDomEditTextCommits({ handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, + handleDomAttributesCommit, handleDomTextCommit, commitDomTextFields, handleDomTextFieldStyleCommit, diff --git a/packages/studio/src/hooks/useInspectorSplitResize.ts b/packages/studio/src/hooks/useInspectorSplitResize.ts new file mode 100644 index 000000000..a28656388 --- /dev/null +++ b/packages/studio/src/hooks/useInspectorSplitResize.ts @@ -0,0 +1,51 @@ +import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; + +const MIN_INSPECTOR_SPLIT_PERCENT = 20; +const MAX_INSPECTOR_SPLIT_PERCENT = 75; + +export function useInspectorSplitResize() { + const [layersPanePercent, setLayersPanePercent] = useState(40); + const splitContainerRef = useRef(null); + const splitDragRef = useRef<{ + startY: number; + startPercent: number; + height: number; + } | null>(null); + + const handleInspectorSplitResizeStart = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + const height = splitContainerRef.current?.getBoundingClientRect().height ?? 0; + splitDragRef.current = { + startY: event.clientY, + startPercent: layersPanePercent, + height, + }; + }, + [layersPanePercent], + ); + + const handleInspectorSplitResizeMove = useCallback((event: ReactPointerEvent) => { + const drag = splitDragRef.current; + if (!drag || drag.height <= 0) return; + const deltaPercent = ((event.clientY - drag.startY) / drag.height) * 100; + const next = Math.min( + MAX_INSPECTOR_SPLIT_PERCENT, + Math.max(MIN_INSPECTOR_SPLIT_PERCENT, drag.startPercent + deltaPercent), + ); + setLayersPanePercent(next); + }, []); + + const handleInspectorSplitResizeEnd = useCallback(() => { + splitDragRef.current = null; + }, []); + + return { + layersPanePercent, + splitContainerRef, + handleInspectorSplitResizeStart, + handleInspectorSplitResizeMove, + handleInspectorSplitResizeEnd, + }; +} diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 44b680af6..b2c316eea 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -360,7 +360,6 @@ export function useTimelineEditing({ const handleToggleElementHidden = useTimelineElementVisibilityEditing({ projectIdRef, activeCompPath, - timelineElements, showToast, writeProjectFile, recordEdit, diff --git a/packages/studio/src/utils/studioHelpers.test.ts b/packages/studio/src/utils/studioHelpers.test.ts index 1a4c0f29a..db1b40163 100644 --- a/packages/studio/src/utils/studioHelpers.test.ts +++ b/packages/studio/src/utils/studioHelpers.test.ts @@ -48,6 +48,51 @@ describe("findMatchingTimelineElementId", () => { it("returns null for an unmatched element in index.html", () => { expect(findMatchingTimelineElementId({ id: "ghost", sourceFile: "index.html" }, [])).toBe(null); }); + + it("resolves the correct repeated composition host by domId, not the first host sharing the same compositionSrc", () => { + // Two hosts import the SAME sub-composition — only compositionSrc alone + // can't tell them apart, but each still has its own domId (as any + // element does). Selecting the SECOND host must not collapse to the + // first one just because an earlier, unrelated element also happens to + // share its compositionSrc. + const els = [ + el({ id: "host-a", domId: "host-a", sourceFile: "index.html", compositionSrc: "scene.html" }), + el({ id: "host-b", domId: "host-b", sourceFile: "index.html", compositionSrc: "scene.html" }), + ]; + expect( + findMatchingTimelineElementId( + { + id: "host-b", + sourceFile: "index.html", + compositionSrc: "scene.html", + isCompositionHost: true, + }, + els, + ), + ).toBe("host-b"); + }); + + it("falls back to compositionSrc-only matching when the host selection has neither a domId nor a selector", () => { + const els = [ + el({ + id: "host-only", + domId: undefined, + sourceFile: "index.html", + compositionSrc: "scene.html", + }), + ]; + expect( + findMatchingTimelineElementId( + { + id: undefined, + sourceFile: "index.html", + compositionSrc: "scene.html", + isCompositionHost: true, + }, + els, + ), + ).toBe("host-only"); + }); }); describe("findTimelineIdByAncestor", () => { diff --git a/packages/studio/src/utils/studioHelpers.ts b/packages/studio/src/utils/studioHelpers.ts index 7d166b89a..fbe04bbf9 100644 --- a/packages/studio/src/utils/studioHelpers.ts +++ b/packages/studio/src/utils/studioHelpers.ts @@ -161,25 +161,34 @@ function matchesBySelector(selection: ElementMatchSelection, element: TimelineEl ); } -function elementMatchesSelection( - selection: ElementMatchSelection, - element: TimelineElement, - selectionSourceFile: string, -): boolean { - return ( - matchesByDomId(selection, element, selectionSourceFile) || - matchesByCompositionHost(selection, element) || - matchesBySelector(selection, element) - ); -} - export function findMatchingTimelineElementId( selection: ElementMatchSelection, elements: TimelineElement[], ): string | null { const selectionSourceFile = selection.sourceFile || "index.html"; - const match = elements.find((el) => elementMatchesSelection(selection, el, selectionSourceFile)); - if (match) return match.key ?? match.id; + // Priority matters, not just "any of the three": a composition-host + // selection always carries its OWN id/selector too (computed generically + // for any element), so two repeated hosts sharing the same compositionSrc + // are still individually addressable by id/selector. Checking + // matchesByCompositionHost with equal priority in a single OR-per-element + // scan let `.find()` stop at an EARLIER, unrelated host that merely shares + // the compositionSrc, before the scan ever reached the correct id/selector + // match further down the list — collapsing every repeated host to the + // first one. Try id, then selector, across the WHOLE list first; only fall + // back to the coarser compositionSrc-only match when neither identifies a + // specific element. + const byId = selection.id + ? elements.find((el) => matchesByDomId(selection, el, selectionSourceFile)) + : undefined; + if (byId) return byId.key ?? byId.id; + + const bySelector = selection.selector + ? elements.find((el) => matchesBySelector(selection, el)) + : undefined; + if (bySelector) return bySelector.key ?? bySelector.id; + + const byHost = elements.find((el) => matchesByCompositionHost(selection, el)); + if (byHost) return byHost.key ?? byHost.id; // Child inside a sub-composition: return a qualified ID so the expansion // hook can resolve the child via clipParentMap even though no timeline