@hyperframes/studio 0.8.102 → 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-BuRiFtrF.js → hyperframes-player-DG795Hwu.js} +1 -1
- package/dist/assets/{index-C2utDZt-.js → index-BPJRf2X9.js} +118 -118
- package/dist/assets/{index-BT3sauee.js → index-BiEtDkae.js} +1 -1
- package/dist/assets/{index-xB5O0hPw.css → index-CQN3M56N.css} +1 -1
- package/dist/assets/{index-BM35fjFf.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 +2 -2
- package/dist/index.html +2 -2
- package/dist/index.js +122 -84
- 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 +2 -1
- package/src/components/editor/PropertyPanel.tsx +2 -2
- package/src/components/editor/domEditOverlayGestures.ts +1 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
- 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/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/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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.103",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/player": "0.8.103",
|
|
64
|
+
"@hyperframes/shader-transitions": "0.8.103",
|
|
65
|
+
"@hyperframes/core": "0.8.103",
|
|
66
|
+
"@hyperframes/studio-server": "0.8.103",
|
|
67
|
+
"@hyperframes/parsers": "0.8.103",
|
|
68
|
+
"@hyperframes/sdk": "0.8.103"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.103"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -76,7 +76,7 @@ export interface DomEditOverlayProps {
|
|
|
76
76
|
selection: DomEditSelection,
|
|
77
77
|
options?: { revealPanel?: boolean; additive?: boolean },
|
|
78
78
|
) => void;
|
|
79
|
-
onBlockedMove: (selection: DomEditSelection) => void;
|
|
79
|
+
onBlockedMove: (selection: DomEditSelection, reason?: string) => void;
|
|
80
80
|
onManualDragStart?: () => void;
|
|
81
81
|
onPathOffsetCommit: (
|
|
82
82
|
selection: DomEditSelection,
|
|
@@ -296,6 +296,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
296
296
|
gestureRef,
|
|
297
297
|
groupGestureRef,
|
|
298
298
|
blockedMoveRef,
|
|
299
|
+
onBlockedMoveRef,
|
|
299
300
|
onManualDragStartRef,
|
|
300
301
|
onPathOffsetCommitRef,
|
|
301
302
|
onGroupPathOffsetCommitRef,
|
|
@@ -178,8 +178,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
178
178
|
transformPerspective: 0,
|
|
179
179
|
};
|
|
180
180
|
// Unconditional like the hooks above: must not sit behind the `!element` return below.
|
|
181
|
+
const manualOffset = element ? readMoveOffset(element.element) : { x: 0, y: 0 };
|
|
181
182
|
const { manualOffsetEditingDisabled, manualSizeEditingDisabled, manualRotationEditingDisabled } =
|
|
182
|
-
useManualEditDisabledFlags(element?.capabilities);
|
|
183
|
+
useManualEditDisabledFlags(element?.capabilities, manualOffset);
|
|
183
184
|
|
|
184
185
|
if (!element) {
|
|
185
186
|
return (
|
|
@@ -200,7 +201,6 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
200
201
|
// selection) so the Timing section shows for pure-GSAP elements with no data-start.
|
|
201
202
|
const sections = resolveEditingSections(domEditSelectionToFacts(element, gsapAnimations.length));
|
|
202
203
|
const showEditableSections = element.capabilities.canEditStyles && sections.style;
|
|
203
|
-
const manualOffset = readMoveOffset(element.element);
|
|
204
204
|
const manualSize = readStudioBoxSize(element.element);
|
|
205
205
|
const resolvedWidth =
|
|
206
206
|
manualSize.width > 0
|
|
@@ -253,7 +253,7 @@ export type UseDomEditOverlayGesturesOptions = {
|
|
|
253
253
|
suppressNextBoxClickRef: RefObject<boolean>;
|
|
254
254
|
setOverlayRect: (next: OverlayRect | null) => void;
|
|
255
255
|
setGroupOverlayItems: (next: GroupOverlayItem[]) => void;
|
|
256
|
-
onBlockedMoveRef: RefObject<(selection: DomEditSelection) => void>;
|
|
256
|
+
onBlockedMoveRef: RefObject<(selection: DomEditSelection, reason?: string) => void>;
|
|
257
257
|
onManualDragStartRef: RefObject<(() => void) | undefined>;
|
|
258
258
|
onPathOffsetCommitRef: RefObject<
|
|
259
259
|
(
|
|
@@ -76,7 +76,7 @@ export function startGroupDrag(
|
|
|
76
76
|
restoreManualOffsetDragMembers(members);
|
|
77
77
|
e.preventDefault();
|
|
78
78
|
e.stopPropagation();
|
|
79
|
-
opts.onBlockedMoveRef.current(result.selection);
|
|
79
|
+
opts.onBlockedMoveRef.current(result.selection, result.reason);
|
|
80
80
|
return false;
|
|
81
81
|
}
|
|
82
82
|
members.push(result.member);
|
|
@@ -198,7 +198,7 @@ export function startGesture(
|
|
|
198
198
|
gesture: "drag",
|
|
199
199
|
});
|
|
200
200
|
if (!result.ok) {
|
|
201
|
-
opts.onBlockedMoveRef.current(result.selection);
|
|
201
|
+
opts.onBlockedMoveRef.current(result.selection, result.reason);
|
|
202
202
|
return false;
|
|
203
203
|
}
|
|
204
204
|
pathOffsetMember = result.member;
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
resolveManualOffsetForPointerDelta,
|
|
14
14
|
type ManualOffsetDragMatrix,
|
|
15
15
|
} from "./manualOffsetDrag";
|
|
16
|
+
import { UNREADABLE_TRANSLATE } from "./plainTranslate";
|
|
16
17
|
import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
|
|
17
18
|
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
18
19
|
|
|
@@ -596,6 +597,21 @@ describe("a move of an element GSAP does not position", () => {
|
|
|
596
597
|
return { window, element, gsapCalls, member };
|
|
597
598
|
}
|
|
598
599
|
|
|
600
|
+
it("refuses a translate it can't read instead of guessing, and leaves the element alone", () => {
|
|
601
|
+
const { element } = plainBox();
|
|
602
|
+
element.style.setProperty("translate", "abs(10% - 50px) 0px");
|
|
603
|
+
const result = createManualOffsetDragMember({
|
|
604
|
+
key: "box",
|
|
605
|
+
selection: { element } as never,
|
|
606
|
+
element,
|
|
607
|
+
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
|
|
608
|
+
gesture: "drag",
|
|
609
|
+
});
|
|
610
|
+
expect(result.ok).toBe(false);
|
|
611
|
+
expect(result.ok ? "" : result.reason).toBe(UNREADABLE_TRANSLATE);
|
|
612
|
+
expect(element.style.getPropertyValue("translate")).toBe("abs(10% - 50px) 0px");
|
|
613
|
+
});
|
|
614
|
+
|
|
599
615
|
it("drafts and drops the element's own translate in plain px, never touching GSAP", () => {
|
|
600
616
|
const { element, gsapCalls, member } = plainBox({ gsapLoaded: true });
|
|
601
617
|
const m = member();
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from "./manualEdits";
|
|
13
13
|
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
14
14
|
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
15
|
-
import { readTranslatePx, writeTranslatePx } from "./plainTranslate";
|
|
15
|
+
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
|
|
16
16
|
|
|
17
17
|
interface OffsetDragGsap {
|
|
18
18
|
set: (el: Element, vars: Record<string, number | string>) => void;
|
|
@@ -345,6 +345,8 @@ export function createManualOffsetDragMember(input: {
|
|
|
345
345
|
const initialOffset = plainTranslate
|
|
346
346
|
? readTranslatePx(input.element)
|
|
347
347
|
: readAppliedStudioPathOffset(input.element);
|
|
348
|
+
if (!finitePoint(initialOffset))
|
|
349
|
+
return { ok: false, reason: UNREADABLE_TRANSLATE, selection: input.selection };
|
|
348
350
|
const win = input.element.ownerDocument.defaultView as
|
|
349
351
|
| (Window & {
|
|
350
352
|
gsap?: { getProperty?: (el: Element, prop: string) => number };
|
|
@@ -382,10 +384,8 @@ export function createManualOffsetDragMember(input: {
|
|
|
382
384
|
plainTranslate,
|
|
383
385
|
});
|
|
384
386
|
if (!measured.ok) {
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
// GSAP position from the iframe runtime, so visual imprecision during
|
|
388
|
-
// drag is acceptable — the final committed position is always exact.
|
|
387
|
+
// GSAP transforms can defeat the probe: approximate with the preview scale. The commit
|
|
388
|
+
// reads GSAP's real position, so only the drag preview is imprecise.
|
|
389
389
|
const scaleX = input.rect.editScaleX || 1;
|
|
390
390
|
const scaleY = input.rect.editScaleY || 1;
|
|
391
391
|
const w = readTransformWDivisor(input.element);
|
|
@@ -28,18 +28,31 @@ describe("readTranslatePx", () => {
|
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
it.each([
|
|
31
|
-
"min(10px, 5%)
|
|
32
|
-
"
|
|
33
|
-
"calc(10px +)
|
|
34
|
-
"
|
|
35
|
-
])("
|
|
36
|
-
|
|
37
|
-
const before = element.getAttribute("style");
|
|
38
|
-
const read = readTranslatePx(element);
|
|
39
|
-
expect(Number.isFinite(read.x) && Number.isFinite(read.y)).toBe(true);
|
|
40
|
-
expect(element.getAttribute("style")).toBe(before);
|
|
31
|
+
["min(10px, 5%) max(-20%, -40px)", { x: 10, y: -32 }],
|
|
32
|
+
["clamp(0px, 10%, 30px) 5px", { x: 24, y: 5 }],
|
|
33
|
+
["calc(min(50%, 100px) * 2 - (10px + 5%)) calc(-1 * clamp(10%, 1px, 20%))", { x: 178, y: -16 }],
|
|
34
|
+
["calc(2 * 10px) calc(80px / 4)", { x: 20, y: 20 }],
|
|
35
|
+
])("works out %s by arithmetic", (translate, want) => {
|
|
36
|
+
expect(readTranslatePx(box(`translate: ${translate}`))).toEqual(want);
|
|
41
37
|
});
|
|
42
38
|
|
|
39
|
+
it.each(["calc(10px +) 0px", "abs(10% - 50px) 0px", "round(10%, 7px) 0px", "min(10px 5%) 0px"])(
|
|
40
|
+
"reads %s as NaN, never as a guess",
|
|
41
|
+
(translate) => {
|
|
42
|
+
expect(readTranslatePx(box(`translate: ${translate}`)).x).toBeNaN();
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
it.each(["content-box", "fill-box"])(
|
|
47
|
+
"resolves a percent against the content box under transform-box: %s",
|
|
48
|
+
(transformBox) => {
|
|
49
|
+
const element = box(
|
|
50
|
+
`translate: 50% 50%; padding: 10px; border: 5px solid; box-sizing: content-box; transform-box: ${transformBox}`,
|
|
51
|
+
);
|
|
52
|
+
expect(readTranslatePx(element)).toEqual({ x: 120, y: 80 });
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
43
56
|
it("counts padding and border in the box a percent resolves against", () => {
|
|
44
57
|
const element = box(
|
|
45
58
|
"translate: 50% 50%; padding: 10px; border: 5px solid; box-sizing: content-box",
|
|
@@ -47,16 +60,19 @@ describe("readTranslatePx", () => {
|
|
|
47
60
|
expect(readTranslatePx(element)).toEqual({ x: 135, y: 95 });
|
|
48
61
|
});
|
|
49
62
|
|
|
50
|
-
it("
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
it.each(["-50% -50%", "min(10px, 5%) clamp(0px, 10%, 30px)"])(
|
|
64
|
+
"writes nothing to the element while it reads %s",
|
|
65
|
+
(translate) => {
|
|
66
|
+
const element = box(`translate: ${translate}; transform: rotate(5deg)`);
|
|
67
|
+
const before = element.getAttribute("style");
|
|
68
|
+
const writes: MutationRecord[] = [];
|
|
69
|
+
const observer = new MutationObserver((records) => writes.push(...records));
|
|
70
|
+
observer.observe(element, { attributes: true });
|
|
71
|
+
readTranslatePx(element);
|
|
72
|
+
writes.push(...observer.takeRecords());
|
|
73
|
+
observer.disconnect();
|
|
74
|
+
expect(writes).toEqual([]);
|
|
75
|
+
expect(element.getAttribute("style")).toBe(before);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
62
78
|
});
|
|
@@ -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
|
};
|
|
@@ -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();
|