@hyperframes/studio 0.8.101 → 0.8.102
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/{hyperframes-player-CFJLrHRF.js → hyperframes-player-BuRiFtrF.js} +1 -1
- package/dist/assets/{index-C33EDBL2.js → index-BM35fjFf.js} +1 -1
- package/dist/assets/{index-CJOzbslX.js → index-BT3sauee.js} +1 -1
- package/dist/assets/{index-BBNVnnyH.js → index-C2utDZt-.js} +113 -113
- package/dist/index.d.ts +11 -1
- package/dist/index.html +1 -1
- package/dist/index.js +810 -766
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/editor/CanvasContextMenu.test.tsx +23 -2
- package/src/components/editor/CanvasContextMenu.tsx +7 -10
- package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -128
- package/src/components/editor/canvasContextMenuZOrder.ts +13 -0
- package/src/components/editor/gsapAnimatesProperty.ts +0 -29
- package/src/components/editor/gsapLivePreview.test.ts +46 -1
- package/src/components/editor/propertyPanelHelpers.ts +3 -0
- package/src/components/editor/useDomEditZOrder.ts +185 -0
- package/src/components/nle/PreviewOverlaysTestMocks.ts +4 -0
- package/src/domEditExports.test.tsx +92 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +6 -0
- package/src/hooks/useDomEditCommits.test.tsx +94 -90
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.102",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.102",
|
|
64
|
+
"@hyperframes/player": "0.8.102",
|
|
65
|
+
"@hyperframes/sdk": "0.8.102",
|
|
66
|
+
"@hyperframes/parsers": "0.8.102",
|
|
67
|
+
"@hyperframes/studio-server": "0.8.102",
|
|
68
|
+
"@hyperframes/shader-transitions": "0.8.102"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@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.
|
|
84
|
+
"@hyperframes/producer": "0.8.102"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -3,7 +3,7 @@ import React, { act } from "react";
|
|
|
3
3
|
import { createRoot, type Root } from "react-dom/client";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
|
|
6
|
-
import { resolveZIndexEntries } from "./
|
|
6
|
+
import { resolveZIndexEntries } from "./useDomEditZOrder";
|
|
7
7
|
import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps";
|
|
8
8
|
import { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils";
|
|
9
9
|
import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
|
|
@@ -193,6 +193,27 @@ function renderCommitHook(captured: CapturedBatchCall[]) {
|
|
|
193
193
|
return { commit: commit!, cleanup: () => act(() => hookRoot.unmount()) };
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
describe("resolveZIndexEntries", () => {
|
|
197
|
+
it("keys a same-class sibling by its index among them, not as the first of its class", () => {
|
|
198
|
+
const parent = document.body.appendChild(document.createElement("div"));
|
|
199
|
+
const [first, second, selected] = ["card", "card", "picked"].map((name) => {
|
|
200
|
+
const child = parent.appendChild(document.createElement("div"));
|
|
201
|
+
child.className = name;
|
|
202
|
+
return child;
|
|
203
|
+
});
|
|
204
|
+
const { entries } = resolveZIndexEntries(
|
|
205
|
+
makeSelection("Picked", selected!),
|
|
206
|
+
[first!, second!].map((element, zIndex) => ({ element, zIndex })),
|
|
207
|
+
null,
|
|
208
|
+
);
|
|
209
|
+
expect(entries.map((entry) => [entry.selector, entry.selectorIndex])).toEqual([
|
|
210
|
+
[".card", 0],
|
|
211
|
+
[".card", 1],
|
|
212
|
+
]);
|
|
213
|
+
parent.remove();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
196
217
|
describe("CanvasContextMenu — z-action commit path", () => {
|
|
197
218
|
it("never mutates live styles itself and persists the position patch for a static element", async () => {
|
|
198
219
|
const { target } = makeStaticFamily();
|
|
@@ -208,7 +229,7 @@ describe("CanvasContextMenu — z-action commit path", () => {
|
|
|
208
229
|
selection,
|
|
209
230
|
onApplyZIndex: (patches, action) => {
|
|
210
231
|
stylesAtApply.push({ zIndex: target.style.zIndex, position: target.style.position });
|
|
211
|
-
const { entries } = resolveZIndexEntries(selection, patches);
|
|
232
|
+
const { entries } = resolveZIndexEntries(selection, patches, null);
|
|
212
233
|
void commit(entries, undefined, action);
|
|
213
234
|
},
|
|
214
235
|
});
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* flow, and captures the TRUE prior styles for its failure rollback.
|
|
17
17
|
*
|
|
18
18
|
* The prop MUST be wired at the call site to route through the full persist
|
|
19
|
-
* path.
|
|
19
|
+
* path. useDomEditZOrder.ts builds the per-patch PatchTargets (the selected
|
|
20
20
|
* element carries its full selection identity; sibling elements are iframe DOM
|
|
21
21
|
* nodes, so their id / selector are derived from the node and they share the
|
|
22
22
|
* selection's sourceFile) and forwards them to handleDomZIndexReorderCommit.
|
|
@@ -29,8 +29,7 @@ import type { DomEditSelection } from "./domEditing";
|
|
|
29
29
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
30
30
|
import {
|
|
31
31
|
isZOrderActionEnabled,
|
|
32
|
-
|
|
33
|
-
resolveZOrderChange,
|
|
32
|
+
resolveZOrderStep,
|
|
34
33
|
type ZOrderAction,
|
|
35
34
|
type ZOrderPatch,
|
|
36
35
|
} from "./canvasContextMenuZOrder";
|
|
@@ -170,13 +169,11 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
|
|
170
169
|
|
|
171
170
|
function handleZAction(action: ZAction) {
|
|
172
171
|
if (!onApplyZIndex) return;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// the z step into a timeline lane move.
|
|
179
|
-
const crossed = resolveCrossedNeighbor(el, action);
|
|
172
|
+
// Resolved BEFORE the commit path mutates live styles. `crossed` is always
|
|
173
|
+
// resolved (not only for the flash): the host mirrors it into a lane move.
|
|
174
|
+
const step = resolveZOrderStep(el, action);
|
|
175
|
+
if (step === null) return;
|
|
176
|
+
const { patches, crossed } = step;
|
|
180
177
|
// Do NOT pre-apply styles here: handleDomZIndexReorderCommit writes the
|
|
181
178
|
// live z-index (and injects position:relative for static elements) in the
|
|
182
179
|
// same synchronous flow, so feedback is still instant — and it must read
|
|
@@ -4,12 +4,7 @@ import {
|
|
|
4
4
|
useDomEditActionsContext,
|
|
5
5
|
useDomEditSelectionContext,
|
|
6
6
|
} from "../../contexts/DomEditContext";
|
|
7
|
-
import {
|
|
8
|
-
import { buildStableSelector } from "./domEditingDom";
|
|
9
|
-
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
|
|
10
|
-
import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
|
|
11
|
-
import { useCanvasZOrderTimelineMirror } from "../nle/useCanvasZOrderTimelineMirror";
|
|
12
|
-
import { runZLaneGesture } from "../nle/zLaneGesture";
|
|
7
|
+
import { useDomEditZOrder } from "./useDomEditZOrder";
|
|
13
8
|
|
|
14
9
|
type HostInputProps = Pick<
|
|
15
10
|
ComponentProps<typeof DomEditOverlay>,
|
|
@@ -25,94 +20,6 @@ export interface ConnectedDomEditOverlayProps extends HostInputProps {
|
|
|
25
20
|
onTextEditingChange?: (editing: boolean) => void;
|
|
26
21
|
}
|
|
27
22
|
|
|
28
|
-
type ZIndexReorderEntry = {
|
|
29
|
-
element: HTMLElement;
|
|
30
|
-
zIndex: number;
|
|
31
|
-
id?: string;
|
|
32
|
-
selector?: string;
|
|
33
|
-
selectorIndex?: number;
|
|
34
|
-
sourceFile: string;
|
|
35
|
-
/** Timeline store key — lets the commit update the store zIndex synchronously. */
|
|
36
|
-
key?: string;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
/** Can this element be robustly re-targeted for a persisted z change? */
|
|
40
|
-
function canTargetZIndexElement(
|
|
41
|
-
element: HTMLElement,
|
|
42
|
-
id: string | undefined,
|
|
43
|
-
selector: string | undefined,
|
|
44
|
-
): boolean {
|
|
45
|
-
return Boolean(id || selector || readHfId(element));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** The selected element carries its full selection identity. */
|
|
49
|
-
function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReorderEntry {
|
|
50
|
-
return {
|
|
51
|
-
element: sel.element,
|
|
52
|
-
zIndex,
|
|
53
|
-
id: sel.id ?? undefined,
|
|
54
|
-
selector: sel.selector,
|
|
55
|
-
selectorIndex: sel.selectorIndex,
|
|
56
|
-
sourceFile: sel.sourceFile,
|
|
57
|
-
key: deriveTimelineStoreKey({
|
|
58
|
-
domId: sel.id ?? undefined,
|
|
59
|
-
selector: sel.selector,
|
|
60
|
-
selectorIndex: sel.selectorIndex,
|
|
61
|
-
sourceFile: sel.sourceFile,
|
|
62
|
-
}),
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** A raw iframe sibling in the selection's file; null with no id or selector (z stays live). */
|
|
67
|
-
function siblingZIndexEntry(
|
|
68
|
-
element: HTMLElement,
|
|
69
|
-
zIndex: number,
|
|
70
|
-
sourceFile: string,
|
|
71
|
-
): ZIndexReorderEntry | null {
|
|
72
|
-
const id = element.id || undefined;
|
|
73
|
-
const selector = buildStableSelector(element);
|
|
74
|
-
if (!canTargetZIndexElement(element, id, selector)) return null;
|
|
75
|
-
return {
|
|
76
|
-
element,
|
|
77
|
-
zIndex,
|
|
78
|
-
id,
|
|
79
|
-
selector,
|
|
80
|
-
selectorIndex: undefined,
|
|
81
|
-
sourceFile,
|
|
82
|
-
key: deriveTimelineStoreKey({ domId: id, selector, sourceFile }),
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Short human-readable label for a dropped sibling, for the console warning below. */
|
|
87
|
-
function describeZIndexElement(element: HTMLElement): string {
|
|
88
|
-
if (element.id) return `#${element.id}`;
|
|
89
|
-
const firstClass = element.classList.item(0);
|
|
90
|
-
return firstClass
|
|
91
|
-
? `${element.tagName.toLowerCase()}.${firstClass}`
|
|
92
|
-
: element.tagName.toLowerCase();
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Resolve z-index patches into commit entries; a sibling with no stable
|
|
96
|
-
// id/selector can't be written to source, so it is returned as `dropped` for
|
|
97
|
-
// the revert-on-reload warning. Exported so tests can drive the menu → commit path.
|
|
98
|
-
export function resolveZIndexEntries(
|
|
99
|
-
sel: DomEditSelection,
|
|
100
|
-
patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
|
|
101
|
-
): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
|
|
102
|
-
const entries: ZIndexReorderEntry[] = [];
|
|
103
|
-
const dropped: Array<{ element: HTMLElement; zIndex: number }> = [];
|
|
104
|
-
for (const patch of patches) {
|
|
105
|
-
if (patch.element === sel.element) {
|
|
106
|
-
entries.push(selectedZIndexEntry(sel, patch.zIndex));
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile);
|
|
110
|
-
if (entry) entries.push(entry);
|
|
111
|
-
else dropped.push(patch);
|
|
112
|
-
}
|
|
113
|
-
return { entries, dropped };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
23
|
// Studio's DomEditOverlay wired to the session in DomEditProvider, so a host
|
|
117
24
|
// outside EditorShell mounts the same canvas editing without its own callbacks.
|
|
118
25
|
export function ConnectedDomEditOverlay({
|
|
@@ -142,9 +49,8 @@ export function ConnectedDomEditOverlay({
|
|
|
142
49
|
handleDomStyleCommit,
|
|
143
50
|
applyMarqueeSelection,
|
|
144
51
|
handleDomEditElementDelete,
|
|
145
|
-
handleDomZIndexReorderCommit,
|
|
146
52
|
} = useDomEditActionsContext();
|
|
147
|
-
const
|
|
53
|
+
const zOrder = useDomEditZOrder();
|
|
148
54
|
|
|
149
55
|
return (
|
|
150
56
|
<DomEditOverlay
|
|
@@ -170,38 +76,7 @@ export function ConnectedDomEditOverlay({
|
|
|
170
76
|
onRotationCommit={handleDomRotationCommit}
|
|
171
77
|
onStyleCommit={handleDomStyleCommit}
|
|
172
78
|
onDeleteSelection={handleDomEditElementDelete}
|
|
173
|
-
onApplyZIndex={
|
|
174
|
-
const { entries, dropped } = resolveZIndexEntries(sel, patches);
|
|
175
|
-
if (dropped.length > 0) {
|
|
176
|
-
// These siblings can't be written to source. Apply their live z
|
|
177
|
-
// anyway so the resolved stacking order renders coherently — it
|
|
178
|
-
// just reverts to the prior order on the next reload.
|
|
179
|
-
for (const patch of dropped) patch.element.style.zIndex = String(patch.zIndex);
|
|
180
|
-
console.warn(
|
|
181
|
-
"[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
|
|
182
|
-
"(will revert on reload):",
|
|
183
|
-
dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
if (entries.length === 0) return;
|
|
187
|
-
// One coalesce key for the z persist AND the lane mirror folds both into one undo entry;
|
|
188
|
-
// passed explicitly so the mirror shares it by construction, not by formula duplication.
|
|
189
|
-
const coalesceKey = zReorderCoalesceKey(entries, action);
|
|
190
|
-
// One serialized z→lane transaction: the mirror runs only AFTER a durable z commit and
|
|
191
|
-
// no second gesture interleaves (see runZLaneGesture). A failed z commit has already
|
|
192
|
-
// toasted and rolled back, so the catch only keeps its rejection from going unhandled.
|
|
193
|
-
runZLaneGesture({
|
|
194
|
-
commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, action),
|
|
195
|
-
mirror: () =>
|
|
196
|
-
mirrorZOrderToTimeline({
|
|
197
|
-
selectionKey: entries.find((e) => e.element === sel.element)?.key,
|
|
198
|
-
action,
|
|
199
|
-
crossed,
|
|
200
|
-
sourceFile: sel.sourceFile,
|
|
201
|
-
coalesceKey,
|
|
202
|
-
}),
|
|
203
|
-
}).catch(() => undefined);
|
|
204
|
-
}}
|
|
79
|
+
onApplyZIndex={zOrder.commit}
|
|
205
80
|
onMarqueeSelect={applyMarqueeSelection}
|
|
206
81
|
/>
|
|
207
82
|
);
|
|
@@ -511,3 +511,16 @@ export function isZOrderActionEnabled(
|
|
|
511
511
|
): boolean {
|
|
512
512
|
return resolveZOrderChange(target, action, options) !== null;
|
|
513
513
|
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* One z-order step: the patches plus the sibling it crosses, both read from the
|
|
517
|
+
* same pre-change render order. Null when the action is a no-op.
|
|
518
|
+
*/
|
|
519
|
+
export function resolveZOrderStep(
|
|
520
|
+
target: HTMLElement,
|
|
521
|
+
action: ZOrderAction,
|
|
522
|
+
): { patches: ZOrderPatch[]; crossed: HTMLElement | null } | null {
|
|
523
|
+
const patches = resolveZOrderChange(target, action);
|
|
524
|
+
if (patches === null) return null;
|
|
525
|
+
return { patches, crossed: resolveCrossedNeighbor(target, action) };
|
|
526
|
+
}
|
|
@@ -1,32 +1,3 @@
|
|
|
1
|
-
// GSAP's CSSPlugin takes ownership of the element's entire transform stack
|
|
2
|
-
// when it tweens ANY of these — it bakes the CSS `translate` longhand into
|
|
3
|
-
// style.transform at init and writes `translate: none` every tick. Position
|
|
4
|
-
// reapply/strip logic must therefore stand down for all of them, not just x/y.
|
|
5
|
-
const GSAP_TRANSFORM_PROPS = [
|
|
6
|
-
"x",
|
|
7
|
-
"y",
|
|
8
|
-
"xPercent",
|
|
9
|
-
"yPercent",
|
|
10
|
-
"scale",
|
|
11
|
-
"scaleX",
|
|
12
|
-
"scaleY",
|
|
13
|
-
"rotation",
|
|
14
|
-
"rotate",
|
|
15
|
-
"rotationX",
|
|
16
|
-
"rotationY",
|
|
17
|
-
"skewX",
|
|
18
|
-
"skewY",
|
|
19
|
-
"transform",
|
|
20
|
-
];
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* True when GSAP animates any transform-affecting property on the element,
|
|
24
|
-
* meaning GSAP owns `style.transform` and has neutralized CSS `translate`.
|
|
25
|
-
*/
|
|
26
|
-
export function gsapAnimatesTransform(el: HTMLElement): boolean {
|
|
27
|
-
return gsapAnimatesProperty(el, ...GSAP_TRANSFORM_PROPS);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
1
|
/**
|
|
31
2
|
* Checks whether GSAP actively animates one or more CSS/GSAP properties on
|
|
32
3
|
* the given element by inspecting all registered `__timelines`.
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
-
import { expect, it, vi } from "vitest";
|
|
2
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
3
3
|
import { createGsapLivePreview } from "./gsapLivePreview";
|
|
4
4
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
5
5
|
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
|
|
6
6
|
import { readGsapRuntimeValuesForPanel } from "./propertyPanelHelpers";
|
|
7
7
|
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
delete (window as { gsap?: unknown }).gsap;
|
|
10
|
+
delete (window as { __timelines?: unknown }).__timelines;
|
|
11
|
+
});
|
|
12
|
+
|
|
8
13
|
it("previews on the selected element, not an earlier same-id copy in a sub-composition", () => {
|
|
9
14
|
document.body.innerHTML =
|
|
10
15
|
'<div data-composition-id="strip"><div id="card" data-hf-id="hf-inner"></div></div>' +
|
|
@@ -61,3 +66,43 @@ it("the panel reads GSAP values off the node the live preview moves", () => {
|
|
|
61
66
|
});
|
|
62
67
|
expect(getProperty.mock.calls[0]?.[0]).toBe(root);
|
|
63
68
|
});
|
|
69
|
+
|
|
70
|
+
it.each([
|
|
71
|
+
["fades it", { opacity: 0 }, ["opacity"]],
|
|
72
|
+
["fades it about its corner", { opacity: 0, transformOrigin: "0 0" }, ["opacity"]],
|
|
73
|
+
[
|
|
74
|
+
"moves it",
|
|
75
|
+
{ x: 100 },
|
|
76
|
+
[
|
|
77
|
+
"x",
|
|
78
|
+
"y",
|
|
79
|
+
"rotation",
|
|
80
|
+
"rotationX",
|
|
81
|
+
"rotationY",
|
|
82
|
+
"rotationZ",
|
|
83
|
+
"z",
|
|
84
|
+
"scale",
|
|
85
|
+
"transformPerspective",
|
|
86
|
+
"opacity",
|
|
87
|
+
],
|
|
88
|
+
],
|
|
89
|
+
])(
|
|
90
|
+
"the panel reads GSAP's transform only off an element GSAP positions: a tween that %s",
|
|
91
|
+
(_, vars, read) => {
|
|
92
|
+
document.body.innerHTML = '<div id="card"></div>';
|
|
93
|
+
const card = document.querySelector<HTMLElement>("#card");
|
|
94
|
+
const tween = { targets: () => [card], vars, duration: () => 1 };
|
|
95
|
+
const getProperty = vi.fn(() => 1);
|
|
96
|
+
const contentWindow = Object.assign(window, {
|
|
97
|
+
gsap: { getProperty },
|
|
98
|
+
__timelines: { main: { getChildren: () => [tween] } },
|
|
99
|
+
});
|
|
100
|
+
const selection = { id: "card", sourceFile: "index.html", element: card } as DomEditSelection;
|
|
101
|
+
const animations = [{ properties: vars }] as unknown as GsapAnimation[];
|
|
102
|
+
readGsapRuntimeValuesForPanel("anim", animations, selection, {
|
|
103
|
+
current: { contentWindow, contentDocument: document } as unknown as HTMLIFrameElement,
|
|
104
|
+
});
|
|
105
|
+
const props = getProperty.mock.calls.map((call) => (call as unknown[])[1]);
|
|
106
|
+
expect(props).toEqual(read);
|
|
107
|
+
},
|
|
108
|
+
);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
playbackStartAttributeForElement,
|
|
10
10
|
readPlaybackStartAttributes,
|
|
11
11
|
} from "../../player/lib/timelineElementHelpers";
|
|
12
|
+
import { GSAP_TRANSFORM_KEYS, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
12
13
|
|
|
13
14
|
export type {
|
|
14
15
|
BackgroundRemovalProgress,
|
|
@@ -496,8 +497,10 @@ export function readGsapRuntimeValuesForPanel(
|
|
|
496
497
|
const el = findPreviewNode(iframe.contentDocument, element);
|
|
497
498
|
if (!el) return null;
|
|
498
499
|
const propKeys = collectPanelPropKeys(gsapAnimations);
|
|
500
|
+
const readsTransform = gsapWritesPosition(el);
|
|
499
501
|
const result: Record<string, number> = {};
|
|
500
502
|
for (const prop of propKeys) {
|
|
503
|
+
if (!readsTransform && GSAP_TRANSFORM_KEYS.has(prop)) continue;
|
|
501
504
|
const v = Number(gsap.getProperty(el, prop));
|
|
502
505
|
if (Number.isFinite(v)) result[prop] = roundToCenti(v);
|
|
503
506
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import { useDomEditActionsContext } from "../../contexts/DomEditContext";
|
|
3
|
+
import { readHfId, type DomEditSelection } from "./domEditing";
|
|
4
|
+
import { buildStableSelector, getSelectorIndex } from "./domEditingDom";
|
|
5
|
+
import { usePreviewReadOnly } from "./previewReadOnlyContext";
|
|
6
|
+
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
7
|
+
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
|
|
8
|
+
import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
|
|
9
|
+
import { useCanvasZOrderTimelineMirror } from "../nle/useCanvasZOrderTimelineMirror";
|
|
10
|
+
import { runZLaneGesture } from "../nle/zLaneGesture";
|
|
11
|
+
import {
|
|
12
|
+
isZOrderActionEnabled,
|
|
13
|
+
resolveZOrderStep,
|
|
14
|
+
type ZOrderAction,
|
|
15
|
+
type ZOrderPatch,
|
|
16
|
+
} from "./canvasContextMenuZOrder";
|
|
17
|
+
|
|
18
|
+
type ZIndexReorderEntry = {
|
|
19
|
+
element: HTMLElement;
|
|
20
|
+
zIndex: number;
|
|
21
|
+
id?: string;
|
|
22
|
+
selector?: string;
|
|
23
|
+
selectorIndex?: number;
|
|
24
|
+
sourceFile: string;
|
|
25
|
+
/** Timeline store key — lets the commit update the store zIndex synchronously. */
|
|
26
|
+
key?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Can this element be robustly re-targeted for a persisted z change? */
|
|
30
|
+
function canTargetZIndexElement(
|
|
31
|
+
element: HTMLElement,
|
|
32
|
+
id: string | undefined,
|
|
33
|
+
selector: string | undefined,
|
|
34
|
+
): boolean {
|
|
35
|
+
return Boolean(id || selector || readHfId(element));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The selected element carries its full selection identity. */
|
|
39
|
+
function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReorderEntry {
|
|
40
|
+
return {
|
|
41
|
+
element: sel.element,
|
|
42
|
+
zIndex,
|
|
43
|
+
id: sel.id ?? undefined,
|
|
44
|
+
selector: sel.selector,
|
|
45
|
+
selectorIndex: sel.selectorIndex,
|
|
46
|
+
sourceFile: sel.sourceFile,
|
|
47
|
+
key: deriveTimelineStoreKey({
|
|
48
|
+
domId: sel.id ?? undefined,
|
|
49
|
+
selector: sel.selector,
|
|
50
|
+
selectorIndex: sel.selectorIndex,
|
|
51
|
+
sourceFile: sel.sourceFile,
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A raw iframe sibling in the selection's file; null with no id or selector (z stays live). Its selector index
|
|
57
|
+
* picks it among same-class siblings, the way the lane mirror keys `crossed`. */
|
|
58
|
+
function siblingZIndexEntry(
|
|
59
|
+
element: HTMLElement,
|
|
60
|
+
zIndex: number,
|
|
61
|
+
sourceFile: string,
|
|
62
|
+
activeCompPath: string | null,
|
|
63
|
+
): ZIndexReorderEntry | null {
|
|
64
|
+
const id = element.id || undefined;
|
|
65
|
+
const selector = buildStableSelector(element);
|
|
66
|
+
if (!canTargetZIndexElement(element, id, selector)) return null;
|
|
67
|
+
const selectorIndex = id
|
|
68
|
+
? undefined
|
|
69
|
+
: getSelectorIndex(element.ownerDocument, element, selector, sourceFile, activeCompPath);
|
|
70
|
+
return {
|
|
71
|
+
element,
|
|
72
|
+
zIndex,
|
|
73
|
+
id,
|
|
74
|
+
selector,
|
|
75
|
+
selectorIndex,
|
|
76
|
+
sourceFile,
|
|
77
|
+
key: deriveTimelineStoreKey({ domId: id, selector, selectorIndex, sourceFile }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Short human-readable label for a dropped sibling, for the console warning below. */
|
|
82
|
+
function describeZIndexElement(element: HTMLElement): string {
|
|
83
|
+
if (element.id) return `#${element.id}`;
|
|
84
|
+
const firstClass = element.classList.item(0);
|
|
85
|
+
return firstClass
|
|
86
|
+
? `${element.tagName.toLowerCase()}.${firstClass}`
|
|
87
|
+
: element.tagName.toLowerCase();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Patches as commit entries; a sibling with no id or selector is `dropped` and reverts on reload.
|
|
91
|
+
export function resolveZIndexEntries(
|
|
92
|
+
sel: DomEditSelection,
|
|
93
|
+
patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
|
|
94
|
+
activeCompPath: string | null,
|
|
95
|
+
): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
|
|
96
|
+
const entries: ZIndexReorderEntry[] = [];
|
|
97
|
+
const dropped: Array<{ element: HTMLElement; zIndex: number }> = [];
|
|
98
|
+
for (const patch of patches) {
|
|
99
|
+
if (patch.element === sel.element) {
|
|
100
|
+
entries.push(selectedZIndexEntry(sel, patch.zIndex));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile, activeCompPath);
|
|
104
|
+
if (entry) entries.push(entry);
|
|
105
|
+
else dropped.push(patch);
|
|
106
|
+
}
|
|
107
|
+
return { entries, dropped };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface DomEditZOrder {
|
|
111
|
+
/** False in a read-only preview, for an element no longer in the live preview, or at that end of its stacking set. */
|
|
112
|
+
enabled: (sel: DomEditSelection, action: ZOrderAction) => boolean;
|
|
113
|
+
/** Resolve and commit one step; false when nothing is sent to be saved. */
|
|
114
|
+
apply: (sel: DomEditSelection, action: ZOrderAction) => boolean;
|
|
115
|
+
/** Commit patches already resolved (the canvas menu resolves its own); false when none could be sent to be saved. */
|
|
116
|
+
commit: (
|
|
117
|
+
sel: DomEditSelection,
|
|
118
|
+
patches: ReadonlyArray<ZOrderPatch>,
|
|
119
|
+
action: ZOrderAction,
|
|
120
|
+
crossed: HTMLElement | null,
|
|
121
|
+
) => boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A selection from before a preview reload holds an element of a document no window shows, with stale z values. */
|
|
125
|
+
const isLive = (element: HTMLElement) =>
|
|
126
|
+
element.isConnected && element.ownerDocument.defaultView !== null;
|
|
127
|
+
|
|
128
|
+
// The canvas menu's z-order (write, undo, lane mirror) for any caller in DomEditProvider.
|
|
129
|
+
export function useDomEditZOrder(): DomEditZOrder {
|
|
130
|
+
const { handleDomZIndexReorderCommit } = useDomEditActionsContext();
|
|
131
|
+
const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror();
|
|
132
|
+
const readOnly = usePreviewReadOnly();
|
|
133
|
+
const activeCompPath = useStudioShellContextOptional()?.activeCompPath ?? null;
|
|
134
|
+
|
|
135
|
+
const enabled = useCallback<DomEditZOrder["enabled"]>(
|
|
136
|
+
(sel, action) => !readOnly && isLive(sel.element) && isZOrderActionEnabled(sel.element, action),
|
|
137
|
+
[readOnly],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const commit = useCallback<DomEditZOrder["commit"]>(
|
|
141
|
+
(sel, patches, action, crossed) => {
|
|
142
|
+
if (readOnly) return false;
|
|
143
|
+
const { entries, dropped } = resolveZIndexEntries(sel, patches, activeCompPath);
|
|
144
|
+
if (dropped.length > 0) {
|
|
145
|
+
// Not writable to source: their live z still applies, so the order renders, until a reload.
|
|
146
|
+
for (const patch of dropped) patch.element.style.zIndex = String(patch.zIndex);
|
|
147
|
+
console.warn(
|
|
148
|
+
"[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
|
|
149
|
+
"(will revert on reload):",
|
|
150
|
+
dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (entries.length === 0) return false;
|
|
154
|
+
// One coalesce key for the z write and the lane mirror: one undo entry.
|
|
155
|
+
const coalesceKey = zReorderCoalesceKey(entries, action);
|
|
156
|
+
// One serialized z→lane transaction: the mirror runs only AFTER a durable z commit and
|
|
157
|
+
// no second gesture interleaves (see runZLaneGesture). A failed z commit has already
|
|
158
|
+
// toasted and rolled back, so the catch only keeps its rejection from going unhandled.
|
|
159
|
+
runZLaneGesture({
|
|
160
|
+
commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, action),
|
|
161
|
+
mirror: () =>
|
|
162
|
+
mirrorZOrderToTimeline({
|
|
163
|
+
selectionKey: entries.find((e) => e.element === sel.element)?.key,
|
|
164
|
+
action,
|
|
165
|
+
crossed,
|
|
166
|
+
sourceFile: sel.sourceFile,
|
|
167
|
+
coalesceKey,
|
|
168
|
+
}),
|
|
169
|
+
}).catch(() => undefined);
|
|
170
|
+
return true;
|
|
171
|
+
},
|
|
172
|
+
[activeCompPath, handleDomZIndexReorderCommit, mirrorZOrderToTimeline, readOnly],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const apply = useCallback<DomEditZOrder["apply"]>(
|
|
176
|
+
(sel, action) => {
|
|
177
|
+
if (!isLive(sel.element)) return false;
|
|
178
|
+
const step = resolveZOrderStep(sel.element, action);
|
|
179
|
+
return step !== null && commit(sel, step.patches, action, step.crossed);
|
|
180
|
+
},
|
|
181
|
+
[commit],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
return useMemo(() => ({ enabled, apply, commit }), [enabled, apply, commit]);
|
|
185
|
+
}
|
|
@@ -8,6 +8,10 @@ export const iframeRef = { current: null as HTMLIFrameElement | null };
|
|
|
8
8
|
|
|
9
9
|
vi.mock("../../contexts/StudioContext", () => ({
|
|
10
10
|
useStudioShellContext: () => ({ activeCompPath: "index.html", previewIframeRef: iframeRef }),
|
|
11
|
+
useStudioShellContextOptional: () => ({
|
|
12
|
+
activeCompPath: "index.html",
|
|
13
|
+
previewIframeRef: iframeRef,
|
|
14
|
+
}),
|
|
11
15
|
useStudioPlaybackContext: () => ({
|
|
12
16
|
captionEditMode: previewState.captionEditMode,
|
|
13
17
|
compositionLoading: false,
|