fix(studio): never re-author a target the DOM proved is not unique

writeTargetSelector returned the selection's bare selector whenever the
structural walk failed, including when a live DOM was there to check
against. An element detached between selecting and committing takes that
path, so the add re-authored the exact `.group` string the function exists
to replace. Return null instead: a failed walk against a live DOM is
evidence, not absence of it. Callers that cannot drop a user edit opt back
in with `?? selectorFromSelection` where the trade is visible.

The replace-with-keyframes paths had the mirror defect. The server deletes
and re-adds the tween, so their target string is a full rewrite, and they
derived it from the selection: promoting a set on a tween already narrowed
to `#scene > div:nth-child(3)` widened it back onto every class sibling,
undoing the narrowing an earlier add had made. They now keep the tween's
own authored target, matching what eight sibling commit modules already do.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 20:37:52 +02:00
parent fd5555be75
commit f04cdb79c5
4 changed files with 147 additions and 10 deletions
@@ -13,7 +13,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
import {
selectorFromSelection,
existingTweenTargetSelector,
computeElementPercentage,
KEYFRAME_PCT_MATCH,
isInstantHold,
@@ -251,7 +251,10 @@ async function extendKeyframedTweenToPlayhead(
iframe: HTMLIFrameElement | null,
commitOverrides?: Partial<CommitMutationOptions>,
): Promise<void> {
const selector = selectorFromSelection(sel);
// Re-authoring an EXISTING tween: keep the target it already writes rather
// than re-deriving one from the selection, which would widen a tween already
// narrowed to one element back onto its class siblings (existingTweenTargetSelector).
const selector = existingTweenTargetSelector(anim, sel);
const position = readElementPosition(iframe, sel, anim);
if (!selector || Object.keys(position).length === 0 || !session.commitMutation) return;
const extended = buildExtendedKeyframes(anim, currentTime, position, duration);
@@ -336,7 +339,10 @@ export async function promoteSetToKeyframes(
t: number,
iframe: HTMLIFrameElement | null,
): Promise<void> {
const selector = selectorFromSelection(sel);
// Re-authoring an EXISTING tween: keep the target it already writes rather
// than re-deriving one from the selection, which would widen a tween already
// narrowed to one element back onto its class siblings (existingTweenTargetSelector).
const selector = existingTweenTargetSelector(setAnim, sel);
const setStart = resolveTweenStart(setAnim) ?? 0;
if (!selector || !session.commitMutation) return;
// Playhead at or before the set → there's no forward range to promote into.
@@ -390,7 +396,10 @@ export async function applyArcKeyframeAtPlayhead(
iframe: HTMLIFrameElement | null,
): Promise<void> {
if (!session.commitMutation) return;
const targetSelector = selectorFromSelection(sel);
// Re-authoring an EXISTING tween: keep the target it already writes rather
// than re-deriving one from the selection, which would widen a tween already
// narrowed to one element back onto its class siblings (existingTweenTargetSelector).
const targetSelector = existingTweenTargetSelector(arcAnim, sel);
if (!targetSelector) return;
const start = resolveTweenStart(arcAnim) ?? 0;
const duration = resolveEditableTweenDuration(arcAnim, sel);