@hyperframes/studio 0.8.177 → 0.8.179

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1010,6 +1010,7 @@ function markScenesStale(iframe, files) {
1010
1010
  }
1011
1011
  meta.content = JSON.stringify(parts);
1012
1012
  }
1013
+ var supersededByNewerEdit = () => new DOMException("superseded by a newer edit", "AbortError");
1013
1014
  function sceneSwapFor(iframe) {
1014
1015
  const win = iframe.contentWindow;
1015
1016
  const swap = win?.__hfSwapScenes;
@@ -1535,6 +1536,7 @@ export {
1535
1536
  announcePreviewPromoted,
1536
1537
  onPreviewPromoted,
1537
1538
  markScenesStale,
1539
+ supersededByNewerEdit,
1538
1540
  sceneSwapFor,
1539
1541
  Player
1540
1542
  };
package/dist/index.d.ts CHANGED
@@ -3463,7 +3463,7 @@ declare function useDomEditSession({ projectId, activeCompPath, compIdToSrc, cap
3463
3463
  handlePreviewCanvasPointerLeave: () => void;
3464
3464
  applyDomSelection: (selection: DomEditSelection | null, options?: ApplyDomSelectionOptions) => void;
3465
3465
  clearDomSelection: () => void;
3466
- refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
3466
+ refreshDomEditSelectionFromPreview: (selection: DomEditSelection, iframe?: HTMLIFrameElement | null) => Promise<void>;
3467
3467
  handleDomStyleCommit: (property: string, value: string) => Promise<DomEditCommitOutcome>;
3468
3468
  handleDomStyleCommitForSelection: (selection: DomEditSelection, propertyOrStylesAsOnePatch: string | Record<string, string>, value?: string) => Promise<DomEditCommitOutcome>;
3469
3469
  handleDomAttributeCommit: (attr: string, value: string) => Promise<void>;
package/dist/index.html CHANGED
@@ -14,7 +14,7 @@
14
14
  document.documentElement.dataset.theme = "paper";
15
15
  }
16
16
  </script>
17
- <script type="module" crossorigin src="/assets/index-B1Gypobp.js"></script>
17
+ <script type="module" crossorigin src="/assets/index-ueMRyAJ2.js"></script>
18
18
  <link rel="stylesheet" crossorigin href="/assets/index-CESvLzz0.css">
19
19
  </head>
20
20
  <body>
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  sceneSwapFor,
40
40
  secondsToFrame,
41
41
  stepFrameTime,
42
+ supersededByNewerEdit,
42
43
  thumbnailRevisionOf,
43
44
  timelineSliderToZoomPercent,
44
45
  timelineZoomPercentToSlider,
@@ -47,7 +48,7 @@ import {
47
48
  usePreviewVariablesStore,
48
49
  whenPreviewBooted,
49
50
  writeStudioUiPreferences
50
- } from "./chunk-BLXESG6S.js";
51
+ } from "./chunk-563JPIL4.js";
51
52
  import {
52
53
  Button,
53
54
  ContextMenu,
@@ -32628,7 +32629,7 @@ function useTimelinePlayer({
32628
32629
  const refreshPlayer = useCallback41(() => {
32629
32630
  const iframe = iframeRef.current;
32630
32631
  if (!iframe) return;
32631
- swapCancelRef.current?.abort(new Error("superseded by a newer edit"));
32632
+ swapCancelRef.current?.abort(supersededByNewerEdit());
32632
32633
  const cancel = new AbortController();
32633
32634
  swapCancelRef.current = cancel;
32634
32635
  logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
@@ -75094,8 +75095,31 @@ import { useState as useState117, useCallback as useCallback104, useRef as useRe
75094
75095
 
75095
75096
  // src/hooks/useFileTree.ts
75096
75097
  import { useState as useState116, useCallback as useCallback101, useEffect as useEffect112, useMemo as useMemo71, useRef as useRef128 } from "react";
75098
+
75099
+ // src/utils/rejectionTelemetry.ts
75100
+ function errorProps(value) {
75101
+ if (value instanceof Error) {
75102
+ return {
75103
+ error_message: value.message,
75104
+ error_name: value.name,
75105
+ stack_trace: value.stack?.slice(0, 4e3) ?? null
75106
+ };
75107
+ }
75108
+ return { error_message: String(value), error_name: null, stack_trace: null };
75109
+ }
75110
+
75111
+ // src/hooks/useFileTree.ts
75097
75112
  import { encodeUrlPath as encodeUrlPath4 } from "@hyperframes/parsers";
75098
75113
  var EMPTY_FILE_LIST = [];
75114
+ async function readTreeListing(projectId, signal) {
75115
+ const res = await studioApiFetch(buildProjectApiPath(projectId), { signal });
75116
+ if (!res.ok) throw new Error(`tree fetch failed: ${res.status}`);
75117
+ return res.json();
75118
+ }
75119
+ function reportRefreshFailure(err) {
75120
+ const { error_name, error_message } = errorProps(err);
75121
+ trackStudioEvent("file_tree_refresh_failed", { error_name, error_message });
75122
+ }
75099
75123
  function useFileTree({ projectId, projectIdRef }) {
75100
75124
  const [fetched, setFetched] = useState116(null);
75101
75125
  const current = fetched?.projectId === projectId ? fetched : null;
@@ -75131,6 +75155,7 @@ function useFileTree({ projectId, projectIdRef }) {
75131
75155
  }, [projectId]);
75132
75156
  const refreshRequestRef = useRef128(0);
75133
75157
  const refreshAbortRef = useRef128(null);
75158
+ const failureReportedRef = useRef128(false);
75134
75159
  const refreshFileTree = useCallback101(async () => {
75135
75160
  const pid = projectIdRef.current;
75136
75161
  if (!pid) return;
@@ -75140,12 +75165,14 @@ function useFileTree({ projectId, projectIdRef }) {
75140
75165
  refreshAbortRef.current = controller;
75141
75166
  let data;
75142
75167
  try {
75143
- const res = await studioApiFetch(buildProjectApiPath(pid), { signal: controller.signal });
75144
- data = await res.json();
75168
+ data = await readTreeListing(pid, controller.signal);
75145
75169
  } catch (err) {
75146
75170
  if (err instanceof DOMException && err.name === "AbortError") return;
75147
- throw err;
75171
+ if (!failureReportedRef.current) reportRefreshFailure(err);
75172
+ failureReportedRef.current = true;
75173
+ return;
75148
75174
  }
75175
+ failureReportedRef.current = false;
75149
75176
  if (data.files && requestId === refreshRequestRef.current) {
75150
75177
  setFetched(
75151
75178
  (prev) => prev?.projectId === pid ? {
@@ -79921,8 +79948,9 @@ function useDomSelection({
79921
79948
  });
79922
79949
  const refreshDomEditSelectionFromPreview = useCallback125(
79923
79950
  // fallow-ignore-next-line complexity
79924
- async (selection) => {
79925
- const iframe = previewIframeRef.current;
79951
+ async (selection, iframe = previewIframeRef.current) => {
79952
+ const stillSelected = () => domEditSelectionsTargetSame(domEditSelectionRef.current, selection);
79953
+ if (!stillSelected()) return;
79926
79954
  let doc = null;
79927
79955
  try {
79928
79956
  doc = iframe?.contentDocument ?? null;
@@ -79945,7 +79973,7 @@ function useDomSelection({
79945
79973
  return;
79946
79974
  }
79947
79975
  const nextSelection = await buildDomSelectionFromTarget(element, { previous: selection });
79948
- if (nextSelection) {
79976
+ if (nextSelection && stillSelected()) {
79949
79977
  applyDomSelection(nextSelection, {
79950
79978
  revealPanel: false,
79951
79979
  preserveGroup: true
@@ -80073,7 +80101,6 @@ function useDomSelection({
80073
80101
  buildDomSelectionForTimelineElement,
80074
80102
  handleTimelineElementSelect,
80075
80103
  refreshDomEditSelectionFromPreview,
80076
- refreshDomEditGroupSelectionsFromPreview,
80077
80104
  applyMarqueeSelection
80078
80105
  };
80079
80106
  }
@@ -83631,14 +83658,10 @@ import { useCallback as useCallback142, useEffect as useEffect119, useRef as use
83631
83658
  import { useEffect as useEffect118, useRef as useRef149 } from "react";
83632
83659
  function useDomEditPreviewSync({
83633
83660
  previewIframe,
83634
- activeCompPath,
83635
83661
  captionEditMode,
83636
83662
  domEditSelectionRef,
83637
- domEditGroupSelectionsRef,
83638
83663
  domEditSelection,
83639
- applyDomSelection,
83640
- refreshDomEditGroupSelectionsFromPreview,
83641
- buildDomSelectionFromTarget,
83664
+ refreshDomEditSelectionFromPreview,
83642
83665
  refreshPreviewDocumentVersion,
83643
83666
  syncPreviewHotkeys,
83644
83667
  applyStudioManualEditsToPreviewRef,
@@ -83659,26 +83682,7 @@ function useDomEditPreviewSync({
83659
83682
  }
83660
83683
  if (!doc) return;
83661
83684
  reapplyPositionEditsAfterSeek(doc);
83662
- const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
83663
- if (!nextElement) {
83664
- const group = domEditGroupSelectionsRef.current;
83665
- logSelect("preview-sync-lost", {
83666
- target: currentSelection.selector ?? currentSelection.id ?? null,
83667
- group: group.length
83668
- });
83669
- if (group.length > 1) {
83670
- await refreshDomEditGroupSelectionsFromPreview(group);
83671
- return;
83672
- }
83673
- applyDomSelection(null, { revealPanel: false });
83674
- return;
83675
- }
83676
- const nextSelection = await buildDomSelectionFromTarget(nextElement, {
83677
- previous: currentSelection
83678
- });
83679
- if (nextSelection) {
83680
- applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
83681
- }
83685
+ await refreshDomEditSelectionFromPreview(currentSelection, previewIframe);
83682
83686
  };
83683
83687
  syncPreviewHotkeys(previewIframe);
83684
83688
  void applyStudioManualEditsToPreviewRef.current(previewIframe);
@@ -83692,14 +83696,10 @@ function useDomEditPreviewSync({
83692
83696
  };
83693
83697
  return onPreviewContentReplaced(previewIframe, handleLoad);
83694
83698
  }, [
83695
- activeCompPath,
83696
- applyDomSelection,
83697
- buildDomSelectionFromTarget,
83698
83699
  captionEditMode,
83699
- domEditGroupSelectionsRef,
83700
83700
  domEditSelectionRef,
83701
83701
  previewIframe,
83702
- refreshDomEditGroupSelectionsFromPreview,
83702
+ refreshDomEditSelectionFromPreview,
83703
83703
  refreshPreviewDocumentVersion,
83704
83704
  syncPreviewHotkeys,
83705
83705
  applyStudioManualEditsToPreviewRef,
@@ -84079,8 +84079,7 @@ function useDomEditWiring(params) {
84079
84079
  activeCompPath,
84080
84080
  domEditSelection,
84081
84081
  domEditSelectionRef,
84082
- domEditGroupSelectionsRef,
84083
- refreshDomEditGroupSelectionsFromPreview,
84082
+ refreshDomEditSelectionFromPreview,
84084
84083
  previewIframeRef,
84085
84084
  previewIframe,
84086
84085
  captionEditMode,
@@ -84091,8 +84090,6 @@ function useDomEditWiring(params) {
84091
84090
  refreshPreviewDocumentVersion,
84092
84091
  syncPreviewHotkeys,
84093
84092
  applyStudioManualEditsToPreviewRef,
84094
- applyDomSelection,
84095
- buildDomSelectionFromTarget,
84096
84093
  openSourceForSelection,
84097
84094
  updateGsapProperty,
84098
84095
  updateGsapMeta,
@@ -84189,14 +84186,10 @@ function useDomEditWiring(params) {
84189
84186
  });
84190
84187
  useDomEditPreviewSync({
84191
84188
  previewIframe,
84192
- activeCompPath,
84193
84189
  captionEditMode,
84194
84190
  domEditSelectionRef,
84195
- domEditGroupSelectionsRef,
84196
84191
  domEditSelection,
84197
- applyDomSelection,
84198
- refreshDomEditGroupSelectionsFromPreview,
84199
- buildDomSelectionFromTarget,
84192
+ refreshDomEditSelectionFromPreview,
84200
84193
  refreshPreviewDocumentVersion,
84201
84194
  syncPreviewHotkeys,
84202
84195
  applyStudioManualEditsToPreviewRef,
@@ -86204,7 +86197,6 @@ function useDomEditSession({
86204
86197
  buildDomSelectionForTimelineElement,
86205
86198
  handleTimelineElementSelect,
86206
86199
  refreshDomEditSelectionFromPreview,
86207
- refreshDomEditGroupSelectionsFromPreview,
86208
86200
  applyMarqueeSelection
86209
86201
  } = useDomSelection({
86210
86202
  projectId,
@@ -86444,8 +86436,7 @@ function useDomEditSession({
86444
86436
  activeCompPath,
86445
86437
  domEditSelection,
86446
86438
  domEditSelectionRef,
86447
- domEditGroupSelectionsRef,
86448
- refreshDomEditGroupSelectionsFromPreview,
86439
+ refreshDomEditSelectionFromPreview,
86449
86440
  previewIframeRef,
86450
86441
  previewIframe,
86451
86442
  captionEditMode: previewCaptionEditMode,
@@ -86456,8 +86447,6 @@ function useDomEditSession({
86456
86447
  refreshPreviewDocumentVersion,
86457
86448
  syncPreviewHotkeys,
86458
86449
  applyStudioManualEditsToPreviewRef,
86459
- applyDomSelection,
86460
- buildDomSelectionFromTarget,
86461
86450
  openSourceForSelection,
86462
86451
  ...gsapScriptEdits,
86463
86452
  handleDomManualEditsReset
package/dist/player.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-BLXESG6S.js";
3
+ } from "./chunk-563JPIL4.js";
4
4
  import "./chunk-FF5DM7BQ.js";
5
5
  import "./chunk-T43DM7Q6.js";
6
6
  import "./chunk-MLKGABMK.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.177",
3
+ "version": "0.8.179",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -59,12 +59,12 @@
59
59
  "dockview-react": "8.3.1",
60
60
  "gsap": "^3.13.0",
61
61
  "mediabunny": "^1.45.3",
62
- "@hyperframes/player": "0.8.177",
63
- "@hyperframes/core": "0.8.177",
64
- "@hyperframes/parsers": "0.8.177",
65
- "@hyperframes/sdk": "0.8.177",
66
- "@hyperframes/shader-transitions": "0.8.177",
67
- "@hyperframes/studio-server": "0.8.177"
62
+ "@hyperframes/core": "0.8.179",
63
+ "@hyperframes/studio-server": "0.8.179",
64
+ "@hyperframes/sdk": "0.8.179",
65
+ "@hyperframes/player": "0.8.179",
66
+ "@hyperframes/shader-transitions": "0.8.179",
67
+ "@hyperframes/parsers": "0.8.179"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.177"
84
+ "@hyperframes/producer": "0.8.179"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -2,7 +2,7 @@
2
2
  import React, { act } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
- import type { DomEditSelection } from "../components/editor/domEditing";
5
+ import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
6
6
  import { resolveElementForOverlay } from "../components/editor/domEditOverlayGeometry";
7
7
  import {
8
8
  makePreview,
@@ -44,14 +44,13 @@ function mountHost(followPromotions: boolean) {
44
44
  const live = useLivePreviewIframe(host);
45
45
  useDomEditPreviewSync({
46
46
  previewIframe: followPromotions ? live : host,
47
- activeCompPath: null,
48
47
  captionEditMode: false,
49
48
  domEditSelectionRef: selectionRef,
50
- domEditGroupSelectionsRef: { current: [] },
51
49
  domEditSelection: selectionRef.current,
52
- refreshDomEditGroupSelectionsFromPreview: async () => {},
53
- applyDomSelection: (selection) => void (selectionRef.current = selection),
54
- buildDomSelectionFromTarget: async (element) => selectionOf(element),
50
+ refreshDomEditSelectionFromPreview: async (selection, iframe) => {
51
+ const element = findElementForSelection(iframe.contentDocument!, selection, null);
52
+ selectionRef.current = element ? selectionOf(element) : null;
53
+ },
55
54
  refreshPreviewDocumentVersion: () => {},
56
55
  syncPreviewHotkeys: () => {},
57
56
  applyStudioManualEditsToPreviewRef: { current: async () => {} },
@@ -25,14 +25,10 @@ describe("useDomEditPreviewSync", () => {
25
25
  function Harness() {
26
26
  useDomEditPreviewSync({
27
27
  previewIframe: iframe,
28
- activeCompPath: null,
29
28
  captionEditMode: false,
30
29
  domEditSelectionRef: { current: null },
31
- domEditGroupSelectionsRef: { current: [] },
32
30
  domEditSelection: null,
33
- refreshDomEditGroupSelectionsFromPreview: async () => {},
34
- applyDomSelection: () => {},
35
- buildDomSelectionFromTarget: async () => null,
31
+ refreshDomEditSelectionFromPreview: async () => {},
36
32
  refreshPreviewDocumentVersion: refreshVersion,
37
33
  syncPreviewHotkeys: () => {},
38
34
  applyStudioManualEditsToPreviewRef: { current: applyManualEdits },
@@ -4,31 +4,22 @@
4
4
  * Extracted from useDomEditSession to keep file sizes under the 600-line limit.
5
5
  */
6
6
  import { useEffect, useRef } from "react";
7
- import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
7
+ import type { DomEditSelection } from "../components/editor/domEditing";
8
8
  import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
9
9
  import { useDockLayoutStore } from "../components/dock/dockLayoutStore";
10
10
  import type { PatchTarget } from "../utils/sourcePatcher";
11
- import { logSelect } from "../utils/selectDebug";
12
11
  import { onPreviewContentReplaced } from "../player/sceneSwap";
13
- import type { ResolveDomSelectionOptions } from "./useDomSelectionTypes";
14
12
 
15
13
  interface UseDomEditPreviewSyncParams {
16
14
  previewIframe: HTMLIFrameElement | null;
17
- activeCompPath: string | null;
18
15
  captionEditMode: boolean;
19
16
  domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
20
- domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
21
17
  domEditSelection: DomEditSelection | null;
22
- /** Re-resolves a whole multi-selection against the current preview document. */
23
- refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
24
- applyDomSelection: (
25
- selection: DomEditSelection | null,
26
- options?: { revealPanel?: boolean; preserveGroup?: boolean },
27
- ) => void;
28
- buildDomSelectionFromTarget: (
29
- element: HTMLElement,
30
- options?: Pick<ResolveDomSelectionOptions, "previous">,
31
- ) => Promise<DomEditSelection | null>;
18
+ /** Re-resolves the selection against the given preview, unless it was deselected meanwhile. */
19
+ refreshDomEditSelectionFromPreview: (
20
+ selection: DomEditSelection,
21
+ iframe: HTMLIFrameElement,
22
+ ) => Promise<void>;
32
23
  refreshPreviewDocumentVersion: () => void;
33
24
  syncPreviewHotkeys: (iframe: HTMLIFrameElement | null) => void;
34
25
  applyStudioManualEditsToPreviewRef: React.MutableRefObject<
@@ -40,14 +31,10 @@ interface UseDomEditPreviewSyncParams {
40
31
 
41
32
  export function useDomEditPreviewSync({
42
33
  previewIframe,
43
- activeCompPath,
44
34
  captionEditMode,
45
35
  domEditSelectionRef,
46
- domEditGroupSelectionsRef,
47
36
  domEditSelection,
48
- applyDomSelection,
49
- refreshDomEditGroupSelectionsFromPreview,
50
- buildDomSelectionFromTarget,
37
+ refreshDomEditSelectionFromPreview,
51
38
  refreshPreviewDocumentVersion,
52
39
  syncPreviewHotkeys,
53
40
  applyStudioManualEditsToPreviewRef,
@@ -73,39 +60,7 @@ export function useDomEditPreviewSync({
73
60
  if (!doc) return;
74
61
 
75
62
  reapplyPositionEditsAfterSeek(doc);
76
-
77
- const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
78
- if (!nextElement) {
79
- // The selected element no longer resolves in the (re-synced) document
80
- // — comp/hot reload, activeCompPath swap, or post-save replacement.
81
- // Clear so overlay geometry isn't computed on a stale, detached node.
82
- // (Drag-release-in-gray-zone is handled separately by
83
- // suppressNextBoxClickRef; the dragged element still resolves here.)
84
- //
85
- // One lost member is not the whole selection though. A multi-select that
86
- // loses its primary here used to be wiped entirely, so moving a group and
87
- // having any one of its elements fail to re-resolve deselected all of
88
- // them. Re-resolve the group instead and keep whoever survived; it only
89
- // clears when nobody did.
90
- const group = domEditGroupSelectionsRef.current;
91
- logSelect("preview-sync-lost", {
92
- target: currentSelection.selector ?? currentSelection.id ?? null,
93
- group: group.length,
94
- });
95
- if (group.length > 1) {
96
- await refreshDomEditGroupSelectionsFromPreview(group);
97
- return;
98
- }
99
- applyDomSelection(null, { revealPanel: false });
100
- return;
101
- }
102
-
103
- const nextSelection = await buildDomSelectionFromTarget(nextElement, {
104
- previous: currentSelection,
105
- });
106
- if (nextSelection) {
107
- applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
108
- }
63
+ await refreshDomEditSelectionFromPreview(currentSelection, previewIframe);
109
64
  };
110
65
 
111
66
  syncPreviewHotkeys(previewIframe);
@@ -122,14 +77,10 @@ export function useDomEditPreviewSync({
122
77
 
123
78
  return onPreviewContentReplaced(previewIframe, handleLoad);
124
79
  }, [
125
- activeCompPath,
126
- applyDomSelection,
127
- buildDomSelectionFromTarget,
128
80
  captionEditMode,
129
- domEditGroupSelectionsRef,
130
81
  domEditSelectionRef,
131
82
  previewIframe,
132
- refreshDomEditGroupSelectionsFromPreview,
83
+ refreshDomEditSelectionFromPreview,
133
84
  refreshPreviewDocumentVersion,
134
85
  syncPreviewHotkeys,
135
86
  applyStudioManualEditsToPreviewRef,
@@ -122,7 +122,6 @@ export function useDomEditSession({
122
122
  buildDomSelectionForTimelineElement,
123
123
  handleTimelineElementSelect,
124
124
  refreshDomEditSelectionFromPreview,
125
- refreshDomEditGroupSelectionsFromPreview,
126
125
  applyMarqueeSelection,
127
126
  } = useDomSelection({
128
127
  projectId,
@@ -394,8 +393,7 @@ export function useDomEditSession({
394
393
  activeCompPath,
395
394
  domEditSelection,
396
395
  domEditSelectionRef,
397
- domEditGroupSelectionsRef,
398
- refreshDomEditGroupSelectionsFromPreview,
396
+ refreshDomEditSelectionFromPreview,
399
397
  previewIframeRef,
400
398
  previewIframe,
401
399
  captionEditMode: previewCaptionEditMode,
@@ -406,8 +404,6 @@ export function useDomEditSession({
406
404
  refreshPreviewDocumentVersion,
407
405
  syncPreviewHotkeys,
408
406
  applyStudioManualEditsToPreviewRef,
409
- applyDomSelection,
410
- buildDomSelectionFromTarget,
411
407
  openSourceForSelection,
412
408
  ...gsapScriptEdits,
413
409
  handleDomManualEditsReset,
@@ -23,8 +23,10 @@ export interface UseDomEditWiringParams {
23
23
  activeCompPath: string | null;
24
24
  domEditSelection: DomEditSelection | null;
25
25
  domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
26
- domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
27
- refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
26
+ refreshDomEditSelectionFromPreview: (
27
+ selection: DomEditSelection,
28
+ iframe: HTMLIFrameElement,
29
+ ) => Promise<void>;
28
30
  previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
29
31
  previewIframe: HTMLIFrameElement | null;
30
32
  captionEditMode: boolean;
@@ -37,11 +39,6 @@ export interface UseDomEditWiringParams {
37
39
  applyStudioManualEditsToPreviewRef: React.MutableRefObject<
38
40
  (iframe: HTMLIFrameElement) => Promise<void>
39
41
  >;
40
- applyDomSelection: (
41
- selection: DomEditSelection | null,
42
- options?: { revealPanel?: boolean; preserveGroup?: boolean },
43
- ) => void;
44
- buildDomSelectionFromTarget: (element: HTMLElement) => Promise<DomEditSelection | null>;
45
42
  openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
46
43
  // GSAP script commit ops (from useGsapScriptCommits)
47
44
  updateGsapProperty: (
@@ -117,8 +114,7 @@ export function useDomEditWiring(params: UseDomEditWiringParams) {
117
114
  activeCompPath,
118
115
  domEditSelection,
119
116
  domEditSelectionRef,
120
- domEditGroupSelectionsRef,
121
- refreshDomEditGroupSelectionsFromPreview,
117
+ refreshDomEditSelectionFromPreview,
122
118
  previewIframeRef,
123
119
  previewIframe,
124
120
  captionEditMode,
@@ -129,8 +125,6 @@ export function useDomEditWiring(params: UseDomEditWiringParams) {
129
125
  refreshPreviewDocumentVersion,
130
126
  syncPreviewHotkeys,
131
127
  applyStudioManualEditsToPreviewRef,
132
- applyDomSelection,
133
- buildDomSelectionFromTarget,
134
128
  openSourceForSelection,
135
129
  updateGsapProperty,
136
130
  updateGsapMeta,
@@ -251,14 +245,10 @@ export function useDomEditWiring(params: UseDomEditWiringParams) {
251
245
 
252
246
  useDomEditPreviewSync({
253
247
  previewIframe,
254
- activeCompPath,
255
248
  captionEditMode,
256
249
  domEditSelectionRef,
257
- domEditGroupSelectionsRef,
258
250
  domEditSelection,
259
- applyDomSelection,
260
- refreshDomEditGroupSelectionsFromPreview,
261
- buildDomSelectionFromTarget,
251
+ refreshDomEditSelectionFromPreview,
262
252
  refreshPreviewDocumentVersion,
263
253
  syncPreviewHotkeys,
264
254
  applyStudioManualEditsToPreviewRef,
@@ -326,6 +326,68 @@ describe("useDomSelection", () => {
326
326
  harness.cleanup();
327
327
  });
328
328
 
329
+ describe("a save's resync after the user moved on", () => {
330
+ function selectHeadlineInPreview() {
331
+ const doc = document.implementation.createHTMLDocument("");
332
+ const element = doc.body.appendChild(
333
+ Object.assign(doc.createElement("div"), { id: "headline" }),
334
+ );
335
+ const headline = makeSelection("Headline", element);
336
+ const harness = renderHarness(
337
+ { activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
338
+ { previewDoc: doc },
339
+ );
340
+ act(() => harness.current().applyDomSelection(headline));
341
+ return { headline, harness };
342
+ }
343
+
344
+ it("keeps a deselect made while the save was in flight", async () => {
345
+ const { headline, harness } = selectHeadlineInPreview();
346
+ // Escape after a crop's release, before its save returns.
347
+ act(() => harness.current().applyDomSelection(null, { revealPanel: false }));
348
+
349
+ await act(() => harness.current().refreshDomEditSelectionFromPreview(headline));
350
+
351
+ expect(harness.current().domEditSelection).toBeNull();
352
+ harness.cleanup();
353
+ });
354
+
355
+ it.each([
356
+ ["still in", false],
357
+ ["gone from", true],
358
+ ])(
359
+ "keeps a different pick made while the save was in flight, the saved element %s the preview",
360
+ async (_, removed) => {
361
+ const { headline, harness } = selectHeadlineInPreview();
362
+ const card = makeSelection(
363
+ "Card",
364
+ Object.assign(document.createElement("div"), { id: "card" }),
365
+ );
366
+ act(() => harness.current().applyDomSelection(card));
367
+ if (removed) headline.element.remove();
368
+
369
+ await act(() => harness.current().refreshDomEditSelectionFromPreview(headline));
370
+
371
+ expect(harness.current().domEditSelection).toBe(card);
372
+ harness.cleanup();
373
+ },
374
+ );
375
+
376
+ it("keeps a deselect made while the resync was rebuilding the selection", async () => {
377
+ const { headline, harness } = selectHeadlineInPreview();
378
+ let resync!: Promise<void>;
379
+ act(() => {
380
+ resync = harness.current().refreshDomEditSelectionFromPreview(headline);
381
+ });
382
+ act(() => harness.current().applyDomSelection(null, { revealPanel: false }));
383
+
384
+ await act(() => resync);
385
+
386
+ expect(harness.current().domEditSelection).toBeNull();
387
+ harness.cleanup();
388
+ });
389
+ });
390
+
329
391
  it("clears a committed selection when the active composition path changes", () => {
330
392
  const { selection, harness } = setupSelectedHarness();
331
393
  expect(harness.current().domEditSelection).toBe(selection);
@@ -356,8 +356,10 @@ export function useDomSelection({
356
356
 
357
357
  const refreshDomEditSelectionFromPreview = useCallback(
358
358
  // fallow-ignore-next-line complexity
359
- async (selection: DomEditSelection) => {
360
- const iframe = previewIframeRef.current;
359
+ async (selection: DomEditSelection, iframe = previewIframeRef.current) => {
360
+ const stillSelected = () =>
361
+ domEditSelectionsTargetSame(domEditSelectionRef.current, selection);
362
+ if (!stillSelected()) return;
361
363
  let doc: Document | null = null;
362
364
  try {
363
365
  doc = iframe?.contentDocument ?? null;
@@ -384,7 +386,7 @@ export function useDomSelection({
384
386
  }
385
387
 
386
388
  const nextSelection = await buildDomSelectionFromTarget(element, { previous: selection });
387
- if (nextSelection) {
389
+ if (nextSelection && stillSelected()) {
388
390
  applyDomSelection(nextSelection, {
389
391
  revealPanel: false,
390
392
  preserveGroup: true,
@@ -547,7 +549,6 @@ export function useDomSelection({
547
549
  buildDomSelectionForTimelineElement,
548
550
  handleTimelineElementSelect,
549
551
  refreshDomEditSelectionFromPreview,
550
- refreshDomEditGroupSelectionsFromPreview,
551
552
  applyMarqueeSelection,
552
553
  };
553
554
  }
@@ -89,7 +89,9 @@ export interface UseDomSelectionReturn {
89
89
  element: TimelineElement,
90
90
  ) => Promise<DomEditSelection | null>;
91
91
  handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
92
- refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
93
- refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
92
+ refreshDomEditSelectionFromPreview: (
93
+ selection: DomEditSelection,
94
+ iframe?: HTMLIFrameElement | null,
95
+ ) => Promise<void>;
94
96
  applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => DomSelectionResult;
95
97
  }