@hyperframes/studio 0.8.101 → 0.8.103
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-DG795Hwu.js} +1 -1
- package/dist/assets/{index-BBNVnnyH.js → index-BPJRf2X9.js} +119 -119
- package/dist/assets/{index-CJOzbslX.js → index-BiEtDkae.js} +1 -1
- package/dist/assets/{index-xB5O0hPw.css → index-CQN3M56N.css} +1 -1
- package/dist/assets/{index-C33EDBL2.js → index-DuCZ3pQw.js} +1 -1
- package/dist/{chunk-MTAVNAGW.js → chunk-46XP7J3B.js} +2 -2
- package/dist/{chunk-S65AQH6M.js → chunk-DI4LCJHL.js} +3 -2
- package/dist/chunk-DI4LCJHL.js.map +1 -0
- package/dist/{chunk-7X4XRP3L.js → chunk-HIGHVWYD.js} +2 -2
- package/dist/{domEditingLayers-NU6Z5QWA.js → domEditingLayers-AQBDFNUC.js} +3 -3
- package/dist/index.d.ts +13 -3
- package/dist/index.html +2 -2
- package/dist/index.js +932 -850
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +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/DomEditOverlay.tsx +2 -1
- package/src/components/editor/PropertyPanel.tsx +2 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +13 -0
- package/src/components/editor/domEditOverlayGestures.ts +1 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
- package/src/components/editor/gsapAnimatesProperty.ts +0 -29
- package/src/components/editor/gsapLivePreview.test.ts +46 -1
- package/src/components/editor/manualOffsetDrag.test.ts +16 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -5
- package/src/components/editor/plainTranslate.test.ts +38 -22
- package/src/components/editor/plainTranslate.ts +66 -36
- package/src/components/editor/previewReadOnly.overlay.test.tsx +30 -0
- package/src/components/editor/previewReadOnlyContext.tsx +3 -1
- package/src/components/editor/propertyPanelHelpers.ts +3 -0
- package/src/components/editor/propertyPanelTransformCommit.test.ts +51 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -1
- package/src/components/editor/useColorGradingController.test.ts +36 -1
- package/src/components/editor/useColorGradingController.ts +2 -2
- package/src/components/editor/useDomEditNudge.test.tsx +1 -0
- package/src/components/editor/useDomEditNudge.ts +6 -3
- 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/hooks/usePreviewInteraction.test.ts +32 -0
- package/src/hooks/usePreviewInteraction.ts +3 -2
- package/src/index.ts +3 -0
- package/src/player/components/TimelineClipFades.test.tsx +103 -3
- package/src/player/components/TimelineClipFades.tsx +53 -50
- package/src/styles/theme.css +1 -1
- package/src/utils/projectRouting.test.ts +9 -0
- package/src/utils/projectRouting.ts +3 -1
- package/dist/chunk-S65AQH6M.js.map +0 -1
- /package/dist/{chunk-MTAVNAGW.js.map → chunk-46XP7J3B.js.map} +0 -0
- /package/dist/{chunk-7X4XRP3L.js.map → chunk-HIGHVWYD.js.map} +0 -0
- /package/dist/{domEditingLayers-NU6Z5QWA.js.map → domEditingLayers-AQBDFNUC.js.map} +0 -0
|
@@ -6,29 +6,65 @@ import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
|
6
6
|
|
|
7
7
|
type Point = { x: number; y: number };
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const TOKEN = /\s*(?:(\d*\.?\d+(?:e[-+]?\d+)?)(px|%)?|([a-z]+)\(|([-+*/(),]))/giy;
|
|
10
|
+
const FUNCTIONS: Record<string, (args: number[]) => number> = {
|
|
11
|
+
"(": ([a = Number.NaN]) => a,
|
|
12
|
+
"calc(": ([a = Number.NaN]) => a,
|
|
13
|
+
"min(": (args) => Math.min(...args),
|
|
14
|
+
"max(": (args) => Math.max(...args),
|
|
15
|
+
"clamp(": ([lo = Number.NaN, v = Number.NaN, hi = Number.NaN]) => Math.max(lo, Math.min(v, hi)),
|
|
16
|
+
};
|
|
10
17
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
18
|
+
/** Lengths become px against `side`; functions keep their "(" so they can't pass for an operator. */
|
|
19
|
+
function tokenize(value: string, side: number): (number | string)[] {
|
|
20
|
+
const tokens: (number | string)[] = [];
|
|
21
|
+
const end = value.trimEnd().length;
|
|
22
|
+
TOKEN.lastIndex = 0;
|
|
23
|
+
for (let m = TOKEN.exec(value); m; m = TOKEN.lastIndex < end ? TOKEN.exec(value) : null) {
|
|
24
|
+
if (m[1]) tokens.push(Number(m[1]) * (m[2] === "%" ? side / 100 : 1));
|
|
25
|
+
else tokens.push(m[3] ? `${m[3].toLowerCase()}(` : (m[4] ?? ""));
|
|
26
|
+
}
|
|
27
|
+
return TOKEN.lastIndex < end ? [] : tokens;
|
|
16
28
|
}
|
|
17
29
|
|
|
18
|
-
/**
|
|
19
|
-
function
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
/** Chrome's computed length (px, %, calc/min/max/clamp, + - * /) in px against `side`; NaN otherwise. */
|
|
31
|
+
function evaluateLength(value: string, side: number): number {
|
|
32
|
+
const tokens = tokenize(value, side);
|
|
33
|
+
let i = 0;
|
|
34
|
+
const next = () => tokens[i++];
|
|
35
|
+
const take = (...ops: string[]) => ops.includes(tokens[i] as string) && next();
|
|
36
|
+
function sum(): number {
|
|
37
|
+
let total = product();
|
|
38
|
+
for (let op = take("+", "-"); op; op = take("+", "-"))
|
|
39
|
+
total += (op === "+" ? 1 : -1) * product();
|
|
40
|
+
return total;
|
|
41
|
+
}
|
|
42
|
+
function product(): number {
|
|
43
|
+
let total = unary();
|
|
44
|
+
for (let op = take("*", "/"); op; op = take("*", "/"))
|
|
45
|
+
total = op === "*" ? total * unary() : total / unary();
|
|
46
|
+
return total;
|
|
47
|
+
}
|
|
48
|
+
function unary(): number {
|
|
49
|
+
return take("-") ? -unary() : atom();
|
|
50
|
+
}
|
|
51
|
+
function atom(): number {
|
|
52
|
+
const token = next();
|
|
53
|
+
if (typeof token === "number") return token;
|
|
54
|
+
const fn = FUNCTIONS[token ?? ""];
|
|
55
|
+
if (!fn) return Number.NaN;
|
|
56
|
+
const args = [sum()];
|
|
57
|
+
while (take(",")) args.push(sum());
|
|
58
|
+
return take(")") ? fn(args) : Number.NaN;
|
|
27
59
|
}
|
|
28
|
-
|
|
60
|
+
const total = tokens.length ? sum() : Number.NaN;
|
|
61
|
+
return i === tokens.length ? total : Number.NaN;
|
|
29
62
|
}
|
|
30
63
|
|
|
31
|
-
|
|
64
|
+
const CONTENT_BOXES = new Set(["content-box", "fill-box"]);
|
|
65
|
+
|
|
66
|
+
/** The side % resolves against: the box `transform-box` names (fill-box is the content box on HTML). */
|
|
67
|
+
function referenceBox(
|
|
32
68
|
cs: CSSStyleDeclaration,
|
|
33
69
|
size: "width" | "height",
|
|
34
70
|
a: string,
|
|
@@ -37,35 +73,29 @@ function borderBox(
|
|
|
37
73
|
const n = (prop: string) => Number.parseFloat(cs.getPropertyValue(prop)) || 0;
|
|
38
74
|
const pad =
|
|
39
75
|
n(`padding-${a}`) + n(`padding-${b}`) + n(`border-${a}-width`) + n(`border-${b}-width`);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// Chrome resolves min()/max()/clamp() inside a transform function to a matrix; an unrendered box has none.
|
|
44
|
-
function resolveInTransform(el: HTMLElement, x: string, y: string): Point {
|
|
45
|
-
const style = el.style;
|
|
46
|
-
const saved = [style.getPropertyValue("transform"), style.getPropertyPriority("transform")];
|
|
47
|
-
style.setProperty("transform", `translate(${x}, ${y})`, "important");
|
|
48
|
-
try {
|
|
49
|
-
const resolved = el.ownerDocument.defaultView?.getComputedStyle(el).transform ?? "";
|
|
50
|
-
const matrix = new DOMMatrixReadOnly(resolved.startsWith("matrix") ? resolved : undefined);
|
|
51
|
-
return { x: matrix.m41, y: matrix.m42 };
|
|
52
|
-
} finally {
|
|
53
|
-
style.setProperty("transform", saved[0] ?? "", saved[1] ?? "");
|
|
54
|
-
}
|
|
76
|
+
const content = n(size) - (cs.boxSizing === "border-box" ? pad : 0);
|
|
77
|
+
return CONTENT_BOXES.has(cs.getPropertyValue("transform-box")) ? content : content + pad;
|
|
55
78
|
}
|
|
56
79
|
|
|
57
80
|
/** The element's CSS `translate` in px, as it renders now. */
|
|
58
81
|
// ponytail: a 3-value translate loses its z on the next move; keep z when a fixture needs it.
|
|
59
82
|
export function readTranslatePx(el: HTMLElement): Point {
|
|
60
83
|
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
61
|
-
|
|
84
|
+
// Computed first: Chrome resolves em, vw and var() there and leaves only % to work out.
|
|
85
|
+
const value = cs?.translate || el.style.getPropertyValue("translate") || "none";
|
|
62
86
|
if (value === "none") return { x: 0, y: 0 };
|
|
63
87
|
const [x = "0px", y = "0px"] = splitTopLevelWhitespace(value);
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
return
|
|
88
|
+
const side = (size: "width" | "height", a: string, b: string) =>
|
|
89
|
+
cs ? referenceBox(cs, size, a, b) : 0;
|
|
90
|
+
return {
|
|
91
|
+
x: evaluateLength(x, side("width", "left", "right")),
|
|
92
|
+
y: evaluateLength(y, side("height", "top", "bottom")),
|
|
93
|
+
};
|
|
67
94
|
}
|
|
68
95
|
|
|
96
|
+
export const UNREADABLE_TRANSLATE =
|
|
97
|
+
"Studio can't read this layer's translate. Move it in the Code tab.";
|
|
98
|
+
|
|
69
99
|
/** Plain px only: GSAP's CSSPlugin splits `translate` on spaces and drops a calc(). */
|
|
70
100
|
function formatTranslatePx(p: Point): string {
|
|
71
101
|
return `${roundTo3(p.x)}px ${roundTo3(p.y)}px`;
|
|
@@ -9,6 +9,7 @@ import { __resetForTests } from "../../utils/canvasNudgeGate";
|
|
|
9
9
|
import { PreviewReadOnlyProvider } from "./previewReadOnlyContext";
|
|
10
10
|
import "./domEditOverlayTestMocks";
|
|
11
11
|
import { DomEditOverlay } from "./DomEditOverlay";
|
|
12
|
+
import { UNREADABLE_TRANSLATE } from "./plainTranslate";
|
|
12
13
|
|
|
13
14
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
15
|
|
|
@@ -475,3 +476,32 @@ describe("DomEditOverlay onTextEditingChange", () => {
|
|
|
475
476
|
expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
|
|
476
477
|
});
|
|
477
478
|
});
|
|
479
|
+
|
|
480
|
+
describe("DomEditOverlay on a layer whose translate Studio can't read", () => {
|
|
481
|
+
it("refuses a drag and an arrow nudge out loud, and commits nothing", () => {
|
|
482
|
+
const { spies, overlay, selection } = fixture();
|
|
483
|
+
selection.element.style.setProperty("translate", "abs(10% - 50px) 0px");
|
|
484
|
+
fire(overlay.querySelector(BOX)!, "pointerdown");
|
|
485
|
+
const presses = [false, true, true].map(
|
|
486
|
+
(repeat) =>
|
|
487
|
+
new KeyboardEvent("keydown", {
|
|
488
|
+
key: "ArrowRight",
|
|
489
|
+
bubbles: true,
|
|
490
|
+
cancelable: true,
|
|
491
|
+
repeat,
|
|
492
|
+
}),
|
|
493
|
+
);
|
|
494
|
+
act(() => {
|
|
495
|
+
for (const press of presses) window.dispatchEvent(press);
|
|
496
|
+
vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
|
|
497
|
+
});
|
|
498
|
+
// One toast for the drag and one for the whole held arrow, whose every press is swallowed.
|
|
499
|
+
expect(spies.onBlockedMove.mock.calls).toEqual([
|
|
500
|
+
[selection, UNREADABLE_TRANSLATE],
|
|
501
|
+
[selection, UNREADABLE_TRANSLATE],
|
|
502
|
+
]);
|
|
503
|
+
expect(presses.map((press) => press.defaultPrevented)).toEqual([true, true, true]);
|
|
504
|
+
expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
|
|
505
|
+
expect(selection.element.style.getPropertyValue("translate")).toBe("abs(10% - 50px) 0px");
|
|
506
|
+
});
|
|
507
|
+
});
|
|
@@ -49,10 +49,12 @@ interface ManualEditCapabilities {
|
|
|
49
49
|
*/
|
|
50
50
|
export function useManualEditDisabledFlags(
|
|
51
51
|
capabilities: ManualEditCapabilities | null | undefined,
|
|
52
|
+
offset = { x: 0, y: 0 },
|
|
52
53
|
) {
|
|
53
54
|
const { readOnly } = useContext(PreviewReadOnlyContext);
|
|
55
|
+
const unreadable = !Number.isFinite(offset.x) || !Number.isFinite(offset.y);
|
|
54
56
|
return {
|
|
55
|
-
manualOffsetEditingDisabled: !capabilities?.canApplyManualOffset || readOnly,
|
|
57
|
+
manualOffsetEditingDisabled: !capabilities?.canApplyManualOffset || readOnly || unreadable,
|
|
56
58
|
manualSizeEditingDisabled: !capabilities?.canApplyManualSize || readOnly,
|
|
57
59
|
manualRotationEditingDisabled: !capabilities?.canApplyManualRotation || readOnly,
|
|
58
60
|
};
|
|
@@ -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
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
3
5
|
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useManualEditDisabledFlags } from "./previewReadOnlyContext";
|
|
4
7
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
5
8
|
import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
|
|
6
9
|
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
|
|
10
|
+
import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate";
|
|
7
11
|
|
|
8
12
|
describe("createTransformCommitHandlers", () => {
|
|
9
13
|
it.each([
|
|
@@ -55,3 +59,50 @@ describe("createTransformCommitHandlers", () => {
|
|
|
55
59
|
expect(onSetManualRotation).not.toHaveBeenCalled();
|
|
56
60
|
});
|
|
57
61
|
});
|
|
62
|
+
|
|
63
|
+
describe("the panel's position fields on a translate Studio can't read", () => {
|
|
64
|
+
it("are disabled", () => {
|
|
65
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
66
|
+
const box = document.createElement("div");
|
|
67
|
+
box.style.setProperty("translate", "abs(10% - 50px) 30px");
|
|
68
|
+
const seen: boolean[] = [];
|
|
69
|
+
function Probe({ element }: { element: HTMLElement }) {
|
|
70
|
+
seen.push(
|
|
71
|
+
useManualEditDisabledFlags({ canApplyManualOffset: true } as never, readMoveOffset(element))
|
|
72
|
+
.manualOffsetEditingDisabled,
|
|
73
|
+
);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const root = createRoot(document.createElement("div"));
|
|
77
|
+
act(() => root.render(React.createElement(Probe, { element: box })));
|
|
78
|
+
box.style.setProperty("translate", "40px 30px");
|
|
79
|
+
act(() => root.render(React.createElement(Probe, { element: box })));
|
|
80
|
+
act(() => root.unmount());
|
|
81
|
+
expect(seen).toEqual([true, false]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("commit nothing and say why", async () => {
|
|
85
|
+
const onSetManualOffset = vi.fn();
|
|
86
|
+
const showToast = vi.fn();
|
|
87
|
+
const box = document.createElement("div");
|
|
88
|
+
box.style.setProperty("translate", "abs(10% - 50px) 30px");
|
|
89
|
+
const element = { id: "box", selector: "#box", element: box } as unknown as DomEditSelection;
|
|
90
|
+
const handlers = createTransformCommitHandlers({
|
|
91
|
+
element,
|
|
92
|
+
styles: {},
|
|
93
|
+
hasGsapAnimation: false,
|
|
94
|
+
gsapAnimId: null,
|
|
95
|
+
gsapKeyframes: null,
|
|
96
|
+
currentPct: 0,
|
|
97
|
+
onCommitAnimatedProperty: undefined,
|
|
98
|
+
onAddKeyframe: undefined,
|
|
99
|
+
onSetManualOffset,
|
|
100
|
+
onSetManualSize: vi.fn(),
|
|
101
|
+
onSetManualRotation: vi.fn(),
|
|
102
|
+
showToast,
|
|
103
|
+
});
|
|
104
|
+
await handlers.commitManualOffset("y", "20px");
|
|
105
|
+
expect(onSetManualOffset).not.toHaveBeenCalled();
|
|
106
|
+
expect(showToast).toHaveBeenCalledWith(UNREADABLE_TRANSLATE);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
2
2
|
import { readStudioBoxSize } from "./manualEdits";
|
|
3
|
-
import { readMoveOffset } from "./plainTranslate";
|
|
3
|
+
import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate";
|
|
4
4
|
import { parsePxMetricValue, type PropertyPanelProps } from "./propertyPanelHelpers";
|
|
5
5
|
|
|
6
6
|
interface TransformCommitDeps {
|
|
@@ -75,6 +75,8 @@ export function createTransformCommitHandlers({
|
|
|
75
75
|
)
|
|
76
76
|
return;
|
|
77
77
|
const current = readMoveOffset(element.element);
|
|
78
|
+
if (!Number.isFinite(current.x) || !Number.isFinite(current.y))
|
|
79
|
+
return void showToast?.(UNREADABLE_TRANSLATE);
|
|
78
80
|
await Promise.resolve(
|
|
79
81
|
onSetManualOffset(element, {
|
|
80
82
|
x: axis === "x" ? parsed : current.x,
|
|
@@ -71,7 +71,9 @@ function HookHost({
|
|
|
71
71
|
element,
|
|
72
72
|
previewIframeRef,
|
|
73
73
|
onApplyScope,
|
|
74
|
+
projectId = "proj",
|
|
74
75
|
}: {
|
|
76
|
+
projectId?: string;
|
|
75
77
|
onState: (state: ReturnType<typeof useColorGradingController>) => void;
|
|
76
78
|
onSetAttributeLive: (attr: string, value: string | null) => void;
|
|
77
79
|
element: DomEditSelection;
|
|
@@ -79,7 +81,7 @@ function HookHost({
|
|
|
79
81
|
onApplyScope?: ApplyScope;
|
|
80
82
|
}) {
|
|
81
83
|
const state = useColorGradingController({
|
|
82
|
-
projectId
|
|
84
|
+
projectId,
|
|
83
85
|
element,
|
|
84
86
|
previewIframeRef,
|
|
85
87
|
onSetAttributeLive,
|
|
@@ -94,6 +96,7 @@ function renderHook(
|
|
|
94
96
|
initialElement: DomEditSelection = makeElement(),
|
|
95
97
|
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
|
96
98
|
onApplyScope?: ApplyScope,
|
|
99
|
+
projectId?: string,
|
|
97
100
|
) {
|
|
98
101
|
const host = document.createElement("div");
|
|
99
102
|
document.body.append(host);
|
|
@@ -108,6 +111,7 @@ function renderHook(
|
|
|
108
111
|
element,
|
|
109
112
|
previewIframeRef,
|
|
110
113
|
onApplyScope,
|
|
114
|
+
projectId,
|
|
111
115
|
}),
|
|
112
116
|
);
|
|
113
117
|
});
|
|
@@ -763,6 +767,37 @@ describe("useColorGradingController", () => {
|
|
|
763
767
|
act(() => second.root.unmount());
|
|
764
768
|
vi.unstubAllGlobals();
|
|
765
769
|
});
|
|
770
|
+
|
|
771
|
+
it("keeps media metadata apart for 'p:a' + 'b.mp4' and 'p' + 'a:b.mp4'", async () => {
|
|
772
|
+
const video = (src: string) => {
|
|
773
|
+
const el = document.createElement("video");
|
|
774
|
+
el.setAttribute("src", src);
|
|
775
|
+
return el;
|
|
776
|
+
};
|
|
777
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
778
|
+
ok: true,
|
|
779
|
+
json: () => Promise.resolve({ metadata: { kind: "video", color: { dynamicRange: "hdr" } } }),
|
|
780
|
+
} as unknown as Response);
|
|
781
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
782
|
+
for (const [projectId, src] of [
|
|
783
|
+
["p:a", "b.mp4"],
|
|
784
|
+
["p", "a:b.mp4"],
|
|
785
|
+
]) {
|
|
786
|
+
const { root } = renderHook(
|
|
787
|
+
vi.fn(),
|
|
788
|
+
makeElement({ element: video(src) }),
|
|
789
|
+
undefined,
|
|
790
|
+
undefined,
|
|
791
|
+
projectId,
|
|
792
|
+
);
|
|
793
|
+
await act(async () => {
|
|
794
|
+
await Promise.resolve();
|
|
795
|
+
});
|
|
796
|
+
act(() => root.unmount());
|
|
797
|
+
}
|
|
798
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
799
|
+
vi.unstubAllGlobals();
|
|
800
|
+
});
|
|
766
801
|
});
|
|
767
802
|
|
|
768
803
|
describe("useColorGradingController after the preview iframe is replaced", () => {
|
|
@@ -284,13 +284,13 @@ export function useColorGradingController({
|
|
|
284
284
|
useEffect(() => {
|
|
285
285
|
setMediaMetadata(null);
|
|
286
286
|
if (!selectedAssetPath) return;
|
|
287
|
-
const cacheKey =
|
|
287
|
+
const cacheKey = mediaMetadataUrl(projectId, selectedAssetPath);
|
|
288
288
|
if (MEDIA_METADATA_CACHE.has(cacheKey)) {
|
|
289
289
|
setMediaMetadata(MEDIA_METADATA_CACHE.get(cacheKey) ?? null);
|
|
290
290
|
return;
|
|
291
291
|
}
|
|
292
292
|
const controller = new AbortController();
|
|
293
|
-
fetch(
|
|
293
|
+
fetch(cacheKey, { signal: controller.signal })
|
|
294
294
|
.then(async (response) => {
|
|
295
295
|
if (!response.ok) return { ok: false as const };
|
|
296
296
|
const data: MediaMetadataResponse | null = await response.json();
|
|
@@ -46,6 +46,7 @@ function Harness({
|
|
|
46
46
|
groupGestureRef: makeRef(null),
|
|
47
47
|
blockedMoveRef: makeRef(null),
|
|
48
48
|
onManualDragStartRef: makeRef(() => {}),
|
|
49
|
+
onBlockedMoveRef: makeRef(() => {}),
|
|
49
50
|
onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
|
|
50
51
|
onGroupPathOffsetCommitRef: makeRef(async () => {}),
|
|
51
52
|
});
|
|
@@ -55,6 +55,7 @@ export interface UseDomEditNudgeParams {
|
|
|
55
55
|
groupGestureRef: RefObject<GroupGestureState | null>;
|
|
56
56
|
blockedMoveRef: RefObject<BlockedMoveState | null>;
|
|
57
57
|
onManualDragStartRef: RefObject<(() => void) | undefined>;
|
|
58
|
+
onBlockedMoveRef: RefObject<(selection: DomEditSelection, reason?: string) => void>;
|
|
58
59
|
onPathOffsetCommitRef: RefObject<
|
|
59
60
|
(
|
|
60
61
|
s: DomEditSelection,
|
|
@@ -179,7 +180,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
179
180
|
// Build drag members for the current target set — the same member snapshot a
|
|
180
181
|
// pointer drag starts from (startGesture / startGroupDrag), so the nudge
|
|
181
182
|
// commit converts offsets → GSAP x/y with identical math.
|
|
182
|
-
const beginSession = (): NudgeSession | null => {
|
|
183
|
+
const beginSession = (event: KeyboardEvent): NudgeSession | "refused" | null => {
|
|
183
184
|
const p = paramsRef.current;
|
|
184
185
|
const groupItems = p.groupOverlayItemsRef.current;
|
|
185
186
|
const isGroup = groupItems.length > 1;
|
|
@@ -192,7 +193,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
192
193
|
const result = createManualOffsetDragMember({ ...target, gesture: "nudge" });
|
|
193
194
|
if (!result.ok) {
|
|
194
195
|
restoreManualOffsetDragMembers(members);
|
|
195
|
-
|
|
196
|
+
if (!event.repeat) p.onBlockedMoveRef.current(result.selection, result.reason);
|
|
197
|
+
return "refused";
|
|
196
198
|
}
|
|
197
199
|
members.push(result.member);
|
|
198
200
|
}
|
|
@@ -207,7 +209,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
207
209
|
if (shouldIgnoreNudgeKey(p, event)) return;
|
|
208
210
|
const delta = resolveCanvasNudgeDelta(event);
|
|
209
211
|
if (!delta) return;
|
|
210
|
-
const session = sessionRef.current ?? beginSession();
|
|
212
|
+
const session = sessionRef.current ?? beginSession(event);
|
|
213
|
+
if (session === "refused") return void event.preventDefault();
|
|
211
214
|
if (!session) return;
|
|
212
215
|
sessionRef.current = session;
|
|
213
216
|
event.preventDefault();
|
|
@@ -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,
|