@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.
- package/dist/assets/{index-BGUJ2C2G.js → index-BhSyGx7y.js} +1 -1
- package/dist/assets/{index-Bz6Eqd_G.js → index-D0yNztV_.js} +1 -1
- package/dist/assets/{index-CLlPjdPl.js → index-kbACg3_I.js} +139 -139
- package/dist/{chunk-AN2EWWK3.js → chunk-JND3XUJL.js} +38 -10
- package/dist/chunk-JND3XUJL.js.map +1 -0
- package/dist/{domEditingLayers-EK7R7R4G.js → domEditingLayers-UIQZJCOA.js} +4 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.html +1 -1
- package/dist/index.js +1145 -675
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/DomEditOverlay.test.ts +95 -0
- package/src/components/editor/DomEditOverlay.tsx +5 -3
- package/src/components/editor/domEditOverlayGestures.ts +3 -4
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.test.ts +52 -0
- package/src/components/editor/domEditingLayers.ts +55 -19
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/persistSeam.integration.test.ts +264 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +15 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +1 -0
- package/src/hooks/domEditCommitRunner.ts +46 -0
- package/src/hooks/domEditPersistFailure.test.ts +123 -0
- package/src/hooks/domEditPersistFailure.ts +89 -0
- package/src/hooks/domEditTextFieldCommitOps.test.ts +111 -0
- package/src/hooks/domEditTextFieldCommitOps.ts +63 -0
- package/src/hooks/domSelectionTestHarness.ts +40 -0
- package/src/hooks/useDomEditAttributeCommits.ts +227 -0
- package/src/hooks/useDomEditCommits.test.tsx +775 -0
- package/src/hooks/useDomEditCommits.ts +33 -5
- package/src/hooks/useDomEditTextCommits.ts +243 -220
- package/src/hooks/useDomSelection.test.ts +134 -0
- package/src/hooks/useDomSelection.ts +29 -15
- package/src/hooks/usePreviewInteraction.test.ts +260 -0
- package/src/hooks/usePreviewInteraction.ts +60 -19
- package/src/utils/sdkCutoverEligibility.test.ts +17 -0
- package/src/utils/sdkCutoverEligibility.ts +8 -2
- package/src/utils/sdkResolverShadow.test.ts +180 -1
- package/src/utils/sdkResolverShadow.ts +94 -1
- package/src/utils/sourcePatcher.ts +2 -0
- package/src/utils/studioPreviewHelpers.test.ts +95 -1
- package/src/utils/studioPreviewHelpers.ts +109 -10
- package/src/utils/studioSaveDiagnostics.ts +5 -2
- package/src/utils/studioTelemetry.ts +27 -20
- package/dist/chunk-AN2EWWK3.js.map +0 -1
- /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?:
|
|
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?:
|
|
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 =
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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 =
|
|
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 =
|
|
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
|
);
|