@hyperframes/studio 0.8.54 → 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.
- package/dist/assets/{hyperframes-player-Cy_AwUa8.js → hyperframes-player-k8LzMtXX.js} +1 -1
- package/dist/assets/{index-DPUJoO8n.js → index-BlkJ_Axe.js} +192 -192
- package/dist/assets/{index-CTa-6Kso.js → index-CxR7F2YQ.js} +1 -1
- package/dist/assets/{index-DkAzKuPZ.js → index-DFlILHJe.js} +1 -1
- package/dist/assets/{index-DrHQAuGO.css → index-ZmBtVrEm.css} +1 -1
- package/dist/index.d.ts +16 -8
- package/dist/index.html +2 -2
- package/dist/index.js +3152 -3010
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +14 -1
- package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
- package/src/components/EditorShell.selectionSync.test.tsx +57 -67
- package/src/components/EditorShell.tsx +59 -50
- package/src/components/EditorShellTestMocks.ts +46 -0
- package/src/components/editor/DomEditOverlay.tsx +19 -40
- package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
- package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
- package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
- package/src/components/editor/PropertyPanel.tsx +4 -3
- package/src/components/editor/SnapToolbar.test.tsx +1 -0
- package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
- package/src/components/editor/previewReadOnlyContext.tsx +59 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
- package/src/components/editor/useDomEditNudge.ts +9 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
- package/src/components/editor/useOffCanvasIndicators.ts +50 -0
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
- package/src/components/nle/PreviewOverlays.tsx +15 -8
- package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
- package/src/components/nle/TimelinePane.tsx +5 -1
- package/src/components/nle/previewReadOnly.test.tsx +128 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
- package/src/components/nle/usePreviewBlockDrop.ts +3 -1
- package/src/hooks/appHotkeysDispatch.ts +16 -1
- package/src/hooks/gsapDragCommit.ts +1 -1
- package/src/hooks/gsapEditOutcome.test.ts +115 -0
- package/src/hooks/gsapEditOutcome.ts +39 -10
- package/src/hooks/gsapResizeIntercept.test.ts +42 -3
- package/src/hooks/gsapResizeIntercept.ts +12 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +10 -2
- package/src/hooks/gsapRuntimeBridge.ts +14 -3
- package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
- package/src/hooks/timelineAudioGroupCreate.ts +3 -4
- package/src/hooks/timelineTrackVisibility.ts +7 -8
- package/src/hooks/useAppHotkeys.test.ts +71 -3
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +4 -0
- package/src/hooks/useDomEditCommits.test.tsx +1 -0
- package/src/hooks/useDomEditCommits.ts +3 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -0
- package/src/hooks/useDomEditSession.ts +7 -4
- package/src/hooks/useDomEditWiring.ts +40 -40
- package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
- package/src/hooks/useDomGeometryCommits.ts +26 -20
- package/src/hooks/useGestureCommit.test.tsx +29 -0
- package/src/hooks/useGestureCommit.ts +23 -0
- package/src/hooks/useInlineTextEdit.test.tsx +23 -2
- package/src/hooks/useInlineTextEdit.ts +11 -2
- package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
- package/src/hooks/useTimelineAssetDropOps.ts +1 -6
- package/src/hooks/useTimelineEditing.test.tsx +10 -7
- package/src/hooks/useTimelineEditing.ts +2 -7
- package/src/player/components/Timeline.tsx +22 -23
- package/src/player/components/TimelineClip.test.tsx +52 -0
- package/src/player/components/TimelineClip.tsx +13 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/timelineMotionStyles.test.ts +3 -3
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +34 -30
- package/src/player/components/timelineTrackDisplay.ts +1 -4
- package/src/player/components/useTimelineTrackDerivations.ts +2 -2
- package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
- package/src/player/hooks/timelineSyncHydration.ts +0 -23
- package/src/player/hooks/useTimelineSyncCallbacks.ts +0 -3
- package/src/player/store/playerStore.ts +0 -4
- package/src/player/store/timelineElement.ts +2 -4
- package/src/player/store/timelineResetState.ts +0 -1
- package/src/styles/hex-baseline.json +3 -4
- package/src/styles/studio.css +7 -7
- package/src/styles/theme.css +29 -0
- package/src/utils/studioSaveDiagnostics.ts +12 -0
- package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
- 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:
|
|
103
|
+
cursor: canManipulate && groupCanMove ? "move" : "default",
|
|
102
104
|
}}
|
|
103
105
|
onPointerDown={(e) => {
|
|
104
|
-
if (!
|
|
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
|
-
{
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
{
|
|
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
|
|
@@ -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",
|
|
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",
|
|
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
|