mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
fix(studio): atomic timing pin, expanded-list Hide All, repeated-host matching, pointercancel revert
Fixes real bugs from two independent re-reviews (#2225 @ 65954c3804, #2416 @ beaf4ffbf6): - FlatTimingRow's pinRange committed a pinned start+duration range through TWO sequential onSetAttribute calls. Each resolves domEditSelection fresh from current hook state, so a selection change between the two awaits could misdirect the second write at the newly-selected element instead of the one being edited, and a failure of just the second call left the pair half-applied (inconsistent inferred/explicit state). Added commitDataAttributes/handleDomAttributesCommit (mirroring onCommitAnimatedProperties's same-shaped fix for GSAP property batches): one PatchOperation[] persist call against an explicit, caller-supplied selection — not the "current" one — threaded through as the new optional onSetAttributes prop. pinRange uses it when provided, falls back to the old sequential behavior otherwise. - Hide All silently dropped nested sub-composition children: a selection inside a sub-comp with no timeline-store entry of its own resolves to a virtual `sourceFile#domId` key (the fallback branch exists so the expansion hook can later resolve it via clipParentMap), but toggleTimelineElementHidden only searched the RAW store list, which never contains that key. useTimelineElementVisibilityEditing now resolves against useExpandedTimelineElements() instead, matching the track-based toggle's existing approach — the expanded list synthesizes a real, patchable TimelineElement (matching key/domId/sourceFile) for each visible child whenever its host is currently expanded. - Two composition hosts importing the same sub-composition collapsed to the first one: findMatchingTimelineElementId ORed domId/selector/ compositionSrc matches with equal priority in a single per-element scan, so `.find()` could stop at an EARLIER, unrelated host that merely shared the compositionSrc, before the scan ever reached the correct domId/ selector match further down the list. Restructured to try domId, then selector, across the WHOLE list first; compositionSrc-only matching is now a true last resort for when neither identifies a specific element. - FlatSlider's native pointercancel handler (a platform-level gesture abort — scroll/touch takeover, pen leaving range) manually duplicated the pointer-capture release logic instead of calling cancelDrag, so it never reverted to the pre-drag value — leaving whatever intermediate position the pointer last reached committed, unlike the Escape/right-click paths added in the previous round. Now calls cancelDrag directly. - useColorGradingController's flushPendingPersist read identityKeyRef.current fresh at flush time rather than a value snapshotted when the edit was scheduled. Defensive fix: added pendingPersistIdentityRef, set alongside pendingPersistValueRef in commitColorGrading, read by flushPendingPersist instead of the live ref — closes the gap regardless of how unlikely the actual race is given the identity-cleanup effect's existing eager-flush behavior. Two prior findings re-verified as already fixed further up this same Graphite stack (not re-fixed here, per established stack-order handling): metadata-cache negative-caching (267cdfce1) and cross-file selectionIdentityKey (6f40e03a1), both landing after #2225's reviewed head. StudioRightPanel.tsx crossed the 600-line file-size gate after wiring the new onSetAttributes prop through; extracted the inspector split-pane resize handlers (previously inlined) into their own useInspectorSplitResize hook. New regression tests: repeated-composition-host resolution, atomic vs. fallback pinRange commit paths, pointercancel revert. Full studio suite still at the known pre-existing 55-failure baseline, zero new regressions. Typecheck/oxlint/oxfmt clean.
This commit is contained in:
@@ -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<HTMLDivElement>(null);
|
||||
const splitDragRef = useRef<{
|
||||
startY: number;
|
||||
startPercent: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const {
|
||||
layersPanePercent,
|
||||
splitContainerRef,
|
||||
handleInspectorSplitResizeStart,
|
||||
handleInspectorSplitResizeMove,
|
||||
handleInspectorSplitResizeEnd,
|
||||
} = useInspectorSplitResize();
|
||||
const backgroundRemovalAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(
|
||||
@@ -231,35 +222,6 @@ export function StudioRightPanel({
|
||||
toggleRightInspectorPane(pane);
|
||||
};
|
||||
|
||||
const handleInspectorSplitResizeStart = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
<FlatTimingRow
|
||||
element={element}
|
||||
animations={[{ position: 2, duration: 3 } as never]}
|
||||
onSetAttribute={onSetAttribute}
|
||||
onSetAttributes={onSetAttributes}
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<FlatTimingRow
|
||||
element={element}
|
||||
animations={[{ position: 2, duration: 3 } as never]}
|
||||
onSetAttribute={onSetAttribute}
|
||||
/>,
|
||||
);
|
||||
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", () => {
|
||||
|
||||
@@ -13,10 +13,17 @@ export function FlatTimingRow({
|
||||
element,
|
||||
animations = [],
|
||||
onSetAttribute,
|
||||
onSetAttributes,
|
||||
}: {
|
||||
element: DomEditSelection;
|
||||
animations?: GsapAnimation[];
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
/** 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<string, string>) => Promise<void>;
|
||||
}) {
|
||||
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<void>;
|
||||
onSetAttributes?: (selection: DomEditSelection, attrs: Record<string, string>) => Promise<void>;
|
||||
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
} & GsapAnimationEditCallbacks) {
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
@@ -109,7 +127,12 @@ export function FlatMotionSection({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{showTiming && (
|
||||
<FlatTimingRow element={element} animations={animations} onSetAttribute={onSetAttribute} />
|
||||
<FlatTimingRow
|
||||
element={element}
|
||||
animations={animations}
|
||||
onSetAttribute={onSetAttribute}
|
||||
onSetAttributes={onSetAttributes}
|
||||
/>
|
||||
)}
|
||||
{showEffects && (
|
||||
<>
|
||||
|
||||
@@ -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(
|
||||
<FlatSlider
|
||||
label="Opacity"
|
||||
value={10}
|
||||
min={0}
|
||||
max={100}
|
||||
tier="explicitCustom"
|
||||
displayValue="10%"
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
);
|
||||
const track = host.querySelector<HTMLElement>('[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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -33,6 +33,12 @@ export interface PropertyPanelProps {
|
||||
onUngroup?: () => void;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
/** 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<string, string>) => Promise<void>;
|
||||
onSetAttributeLive: (
|
||||
attr: string,
|
||||
value: string | null,
|
||||
|
||||
@@ -195,6 +195,10 @@ export function useColorGradingController({
|
||||
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingPersistValueRef = useRef<string | null | undefined>(undefined);
|
||||
const pendingPersistGradingRef = useRef<NormalizedHfColorGrading | null>(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<string | null>(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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -63,7 +63,7 @@ interface UseTimelineTrackVisibilityEditingInput extends Omit<
|
||||
|
||||
interface UseTimelineElementVisibilityEditingInput extends Omit<
|
||||
ToggleTimelineElementHiddenInput,
|
||||
"projectId" | "elementKey" | "hidden" | "previewIframe"
|
||||
"projectId" | "elementKey" | "hidden" | "previewIframe" | "timelineElements"
|
||||
> {
|
||||
projectIdRef: ReadonlyRef<string | null>;
|
||||
previewIframeRef: ReadonlyRef<HTMLIFrameElement | null>;
|
||||
@@ -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<void> {
|
||||
// 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,
|
||||
|
||||
@@ -58,6 +58,25 @@ interface CapturedAttributeElement {
|
||||
previousValue: string | null;
|
||||
}
|
||||
|
||||
interface CapturedMultiAttributeElement {
|
||||
element: HTMLElement;
|
||||
previousValues: Map<string, string | null>;
|
||||
}
|
||||
|
||||
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<string, string | null>,
|
||||
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<string, string>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -145,15 +145,19 @@ export function useDomEditTextCommits({
|
||||
const domTextCommitVersionRef = useRef(0);
|
||||
const domStyleCommitVersionRef = useRef(new Map<string, number>());
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const splitDragRef = useRef<{
|
||||
startY: number;
|
||||
startPercent: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleInspectorSplitResizeStart = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -360,7 +360,6 @@ export function useTimelineEditing({
|
||||
const handleToggleElementHidden = useTimelineElementVisibilityEditing({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user