diff --git a/packages/studio/src/hooks/previewPromotionSelection.test.tsx b/packages/studio/src/hooks/previewPromotionSelection.test.tsx index a60a4b4e0c..3cc5628f32 100644 --- a/packages/studio/src/hooks/previewPromotionSelection.test.tsx +++ b/packages/studio/src/hooks/previewPromotionSelection.test.tsx @@ -2,7 +2,7 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { DomEditSelection } from "../components/editor/domEditing"; +import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing"; import { resolveElementForOverlay } from "../components/editor/domEditOverlayGeometry"; import { makePreview, @@ -44,14 +44,13 @@ function mountHost(followPromotions: boolean) { const live = useLivePreviewIframe(host); useDomEditPreviewSync({ previewIframe: followPromotions ? live : host, - activeCompPath: null, captionEditMode: false, domEditSelectionRef: selectionRef, - domEditGroupSelectionsRef: { current: [] }, domEditSelection: selectionRef.current, - refreshDomEditGroupSelectionsFromPreview: async () => {}, - applyDomSelection: (selection) => void (selectionRef.current = selection), - buildDomSelectionFromTarget: async (element) => selectionOf(element), + refreshDomEditSelectionFromPreview: async (selection, iframe) => { + const element = findElementForSelection(iframe.contentDocument!, selection, null); + selectionRef.current = element ? selectionOf(element) : null; + }, refreshPreviewDocumentVersion: () => {}, syncPreviewHotkeys: () => {}, applyStudioManualEditsToPreviewRef: { current: async () => {} }, diff --git a/packages/studio/src/hooks/useDomEditPreviewSync.test.ts b/packages/studio/src/hooks/useDomEditPreviewSync.test.ts index d7783e6493..e1aa6ef57d 100644 --- a/packages/studio/src/hooks/useDomEditPreviewSync.test.ts +++ b/packages/studio/src/hooks/useDomEditPreviewSync.test.ts @@ -25,14 +25,10 @@ describe("useDomEditPreviewSync", () => { function Harness() { useDomEditPreviewSync({ previewIframe: iframe, - activeCompPath: null, captionEditMode: false, domEditSelectionRef: { current: null }, - domEditGroupSelectionsRef: { current: [] }, domEditSelection: null, - refreshDomEditGroupSelectionsFromPreview: async () => {}, - applyDomSelection: () => {}, - buildDomSelectionFromTarget: async () => null, + refreshDomEditSelectionFromPreview: async () => {}, refreshPreviewDocumentVersion: refreshVersion, syncPreviewHotkeys: () => {}, applyStudioManualEditsToPreviewRef: { current: applyManualEdits }, diff --git a/packages/studio/src/hooks/useDomEditPreviewSync.ts b/packages/studio/src/hooks/useDomEditPreviewSync.ts index 1a2b5cb265..b286086599 100644 --- a/packages/studio/src/hooks/useDomEditPreviewSync.ts +++ b/packages/studio/src/hooks/useDomEditPreviewSync.ts @@ -4,31 +4,22 @@ * Extracted from useDomEditSession to keep file sizes under the 600-line limit. */ import { useEffect, useRef } from "react"; -import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing"; +import type { DomEditSelection } from "../components/editor/domEditing"; import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"; import { useDockLayoutStore } from "../components/dock/dockLayoutStore"; import type { PatchTarget } from "../utils/sourcePatcher"; -import { logSelect } from "../utils/selectDebug"; import { onPreviewContentReplaced } from "../player/sceneSwap"; -import type { ResolveDomSelectionOptions } from "./useDomSelectionTypes"; interface UseDomEditPreviewSyncParams { previewIframe: HTMLIFrameElement | null; - activeCompPath: string | null; captionEditMode: boolean; domEditSelectionRef: React.MutableRefObject; - domEditGroupSelectionsRef: React.MutableRefObject; domEditSelection: DomEditSelection | null; - /** Re-resolves a whole multi-selection against the current preview document. */ - refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise; - applyDomSelection: ( - selection: DomEditSelection | null, - options?: { revealPanel?: boolean; preserveGroup?: boolean }, - ) => void; - buildDomSelectionFromTarget: ( - element: HTMLElement, - options?: Pick, - ) => Promise; + /** Re-resolves the selection against the given preview, unless it was deselected meanwhile. */ + refreshDomEditSelectionFromPreview: ( + selection: DomEditSelection, + iframe: HTMLIFrameElement, + ) => Promise; refreshPreviewDocumentVersion: () => void; syncPreviewHotkeys: (iframe: HTMLIFrameElement | null) => void; applyStudioManualEditsToPreviewRef: React.MutableRefObject< @@ -40,14 +31,10 @@ interface UseDomEditPreviewSyncParams { export function useDomEditPreviewSync({ previewIframe, - activeCompPath, captionEditMode, domEditSelectionRef, - domEditGroupSelectionsRef, domEditSelection, - applyDomSelection, - refreshDomEditGroupSelectionsFromPreview, - buildDomSelectionFromTarget, + refreshDomEditSelectionFromPreview, refreshPreviewDocumentVersion, syncPreviewHotkeys, applyStudioManualEditsToPreviewRef, @@ -73,39 +60,7 @@ export function useDomEditPreviewSync({ if (!doc) return; reapplyPositionEditsAfterSeek(doc); - - const nextElement = findElementForSelection(doc, currentSelection, activeCompPath); - if (!nextElement) { - // The selected element no longer resolves in the (re-synced) document - // — comp/hot reload, activeCompPath swap, or post-save replacement. - // Clear so overlay geometry isn't computed on a stale, detached node. - // (Drag-release-in-gray-zone is handled separately by - // suppressNextBoxClickRef; the dragged element still resolves here.) - // - // One lost member is not the whole selection though. A multi-select that - // loses its primary here used to be wiped entirely, so moving a group and - // having any one of its elements fail to re-resolve deselected all of - // them. Re-resolve the group instead and keep whoever survived; it only - // clears when nobody did. - const group = domEditGroupSelectionsRef.current; - logSelect("preview-sync-lost", { - target: currentSelection.selector ?? currentSelection.id ?? null, - group: group.length, - }); - if (group.length > 1) { - await refreshDomEditGroupSelectionsFromPreview(group); - return; - } - applyDomSelection(null, { revealPanel: false }); - return; - } - - const nextSelection = await buildDomSelectionFromTarget(nextElement, { - previous: currentSelection, - }); - if (nextSelection) { - applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true }); - } + await refreshDomEditSelectionFromPreview(currentSelection, previewIframe); }; syncPreviewHotkeys(previewIframe); @@ -122,14 +77,10 @@ export function useDomEditPreviewSync({ return onPreviewContentReplaced(previewIframe, handleLoad); }, [ - activeCompPath, - applyDomSelection, - buildDomSelectionFromTarget, captionEditMode, - domEditGroupSelectionsRef, domEditSelectionRef, previewIframe, - refreshDomEditGroupSelectionsFromPreview, + refreshDomEditSelectionFromPreview, refreshPreviewDocumentVersion, syncPreviewHotkeys, applyStudioManualEditsToPreviewRef, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index fb55c23a02..97c246cb88 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -122,7 +122,6 @@ export function useDomEditSession({ buildDomSelectionForTimelineElement, handleTimelineElementSelect, refreshDomEditSelectionFromPreview, - refreshDomEditGroupSelectionsFromPreview, applyMarqueeSelection, } = useDomSelection({ projectId, @@ -394,8 +393,7 @@ export function useDomEditSession({ activeCompPath, domEditSelection, domEditSelectionRef, - domEditGroupSelectionsRef, - refreshDomEditGroupSelectionsFromPreview, + refreshDomEditSelectionFromPreview, previewIframeRef, previewIframe, captionEditMode: previewCaptionEditMode, @@ -406,8 +404,6 @@ export function useDomEditSession({ refreshPreviewDocumentVersion, syncPreviewHotkeys, applyStudioManualEditsToPreviewRef, - applyDomSelection, - buildDomSelectionFromTarget, openSourceForSelection, ...gsapScriptEdits, handleDomManualEditsReset, diff --git a/packages/studio/src/hooks/useDomEditWiring.ts b/packages/studio/src/hooks/useDomEditWiring.ts index 106eef1bce..6d711d4af6 100644 --- a/packages/studio/src/hooks/useDomEditWiring.ts +++ b/packages/studio/src/hooks/useDomEditWiring.ts @@ -23,8 +23,10 @@ export interface UseDomEditWiringParams { activeCompPath: string | null; domEditSelection: DomEditSelection | null; domEditSelectionRef: React.MutableRefObject; - domEditGroupSelectionsRef: React.MutableRefObject; - refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise; + refreshDomEditSelectionFromPreview: ( + selection: DomEditSelection, + iframe: HTMLIFrameElement, + ) => Promise; previewIframeRef: React.RefObject; previewIframe: HTMLIFrameElement | null; captionEditMode: boolean; @@ -37,11 +39,6 @@ export interface UseDomEditWiringParams { applyStudioManualEditsToPreviewRef: React.MutableRefObject< (iframe: HTMLIFrameElement) => Promise >; - applyDomSelection: ( - selection: DomEditSelection | null, - options?: { revealPanel?: boolean; preserveGroup?: boolean }, - ) => void; - buildDomSelectionFromTarget: (element: HTMLElement) => Promise; openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void; // GSAP script commit ops (from useGsapScriptCommits) updateGsapProperty: ( @@ -117,8 +114,7 @@ export function useDomEditWiring(params: UseDomEditWiringParams) { activeCompPath, domEditSelection, domEditSelectionRef, - domEditGroupSelectionsRef, - refreshDomEditGroupSelectionsFromPreview, + refreshDomEditSelectionFromPreview, previewIframeRef, previewIframe, captionEditMode, @@ -129,8 +125,6 @@ export function useDomEditWiring(params: UseDomEditWiringParams) { refreshPreviewDocumentVersion, syncPreviewHotkeys, applyStudioManualEditsToPreviewRef, - applyDomSelection, - buildDomSelectionFromTarget, openSourceForSelection, updateGsapProperty, updateGsapMeta, @@ -251,14 +245,10 @@ export function useDomEditWiring(params: UseDomEditWiringParams) { useDomEditPreviewSync({ previewIframe, - activeCompPath, captionEditMode, domEditSelectionRef, - domEditGroupSelectionsRef, domEditSelection, - applyDomSelection, - refreshDomEditGroupSelectionsFromPreview, - buildDomSelectionFromTarget, + refreshDomEditSelectionFromPreview, refreshPreviewDocumentVersion, syncPreviewHotkeys, applyStudioManualEditsToPreviewRef, diff --git a/packages/studio/src/hooks/useDomSelection.test.ts b/packages/studio/src/hooks/useDomSelection.test.ts index 1f65bfd51e..371b555938 100644 --- a/packages/studio/src/hooks/useDomSelection.test.ts +++ b/packages/studio/src/hooks/useDomSelection.test.ts @@ -326,6 +326,68 @@ describe("useDomSelection", () => { harness.cleanup(); }); + describe("a save's resync after the user moved on", () => { + function selectHeadlineInPreview() { + const doc = document.implementation.createHTMLDocument(""); + const element = doc.body.appendChild( + Object.assign(doc.createElement("div"), { id: "headline" }), + ); + const headline = makeSelection("Headline", element); + const harness = renderHarness( + { activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 }, + { previewDoc: doc }, + ); + act(() => harness.current().applyDomSelection(headline)); + return { headline, harness }; + } + + it("keeps a deselect made while the save was in flight", async () => { + const { headline, harness } = selectHeadlineInPreview(); + // Escape after a crop's release, before its save returns. + act(() => harness.current().applyDomSelection(null, { revealPanel: false })); + + await act(() => harness.current().refreshDomEditSelectionFromPreview(headline)); + + expect(harness.current().domEditSelection).toBeNull(); + harness.cleanup(); + }); + + it.each([ + ["still in", false], + ["gone from", true], + ])( + "keeps a different pick made while the save was in flight, the saved element %s the preview", + async (_, removed) => { + const { headline, harness } = selectHeadlineInPreview(); + const card = makeSelection( + "Card", + Object.assign(document.createElement("div"), { id: "card" }), + ); + act(() => harness.current().applyDomSelection(card)); + if (removed) headline.element.remove(); + + await act(() => harness.current().refreshDomEditSelectionFromPreview(headline)); + + expect(harness.current().domEditSelection).toBe(card); + harness.cleanup(); + }, + ); + + it("keeps a deselect made while the resync was rebuilding the selection", async () => { + const { headline, harness } = selectHeadlineInPreview(); + let resync!: Promise; + act(() => { + resync = harness.current().refreshDomEditSelectionFromPreview(headline); + }); + act(() => harness.current().applyDomSelection(null, { revealPanel: false })); + + await act(() => resync); + + expect(harness.current().domEditSelection).toBeNull(); + harness.cleanup(); + }); + }); + it("clears a committed selection when the active composition path changes", () => { const { selection, harness } = setupSelectedHarness(); expect(harness.current().domEditSelection).toBe(selection); diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index f830c017e9..856dd0dfa2 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -356,8 +356,10 @@ export function useDomSelection({ const refreshDomEditSelectionFromPreview = useCallback( // fallow-ignore-next-line complexity - async (selection: DomEditSelection) => { - const iframe = previewIframeRef.current; + async (selection: DomEditSelection, iframe = previewIframeRef.current) => { + const stillSelected = () => + domEditSelectionsTargetSame(domEditSelectionRef.current, selection); + if (!stillSelected()) return; let doc: Document | null = null; try { doc = iframe?.contentDocument ?? null; @@ -384,7 +386,7 @@ export function useDomSelection({ } const nextSelection = await buildDomSelectionFromTarget(element, { previous: selection }); - if (nextSelection) { + if (nextSelection && stillSelected()) { applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true, @@ -547,7 +549,6 @@ export function useDomSelection({ buildDomSelectionForTimelineElement, handleTimelineElementSelect, refreshDomEditSelectionFromPreview, - refreshDomEditGroupSelectionsFromPreview, applyMarqueeSelection, }; } diff --git a/packages/studio/src/hooks/useDomSelectionTypes.ts b/packages/studio/src/hooks/useDomSelectionTypes.ts index 6d994d2f86..2ff631a0df 100644 --- a/packages/studio/src/hooks/useDomSelectionTypes.ts +++ b/packages/studio/src/hooks/useDomSelectionTypes.ts @@ -89,7 +89,9 @@ export interface UseDomSelectionReturn { element: TimelineElement, ) => Promise; handleTimelineElementSelect: (element: TimelineElement | null) => Promise; - refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise; - refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise; + refreshDomEditSelectionFromPreview: ( + selection: DomEditSelection, + iframe?: HTMLIFrameElement | null, + ) => Promise; applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => DomSelectionResult; }