@hyperframes/studio 0.7.28 → 0.7.30

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.
Files changed (46) hide show
  1. package/dist/assets/{index-BGUJ2C2G.js → index-BhSyGx7y.js} +1 -1
  2. package/dist/assets/{index-Bz6Eqd_G.js → index-D0yNztV_.js} +1 -1
  3. package/dist/assets/{index-CLlPjdPl.js → index-kbACg3_I.js} +139 -139
  4. package/dist/{chunk-AN2EWWK3.js → chunk-JND3XUJL.js} +38 -10
  5. package/dist/chunk-JND3XUJL.js.map +1 -0
  6. package/dist/{domEditingLayers-EK7R7R4G.js → domEditingLayers-UIQZJCOA.js} +4 -2
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1145 -675
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -7
  12. package/src/components/editor/DomEditOverlay.test.ts +95 -0
  13. package/src/components/editor/DomEditOverlay.tsx +5 -3
  14. package/src/components/editor/domEditOverlayGestures.ts +3 -4
  15. package/src/components/editor/domEditing.ts +1 -0
  16. package/src/components/editor/domEditingLayers.test.ts +52 -0
  17. package/src/components/editor/domEditingLayers.ts +55 -19
  18. package/src/components/editor/domEditingTypes.ts +1 -0
  19. package/src/components/editor/persistSeam.integration.test.ts +264 -0
  20. package/src/components/editor/propertyPanelPrimitives.tsx +15 -1
  21. package/src/components/editor/useDomEditOverlayGestures.ts +1 -0
  22. package/src/hooks/domEditCommitRunner.ts +46 -0
  23. package/src/hooks/domEditPersistFailure.test.ts +123 -0
  24. package/src/hooks/domEditPersistFailure.ts +89 -0
  25. package/src/hooks/domEditTextFieldCommitOps.test.ts +111 -0
  26. package/src/hooks/domEditTextFieldCommitOps.ts +63 -0
  27. package/src/hooks/domSelectionTestHarness.ts +40 -0
  28. package/src/hooks/useDomEditAttributeCommits.ts +227 -0
  29. package/src/hooks/useDomEditCommits.test.tsx +775 -0
  30. package/src/hooks/useDomEditCommits.ts +33 -5
  31. package/src/hooks/useDomEditTextCommits.ts +243 -220
  32. package/src/hooks/useDomSelection.test.ts +134 -0
  33. package/src/hooks/useDomSelection.ts +29 -15
  34. package/src/hooks/usePreviewInteraction.test.ts +260 -0
  35. package/src/hooks/usePreviewInteraction.ts +60 -19
  36. package/src/utils/sdkCutoverEligibility.test.ts +17 -0
  37. package/src/utils/sdkCutoverEligibility.ts +8 -2
  38. package/src/utils/sdkResolverShadow.test.ts +180 -1
  39. package/src/utils/sdkResolverShadow.ts +94 -1
  40. package/src/utils/sourcePatcher.ts +2 -0
  41. package/src/utils/studioPreviewHelpers.test.ts +95 -1
  42. package/src/utils/studioPreviewHelpers.ts +109 -10
  43. package/src/utils/studioSaveDiagnostics.ts +5 -2
  44. package/src/utils/studioTelemetry.ts +27 -20
  45. package/dist/chunk-AN2EWWK3.js.map +0 -1
  46. /package/dist/{domEditingLayers-EK7R7R4G.js.map → domEditingLayers-UIQZJCOA.js.map} +0 -0
