mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
fix(studio): losing one member of a group no longer deselects all of it
After a move the preview re-syncs and the selection is re-resolved against the new document. When the primary could not be found there, both re-resolve paths cleared the entire selection — so a group of five, all still on screen, was deselected because one of them failed to resolve. The trace showed the clear landing 600ms after the drop with five members still held, and the timeline sync running afterwards on an already-empty canvas, which ruled it out as the cause. A live group now re-resolves as a group and keeps whoever survived, picking a new primary from them; it only clears when nobody did. That is what refreshDomEditGroupSelectionsFromPreview was written for — it existed and was never called. Both clears also say which one they are and how many members were held, so if this is not the last of it the next trace names the path immediately.
This commit is contained in:
@@ -8,13 +8,17 @@ import { findElementForSelection, type DomEditSelection } from "../components/ed
|
||||
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
|
||||
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
|
||||
import type { PatchTarget } from "../utils/sourcePatcher";
|
||||
import { logSelect } from "../utils/selectDebug";
|
||||
|
||||
interface UseDomEditPreviewSyncParams {
|
||||
previewIframe: HTMLIFrameElement | null;
|
||||
activeCompPath: string | null;
|
||||
captionEditMode: boolean;
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
|
||||
domEditSelection: DomEditSelection | null;
|
||||
/** Re-resolves a whole multi-selection against the current preview document. */
|
||||
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
|
||||
applyDomSelection: (
|
||||
selection: DomEditSelection | null,
|
||||
options?: { revealPanel?: boolean; preserveGroup?: boolean },
|
||||
@@ -35,8 +39,10 @@ export function useDomEditPreviewSync({
|
||||
activeCompPath,
|
||||
captionEditMode,
|
||||
domEditSelectionRef,
|
||||
domEditGroupSelectionsRef,
|
||||
domEditSelection,
|
||||
applyDomSelection,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
buildDomSelectionFromTarget,
|
||||
refreshPreviewDocumentVersion,
|
||||
syncPreviewHistoryHotkey,
|
||||
@@ -72,6 +78,21 @@ export function useDomEditPreviewSync({
|
||||
// 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;
|
||||
}
|
||||
@@ -103,8 +124,10 @@ export function useDomEditPreviewSync({
|
||||
applyDomSelection,
|
||||
buildDomSelectionFromTarget,
|
||||
captionEditMode,
|
||||
domEditGroupSelectionsRef,
|
||||
domEditSelectionRef,
|
||||
previewIframe,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
refreshPreviewDocumentVersion,
|
||||
syncPreviewHistoryHotkey,
|
||||
applyStudioManualEditsToPreviewRef,
|
||||
|
||||
@@ -129,6 +129,7 @@ export function useDomEditSession({
|
||||
buildDomSelectionForTimelineElement,
|
||||
handleTimelineElementSelect,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
applyMarqueeSelection,
|
||||
} = useDomSelection({
|
||||
projectId,
|
||||
@@ -385,6 +386,8 @@ export function useDomEditSession({
|
||||
activeCompPath,
|
||||
domEditSelection,
|
||||
domEditSelectionRef,
|
||||
domEditGroupSelectionsRef,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
previewIframeRef,
|
||||
previewIframe,
|
||||
captionEditMode,
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface UseDomEditWiringParams {
|
||||
activeCompPath: string | null;
|
||||
domEditSelection: DomEditSelection | null;
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
|
||||
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
|
||||
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
previewIframe: HTMLIFrameElement | null;
|
||||
captionEditMode: boolean;
|
||||
@@ -115,6 +117,8 @@ export function useDomEditWiring({
|
||||
activeCompPath,
|
||||
domEditSelection,
|
||||
domEditSelectionRef,
|
||||
domEditGroupSelectionsRef,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
previewIframeRef,
|
||||
previewIframe,
|
||||
captionEditMode,
|
||||
@@ -254,8 +258,10 @@ export function useDomEditWiring({
|
||||
activeCompPath,
|
||||
captionEditMode,
|
||||
domEditSelectionRef,
|
||||
domEditGroupSelectionsRef,
|
||||
domEditSelection,
|
||||
applyDomSelection,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
buildDomSelectionFromTarget,
|
||||
refreshPreviewDocumentVersion,
|
||||
syncPreviewHistoryHotkey,
|
||||
|
||||
@@ -427,6 +427,13 @@ export function useDomSelection({
|
||||
[applyDomSelection, buildDomSelectionForTimelineElement],
|
||||
);
|
||||
|
||||
// Forward handle to the group refresher defined below: the single-selection
|
||||
// refresher falls back to it when the primary is gone, and a ref keeps that from
|
||||
// forcing either callback to be declared in the other's dependency list.
|
||||
const refreshDomEditGroupSelectionsFromPreviewRef = useRef<
|
||||
(selections: DomEditSelection[]) => Promise<void>
|
||||
>(async () => {});
|
||||
|
||||
const refreshDomEditSelectionFromPreview = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (selection: DomEditSelection) => {
|
||||
@@ -441,6 +448,17 @@ export function useDomSelection({
|
||||
|
||||
const element = findElementForSelection(doc, selection, activeCompPath);
|
||||
if (!element) {
|
||||
// Losing the primary is not losing the selection. When a group is live,
|
||||
// re-resolve it and keep whoever still exists rather than wiping the lot.
|
||||
const group = domEditGroupSelectionsRef.current;
|
||||
logSelect("refresh-lost", {
|
||||
target: selection.selector ?? selection.id ?? null,
|
||||
group: group.length,
|
||||
});
|
||||
if (group.length > 1) {
|
||||
await refreshDomEditGroupSelectionsFromPreviewRef.current(group);
|
||||
return;
|
||||
}
|
||||
applyDomSelection(null, { revealPanel: false });
|
||||
return;
|
||||
}
|
||||
@@ -493,6 +511,8 @@ export function useDomSelection({
|
||||
[activeCompPath, announceTimelineSelection, buildDomSelectionFromTarget, previewIframeRef],
|
||||
);
|
||||
|
||||
refreshDomEditGroupSelectionsFromPreviewRef.current = refreshDomEditGroupSelectionsFromPreview;
|
||||
|
||||
// ── Effects ──
|
||||
|
||||
// Clear hover unconditionally on composition/project/preview change
|
||||
|
||||
Reference in New Issue
Block a user