@hyperframes/studio 0.8.100 → 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-fWt87gyh.js → hyperframes-player-BuRiFtrF.js} +1 -1
- package/dist/assets/{index-DofdzdxJ.js → index-BM35fjFf.js} +1 -1
- package/dist/assets/{index-D34Oy5DN.js → index-BT3sauee.js} +1 -1
- package/dist/assets/{index-D6TjRAvz.js → index-C2utDZt-.js} +114 -114
- package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
- package/dist/chunk-7X4XRP3L.js.map +1 -0
- package/dist/index.d.ts +101 -21
- package/dist/index.html +1 -1
- package/dist/index.js +1211 -990
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +2 -0
- package/src/components/dock/Dock.test.tsx +78 -3
- package/src/components/dock/Dock.tsx +45 -18
- package/src/components/dock/dockLayout.ts +27 -14
- package/src/components/dock/dockLayoutSchema.ts +11 -4
- package/src/components/dock/dockLayoutStore.ts +4 -2
- 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/appHotkeysDispatch.ts +1 -7
- package/src/hooks/gsapRuntimeKeyframes.ts +6 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
- package/src/hooks/useAppHotkeys.ts +18 -45
- package/src/hooks/useDomEditCommits.test.tsx +94 -90
- package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
- package/src/hooks/useEditHistoryActions.test.tsx +67 -9
- package/src/hooks/useEditHistoryActions.ts +36 -8
- package/src/hooks/usePersistentEditHistory.test.ts +46 -0
- package/src/hooks/usePersistentEditHistory.ts +101 -19
- package/src/hooks/usePreviewPersistence.ts +25 -2
- package/src/hooks/useStudioExternalFileChanges.ts +7 -3
- package/src/index.ts +7 -0
- package/src/player/components/timelineDragDrop.test.tsx +61 -5
- package/src/player/components/timelineDragDrop.ts +29 -8
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/utils/domEditSaveQueue.ts +7 -2
- package/src/utils/gsapSoftReload.ts +1 -1
- package/src/utils/gsapUndoRestore.test.ts +46 -0
- package/src/utils/gsapUndoRestore.ts +166 -91
- package/src/utils/studioPendingEdits.ts +14 -5
- package/src/utils/studioUiPreferences.ts +8 -6
- package/dist/chunk-U3EE4ACX.js.map +0 -1
|
@@ -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,
|
|
@@ -9,13 +9,17 @@ import {
|
|
|
9
9
|
PreviewReadOnlyProvider,
|
|
10
10
|
useDomEditSelectionContext,
|
|
11
11
|
useDomEditSession,
|
|
12
|
+
useDomEditZOrder,
|
|
12
13
|
usePreviewPersistence,
|
|
13
14
|
type ConnectedDomEditOverlayProps,
|
|
14
15
|
type DomEditCapabilities,
|
|
15
16
|
type DomEditSelection,
|
|
17
|
+
type DomEditZOrder,
|
|
16
18
|
type UseDomEditSessionParams,
|
|
17
19
|
type UsePreviewPersistenceParams,
|
|
20
|
+
type ZOrderAction,
|
|
18
21
|
} from "@hyperframes/studio";
|
|
22
|
+
import { makeSelection } from "./hooks/domSelectionTestHarness";
|
|
19
23
|
|
|
20
24
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
21
25
|
|
|
@@ -66,4 +70,92 @@ describe("DOM editing package exports", () => {
|
|
|
66
70
|
expect(canvas?.className).toContain("pointer-events-none");
|
|
67
71
|
await act(async () => root.unmount());
|
|
68
72
|
});
|
|
73
|
+
|
|
74
|
+
it("gives a host the canvas menu's z-order: enabled, then one step through the session's commit", async () => {
|
|
75
|
+
const parent = document.createElement("div");
|
|
76
|
+
const back = document.createElement("div");
|
|
77
|
+
back.id = "back";
|
|
78
|
+
const front = document.createElement("div");
|
|
79
|
+
front.id = "front";
|
|
80
|
+
parent.append(back, front);
|
|
81
|
+
document.body.append(parent);
|
|
82
|
+
const commitZ = vi.fn(() => Promise.resolve());
|
|
83
|
+
const session = {
|
|
84
|
+
handleDomZIndexReorderCommit: commitZ,
|
|
85
|
+
} as unknown as Parameters<typeof DomEditProvider>[0]["value"];
|
|
86
|
+
let zOrder: DomEditZOrder | undefined;
|
|
87
|
+
function Probe() {
|
|
88
|
+
zOrder = useDomEditZOrder();
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const el = document.createElement("div");
|
|
92
|
+
document.body.append(el);
|
|
93
|
+
const root = createRoot(el);
|
|
94
|
+
await act(async () =>
|
|
95
|
+
root.render(
|
|
96
|
+
<DomEditProvider value={session}>
|
|
97
|
+
<Probe />
|
|
98
|
+
</DomEditProvider>,
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
const sel = makeSelection("Back", back);
|
|
102
|
+
const toFront: ZOrderAction = "bring-to-front";
|
|
103
|
+
expect(zOrder?.enabled(sel, "send-to-back")).toBe(false);
|
|
104
|
+
expect(zOrder?.enabled(sel, toFront)).toBe(true);
|
|
105
|
+
expect(zOrder?.apply(sel, "send-to-back")).toBe(false);
|
|
106
|
+
expect(commitZ).not.toHaveBeenCalled();
|
|
107
|
+
await act(async () => {
|
|
108
|
+
expect(zOrder?.apply(sel, toFront)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
expect(commitZ).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(commitZ).toHaveBeenCalledWith(
|
|
112
|
+
expect.arrayContaining([expect.objectContaining({ element: back, id: "back" })]),
|
|
113
|
+
expect.stringContaining(toFront),
|
|
114
|
+
toFront,
|
|
115
|
+
);
|
|
116
|
+
await act(async () => root.unmount());
|
|
117
|
+
parent.remove();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("gives a read-only preview or a detached element no z-order, and never asks the session", async () => {
|
|
121
|
+
const parent = document.createElement("div");
|
|
122
|
+
const back = document.createElement("div");
|
|
123
|
+
back.id = "back";
|
|
124
|
+
parent.append(back, document.createElement("div"));
|
|
125
|
+
document.body.append(parent);
|
|
126
|
+
const commitZ = vi.fn(() => Promise.resolve());
|
|
127
|
+
const session = {
|
|
128
|
+
handleDomZIndexReorderCommit: commitZ,
|
|
129
|
+
} as unknown as Parameters<typeof DomEditProvider>[0]["value"];
|
|
130
|
+
let zOrder: DomEditZOrder | undefined;
|
|
131
|
+
function Probe() {
|
|
132
|
+
zOrder = useDomEditZOrder();
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const el = document.body.appendChild(document.createElement("div"));
|
|
136
|
+
const root = createRoot(el);
|
|
137
|
+
const mount = (readOnly: boolean) =>
|
|
138
|
+
act(async () =>
|
|
139
|
+
root.render(
|
|
140
|
+
<PreviewReadOnlyProvider readOnly={readOnly}>
|
|
141
|
+
<DomEditProvider value={session}>
|
|
142
|
+
<Probe />
|
|
143
|
+
</DomEditProvider>
|
|
144
|
+
</PreviewReadOnlyProvider>,
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
const sel = makeSelection("Back", back);
|
|
148
|
+
await mount(true);
|
|
149
|
+
expect(zOrder?.enabled(sel, "bring-to-front")).toBe(false);
|
|
150
|
+
expect(zOrder?.apply(sel, "bring-to-front")).toBe(false);
|
|
151
|
+
expect(zOrder?.commit(sel, [{ element: back, zIndex: 1 }], "bring-to-front", null)).toBe(false);
|
|
152
|
+
await mount(false);
|
|
153
|
+
// A reload leaves the old nodes connected to their old document, one no window shows.
|
|
154
|
+
document.implementation.createHTMLDocument().body.append(parent);
|
|
155
|
+
expect(back.isConnected).toBe(true);
|
|
156
|
+
expect(zOrder?.enabled(sel, "bring-to-front"), "a selection from before a reload").toBe(false);
|
|
157
|
+
expect(zOrder?.apply(sel, "bring-to-front")).toBe(false);
|
|
158
|
+
expect(commitZ).not.toHaveBeenCalled();
|
|
159
|
+
await act(async () => root.unmount());
|
|
160
|
+
});
|
|
69
161
|
});
|
|
@@ -14,13 +14,7 @@ import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
|
|
|
14
14
|
// following useTimelineDeleteOps's precedent. Pure functions, no hooks — the
|
|
15
15
|
// hook still owns the actual keydown listeners and calls into these.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
* the same undo/redo key arbitration without duplicating it. */
|
|
19
|
-
export function handleUndoRedoKey(
|
|
20
|
-
event: KeyboardEvent,
|
|
21
|
-
onUndo: () => void,
|
|
22
|
-
onRedo: () => void,
|
|
23
|
-
): boolean {
|
|
17
|
+
function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: () => void): boolean {
|
|
24
18
|
const key = event.key.toLowerCase();
|
|
25
19
|
if (key === "z" && !event.shiftKey) {
|
|
26
20
|
event.preventDefault();
|
|
@@ -419,6 +419,12 @@ export const POSITION_CHANNELS: string[] = [
|
|
|
419
419
|
];
|
|
420
420
|
const MOVE_CHANNELS = [...POSITION_CHANNELS, "motionPath"];
|
|
421
421
|
|
|
422
|
+
export const GSAP_TRANSFORM_KEYS = new Set(
|
|
423
|
+
"x,y,z,scale,scaleX,scaleY,xPercent,yPercent,rotation,rotationX,rotationY,skewX,skewY,transformOrigin,svgOrigin,force3D,smoothOrigin,transformPerspective,translateX,translateY,translateZ,rotate,rotationZ,rotateZ,rotateX,rotateY".split(
|
|
424
|
+
",",
|
|
425
|
+
),
|
|
426
|
+
);
|
|
427
|
+
|
|
422
428
|
/** Whether a live timeline tween or hold writes any of `channels` on `el`. Sync, no fetch. */
|
|
423
429
|
function gsapWritesChannels(el: Element, channels: string[]): boolean {
|
|
424
430
|
const win = el.ownerDocument.defaultView as { __timelines?: Record<string, RuntimeTimeline> };
|