@hyperframes/studio 0.8.55 → 0.8.56

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 (82) hide show
  1. package/dist/assets/{hyperframes-player-BjAGc2ID.js → hyperframes-player-k8LzMtXX.js} +1 -1
  2. package/dist/assets/{index-TcBZyzNA.js → index-BlkJ_Axe.js} +191 -191
  3. package/dist/assets/{index-DaNpNFJC.js → index-CxR7F2YQ.js} +1 -1
  4. package/dist/assets/{index-BEIRytvV.js → index-DFlILHJe.js} +1 -1
  5. package/dist/assets/{index-DrHQAuGO.css → index-ZmBtVrEm.css} +1 -1
  6. package/dist/index.d.ts +16 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +3111 -2998
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +14 -1
  13. package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
  14. package/src/components/EditorShell.selectionSync.test.tsx +57 -67
  15. package/src/components/EditorShell.tsx +59 -50
  16. package/src/components/EditorShellTestMocks.ts +46 -0
  17. package/src/components/editor/DomEditOverlay.tsx +19 -40
  18. package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
  19. package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
  20. package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
  21. package/src/components/editor/PropertyPanel.tsx +4 -3
  22. package/src/components/editor/SnapToolbar.test.tsx +1 -0
  23. package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
  24. package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
  25. package/src/components/editor/previewReadOnlyContext.tsx +59 -0
  26. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
  27. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
  28. package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
  29. package/src/components/editor/useDomEditNudge.ts +9 -0
  30. package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
  31. package/src/components/editor/useOffCanvasIndicators.ts +50 -0
  32. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
  33. package/src/components/nle/PreviewOverlays.tsx +15 -8
  34. package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
  35. package/src/components/nle/TimelinePane.tsx +5 -1
  36. package/src/components/nle/previewReadOnly.test.tsx +128 -0
  37. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  38. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
  39. package/src/components/nle/usePreviewBlockDrop.ts +3 -1
  40. package/src/hooks/appHotkeysDispatch.ts +16 -1
  41. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  42. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  43. package/src/hooks/timelineTrackVisibility.ts +7 -8
  44. package/src/hooks/useAppHotkeys.test.ts +71 -3
  45. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  46. package/src/hooks/useAppHotkeys.ts +4 -0
  47. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  48. package/src/hooks/useDomEditCommits.ts +3 -0
  49. package/src/hooks/useDomEditSession.test.tsx +1 -0
  50. package/src/hooks/useDomEditSession.ts +7 -4
  51. package/src/hooks/useDomEditWiring.ts +40 -40
  52. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  53. package/src/hooks/useDomGeometryCommits.ts +26 -20
  54. package/src/hooks/useGestureCommit.test.tsx +29 -0
  55. package/src/hooks/useGestureCommit.ts +23 -0
  56. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  57. package/src/hooks/useInlineTextEdit.ts +11 -2
  58. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  59. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  60. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  61. package/src/hooks/useTimelineEditing.ts +2 -7
  62. package/src/player/components/Timeline.tsx +22 -23
  63. package/src/player/components/TimelineClip.test.tsx +52 -0
  64. package/src/player/components/TimelineClip.tsx +13 -2
  65. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  66. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  67. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  68. package/src/player/components/timelineTheme.test.ts +4 -4
  69. package/src/player/components/timelineTheme.ts +34 -30
  70. package/src/player/components/timelineTrackDisplay.ts +1 -4
  71. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  72. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  73. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  74. package/src/player/hooks/useTimelineSyncCallbacks.ts +0 -3
  75. package/src/player/store/playerStore.ts +0 -4
  76. package/src/player/store/timelineElement.ts +2 -4
  77. package/src/player/store/timelineResetState.ts +0 -1
  78. package/src/styles/hex-baseline.json +3 -4
  79. package/src/styles/studio.css +7 -7
  80. package/src/styles/theme.css +29 -0
  81. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  82. package/src/player/hooks/useTimelineRowElements.ts +0 -17
@@ -4,6 +4,7 @@ import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { expect, it, vi } from "vitest";
6
6
  import type { DomEditSelection } from "./domEditing";
7
+ import "./domEditOverlayTestMocks";
7
8
  import { DomEditOverlay } from "./DomEditOverlay";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -33,21 +34,6 @@ vi.mock("./useDomEditOverlayRects", () => ({
33
34
  }),
34
35
  }));
