@hyperframes/studio 0.8.102 → 0.8.104
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-BuRiFtrF.js → hyperframes-player-DAoaajuM.js} +1 -1
- package/dist/assets/{index-BT3sauee.js → index-BDK4rTMb.js} +1 -1
- package/dist/assets/{index-xB5O0hPw.css → index-CQN3M56N.css} +1 -1
- package/dist/assets/{index-C2utDZt-.js → index-De2aO4XM.js} +179 -179
- package/dist/assets/{index-BM35fjFf.js → index-WV9LroAD.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 +183 -170
- package/dist/index.html +2 -2
- package/dist/index.js +550 -389
- 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/DomEditOverlay.tsx +7 -7
- package/src/components/editor/PropertyPanel.tsx +5 -5
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +46 -6
- package/src/components/editor/domEditOverlayGestures.ts +14 -20
- package/src/components/editor/domEditOverlayStartGesture.test.ts +170 -13
- package/src/components/editor/domEditOverlayStartGesture.ts +17 -27
- package/src/components/editor/manualEdits.test.ts +3 -9
- package/src/components/editor/manualEdits.ts +0 -1
- package/src/components/editor/manualEditsDom.ts +0 -10
- package/src/components/editor/manualEditsDomPatches.test.ts +1 -45
- package/src/components/editor/manualEditsDomPatches.ts +1 -23
- package/src/components/editor/manualEditsSnapshot.ts +2 -0
- package/src/components/editor/manualEditsTypes.ts +2 -0
- package/src/components/editor/manualOffsetDrag.test.ts +57 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -32
- package/src/components/editor/plainTranslate.test.ts +38 -22
- package/src/components/editor/plainTranslate.ts +75 -40
- package/src/components/editor/previewReadOnly.overlay.test.tsx +30 -0
- package/src/components/editor/previewReadOnlyContext.tsx +3 -1
- package/src/components/editor/propertyPanelTransformCommit.test.ts +80 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +11 -6
- package/src/components/editor/propertyPanelTypes.ts +1 -0
- package/src/components/editor/rotateInlineSave.test.ts +111 -0
- package/src/components/editor/rotationDraft.test.ts +111 -0
- package/src/components/editor/rotationDraft.ts +145 -0
- package/src/components/editor/useColorGradingController.test.ts +36 -1
- package/src/components/editor/useColorGradingController.ts +2 -2
- package/src/components/editor/useDomEditNudge.test.tsx +34 -0
- package/src/components/editor/useDomEditNudge.ts +12 -5
- package/src/components/editor/useDomEditOverlayGestures.ts +22 -24
- package/src/hooks/draggedGsapPosition.ts +28 -0
- package/src/hooks/elementOffsetStager.ts +26 -6
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +36 -2
- package/src/hooks/plainRotation.test.ts +122 -0
- package/src/hooks/plainRotation.ts +42 -0
- package/src/hooks/useDomEditSession.ts +5 -5
- package/src/hooks/useDomGeometryCommit.test.tsx +75 -0
- package/src/hooks/useDomGeometryCommit.ts +17 -17
- package/src/hooks/useDomGeometryCommits.test.tsx +56 -1
- package/src/hooks/useDomGeometryCommits.ts +28 -36
- package/src/hooks/useGsapAwareEditing.test.tsx +194 -11
- package/src/hooks/useGsapAwareEditing.ts +43 -42
- package/src/hooks/useInlineTextEdit.test.tsx +33 -3
- package/src/hooks/useInlineTextEdit.ts +2 -2
- package/src/hooks/usePreviewInteraction.test.ts +32 -0
- package/src/hooks/usePreviewInteraction.ts +3 -2
- 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
|
@@ -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,79 @@ 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
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("the panel's position fields", () => {
|
|
111
|
+
it("read and move an element without GSAP by its CSS translate, on the route they chose", async () => {
|
|
112
|
+
const onSetManualOffset = vi.fn();
|
|
113
|
+
const box = document.createElement("div");
|
|
114
|
+
box.style.setProperty("translate", "40px 30px");
|
|
115
|
+
const element = { id: "box", selector: "#box", element: box } as unknown as DomEditSelection;
|
|
116
|
+
const handlers = createTransformCommitHandlers({
|
|
117
|
+
element,
|
|
118
|
+
styles: {},
|
|
119
|
+
hasGsapAnimation: false,
|
|
120
|
+
gsapAnimId: null,
|
|
121
|
+
gsapKeyframes: null,
|
|
122
|
+
currentPct: 0,
|
|
123
|
+
onCommitAnimatedProperty: undefined,
|
|
124
|
+
onAddKeyframe: undefined,
|
|
125
|
+
onSetManualOffset,
|
|
126
|
+
onSetManualSize: vi.fn(),
|
|
127
|
+
onSetManualRotation: vi.fn(),
|
|
128
|
+
showToast: vi.fn(),
|
|
129
|
+
});
|
|
130
|
+
await handlers.commitManualOffset("x", "100px");
|
|
131
|
+
expect(onSetManualOffset).toHaveBeenCalledWith(
|
|
132
|
+
element,
|
|
133
|
+
{ x: 100, y: 30 },
|
|
134
|
+
{ plainTranslate: true },
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
1
2
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
2
3
|
import { readStudioBoxSize } from "./manualEdits";
|
|
3
|
-
import { readMoveOffset } from "./plainTranslate";
|
|
4
|
+
import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate";
|
|
4
5
|
import { parsePxMetricValue, type PropertyPanelProps } from "./propertyPanelHelpers";
|
|
5
6
|
|
|
6
7
|
interface TransformCommitDeps {
|
|
@@ -74,12 +75,16 @@ export function createTransformCommitHandlers({
|
|
|
74
75
|
)
|
|
75
76
|
)
|
|
76
77
|
return;
|
|
77
|
-
const
|
|
78
|
+
const plainTranslate = !gsapWritesPosition(element.element);
|
|
79
|
+
const current = readMoveOffset(element.element, plainTranslate);
|
|
80
|
+
if (!Number.isFinite(current.x) || !Number.isFinite(current.y))
|
|
81
|
+
return void showToast?.(UNREADABLE_TRANSLATE);
|
|
78
82
|
await Promise.resolve(
|
|
79
|
-
onSetManualOffset(
|
|
80
|
-
|
|
81
|
-
y: axis === "y" ? parsed : current.y,
|
|
82
|
-
|
|
83
|
+
onSetManualOffset(
|
|
84
|
+
element,
|
|
85
|
+
{ x: axis === "x" ? parsed : current.x, y: axis === "y" ? parsed : current.y },
|
|
86
|
+
{ plainTranslate },
|
|
87
|
+
),
|
|
83
88
|
);
|
|
84
89
|
};
|
|
85
90
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
4
|
+
import { savePlainRotation } from "../../hooks/plainRotation";
|
|
5
|
+
import { applyStudioRotation } from "./manualEdits";
|
|
6
|
+
import type { ElementOffsetStagerDeps } from "../../hooks/elementOffsetStager";
|
|
7
|
+
import type { DomEditSelection } from "./domEditing";
|
|
8
|
+
import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
|
|
9
|
+
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
10
|
+
|
|
11
|
+
const ref = <T>(current: T) => ({ current });
|
|
12
|
+
|
|
13
|
+
function evt(clientX: number, clientY: number) {
|
|
14
|
+
return {
|
|
15
|
+
clientX,
|
|
16
|
+
clientY,
|
|
17
|
+
pointerId: 1,
|
|
18
|
+
button: 0,
|
|
19
|
+
altKey: false,
|
|
20
|
+
shiftKey: false,
|
|
21
|
+
preventDefault() {},
|
|
22
|
+
stopPropagation() {},
|
|
23
|
+
currentTarget: { setPointerCapture() {} },
|
|
24
|
+
} as unknown as React.PointerEvent<HTMLDivElement>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
document.head.innerHTML = "";
|
|
29
|
+
document.body.innerHTML = "";
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
async function rotateWord(before: (word: HTMLElement) => void = () => {}) {
|
|
33
|
+
const sheet = document.head.appendChild(document.createElement("style"));
|
|
34
|
+
sheet.textContent = "#word { display: inline; }";
|
|
35
|
+
const element = document.body.appendChild(document.createElement("span"));
|
|
36
|
+
element.id = "word";
|
|
37
|
+
element.textContent = "Word";
|
|
38
|
+
const selection = {
|
|
39
|
+
element,
|
|
40
|
+
id: "word",
|
|
41
|
+
selector: "#word",
|
|
42
|
+
selectorIndex: 0,
|
|
43
|
+
sourceFile: "index.html",
|
|
44
|
+
tagName: "span",
|
|
45
|
+
label: "Word",
|
|
46
|
+
textContent: "Word",
|
|
47
|
+
textFields: [],
|
|
48
|
+
capabilities: { canMove: true, canApplyManualRotation: true },
|
|
49
|
+
} as unknown as DomEditSelection;
|
|
50
|
+
const commitPositionPatchToHtml = vi.fn<ElementOffsetStagerDeps["commitPositionPatchToHtml"]>(
|
|
51
|
+
() => Promise.resolve(),
|
|
52
|
+
);
|
|
53
|
+
const saves: Promise<void>[] = [];
|
|
54
|
+
const opts: UseDomEditOverlayGesturesOptions = {
|
|
55
|
+
overlayRef: ref<HTMLDivElement | null>(document.createElement("div")),
|
|
56
|
+
iframeRef: ref<HTMLIFrameElement | null>(document.createElement("iframe")),
|
|
57
|
+
boxRef: ref<HTMLDivElement | null>(document.createElement("div")),
|
|
58
|
+
selectionRef: ref<DomEditSelection | null>(selection),
|
|
59
|
+
hoverSelectionRef: ref<DomEditSelection | null>(null),
|
|
60
|
+
overlayRectRef: ref({ left: 0, top: 0, width: 200, height: 100, editScaleX: 1, editScaleY: 1 }),
|
|
61
|
+
groupOverlayItemsRef: ref([]),
|
|
62
|
+
gestureRef: ref<GestureState | null>(null),
|
|
63
|
+
groupGestureRef: ref(null),
|
|
64
|
+
blockedMoveRef: ref(null),
|
|
65
|
+
rafPausedRef: ref(false),
|
|
66
|
+
suppressNextBoxClickRef: ref(false),
|
|
67
|
+
setOverlayRect: () => {},
|
|
68
|
+
setGroupOverlayItems: () => {},
|
|
69
|
+
onBlockedMoveRef: ref(() => {}),
|
|
70
|
+
onManualDragStartRef: ref(() => {}),
|
|
71
|
+
onPathOffsetCommitRef: ref(() => {}),
|
|
72
|
+
onGroupPathOffsetCommitRef: ref(() => {}),
|
|
73
|
+
onBoxSizeCommitRef: ref(() => {}),
|
|
74
|
+
onRotationCommitRef: ref((s, next) => {
|
|
75
|
+
const saved = savePlainRotation({ commitPositionPatchToHtml }, s, next);
|
|
76
|
+
saves.push(saved);
|
|
77
|
+
return saved;
|
|
78
|
+
}),
|
|
79
|
+
onCanvasPointerMoveRef: ref(() => Promise.resolve(null)),
|
|
80
|
+
onCanvasMouseDown: () => {},
|
|
81
|
+
snapGuidesRef: ref(null),
|
|
82
|
+
} as unknown as UseDomEditOverlayGesturesOptions;
|
|
83
|
+
before(element);
|
|
84
|
+
const handlers = createDomEditOverlayGestureHandlers(opts);
|
|
85
|
+
|
|
86
|
+
handlers.startGesture("rotate", evt(200, 50));
|
|
87
|
+
handlers.onPointerMove(evt(100, 150));
|
|
88
|
+
handlers.onPointerUp(evt(100, 150));
|
|
89
|
+
await Promise.all(saves);
|
|
90
|
+
|
|
91
|
+
expect(saves).toHaveLength(1);
|
|
92
|
+
return commitPositionPatchToHtml.mock.calls[0]![1];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const INLINE_BLOCK = { type: "inline-style", property: "display", value: "inline-block" };
|
|
96
|
+
const turnOf = (patches: Awaited<ReturnType<typeof rotateWord>>) =>
|
|
97
|
+
patches.findLast((patch) => patch.type === "inline-style" && patch.property === "rotate");
|
|
98
|
+
|
|
99
|
+
it("saves an inline span's turn with the inline-block it was drawn with, after the draft and the hold", async () => {
|
|
100
|
+
expect(await rotateWord()).toContainEqual(INLINE_BLOCK);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("saves inline-block and the whole turn for a word an older Studio already turned 15 deg", async () => {
|
|
104
|
+
const plain = await rotateWord();
|
|
105
|
+
document.head.innerHTML = "";
|
|
106
|
+
document.body.innerHTML = "";
|
|
107
|
+
const legacy = await rotateWord((word) => applyStudioRotation(word, { angle: 15 }));
|
|
108
|
+
expect(legacy).toContainEqual(INLINE_BLOCK);
|
|
109
|
+
const degrees = (patches: typeof plain) => Number.parseFloat(String(turnOf(patches)?.value));
|
|
110
|
+
expect(degrees(legacy)).toBe(degrees(plain) + 15);
|
|
111
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Window } from "happy-dom";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { applyRotationDraft, readCssRotationTarget, readRotationBase } from "./rotationDraft";
|
|
4
|
+
|
|
5
|
+
describe("rotate in a composition without GSAP", () => {
|
|
6
|
+
it("starts from the authored CSS rotation and drafts the absolute angle the commit writes", () => {
|
|
7
|
+
const window = new Window();
|
|
8
|
+
window.document.head.innerHTML = "<style>#title { rotate: 30deg; }</style>";
|
|
9
|
+
const element = window.document.createElement("h1");
|
|
10
|
+
element.id = "title";
|
|
11
|
+
window.document.body.append(element);
|
|
12
|
+
const base = readRotationBase(element, true);
|
|
13
|
+
expect(base).toBeCloseTo(30);
|
|
14
|
+
applyRotationDraft(element, 55, readCssRotationTarget(element));
|
|
15
|
+
expect(element.style.getPropertyValue("rotate")).toBe("55deg");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("drafts only the part of the angle the CSS rotate owns, leaving transform and scale theirs", () => {
|
|
19
|
+
const cases: Array<[string, number, number]> = [
|
|
20
|
+
["transform: rotate(30deg);", 30, 25],
|
|
21
|
+
["rotate: 20deg; transform: rotate(10deg);", 30, 45],
|
|
22
|
+
["scale: -1 1;", 180, 25],
|
|
23
|
+
["transform: scaleX(-1);", 180, 25],
|
|
24
|
+
];
|
|
25
|
+
for (const [css, base, drafted] of cases) {
|
|
26
|
+
const window = new Window();
|
|
27
|
+
window.document.head.innerHTML = `<style>#title { ${css} }</style>`;
|
|
28
|
+
const element = window.document.createElement("h1");
|
|
29
|
+
element.id = "title";
|
|
30
|
+
window.document.body.append(element);
|
|
31
|
+
expect({ css, base: readRotationBase(element, true) }).toEqual({
|
|
32
|
+
css,
|
|
33
|
+
base: expect.closeTo(base),
|
|
34
|
+
});
|
|
35
|
+
applyRotationDraft(element, base + 25, readCssRotationTarget(element));
|
|
36
|
+
const rotate = Number.parseFloat(element.style.getPropertyValue("rotate"));
|
|
37
|
+
expect({ css, rotate }).toEqual({ css, rotate: expect.closeTo(drafted) });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("never asks GSAP, nor restyles per frame, when GSAP turns nothing on the element", () => {
|
|
42
|
+
const window = new Window();
|
|
43
|
+
const gsap = { set: vi.fn(), getProperty: vi.fn(() => 0) };
|
|
44
|
+
Object.assign(window, { gsap });
|
|
45
|
+
window.document.head.innerHTML = "<style>#title { rotate: 30deg; }</style>";
|
|
46
|
+
const element = window.document.createElement("h1");
|
|
47
|
+
element.id = "title";
|
|
48
|
+
window.document.body.append(element);
|
|
49
|
+
expect(readRotationBase(element, true)).toBeCloseTo(30);
|
|
50
|
+
const target = readCssRotationTarget(element);
|
|
51
|
+
const styleReads = vi.spyOn(window, "getComputedStyle");
|
|
52
|
+
applyRotationDraft(element, 55, target);
|
|
53
|
+
expect(styleReads).not.toHaveBeenCalled();
|
|
54
|
+
expect(element.style.getPropertyValue("rotate")).toBe("55deg");
|
|
55
|
+
expect(gsap.set).not.toHaveBeenCalled();
|
|
56
|
+
expect(gsap.getProperty).not.toHaveBeenCalled();
|
|
57
|
+
applyRotationDraft(element, 55, null);
|
|
58
|
+
expect(element.style.getPropertyValue("rotate")).toBe("none");
|
|
59
|
+
expect(gsap.set).toHaveBeenCalledWith(element, { rotation: 55 });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("turns a transform-centred element inside its transform, after the translate, so it stays put", () => {
|
|
63
|
+
const window = new Window();
|
|
64
|
+
window.document.head.innerHTML =
|
|
65
|
+
"<style>#title { rotate: 30deg; transform: translate(-120px, -80px); }</style>";
|
|
66
|
+
const element = window.document.createElement("h1");
|
|
67
|
+
element.id = "title";
|
|
68
|
+
window.document.body.append(element);
|
|
69
|
+
const target = readCssRotationTarget(element);
|
|
70
|
+
expect(target).toEqual({
|
|
71
|
+
property: "transform",
|
|
72
|
+
before: "translate(-120px, -80px)",
|
|
73
|
+
after: "",
|
|
74
|
+
share: expect.closeTo(30),
|
|
75
|
+
sign: 1,
|
|
76
|
+
inline: false,
|
|
77
|
+
});
|
|
78
|
+
applyRotationDraft(element, 55, target);
|
|
79
|
+
expect(element.style.getPropertyValue("transform")).toBe(
|
|
80
|
+
"translate(-120px, -80px) rotate(25deg)",
|
|
81
|
+
);
|
|
82
|
+
expect(element.style.getPropertyValue("rotate")).toBe("");
|
|
83
|
+
// The next rotate replaces its own turn instead of stacking a second one.
|
|
84
|
+
applyRotationDraft(element, 70, readCssRotationTarget(element));
|
|
85
|
+
expect(element.style.getPropertyValue("transform")).toBe(
|
|
86
|
+
"translate(-120px, -80px) rotate(40deg)",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Each case turns 25 deg with the cursor: the fold GSAP and the outline read goes up by 25, unsheared.
|
|
91
|
+
it.each([
|
|
92
|
+
["a mirrored transform", "transform: translate(-120px, -80px) scaleX(-1);", 180],
|
|
93
|
+
["a stretched transform", "transform: translate(-120px, -80px) scale(2, 1);", 0],
|
|
94
|
+
["a mirroring scale property", "scale: -1 1; transform: translate(-120px, -80px);", 180],
|
|
95
|
+
])("turns %s centred element with the cursor, in place", (_, css, base) => {
|
|
96
|
+
const window = new Window();
|
|
97
|
+
window.document.head.innerHTML = `<style>#title { ${css} }</style>`;
|
|
98
|
+
const element = window.document.createElement("h1");
|
|
99
|
+
element.id = "title";
|
|
100
|
+
window.document.body.append(element);
|
|
101
|
+
const turned = (angle: number) => (((angle % 360) + 360) % 360) % 360;
|
|
102
|
+
expect(turned(readRotationBase(element, true))).toBeCloseTo(base);
|
|
103
|
+
applyRotationDraft(element, base + 25, readCssRotationTarget(element));
|
|
104
|
+
const transform = element.style.getPropertyValue("transform");
|
|
105
|
+
expect(transform.startsWith("translate(-120px, -80px) rotate(")).toBe(true);
|
|
106
|
+
expect(turned(readRotationBase(element, true))).toBeCloseTo(base + 25);
|
|
107
|
+
const m = new window.DOMMatrix(window.getComputedStyle(element).transform);
|
|
108
|
+
expect(m.a * m.c + m.b * m.d).toBeCloseTo(0);
|
|
109
|
+
expect([m.e, m.f]).toEqual([-120, -80]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { readCssRotation } from "../../hooks/draggedGsapPosition";
|
|
2
|
+
import { roundTo3 } from "../../utils/rounding";
|
|
3
|
+
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
4
|
+
import {
|
|
5
|
+
readStudioRotation,
|
|
6
|
+
restoreStudioRotation,
|
|
7
|
+
type StudioRotationSnapshot,
|
|
8
|
+
} from "./manualEdits";
|
|
9
|
+
import { getOffsetDragGsap } from "./manualOffsetDrag";
|
|
10
|
+
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
|
11
|
+
|
|
12
|
+
function lastRuleTransform(element: HTMLElement): string {
|
|
13
|
+
let value = "";
|
|
14
|
+
for (const sheet of Array.from(element.ownerDocument.styleSheets)) {
|
|
15
|
+
let rules: CSSStyleRule[] = [];
|
|
16
|
+
try {
|
|
17
|
+
rules = Array.from(sheet.cssRules) as CSSStyleRule[];
|
|
18
|
+
} catch {
|
|
19
|
+
// a cross-origin sheet
|
|
20
|
+
}
|
|
21
|
+
for (const rule of rules) {
|
|
22
|
+
const declared = rule.style?.getPropertyValue("transform");
|
|
23
|
+
if (declared && element.matches(rule.selectorText)) value = declared;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ponytail: the authored transform if it moves the box, from the last matching rule in sheet order;
|
|
30
|
+
// specificity, !important and @media are not weighed. Weigh them when a film's rule is missed.
|
|
31
|
+
function translatingTransform(element: HTMLElement): string {
|
|
32
|
+
const view = element.ownerDocument.defaultView;
|
|
33
|
+
const computed = view?.getComputedStyle(element).transform ?? "none";
|
|
34
|
+
const m = computed === "none" ? null : new view!.DOMMatrix(computed);
|
|
35
|
+
if (!m || (m.m41 === 0 && m.m42 === 0)) return "";
|
|
36
|
+
const value = element.style.getPropertyValue("transform") || lastRuleTransform(element);
|
|
37
|
+
return value === "none" ? "" : value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Where a plain rotate draws its turn, read once at press: the element's own `rotate`, or, when its
|
|
41
|
+
* transform translates it (often the translate(-50%, -50%) centring), a rotate() right after that
|
|
42
|
+
* translate, so the box turns in place in screen space. `share` is what the rest already turns. */
|
|
43
|
+
export interface CssRotationTarget {
|
|
44
|
+
property: "rotate" | "transform";
|
|
45
|
+
before: string;
|
|
46
|
+
after: string;
|
|
47
|
+
share: number;
|
|
48
|
+
/** -1 when a mirroring `scale` property flips a turn made inside the transform. */
|
|
49
|
+
sign: number;
|
|
50
|
+
/** An inline box does not transform, so the turn makes it inline-block. */
|
|
51
|
+
inline: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type RotationCommit = { angle: number; plain?: CssRotationTarget };
|
|
55
|
+
|
|
56
|
+
const TRANSLATE = /^translate(?:3d|X|Y|Z)?\(/i;
|
|
57
|
+
const OWN_TURN = /^rotate\(\s*-?[\d.]+(?:e[+-]?\d+)?deg\s*\)$/i;
|
|
58
|
+
|
|
59
|
+
export function readCssRotationTarget(element: HTMLElement): CssRotationTarget {
|
|
60
|
+
const style = element.ownerDocument.defaultView?.getComputedStyle(element);
|
|
61
|
+
const inline = style?.display === "inline";
|
|
62
|
+
const transform = translatingTransform(element);
|
|
63
|
+
if (!transform) {
|
|
64
|
+
const share = readCssRotation(element, false);
|
|
65
|
+
return { property: "rotate", before: "", after: "", share, sign: 1, inline };
|
|
66
|
+
}
|
|
67
|
+
const parts = splitTopLevelWhitespace(transform);
|
|
68
|
+
const lead = parts.findIndex((part) => !TRANSLATE.test(part));
|
|
69
|
+
const split = lead < 0 ? parts.length : lead;
|
|
70
|
+
const rest = parts.slice(split);
|
|
71
|
+
if (OWN_TURN.test(rest[0] ?? "")) rest.shift();
|
|
72
|
+
const [before, after] = [parts.slice(0, split).join(" "), rest.join(" ")];
|
|
73
|
+
const [sx = 1, sy = sx] = splitTopLevelWhitespace(style?.getPropertyValue("scale") ?? "").map(
|
|
74
|
+
Number.parseFloat,
|
|
75
|
+
);
|
|
76
|
+
const inlineStyle = element.style;
|
|
77
|
+
const saved = [
|
|
78
|
+
inlineStyle.getPropertyValue("transform"),
|
|
79
|
+
inlineStyle.getPropertyPriority("transform"),
|
|
80
|
+
];
|
|
81
|
+
inlineStyle.setProperty("transform", `${before} ${after}`.trim() || "none");
|
|
82
|
+
const share = readCssRotation(element);
|
|
83
|
+
inlineStyle.setProperty("transform", saved[0] ?? "", saved[1] ?? "");
|
|
84
|
+
return { property: "transform", before, after, share, sign: sx * sy < 0 ? -1 : 1, inline };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Draws `angle` where `target` says, and returns the source patches that save it as drawn. */
|
|
88
|
+
export function applyCssRotation(
|
|
89
|
+
element: HTMLElement,
|
|
90
|
+
angle: number,
|
|
91
|
+
target = readCssRotationTarget(element),
|
|
92
|
+
): Array<PatchOperation & { value: string }> {
|
|
93
|
+
const turn = `${roundTo3(target.sign * (angle - target.share))}deg`;
|
|
94
|
+
const value =
|
|
95
|
+
target.property === "rotate"
|
|
96
|
+
? turn
|
|
97
|
+
: [target.before, `rotate(${turn})`, target.after].filter(Boolean).join(" ");
|
|
98
|
+
const patches: Array<PatchOperation & { value: string }> = [
|
|
99
|
+
{ type: "inline-style", property: target.property, value },
|
|
100
|
+
];
|
|
101
|
+
if (target.inline) {
|
|
102
|
+
patches.unshift({ type: "inline-style", property: "display", value: "inline-block" });
|
|
103
|
+
}
|
|
104
|
+
for (const patch of patches) element.style.setProperty(patch.property, patch.value);
|
|
105
|
+
return patches;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Back to the press: the rotation snapshot and, for a plain rotate, the inline transform it drew in. */
|
|
109
|
+
export function restorePlainRotation(element: HTMLElement, snapshot: StudioRotationSnapshot): void {
|
|
110
|
+
restoreStudioRotation(element, snapshot);
|
|
111
|
+
element.style.setProperty("transform", snapshot.transform);
|
|
112
|
+
element.style.setProperty("display", snapshot.display);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// `plain`, read at press: where a turn GSAP does not own draws; null for GSAP's rotation.
|
|
116
|
+
export function applyRotationDraft(
|
|
117
|
+
element: HTMLElement,
|
|
118
|
+
angle: number,
|
|
119
|
+
plain: CssRotationTarget | null,
|
|
120
|
+
): void {
|
|
121
|
+
if (plain) return void applyCssRotation(element, angle, plain);
|
|
122
|
+
element.style.setProperty("rotate", "none");
|
|
123
|
+
getOffsetDragGsap(element)?.set(element, { rotation: angle });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Back to the gesture start: the CSS snapshot, and GSAP's rotation without the legacy var. */
|
|
127
|
+
export function restoreRotationDraft(
|
|
128
|
+
element: HTMLElement,
|
|
129
|
+
angle: number,
|
|
130
|
+
snapshot: StudioRotationSnapshot,
|
|
131
|
+
plain: boolean,
|
|
132
|
+
): void {
|
|
133
|
+
if (plain) return restorePlainRotation(element, snapshot);
|
|
134
|
+
getOffsetDragGsap(element)?.set(element, {
|
|
135
|
+
rotation: angle - (Number.parseFloat(snapshot.studioRotation) || 0),
|
|
136
|
+
});
|
|
137
|
+
restoreStudioRotation(element, snapshot);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The angle a rotate gesture starts from, as the element shows it. */
|
|
141
|
+
export function readRotationBase(element: HTMLElement, plain: boolean): number {
|
|
142
|
+
if (plain) return readCssRotation(element);
|
|
143
|
+
const gsapRotation = Number(getOffsetDragGsap(element)?.getProperty(element, "rotation") ?? 0);
|
|
144
|
+
return gsapRotation + readStudioRotation(element).angle;
|
|
145
|
+
}
|
|
@@ -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
|
});
|
|
@@ -226,3 +227,36 @@ describe("useDomEditNudge — selection cleanup keyed on stable identity", () =>
|
|
|
226
227
|
elementB.remove();
|
|
227
228
|
});
|
|
228
229
|
});
|
|
230
|
+
|
|
231
|
+
describe("useDomEditNudge carries the route its press chose", () => {
|
|
232
|
+
beforeEach(() => {
|
|
233
|
+
vi.useFakeTimers();
|
|
234
|
+
__resetForTests();
|
|
235
|
+
});
|
|
236
|
+
afterEach(() => vi.useRealTimers());
|
|
237
|
+
|
|
238
|
+
it("commits an element without GSAP on the CSS route", () => {
|
|
239
|
+
const host = document.createElement("div");
|
|
240
|
+
const root = createRoot(host);
|
|
241
|
+
const element = document.createElement("div");
|
|
242
|
+
document.body.append(element);
|
|
243
|
+
const commit = vi.fn();
|
|
244
|
+
act(() => {
|
|
245
|
+
root.render(
|
|
246
|
+
React.createElement(Harness, {
|
|
247
|
+
selection: makeSelection("Dot", element),
|
|
248
|
+
onPathOffsetCommit: commit,
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
act(() => dispatchArrowRight());
|
|
253
|
+
act(() => vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10));
|
|
254
|
+
expect(commit).toHaveBeenCalledWith(
|
|
255
|
+
expect.anything(),
|
|
256
|
+
expect.anything(),
|
|
257
|
+
expect.objectContaining({ plainTranslate: true }),
|
|
258
|
+
);
|
|
259
|
+
act(() => root.unmount());
|
|
260
|
+
element.remove();
|
|
261
|
+
});
|
|
262
|
+
});
|