@@ -0,0 +1,134 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
7
+ import { useDomSelection } from "./useDomSelection";
8
+
9
+ installReactActEnvironment();
10
+
11
+ interface HarnessProps {
12
+ activeCompPath: string | null;
13
+ projectId: string | null;
14
+ refreshKey: number;
15
+ }
16
+
17
+ function renderHarness(initialProps: HarnessProps): {
18
+ current: () => ReturnType<typeof useDomSelection>;
19
+ rerender: (props: HarnessProps) => void;
20
+ cleanup: () => void;
21
+ } {
22
+ const host = document.createElement("div");
23
+ document.body.append(host);
24
+ const root = createRoot(host);
25
+ let currentHook: ReturnType<typeof useDomSelection> | null = null;
26
+
27
+ function Harness(props: HarnessProps) {
28
+ currentHook = useDomSelection({
29
+ projectId: props.projectId,
30
+ activeCompPath: props.activeCompPath,
31
+ isMasterView: false,
32
+ compIdToSrc: new Map(),
33
+ captionEditMode: false,
34
+ previewIframeRef: { current: null },
35
+ timelineElements: [],
36
+ setSelectedTimelineElementId: vi.fn(),
37
+ setRightCollapsed: vi.fn(),
38
+ setRightPanelTab: vi.fn(),
39
+ previewIframe: null,
40
+ refreshKey: props.refreshKey,
41
+ rightPanelTab: "design",
42
+ });
43
+ return null;
44
+ }
45
+
46
+ const rerender = (props: HarnessProps) => {
47
+ act(() => {
48
+ root.render(React.createElement(Harness, props));
49
+ });
50
+ };
51
+
52
+ rerender(initialProps);
53
+
54
+ return {
55
+ current: () => {
56
+ if (!currentHook) throw new Error("Expected hook result");
57
+ return currentHook;
58
+ },
59
+ rerender,
60
+ cleanup: () => {
61
+ act(() => root.unmount());
62
+ host.remove();
63
+ },
64
+ };
65
+ }
66
+
67
+ function setupSelectedHarness() {
68
+ const element = document.createElement("div");
69
+ element.id = "headline";
70
+ const selection = makeSelection("Headline", element);
71
+ const harness = renderHarness({
72
+ activeCompPath: "intro.html",
73
+ projectId: "project-1",
74
+ refreshKey: 0,
75
+ });
76
+ act(() => harness.current().applyDomSelection(selection));
77
+ return { selection, harness };
78
+ }
79
+
80
+ describe("useDomSelection", () => {
81
+ it("clears a committed selection when the active composition path changes", () => {
82
+ const { selection, harness } = setupSelectedHarness();
83
+ expect(harness.current().domEditSelection).toBe(selection);
84
+
85
+ harness.rerender({
86
+ activeCompPath: "outro.html",
87
+ projectId: "project-1",
88
+ refreshKey: 0,
89
+ });
90
+
91
+ expect(harness.current().domEditSelection).toBe(null);
92
+ harness.cleanup();
93
+ });
94
+
95
+ it("preserves a committed selection when composition identity is unchanged", () => {
96
+ const { selection, harness } = setupSelectedHarness();
97
+ harness.rerender({
98
+ activeCompPath: "intro.html",
99
+ projectId: "project-1",
100
+ refreshKey: 1,
101
+ });
102
+
103
+ expect(harness.current().domEditSelection).toBe(selection);
104
+ harness.cleanup();
105
+ });
106
+
107
+ it("preserves selection when clearing an already inactive group scope", () => {
108
+ const { selection, harness } = setupSelectedHarness();
109
+ act(() => harness.current().setActiveGroupElement(null));
110
+
111
+ expect(harness.current().domEditSelection).toBe(selection);
112
+ harness.cleanup();
113
+ });
114
+
115
+ it("preserves selection when entering the already active group scope", () => {
116
+ const group = document.createElement("div");
117
+ const child = document.createElement("span");
118
+ child.id = "headline";
119
+ group.append(child);
120
+ const selection = makeSelection("Headline", child);
121
+ const harness = renderHarness({
122
+ activeCompPath: "intro.html",
123
+ projectId: "project-1",
124
+ refreshKey: 0,
125
+ });
126
+
127
+ act(() => harness.current().setActiveGroupElement(group));
128
+ act(() => harness.current().applyDomSelection(selection));
129
+ act(() => harness.current().setActiveGroupElement(group));
130
+
131
+ expect(harness.current().domEditSelection).toBe(selection);
132
+ harness.cleanup();
133
+ });
134
+ });
@@ -27,6 +27,18 @@ import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"
27
27
 