35
36
 
36
- vi.mock("./useDomEditCompositionRect", () => ({
37
- useDomEditCompositionRect: () => ({
38
- left: 0,
39
- top: 0,
40
- width: 800,
41
- height: 450,
42
- scaleX: 1,
43
- scaleY: 1,
44
- }),
45
- }));
46
-
47
- vi.mock("./offCanvasIndicatorRefresh", () => ({
48
- startOffCanvasIndicatorRefresh: () => () => undefined,
49
- }));
50
-
51
37
  function renderHover(angle: number): string {
52
38
  hoverState.angle = angle;
53
39
  const host = document.createElement("div");
@@ -6,6 +6,7 @@ import type { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGes
6
6
  import { DomEditCropHandles } from "./DomEditCropHandles";
7
7
  import { DomEditRotateHandle } from "./DomEditRotateHandle";
8
8
  import { resolveRotatedResizeCursor } from "./domEditResizeLocal";
9
+ import { usePreviewReadOnly } from "./previewReadOnlyContext";
9
10
 
10
11
  // Corner resize handles, Canva-style: one per corner, diagonal cursors.
11
12
  // Corners scale about the element center; the translate keeps the center
@@ -75,6 +76,7 @@ export function DomEditGroupChrome({
75
76
  onBoxMouseDown,
76
77
  onBoxClick,
77
78
  }: DomEditGroupChromeProps) {
79
+ const canManipulate = allowCanvasMovement && !usePreviewReadOnly();
78
80
  return (
79
81
  <>
80
82
  {groupOverlayItems.map((item) => (
@@ -98,10 +100,10 @@ export function DomEditGroupChrome({
98
100
  top: groupBounds.top,
99
101
  width: groupBounds.width,
100
102
  height: groupBounds.height,
101
- cursor: allowCanvasMovement && groupCanMove ? "move" : "default",
103
+ cursor: canManipulate && groupCanMove ? "move" : "default",
102
104
  }}
103
105
  onPointerDown={(e) => {
104
- if (!allowCanvasMovement || !groupCanMove || e.shiftKey) return;
106
+ if (!canManipulate || !groupCanMove || e.shiftKey) return;
105
107
  gestures.startGroupDrag(e);
106
108
  }}
107
109
  onMouseDown={onBoxMouseDown}
@@ -164,6 +166,8 @@ export function DomEditSelectionChrome({
164
166
  // into: left interactive, it swallows every press, so the caret can never be
165
167
  // moved and characters can never be selected by dragging.
166
168
  const editing = inlineText?.editing ?? false;
169
+ const readOnly = usePreviewReadOnly();
170
+ const canManipulate = allowCanvasMovement && !readOnly;
167
171
 
168
172
  return (
169
173
  <>
@@ -174,7 +178,7 @@ export function DomEditSelectionChrome({
174
178
  transform: overlayRect.angle ? `rotate(${overlayRect.angle}deg)` : undefined,
175
179
  }}
176
180
  >
177
- {allowCanvasMovement && !editing && selection.capabilities.canApplyManualRotation && (
181
+ {canManipulate && !editing && selection.capabilities.canApplyManualRotation && (
178
182
  <DomEditRotateHandle
179
183
  overlayRect={overlayRect}
180
184
  cropOutlineInsetPx={cropOutlineInsetPx}
@@ -196,9 +200,7 @@ export function DomEditSelectionChrome({
196
200
  height: overlayRect.height,
197
201
  clipPath: boxClipPath,
198
202
  cursor:
199
- allowCanvasMovement && selection.capabilities.canApplyManualOffset
200
- ? "move"
201
- : "default",
203
+ canManipulate && selection.capabilities.canApplyManualOffset ? "move" : "default",
202
204
  }}
203
205
  onPointerDown={(e) => {
204
206
  // A second press opens the element's text for editing, and must be
@@ -211,7 +213,7 @@ export function DomEditSelectionChrome({
211
213
  e.stopPropagation();
212
214
  return;
213
215
  }
214
- if (!allowCanvasMovement || e.shiftKey) return;
216
+ if (!canManipulate || e.shiftKey) return;
215
217
  if (selection.capabilities.canApplyManualOffset) {
216
218
  gestures.startGesture("drag", e);
217
219
  return;
@@ -245,7 +247,7 @@ export function DomEditSelectionChrome({
245
247
  children, so they paint strictly above the box border. Each handle
246
248
  is positioned relative to the overlay container using the
247
249
  overlayRect origin, matching the old child-relative offsets. */}
248
- {allowCanvasMovement &&
250
+ {canManipulate &&
249
251
  !editing &&
250
252
  selection.capabilities.canApplyManualSize &&
251
253
  RESIZE_HANDLE_DEFS.map((def) =>
@@ -271,7 +273,7 @@ export function DomEditSelectionChrome({
271
273
  </div>
272
274
  {/* Crop owns its element-local oriented frame. Keep it outside the chrome's
273
275
  rotated plane or a rotated selection applies the angle twice. */}
274
- {selection.capabilities.canCrop && !editing && groupSelectionCount <= 1 && (
276
+ {selection.capabilities.canCrop && !editing && !readOnly && groupSelectionCount <= 1 && (
275
277
  <DomEditCropHandles
276
278
  selection={selection}
277
279
  overlayRect={overlayRect}
@@ -0,0 +1,71 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { PropertyPanel } from "./PropertyPanel";
7
+ import type { PropertyPanelProps } from "./propertyPanelHelpers";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ vi.mock("../../contexts/StudioContext", async () => {
12
+ const actual = await vi.importActual<typeof import("../../contexts/StudioContext")>(
13
+ "../../contexts/StudioContext",
14
+ );
15
+ return { ...actual, useStudioShellContext: () => ({ showToast: vi.fn() }) };
16
+ });
17
+
18
+ function selectedElement(): NonNullable<PropertyPanelProps["element"]> {
19
+ return {
20
+ element: document.createElement("div"),
21
+ id: "el",
22
+ selector: "#el",
23
+ label: "El",
24
+ tagName: "div",
25
+ sourceFile: "index.html",
26
+ compositionPath: "index.html",
27
+ isCompositionHost: false,
28
+ isInsideLockedComposition: false,
29
+ boundingBox: { x: 0, y: 0, width: 10, height: 10 },
30
+ textContent: "",
31
+ dataAttributes: {},
32
+ inlineStyles: {},
33
+ computedStyles: {},
34
+ textFields: [],
35
+ capabilities: {
36
+ canSelect: true,
37
+ canEditStyles: true,
38
+ canCrop: true,
39
+ canMove: true,
40
+ canResize: true,
41
+ canApplyManualOffset: true,
42
+ canApplyManualSize: true,
43
+ canApplyManualRotation: true,
44
+ },
45
+ };
46
+ }
47
+
48
+ describe("PropertyPanel deselect", () => {
49
+ it("re-renders to the empty state on deselect without a hooks-order crash", () => {
50
+ const props = {
51
+ element: selectedElement(),
52
+ assets: [],
53
+ onSetStyle: vi.fn(),
54
+ onSetText: vi.fn(),
55
+ onSetAttributeLive: vi.fn(),
56
+ onClearSelection: vi.fn(),
57
+ } as unknown as PropertyPanelProps;
58
+ const host = document.createElement("div");
59
+ document.body.append(host);
60
+ const root = createRoot(host);
61
+ act(() => root.render(<PropertyPanel {...props} />));
62
+
63
+ // A same-instance re-render with no element — Escape, clicking empty canvas,
64
+ // or "Clear selection" — must not change which hooks ran.
65
+ expect(() => {
66
+ act(() => root.render(<PropertyPanel {...props} element={null} />));
67
+ }).not.toThrow();
68
+
69
+ act(() => root.unmount());
70
+ });
71
+ });
@@ -37,6 +37,7 @@ import { GestureRecordPanelButton } from "./GestureRecordControl";
37
37
  import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState";
38
38
  import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
39
39
  import { isAudioDomElement } from "../../utils/timelineInspector";
40
+ import { useManualEditDisabledFlags } from "./previewReadOnlyContext";
40
41
 
41
42
  // Re-export helpers that external consumers import from this module
42
43
  export {
@@ -175,6 +176,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
175
176
  scale: 1,
176
177
  transformPerspective: 0,
177
178
  };
179
+ // Unconditional like the hooks above: must not sit behind the `!element` return below.
180
+ const { manualOffsetEditingDisabled, manualSizeEditingDisabled, manualRotationEditingDisabled } =
181
+ useManualEditDisabledFlags(element?.capabilities);
178
182
 
179
183
  if (!element) {
180
184
  return (
@@ -189,9 +193,6 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
189
193
  );
190
194
  }
191
195
 
192
- const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
193
- const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize;
194
- const manualRotationEditingDisabled = !element.capabilities.canApplyManualRotation;
195
196
  const sourceLabel = element.id ? `#${element.id}` : (element.selector ?? "");
196
197
  // Capabilities are already resolved on the selection; recompute only sections,
197
198
  // feeding the live GSAP tween count (arrives on the gsapAnimations prop, not the
@@ -54,6 +54,7 @@ function AppHotkeyHarness() {
54
54
  handleDuplicate: vi.fn(async () => false),
55
55
  onResetKeyframes: vi.fn(() => false),
56
56
  onDeleteSelectedKeyframes: vi.fn(),
57
+ readOnlyPreview: false,
57
58
  });
58
59
 
59
60
  return null;
@@ -0,0 +1,16 @@
1
+ import { vi } from "vitest";
2
+
3
+ vi.mock("./useDomEditCompositionRect", () => ({
4
+ useDomEditCompositionRect: () => ({
5
+ left: 0,
6
+ top: 0,
7
+ width: 800,
8
+ height: 450,
9
+ scaleX: 1,
10
+ scaleY: 1,
11
+ }),
12
+ }));
13
+
14
+ vi.mock("./offCanvasIndicatorRefresh", () => ({
15
+ startOffCanvasIndicatorRefresh: () => () => undefined,
16
+ }));
@@ -0,0 +1,260 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { makeSelection } from "../../hooks/domSelectionTestHarness";
7
+ import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS } from "./domEditNudge";
8
+ import { __resetForTests } from "../../utils/canvasNudgeGate";
9
+ import { PreviewReadOnlyProvider } from "./previewReadOnlyContext";
10
+ import "./domEditOverlayTestMocks";
11
+ import { DomEditOverlay } from "./DomEditOverlay";
12
+
13
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ const RECT = { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 };
16
+ const layout = vi.hoisted(() => ({ group: [] as unknown[] }));
17
+
18
+ vi.mock("./useDomEditOverlayRects", () => ({
19
+ useDomEditOverlayRects: () => ({
20
+ overlayRect: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
21
+ overlayRectRef: {
22
+ current: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
23
+ },
24
+ setOverlayRect: () => undefined,
25
+ hoverRect: null,
26
+ groupOverlayItems: layout.group,
27
+ groupOverlayItemsRef: { current: layout.group },
28
+ setGroupOverlayItems: () => undefined,
29
+ childRects: [],
30
+ }),
31
+ }));
32
+ const BOX = '[data-dom-edit-selection-box="true"]';
33
+ let root: Root;
34
+ let host: HTMLElement;
35
+
36
+ function textElement(id: string): HTMLElement {
37
+ const element = document.createElement("h1");
38
+ element.id = id;
39
+ element.textContent = "Title";
40
+ document.body.append(element);
41
+ return element;
42
+ }
43
+
44
+ function fixture(
45
+ overrides: Partial<React.ComponentProps<typeof DomEditOverlay>> = {},
46
+ readOnly = false,
47
+ ) {
48
+ const spies = {
49
+ onCanvasMouseDown: vi.fn(),
50
+ onSelectionChange: vi.fn(),
51
+ onManualDragStart: vi.fn(),
52
+ onBlockedMove: vi.fn(),
53
+ onPathOffsetCommit: vi.fn(),
54
+ onGroupPathOffsetCommit: vi.fn(),
55
+ onBoxSizeCommit: vi.fn(),
56
+ onRotationCommit: vi.fn(),
57
+ onStyleCommit: vi.fn(),
58
+ onDeleteSelection: vi.fn(),
59
+ onApplyZIndex: vi.fn(),
60
+ };
61
+ const selection = makeSelection("Title", textElement("title"));
62
+ selection.capabilities.canApplyManualRotation = true;
63
+ selection.textFields = [{ key: "text", label: "Text", value: "Title" }] as never;
64
+ const props = {
65
+ iframeRef: { current: document.createElement("iframe") },
66
+ activeCompositionPath: null,
67
+ selection,
68
+ hoverSelection: null,
69
+ onCanvasPointerMove: () => Promise.resolve(selection),
70
+ onCanvasPointerLeave: () => undefined,
71
+ ...spies,
72
+ ...overrides,
73
+ };
74
+ host = document.createElement("div");
75
+ document.body.append(host);
76
+ root = createRoot(host);
77
+ act(() =>
78
+ root.render(
79
+ <PreviewReadOnlyProvider readOnly={readOnly}>
80
+ <DomEditOverlay {...props} />
81
+ </PreviewReadOnlyProvider>,
82
+ ),
83
+ );
84
+ return { spies, selection, overlay: host.firstElementChild as HTMLElement };
85
+ }
86
+
87
+ const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
88
+ const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, ...init });
89
+ act(() => {
90
+ target.dispatchEvent(event);
91
+ });
92
+ return event;
93
+ };
94
+
95
+ beforeEach(() => {
96
+ vi.useFakeTimers();
97
+ __resetForTests();
98
+ HTMLElement.prototype.setPointerCapture = () => undefined;
99
+ });
100
+
101
+ afterEach(() => {
102
+ act(() => root.unmount());
103
+ document.body.innerHTML = "";
104
+ layout.group = [];
105
+ vi.useRealTimers();
106
+ });
107
+
108
+ describe("DomEditOverlay with the preview read-only", () => {
109
+ it("control: with the flag off a press on the box starts a move", () => {
110
+ const { spies, overlay } = fixture();
111
+ fire(overlay.querySelector(BOX)!, "pointerdown");
112
+ expect(spies.onManualDragStart).toHaveBeenCalledTimes(1);
113
+ });
114
+
115
+ it("refuses to start a move from the selection box", () => {
116
+ const { spies, overlay } = fixture({}, true);
117
+ const box = overlay.querySelector(BOX)!;
118
+ fire(box, "pointerdown");
119
+ fire(box, "pointermove", { clientX: 40 });
120
+ fire(box, "pointerup");
121
+ expect(spies.onManualDragStart).not.toHaveBeenCalled();
122
+ expect(spies.onBlockedMove).not.toHaveBeenCalled();
123
+ expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it("offers no resize dots, and none can start a resize", () => {
127
+ const off = fixture();
128
+ expect(off.overlay.querySelectorAll("div.h-4.w-4")).toHaveLength(4);
129
+ act(() => root.unmount());
130
+ document.body.innerHTML = "";
131
+ const { spies, overlay } = fixture({}, true);
132
+ expect(overlay.querySelectorAll("div.h-4.w-4")).toHaveLength(0);
133
+ expect(spies.onBoxSizeCommit).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it("offers no rotate handle", () => {
137
+ const off = fixture();
138
+ expect(off.overlay.querySelector('[aria-label="Rotate selection"]')).not.toBeNull();
139
+ act(() => root.unmount());
140
+ document.body.innerHTML = "";
141
+ const { spies, overlay } = fixture({}, true);
142
+ expect(overlay.querySelector('[aria-label="Rotate selection"]')).toBeNull();
143
+ expect(spies.onRotationCommit).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it("offers no crop handles, so clip-path is never committed", () => {
147
+ const off = fixture();
148
+ expect(off.overlay.querySelector("[data-dom-edit-crop-frame]")).not.toBeNull();
149
+ act(() => root.unmount());
150
+ document.body.innerHTML = "";
151
+ const { spies, overlay } = fixture({}, true);
152
+ expect(overlay.querySelector("[data-dom-edit-crop-frame]")).toBeNull();
153
+ expect(spies.onStyleCommit).not.toHaveBeenCalled();
154
+ });
155
+
156
+ it("does not drag a multi-selection", () => {
157
+ const members = ["a", "b"].map((id) => {
158
+ const selection = makeSelection(id, textElement(id));
159
+ return { key: id, selection, element: selection.element, rect: RECT };
160
+ });
161
+ layout.group = members;
162
+ const { spies, overlay } = fixture(
163
+ {
164
+ selection: null,
165
+ groupSelections: members.map((m) => m.selection),
166
+ },
167
+ true,
168
+ );
169
+ fire(overlay.querySelector(BOX)!, "pointerdown");
170
+ expect(spies.onManualDragStart).not.toHaveBeenCalled();
171
+ expect(spies.onGroupPathOffsetCommit).not.toHaveBeenCalled();
172
+ });
173
+
174
+ const nudge = () => {
175
+ const event = new KeyboardEvent("keydown", {
176
+ key: "ArrowRight",
177
+ bubbles: true,
178
+ cancelable: true,
179
+ });
180
+ act(() => {
181
+ window.dispatchEvent(event);
182
+ vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
183
+ });
184
+ return event;
185
+ };
186
+
187
+ it("control: with the flag off an arrow key nudges", () => {
188
+ const { spies } = fixture();
189
+ expect(nudge().defaultPrevented).toBe(true);
190
+ expect(spies.onPathOffsetCommit).toHaveBeenCalledTimes(1);
191
+ });
192
+
193
+ it("neither nudges on an arrow key nor swallows it", () => {
194
+ const { spies } = fixture({}, true);
195
+ expect(nudge().defaultPrevented).toBe(false);
196
+ expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
197
+ });
198
+
199
+ const pressEnter = (overlay: HTMLElement) =>
200
+ act(() => {
201
+ overlay.dispatchEvent(
202
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
203
+ );
204
+ });
205
+
206
+ it("control: with the flag off Enter opens the text for editing", () => {
207
+ const { selection, overlay } = fixture();
208
+ pressEnter(overlay);
209
+ expect(selection.element.hasAttribute("contenteditable")).toBe(true);
210
+ });
211
+
212
+ it("does not open text for editing on Enter", () => {
213
+ const { selection, overlay } = fixture({}, true);
214
+ pressEnter(overlay);
215
+ expect(selection.element.hasAttribute("contenteditable")).toBe(false);
216
+ });
217
+
218
+ const rightClick = async (overlay: HTMLElement) => {
219
+ await act(async () => {
220
+ overlay.dispatchEvent(
221
+ new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 5, clientY: 5 }),
222
+ );
223
+ });
224
+ };
225
+
226
+ it("control: with the flag off right-click offers delete and z-order", async () => {
227
+ const { overlay } = fixture();
228
+ await rightClick(overlay);
229
+ expect(document.body.textContent).toContain("Delete");
230
+ });
231
+
232
+ it("offers no delete or z-order on right-click when read-only", async () => {
233
+ // Same fixture as the control test above (a real, already-selected element) so the
234
+ // context menu actually opens; `selection: null` here would close it via
235
+ // useCanvasContextMenuState's own deselect effect, before read-only ever mattered.
236
+ const { spies, overlay } = fixture({}, true);
237
+ await rightClick(overlay);
238
+ expect(document.body.textContent).not.toContain("Delete");
239
+ expect(spies.onDeleteSelection).not.toHaveBeenCalled();
240
+ expect(spies.onApplyZIndex).not.toHaveBeenCalled();
241
+ });
242
+
243
+ it("still selects and reports to the host on right-click of an unselected element", async () => {
244
+ const { spies, overlay } = fixture({ selection: null }, true);
245
+ await rightClick(overlay);
246
+ expect(spies.onSelectionChange).toHaveBeenCalledTimes(1);
247
+ });
248
+
249
+ it("still selects and reports to the host on a click", () => {
250
+ const { spies, overlay } = fixture({ selection: null }, true);
251
+ fire(overlay, "mousedown");
252
+ expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
253
+ });
254
+
255
+ it("still selects on a click of the selection box", () => {
256
+ const { spies, overlay } = fixture({}, true);
257
+ fire(overlay.querySelector(BOX)!, "click");
258
+ expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
259
+ });
260
+ });
@@ -0,0 +1,59 @@
1
+ import { useContext, useMemo, type ReactNode } from "react";
2
+ import { createStableContext } from "../../utils/hmrStableContext";
3
+
4
+ interface PreviewReadOnlyValue {
5
+ readOnly: boolean;
6
+ reason: string;
7
+ }
8
+
9
+ const DEFAULT_REASON = "Preview is read-only.";
10
+
11
+ // Real default (not a throwing required-provider context): most existing
12
+ // mounts render with no wrapper at all and must stay editable, exactly as today.
13
+ const PreviewReadOnlyContext = createStableContext<PreviewReadOnlyValue>("PreviewReadOnlyContext", {
14
+ readOnly: false,
15
+ reason: DEFAULT_REASON,
16
+ });
17
+
18
+ export function PreviewReadOnlyProvider({
19
+ readOnly,
20
+ reason,
21
+ children,
22
+ }: {
23
+ readOnly: boolean;
24
+ reason?: string;
25
+ children: ReactNode;
26
+ }) {
27
+ const value = useMemo(() => ({ readOnly, reason: reason ?? DEFAULT_REASON }), [readOnly, reason]);
28
+ return <PreviewReadOnlyContext value={value}>{children}</PreviewReadOnlyContext>;
29
+ }
30
+
31
+ export function usePreviewReadOnly(): boolean {
32
+ return useContext(PreviewReadOnlyContext).readOnly;
33
+ }
34
+
35
+ /** Short host-supplied text for why a hand-edit control is disabled. */
36
+ export function usePreviewReadOnlyReason(): string {
37
+ return useContext(PreviewReadOnlyContext).reason;
38
+ }
39
+
40
+ interface ManualEditCapabilities {
41
+ canApplyManualOffset: boolean;
42
+ canApplyManualSize: boolean;
43
+ canApplyManualRotation: boolean;
44
+ }
45
+
46
+ /**
47
+ * The manual X/Y/W/H/rotation fields' disabled state: capability, or preview read-only.
48
+ * Call unconditionally, even with no selection — `capabilities` may be null/undefined.
49
+ */
50
+ export function useManualEditDisabledFlags(
51
+ capabilities: ManualEditCapabilities | null | undefined,
52
+ ) {
53
+ const { readOnly } = useContext(PreviewReadOnlyContext);
54
+ return {
55
+ manualOffsetEditingDisabled: !capabilities?.canApplyManualOffset || readOnly,
56
+ manualSizeEditingDisabled: !capabilities?.canApplyManualSize || readOnly,
57
+ manualRotationEditingDisabled: !capabilities?.canApplyManualRotation || readOnly,
58
+ };
59
+ }
@@ -5,6 +5,7 @@ import { formatPxMetricValue } from "./propertyPanelHelpers";
5
5
  import { resolveValueTier } from "./propertyPanelValueTier";
6
6
  import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
7
7
  import type { DomEditSelection } from "./domEditingTypes";
8
+ import { usePreviewReadOnly, usePreviewReadOnlyReason } from "./previewReadOnlyContext";
8
9
 
9
10
  type KeyframeEntry = Array<{
10
11
  percentage: number;
@@ -119,6 +120,9 @@ export function LayoutGeometryRows({
119
120
  onRemoveKeyframe,
120
121
  onConvertToKeyframes,
121
122
  }: GeometryRowsProps) {
123
+ const readOnlyPreview = usePreviewReadOnly();
124
+ const readOnlyPreviewReason = usePreviewReadOnlyReason();
125
+ const readOnlyTooltip = readOnlyPreview ? readOnlyPreviewReason : undefined;
122
126
  const gutterProps = {
123
127
  element,
124
128
  gsapAnimId,
@@ -137,6 +141,7 @@ export function LayoutGeometryRows({
137
141
  value={formatPxMetricValue(displayX)}
138
142
  tier={displayX === 0 ? "default" : "explicitCustom"}
139
143
  disabled={manualOffsetEditingDisabled}
144
+ tooltip={readOnlyTooltip}
140
145
  onCommit={(next) => commitManualOffset("x", next)}
141
146
  suffix={<KeyframeGutter property="x" displayValue={displayX} {...gutterProps} />}
142
147
  />
@@ -145,6 +150,7 @@ export function LayoutGeometryRows({
145
150
  value={formatPxMetricValue(displayY)}
146
151
  tier={displayY === 0 ? "default" : "explicitCustom"}
147
152
  disabled={manualOffsetEditingDisabled}
153
+ tooltip={readOnlyTooltip}
148
154
  onCommit={(next) => commitManualOffset("y", next)}
149
155
  suffix={<KeyframeGutter property="y" displayValue={displayY} {...gutterProps} />}
150
156
  />
@@ -153,6 +159,7 @@ export function LayoutGeometryRows({
153
159
  value={formatPxMetricValue(displayW)}
154
160
  tier="default"
155
161
  disabled={manualSizeEditingDisabled}
162
+ tooltip={readOnlyTooltip}
156
163
  onCommit={(next) => commitManualSize("width", next)}
157
164
  suffix={<KeyframeGutter property="width" displayValue={displayW} {...gutterProps} />}
158
165
  />
@@ -161,6 +168,7 @@ export function LayoutGeometryRows({
161
168
  value={formatPxMetricValue(displayH)}
162
169
  tier="default"
163
170
  disabled={manualSizeEditingDisabled}
171
+ tooltip={readOnlyTooltip}
164
172
  onCommit={(next) => commitManualSize("height", next)}
165
173
  suffix={<KeyframeGutter property="height" displayValue={displayH} {...gutterProps} />}
166
174
  />
@@ -169,6 +177,7 @@ export function LayoutGeometryRows({
169
177
  value={`${displayR}°`}
170
178
  tier="default"
171
179
  disabled={manualRotationEditingDisabled}
180
+ tooltip={readOnlyTooltip}
172
181
  onCommit={(next) => commitManualRotation(next.replace("°", ""))}
173
182
  suffix={<KeyframeGutter property="rotation" displayValue={displayR} {...gutterProps} />}
174
183
  />
@@ -19,6 +19,7 @@ export function FlatRow({
19
19
  value,
20
20
  tier,
21
21
  disabled,
22
+ tooltip,
22
23
  liveCommit,
23
24
  suffix,
24
25
  dropdown,
@@ -30,6 +31,8 @@ export function FlatRow({
30
31
  value: string;
31
32
  tier: PropertyValueTier;
32
33
  disabled?: boolean;
34
+ /** Shown as a title attribute, e.g. why the row is disabled. */
35
+ tooltip?: string;
33
36
  liveCommit?: boolean;
34
37
  suffix?: ReactNode;
35
38
  /** Renders a trailing 10px caret-down, for select-backed rows. */
@@ -40,7 +43,7 @@ export function FlatRow({
40
43
  }) {
41
44
  const track = useTrackDesignInput();
42
45
  return (
43
- <div className="group flex min-h-[30px] items-center justify-between gap-3">
46
+ <div className="group flex min-h-[30px] items-center justify-between gap-3" title={tooltip}>
44
47
  <span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
45
48
  <span className="flex min-w-0 shrink-0 items-center gap-1.5">
46
49
  <span
@@ -459,12 +459,7 @@ function representativeElement() {
459
459
  describe("classic PropertyPanel input coverage", () => {
460
460
  it("emits only named, known-section events across body inputs and header/footer chrome", async () => {
461
461
  vi.resetModules();
462
- vi.doMock("./manualEditingAvailability", async () => {
463
- const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
464
- "./manualEditingAvailability",
465
- );
466
- return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false };
467
- });
462
+ vi.doMock("./manualEditingAvailability", () => ({ STUDIO_FLAT_INSPECTOR_ENABLED: false }));
468
463
  const { PropertyPanel } = await import("./PropertyPanel");
469
464
  const host = render(
470
465
  <PropertyPanel
@@ -524,18 +519,13 @@ describe("classic PropertyPanel input coverage", () => {
524
519
  expect(sections.has("header")).toBe(true);
525
520
  expect(sections.has("footer")).toBe(true);
526
521
  expect(sections.size).toBeGreaterThan(2);
527
- });
522
+ }, 15000);
528
523
  });
529
524
 
530
525
  describe("flat PropertyPanel input coverage", () => {
531
526
  it("emits only named flat events from known sections for every visible layout input", async () => {
532
527
  vi.resetModules();
533
- vi.doMock("./manualEditingAvailability", async () => {
534
- const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
535
- "./manualEditingAvailability",
536
- );
537
- return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: true };
538
- });
528
+ vi.doMock("./manualEditingAvailability", () => ({ STUDIO_FLAT_INSPECTOR_ENABLED: true }));
539
529
  const { PropertyPanel } = await import("./PropertyPanel");
540
530
  const host = render(
541
531
  <PropertyPanel