@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.
Files changed (44) hide show
  1. package/dist/assets/{hyperframes-player-BuRiFtrF.js → hyperframes-player-DG795Hwu.js} +1 -1
  2. package/dist/assets/{index-C2utDZt-.js → index-BPJRf2X9.js} +118 -118
  3. package/dist/assets/{index-BT3sauee.js → index-BiEtDkae.js} +1 -1
  4. package/dist/assets/{index-xB5O0hPw.css → index-CQN3M56N.css} +1 -1
  5. package/dist/assets/{index-BM35fjFf.js → index-DuCZ3pQw.js} +1 -1
  6. package/dist/{chunk-MTAVNAGW.js → chunk-46XP7J3B.js} +2 -2
  7. package/dist/{chunk-S65AQH6M.js → chunk-DI4LCJHL.js} +3 -2
  8. package/dist/chunk-DI4LCJHL.js.map +1 -0
  9. package/dist/{chunk-7X4XRP3L.js → chunk-HIGHVWYD.js} +2 -2
  10. package/dist/{domEditingLayers-NU6Z5QWA.js → domEditingLayers-AQBDFNUC.js} +3 -3
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +122 -84
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +2 -2
  16. package/dist/styles.css +1 -1
  17. package/package.json +8 -8
  18. package/src/components/editor/DomEditOverlay.tsx +2 -1
  19. package/src/components/editor/PropertyPanel.tsx +2 -2
  20. package/src/components/editor/domEditOverlayGestures.ts +1 -1
  21. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  22. package/src/components/editor/manualOffsetDrag.test.ts +16 -0
  23. package/src/components/editor/manualOffsetDrag.ts +5 -5
  24. package/src/components/editor/plainTranslate.test.ts +38 -22
  25. package/src/components/editor/plainTranslate.ts +66 -36
  26. package/src/components/editor/previewReadOnly.overlay.test.tsx +30 -0
  27. package/src/components/editor/previewReadOnlyContext.tsx +3 -1
  28. package/src/components/editor/propertyPanelTransformCommit.test.ts +51 -0
  29. package/src/components/editor/propertyPanelTransformCommit.ts +3 -1
  30. package/src/components/editor/useColorGradingController.test.ts +36 -1
  31. package/src/components/editor/useColorGradingController.ts +2 -2
  32. package/src/components/editor/useDomEditNudge.test.tsx +1 -0
  33. package/src/components/editor/useDomEditNudge.ts +6 -3
  34. package/src/hooks/usePreviewInteraction.test.ts +32 -0
  35. package/src/hooks/usePreviewInteraction.ts +3 -2
  36. package/src/player/components/TimelineClipFades.test.tsx +103 -3
  37. package/src/player/components/TimelineClipFades.tsx +53 -50
  38. package/src/styles/theme.css +1 -1
  39. package/src/utils/projectRouting.test.ts +9 -0
  40. package/src/utils/projectRouting.ts +3 -1
  41. package/dist/chunk-S65AQH6M.js.map +0 -1
  42. /package/dist/{chunk-MTAVNAGW.js.map → chunk-46XP7J3B.js.map} +0 -0
  43. /package/dist/{chunk-7X4XRP3L.js.map → chunk-HIGHVWYD.js.map} +0 -0
  44. /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.102",
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/core": "0.8.102",
64
- "@hyperframes/player": "0.8.102",
65
- "@hyperframes/sdk": "0.8.102",
66
- "@hyperframes/parsers": "0.8.102",
67
- "@hyperframes/studio-server": "0.8.102",
68
- "@hyperframes/shader-transitions": "0.8.102"
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.102"
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
- // Fallback: when GSAP transforms interfere with probe measurement, use
386
- // the preview scale as an approximation. The commit path reads the actual
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%) 0px",
32
- "calc(2 * 10px) 0px",
33
- "calc(10px +) 0px",
34
- "clamp(0px, 10%, 30px) 5px",
35
- ])("hands %s to Chrome's own resolution and puts the element's style back", (translate) => {
36
- const element = box(`translate: ${translate}; transform: rotate(5deg)`);
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("writes nothing to the element it reads", () => {
51
- const element = box("translate: -50% -50%");
52
- const before = element.getAttribute("style");
53
- const writes: MutationRecord[] = [];
54
- const observer = new MutationObserver((records) => writes.push(...records));
55
- observer.observe(element, { attributes: true });
56
- readTranslatePx(element);
57
- writes.push(...observer.takeRecords());
58
- observer.disconnect();
59
- expect(writes).toEqual([]);
60
- expect(element.getAttribute("style")).toBe(before);
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 TERM = /^(-?\d*\.?\d+(?:e[-+]?\d+)?)(px|%)$/i;
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
- const SIGN: Record<string, number> = { "+": 1, "-": -1 };
12
-
13
- function termPx(term: string | undefined, side: number): number | null {
14
- const match = TERM.exec(term ?? "");
15
- return match ? Number(match[1]) * (match[2] === "%" ? side / 100 : 1) : null;
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
- /** px, %, or Chrome's computed `calc(P% + Lpx)`, against the border-box side; null for min()/max()/clamp(). */
19
- function resolveLength(value: string, side: number): number | null {
20
- const parts = (/^calc\((.*)\)$/.exec(value)?.[1] ?? value).split(" ");
21
- if (parts.length % 2 === 0) return null;
22
- let total = termPx(parts[0], side);
23
- for (let i = 1; i < parts.length && total !== null; i += 2) {
24
- const term = termPx(parts[i + 1], side);
25
- const sign = SIGN[parts[i] ?? ""];
26
- total = term === null || !sign ? null : total + sign * term;
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
- return total;
60
+ const total = tokens.length ? sum() : Number.NaN;
61
+ return i === tokens.length ? total : Number.NaN;
29
62
  }
30
63
 
31
- function borderBox(
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
- return n(size) + (cs.boxSizing === "border-box" ? 0 : pad);
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
- const value = el.style.getPropertyValue("translate") || cs?.translate || "none";
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 px = cs && resolveLength(x, borderBox(cs, "width", "left", "right"));
65
- const py = cs && resolveLength(y, borderBox(cs, "height", "top", "bottom"));
66
- return px != null && py != null ? { x: px, y: py } : resolveInTransform(el, x, y);
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: "proj",
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 = `${projectId}:${selectedAssetPath}`;
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(mediaMetadataUrl(projectId, selectedAssetPath), { signal: controller.signal })
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
- return null;
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();