28
28
  // ── Types ──
29
29
 
30
+ export interface ApplyDomSelectionOptions {
31
+ revealPanel?: boolean;
32
+ additive?: boolean;
33
+ preserveGroup?: boolean;
34
+ }
35
+
36
+ export interface ResolveDomSelectionOptions {
37
+ preferClipAncestor?: boolean;
38
+ skipSourceProbe?: boolean;
39
+ activeGroupElement?: HTMLElement | null;
40
+ }
41
+
30
42
  export interface UseDomSelectionParams {
31
43
  projectId: string | null;
32
44
  activeCompPath: string | null;
@@ -61,29 +73,17 @@ export interface UseDomSelectionReturn {
61
73
  // Callbacks
62
74
  applyDomSelection: (
63
75
  selection: DomEditSelection | null,
64
- options?: {
65
- revealPanel?: boolean;
66
- additive?: boolean;
67
- preserveGroup?: boolean;
68
- },
76
+ options?: ApplyDomSelectionOptions,
69
77
  ) => void;
70
78
  clearDomSelection: () => void;
71
79
  buildDomSelectionFromTarget: (
72
80
  target: HTMLElement,
73
- options?: {
74
- preferClipAncestor?: boolean;
75
- skipSourceProbe?: boolean;
76
- activeGroupElement?: HTMLElement | null;
77
- },
81
+ options?: ResolveDomSelectionOptions,
78
82
  ) => Promise<DomEditSelection | null>;
79
83
  resolveDomSelectionFromPreviewPoint: (
80
84
  clientX: number,
81
85
  clientY: number,
82
- options?: {
83
- preferClipAncestor?: boolean;
84
- skipSourceProbe?: boolean;
85
- activeGroupElement?: HTMLElement | null;
86
- },
86
+ options?: ResolveDomSelectionOptions,
87
87
  ) => Promise<DomEditSelection | null>;
88
88
  resolveAllDomSelectionsFromPreviewPoint: (
89
89
  clientX: number,
@@ -130,6 +130,7 @@ export function useDomSelection({
130
130
  const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
131
131
  const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
132
132
  const activeGroupElementRef = useRef<HTMLElement | null>(activeGroupElement);
133
+ const compositionIdentityRef = useRef({ activeCompPath, projectId });
133
134
 
134
135
  // Keep refs in sync with state
135
136
  domEditSelectionRef.current = domEditSelection;
@@ -230,6 +231,8 @@ export function useDomSelection({
230
231
  // the user isn't left with an out-of-scope element selected.
231
232
  const setActiveGroupElement = useCallback(
232
233
  (el: HTMLElement | null) => {
234
+ if (activeGroupElementRef.current === el) return;
235
+ activeGroupElementRef.current = el;
233
236
  setActiveGroupElementState(el);
234
237
  applyDomSelection(null, { revealPanel: false });
235
238
  },
@@ -454,6 +457,17 @@ export function useDomSelection({
454
457
  updateDomEditHoverSelection(null);
455
458
  }, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]);
456
459
 
460
+ // Clear committed selection only when the composition identity actually changes.
461
+ // eslint-disable-next-line no-restricted-syntax
462
+ useEffect(() => {
463
+ const previous = compositionIdentityRef.current;
464
+ if (previous.activeCompPath === activeCompPath && previous.projectId === projectId) return;
465
+ compositionIdentityRef.current = { activeCompPath, projectId };
466
+ activeGroupElementRef.current = null;
467
+ setActiveGroupElementState(null);
468
+ applyDomSelection(null, { revealPanel: false });
469
+ }, [activeCompPath, projectId, applyDomSelection]);
470
+
457
471
  // Clear hover conditionally (caption mode, matches selection, disconnected element)
458
472
  // eslint-disable-next-line no-restricted-syntax
459
473
  useEffect(() => {
@@ -0,0 +1,260 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { DomEditSelection } from "../components/editor/domEditing";
7
+ import { usePlayerStore } from "../player";
8
+ import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
9
+ import { usePreviewInteraction } from "./usePreviewInteraction";
10
+
11
+ installReactActEnvironment();
12
+
13
+ function createPreviewIframe(playerPause?: () => void): HTMLIFrameElement {
14
+ const iframe = document.createElement("iframe");
15
+ if (playerPause) {
16
+ Object.defineProperty(iframe, "contentWindow", {
17
+ configurable: true,
18
+ value: {
19
+ __player: {
20
+ getTime: () => 3.25,
21
+ pause: playerPause,
22
+ },
23
+ },
24
+ });
25
+ }
26
+ return iframe;
27
+ }
28
+
29
+ interface HarnessArgs {
30
+ previewIframe: HTMLIFrameElement | null;
31
+ resolveDomSelectionFromPreviewPoint: (
32
+ clientX: number,
33
+ clientY: number,
34
+ options?: { activeGroupElement?: HTMLElement | null },
35
+ ) => Promise<DomEditSelection | null>;
36
+ resolveAllDomSelectionsFromPreviewPoint?: (
37
+ clientX: number,
38
+ clientY: number,
39
+ ) => Promise<DomEditSelection[]>;
40
+ applyDomSelection: (
41
+ selection: DomEditSelection | null,
42
+ options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
43
+ ) => void;
44
+ setActiveGroupElement?: (el: HTMLElement | null) => void;
45
+ mouseDownOptions?: {
46
+ preferClipAncestor?: boolean;
47
+ hoverSelection?: DomEditSelection | null;
48
+ };
49
+ }
50
+
51
+ function renderHarness(args: HarnessArgs): {
52
+ canvas: HTMLDivElement;
53
+ cleanup: () => void;
54
+ } {
55
+ const host = document.createElement("div");
56
+ document.body.append(host);
57
+ const root = createRoot(host);
58
+
59
+ function Harness() {
60
+ const interaction = usePreviewInteraction({
61
+ captionEditMode: false,
62
+ compositionLoading: false,
63
+ previewIframeRef: { current: args.previewIframe },
64
+ showToast: vi.fn(),
65
+ applyDomSelection: args.applyDomSelection,
66
+ resolveDomSelectionFromPreviewPoint: args.resolveDomSelectionFromPreviewPoint,
67
+ resolveAllDomSelectionsFromPreviewPoint:
68
+ args.resolveAllDomSelectionsFromPreviewPoint ?? vi.fn(async () => []),
69
+ updateDomEditHoverSelection: vi.fn(),
70
+ setActiveGroupElement: args.setActiveGroupElement ?? vi.fn(),
71
+ });
72
+
73
+ return React.createElement("div", {
74
+ id: "canvas",
75
+ onMouseDown: (event: React.MouseEvent<HTMLDivElement>) => {
76
+ void interaction.handlePreviewCanvasMouseDown(event, args.mouseDownOptions);
77
+ },
78
+ });
79
+ }
80
+
81
+ act(() => {
82
+ root.render(React.createElement(Harness));
83
+ });
84
+
85
+ const canvas = host.querySelector("#canvas");
86
+ if (!(canvas instanceof HTMLDivElement)) throw new Error("Expected canvas div");
87
+
88
+ return {
89
+ canvas,
90
+ cleanup: () => {
91
+ act(() => root.unmount());
92
+ host.remove();
93
+ },
94
+ };
95
+ }
96
+
97
+ async function dispatchMouseDown(canvas: HTMLDivElement, init: MouseEventInit): Promise<void> {
98
+ await act(async () => {
99
+ canvas.dispatchEvent(
100
+ new MouseEvent("mousedown", {
101
+ bubbles: true,
102
+ cancelable: true,
103
+ clientX: 50,
104
+ clientY: 60,
105
+ ...init,
106
+ }),
107
+ );
108
+ await Promise.resolve();
109
+ await Promise.resolve();
110
+ });
111
+ }
112
+
113
+ describe("usePreviewInteraction", () => {
114
+ beforeEach(() => {
115
+ vi.restoreAllMocks();
116
+ });
117
+
118
+ it("pauses playback before resolving a click and falls back to the tracked hover selection", async () => {
119
+ const order: string[] = [];
120
+ const playerPause = vi.fn(() => {
121
+ order.push("pause");
122
+ });
123
+ const element = document.createElement("div");
124
+ element.id = "headline";
125
+ const hoverSelection = makeSelection("Headline", element);
126
+ const applyDomSelection = vi.fn();
127
+ const resolveDomSelectionFromPreviewPoint = vi.fn(async () => {
128
+ order.push("resolve");
129
+ return null;
130
+ });
131
+ const { canvas, cleanup } = renderHarness({
132
+ previewIframe: createPreviewIframe(playerPause),
133
+ resolveDomSelectionFromPreviewPoint,
134
+ applyDomSelection,
135
+ mouseDownOptions: { preferClipAncestor: false, hoverSelection },
136
+ });
137
+
138
+ await dispatchMouseDown(canvas, {});
139
+
140
+ expect(order[0]).toBe("pause");
141
+ expect(applyDomSelection).toHaveBeenCalledWith(hoverSelection);
142
+ cleanup();
143
+ });
144
+
145
+ it("treats a double-click on a regular element as a plain selection", async () => {
146
+ const element = document.createElement("div");
147
+ element.id = "headline";
148
+ const selection = makeSelection("Headline", element);
149
+ const applyDomSelection = vi.fn();
150
+ let resolveCount = 0;
151
+ const resolveDomSelectionFromPreviewPoint = vi.fn(async () => {
152
+ resolveCount += 1;
153
+ return resolveCount === 1 ? selection : null;
154
+ });
155
+ const { canvas, cleanup } = renderHarness({
156
+ previewIframe: null,
157
+ resolveDomSelectionFromPreviewPoint,
158
+ applyDomSelection,
159
+ });
160
+
161
+ await dispatchMouseDown(canvas, { detail: 2 });
162
+
163
+ expect(applyDomSelection).toHaveBeenCalledWith(selection);
164
+ expect(applyDomSelection).not.toHaveBeenCalledWith(null, { revealPanel: false });
165
+ cleanup();
166
+ });
167
+
168
+ it("preserves group drill-in on double-click", async () => {
169
+ const group = document.createElement("div");
170
+ group.setAttribute("data-hf-group", "hero");
171
+ const child = document.createElement("span");
172
+ child.id = "headline";
173
+ group.append(child);
174
+ const groupSelection = makeSelection("Hero Group", group);
175
+ const childSelection = makeSelection("Headline", child);
176
+ const applyDomSelection = vi.fn();
177
+ const setActiveGroupElement = vi.fn();
178
+ const resolveDomSelectionFromPreviewPoint = vi.fn(
179
+ async (
180
+ _clientX: number,
181
+ _clientY: number,
182
+ options?: { activeGroupElement?: HTMLElement | null },
183
+ ) => (options?.activeGroupElement === group ? childSelection : groupSelection),
184
+ );
185
+ const { canvas, cleanup } = renderHarness({
186
+ previewIframe: null,
187
+ resolveDomSelectionFromPreviewPoint,
188
+ applyDomSelection,
189
+ setActiveGroupElement,
190
+ });
191
+
192
+ await dispatchMouseDown(canvas, { detail: 2 });
193
+
194
+ expect(setActiveGroupElement).toHaveBeenCalledWith(group);
195
+ expect(applyDomSelection).toHaveBeenCalledWith(childSelection);
196
+ cleanup();
197
+ });
198
+
199
+ it("cycles stacked candidates on a rapid second click at the same spot", async () => {
200
+ const topElement = document.createElement("div");
201
+ topElement.id = "top";
202
+ const bottomElement = document.createElement("div");
203
+ bottomElement.id = "bottom";
204
+ const topSelection = makeSelection("Top", topElement);
205
+ const bottomSelection = makeSelection("Bottom", bottomElement);
206
+ const applyDomSelection = vi.fn();
207
+ const resolveDomSelectionFromPreviewPoint = vi.fn(async () => topSelection);
208
+ const resolveAllDomSelectionsFromPreviewPoint = vi.fn(async () => [
209
+ topSelection,
210
+ bottomSelection,
211
+ ]);
212
+ const { canvas, cleanup } = renderHarness({
213
+ previewIframe: null,
214
+ resolveDomSelectionFromPreviewPoint,
215
+ resolveAllDomSelectionsFromPreviewPoint,
216
+ applyDomSelection,
217
+ });
218
+
219
+ await dispatchMouseDown(canvas, { detail: 1 });
220
+ await dispatchMouseDown(canvas, { detail: 2 });
221
+
222
+ expect(applyDomSelection).toHaveBeenNthCalledWith(1, topSelection);
223
+ expect(applyDomSelection).toHaveBeenNthCalledWith(2, bottomSelection);
224
+ cleanup();
225
+ });
226
+
227
+ it("resumes playback when a click resolves to nothing (dead-zone / deselect)", async () => {
228
+ usePlayerStore.setState({ isPlaying: true });
229
+ const applyDomSelection = vi.fn();
230
+ const resolveDomSelectionFromPreviewPoint = vi.fn(async () => null);
231
+ const { canvas, cleanup } = renderHarness({
232
+ previewIframe: createPreviewIframe(vi.fn()),
233
+ resolveDomSelectionFromPreviewPoint,
234
+ applyDomSelection,
235
+ });
236
+
237
+ await dispatchMouseDown(canvas, {});
238
+
239
+ expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false });
240
+ expect(usePlayerStore.getState().isPlaying).toBe(true);
241
+ cleanup();
242
+ });
243
+
244
+ it("does not resume playback on deselect when it was already paused", async () => {
245
+ usePlayerStore.setState({ isPlaying: false });
246
+ const applyDomSelection = vi.fn();
247
+ const resolveDomSelectionFromPreviewPoint = vi.fn(async () => null);
248
+ const { canvas, cleanup } = renderHarness({
249
+ previewIframe: createPreviewIframe(vi.fn()),
250
+ resolveDomSelectionFromPreviewPoint,
251
+ applyDomSelection,
252
+ });
253
+
254
+ await dispatchMouseDown(canvas, {});
255
+
256
+ expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false });
257
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
258
+ cleanup();
259
+ });
260
+ });
@@ -3,6 +3,7 @@ import { liveTime, usePlayerStore } from "../player";
3
3
  import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers";
4
4
  import { STUDIO_PREVIEW_SELECTION_ENABLED } from "../components/editor/manualEditingAvailability";
5
5
  import { type DomEditSelection } from "../components/editor/domEditing";
6
+ import type { ApplyDomSelectionOptions, ResolveDomSelectionOptions } from "./useDomSelection";
6
7
  import { trackStudioEvent } from "../utils/studioTelemetry";
7
8
 
8
9
  // ── Types ──
@@ -16,16 +17,12 @@ export interface UsePreviewInteractionParams {
16
17
  // From useDomSelection
17
18
  applyDomSelection: (
18
19
  selection: DomEditSelection | null,
19
- options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
20
+ options?: ApplyDomSelectionOptions,
20
21
  ) => void;
21
22
  resolveDomSelectionFromPreviewPoint: (
22
23
  clientX: number,
23
24
  clientY: number,
24
- options?: {
25
- preferClipAncestor?: boolean;
26
- skipSourceProbe?: boolean;
27
- activeGroupElement?: HTMLElement | null;
28
- },
25
+ options?: ResolveDomSelectionOptions,
29
26
  ) => Promise<DomEditSelection | null>;
30
27
  resolveAllDomSelectionsFromPreviewPoint: (
31
28
  clientX: number,
@@ -46,6 +43,11 @@ interface ClickCycleState {
46
43
  at: number;
47
44
  }
48
45
 
46
+ export interface PreviewMouseDownOptions {
47
+ preferClipAncestor?: boolean;
48
+ hoverSelection?: DomEditSelection | null;
49
+ }
50
+
49
51
  const CYCLE_RADIUS_PX = 6;
50
52
  const CYCLE_WINDOW_MS = 600;
51
53
  // Manual double-click window. `e.detail` can't be trusted here: the first click
@@ -72,9 +74,19 @@ export function usePreviewInteraction({
72
74
  const cycleRef = useRef<ClickCycleState | null>(null);
73
75
  const lastDownRef = useRef<{ t: number; x: number; y: number } | null>(null);
74
76
 
77
+ const pausePreviewPlayback = useCallback(() => {
78
+ const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current);
79
+ const playerStore = usePlayerStore.getState();
80
+ playerStore.setIsPlaying(false);
81
+ if (pausedTime != null) {
82
+ playerStore.setCurrentTime(pausedTime);
83
+ liveTime.notify(pausedTime);
84
+ }
85
+ }, [previewIframeRef]);
86
+
75
87
  const handlePreviewCanvasMouseDown = useCallback(
76
88
  // fallow-ignore-next-line complexity
77
- async (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
89
+ async (e: React.MouseEvent<HTMLDivElement>, options?: PreviewMouseDownOptions) => {
78
90
  if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
79
91
 
80
92
  // Manual double-click detection (see DOUBLE_CLICK_MS): the first click
@@ -87,12 +99,26 @@ export function usePreviewInteraction({
87
99
  downTs - lastDown.t < DOUBLE_CLICK_MS &&
88
100
  Math.hypot(e.clientX - lastDown.x, e.clientY - lastDown.y) < DOUBLE_CLICK_RADIUS_PX);
89
101
  lastDownRef.current = { t: downTs, x: e.clientX, y: e.clientY };
102
+ const wasPlaying = usePlayerStore.getState().isPlaying;
103
+ pausePreviewPlayback();
104
+ // A click that resolves to nothing (dead-zone / deselect) shouldn't leave
105
+ // playback paused — pausing before sampling only exists to keep the hit
106
+ // target stable while resolving; resume if nothing was selected.
107
+ const resumeIfNothingSelected = () => {
108
+ if (wasPlaying) usePlayerStore.getState().setIsPlaying(true);
109
+ };
90
110
 
91
111
  // Double-click a group → drill into it and select the child under the
92
112
  // pointer (resolve with the group as the explicit drill-in scope, since the
93
113
  // activeGroupElement state hasn't re-rendered yet within this handler).
94
114
  if (isDoubleClick && !e.shiftKey) {
95
115
  const hit = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY);
116
+ const cycle = cycleRef.current;
117
+ const hasStackCycleAtSpot =
118
+ cycle !== null &&
119
+ cycle.candidates.length > 1 &&
120
+ Math.hypot(e.clientX - cycle.x, e.clientY - cycle.y) < CYCLE_RADIUS_PX &&
121
+ downTs - cycle.at < CYCLE_WINDOW_MS;
96
122
  if (hit?.element.hasAttribute("data-hf-group")) {
97
123
  e.preventDefault();
98
124
  e.stopPropagation();
@@ -105,6 +131,18 @@ export function usePreviewInteraction({
105
131
  applyDomSelection(child ?? hit);
106
132
  return;
107
133
  }
134
+ if (
135
+ hit &&
136
+ !hasStackCycleAtSpot &&
137
+ !hit.element.hasAttribute("data-composition-src") &&
138
+ !hit.element.hasAttribute("data-composition-file")
139
+ ) {
140
+ e.preventDefault();
141
+ e.stopPropagation();
142
+ cycleRef.current = null;
143
+ applyDomSelection(hit);
144
+ return;
145
+ }
108
146
  }
109
147
 
110
148
  const now = Date.now();
@@ -119,10 +157,16 @@ export function usePreviewInteraction({
119
157
  if (e.shiftKey) {
120
158
  // Additive selection — no cycling
121
159
  cycleRef.current = null;
122
- const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
123
- preferClipAncestor: options?.preferClipAncestor ?? false,
124
- });
125
- if (!nextSelection) return;
160
+ const nextSelection =
161
+ (await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
162
+ preferClipAncestor: options?.preferClipAncestor ?? false,
163
+ })) ??
164
+ options?.hoverSelection ??
165
+ null;
166
+ if (!nextSelection) {
167
+ resumeIfNothingSelected();
168
+ return;
169
+ }
126
170
  e.preventDefault();
127
171
  e.stopPropagation();
128
172
  applyDomSelection(nextSelection, { additive: true });
@@ -156,9 +200,11 @@ export function usePreviewInteraction({
156
200
  activeGroupElement: null,
157
201
  });
158
202
  }
203
+ nextSelection = nextSelection ?? options?.hoverSelection ?? null;
159
204
  if (!nextSelection) {
160
205
  cycleRef.current = null;
161
206
  applyDomSelection(null, { revealPanel: false });
207
+ resumeIfNothingSelected();
162
208
  return;
163
209
  }
164
210
  e.preventDefault();
@@ -180,6 +226,7 @@ export function usePreviewInteraction({
180
226
  captionEditMode,
181
227
  compositionLoading,
182
228
  onClickToSource,
229
+ pausePreviewPlayback,
183
230
  resolveAllDomSelectionsFromPreviewPoint,
184
231
  resolveDomSelectionFromPreviewPoint,
185
232
  setActiveGroupElement,
@@ -225,14 +272,8 @@ export function usePreviewInteraction({
225
272
  );
226
273
 
227
274
  const handleDomManualDragStart = useCallback(() => {
228
- const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current);
229
- const playerStore = usePlayerStore.getState();
230
- playerStore.setIsPlaying(false);
231
- if (pausedTime != null) {
232
- playerStore.setCurrentTime(pausedTime);
233
- liveTime.notify(pausedTime);
234
- }
235
- }, [previewIframeRef]);
275
+ pausePreviewPlayback();
276
+ }, [pausePreviewPlayback]);
236
277
 
237
278
  return {
238
279
  handlePreviewCanvasMouseDown,
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { PatchOperation } from "./sourcePatcher";
3
+ import { shouldUseSdkCutover } from "./sdkCutoverEligibility";
4
+
5
+ const childStyleOp: PatchOperation = {
6
+ type: "inline-style",
7
+ property: "color",
8
+ value: "blue",
9
+ childSelector: ":scope > span",
10
+ childIndex: 0,
11
+ };
12
+
13
+ describe("shouldUseSdkCutover child-scoped operations", () => {
14
+ it("declines child-scoped operations because SDK patch ops target only the parent hfId", () => {
15
+ expect(shouldUseSdkCutover(true, true, "hf-parent", [childStyleOp])).toBe(false);
16
+ });
17
+ });
@@ -61,19 +61,23 @@ function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean {
61
61
  );
62
62
  }
63
63
 
64
+ function hasChildScopedOp(ops: PatchOperation[]): boolean {
65
+ return ops.some((op) => op.childSelector !== undefined);
66
+ }
67
+
64
68
  function hasTextContentOp(ops: PatchOperation[]): boolean {
65
69
  return ops.some((op) => op.type === "text-content");
66
70
  }
67
71
 
68
72
  function targetChildren(target: unknown): unknown[] | null {
69
73
  if (!target || typeof target !== "object" || !("children" in target)) return null;
70
- const children = (target as { children?: unknown }).children;
74
+ const children = target.children;
71
75
  return Array.isArray(children) ? children : null;
72
76
  }
73
77
 
74
78
  function elementTag(element: unknown): string | null {
75
79
  if (!element || typeof element !== "object" || !("tag" in element)) return null;
76
- const tag = (element as { tag?: unknown }).tag;
80
+ const tag = element.tag;
77
81
  return typeof tag === "string" ? tag.toLowerCase() : null;
78
82
  }
79
83
 
@@ -110,6 +114,8 @@ export function shouldUseSdkCutover(
110
114
  !!hfId &&
111
115
  ops.length > 0 &&
112
116
  ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
117
+ // SDK edit ops target only the element hfId; child-scoped patch ops need the server path.
118
+ !hasChildScopedOp(ops) &&
113
119
  !ops.some(mapsToReservedAttr) &&
114
120
  !hasUnsafeHtmlAttributeOp(ops)
115
121
  );