@hyperframes/studio 0.8.55 → 0.8.57
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-c23wnIfc.js +464 -0
- package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
- package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
- package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
- package/dist/index.d.ts +111 -17
- package/dist/index.html +2 -2
- package/dist/index.js +21534 -20683
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +18 -1
- package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
- package/src/components/EditorShell.selectionSync.test.tsx +57 -67
- package/src/components/EditorShell.tsx +62 -51
- package/src/components/EditorShellTestMocks.ts +46 -0
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +19 -55
- package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
- package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
- package/src/components/editor/GridOverlay.tsx +12 -23
- package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
- package/src/components/editor/MotionPathOverlay.tsx +3 -1
- package/src/components/editor/PreviewGuides.test.tsx +6 -1
- package/src/components/editor/PreviewGuides.tsx +8 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
- 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 +26 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- 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/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- 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/editor/usePreviewCompositionRect.test.tsx +19 -13
- package/src/components/editor/usePreviewCompositionRect.ts +12 -12
- package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
- package/src/components/editor/useVolumeAutomation.ts +57 -16
- package/src/components/nle/NLEContext.tsx +40 -4
- package/src/components/nle/NLEPreview.test.ts +66 -1
- package/src/components/nle/NLEPreview.tsx +88 -44
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
- package/src/components/nle/PreviewOverlays.tsx +19 -24
- package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/TimelinePane.tsx +5 -1
- package/src/components/nle/previewReadOnly.test.tsx +129 -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/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/useCompositionDimensions.test.tsx +38 -0
- package/src/hooks/useCompositionDimensions.ts +17 -1
- package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
- package/src/hooks/useDomEditAttributeCommits.ts +14 -3
- 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/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- 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/useSdkSession.lifecycle.test.tsx +80 -0
- package/src/hooks/useSdkSession.ts +46 -10
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- 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/index.ts +12 -0
- package/src/player/components/Player.test.ts +141 -13
- package/src/player/components/Player.tsx +58 -2
- package/src/player/components/ShortcutsPanel.test.tsx +4 -10
- package/src/player/components/Timeline.tsx +23 -573
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineClip.test.tsx +52 -0
- package/src/player/components/TimelineClip.tsx +13 -2
- package/src/player/components/TimelineOverlays.test.ts +67 -32
- package/src/player/components/TimelineOverlays.tsx +46 -107
- package/src/player/components/TimelineProvider.tsx +191 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineMotionStyles.test.ts +3 -3
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- 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/useTimelineClipContextMenu.ts +1 -1
- package/src/player/components/useTimelineOverlaysState.ts +100 -0
- package/src/player/components/useTimelineProviderState.tsx +583 -0
- package/src/player/components/useTimelineTrackDerivations.ts +2 -2
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
- package/src/player/hooks/timelineSyncHydration.ts +0 -23
- package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
- package/src/player/hooks/useShadowPreviewReload.ts +183 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
- package/src/player/hooks/useTimelinePlayer.ts +65 -99
- package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -91
- 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/telemetry/events.test.ts +18 -0
- package/src/telemetry/events.ts +10 -0
- package/src/telemetry/system.test.ts +49 -0
- package/src/telemetry/system.ts +6 -0
- package/src/telemetry/tabId.test.ts +43 -0
- package/src/telemetry/tabId.ts +44 -0
- package/src/utils/previewCompositionSize.ts +26 -0
- package/src/utils/reloadDebug.ts +2 -4
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioTelemetry.test.ts +14 -0
- package/src/utils/studioTelemetry.ts +4 -0
- package/dist/assets/hyperframes-player-BjAGc2ID.js +0 -464
- package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
- package/src/player/hooks/useTimelineRowElements.ts +0 -17
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { GridOverlay } from "./GridOverlay";
|
|
7
|
+
import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
|
|
8
|
+
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = "";
|
|
14
|
+
window.localStorage.clear();
|
|
15
|
+
usePreviewIframeStore.getState().setIframe(null);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function compositionFrame() {
|
|
19
|
+
const iframe = document.createElement("iframe");
|
|
20
|
+
iframe.getBoundingClientRect = () => new DOMRect(12, 18, 640, 360);
|
|
21
|
+
const root = iframe.contentDocument?.createElement("div");
|
|
22
|
+
root?.setAttribute("data-composition-id", "main");
|
|
23
|
+
root?.setAttribute("data-width", "1280");
|
|
24
|
+
root?.setAttribute("data-height", "720");
|
|
25
|
+
if (root) iframe.contentDocument?.body.append(root);
|
|
26
|
+
document.body.append(iframe);
|
|
27
|
+
return iframe;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("PreviewOverlayProvider", () => {
|
|
31
|
+
it("derives grid geometry after the iframe prop becomes available", async () => {
|
|
32
|
+
window.localStorage.setItem(
|
|
33
|
+
"hf-studio-ui-preferences",
|
|
34
|
+
JSON.stringify({ gridVisible: true, gridSpacing: 20 }),
|
|
35
|
+
);
|
|
36
|
+
const host = document.createElement("div");
|
|
37
|
+
document.body.append(host);
|
|
38
|
+
const root = createRoot(host);
|
|
39
|
+
|
|
40
|
+
act(() => {
|
|
41
|
+
root.render(
|
|
42
|
+
<PreviewOverlayProvider iframe={null}>
|
|
43
|
+
<GridOverlay />
|
|
44
|
+
</PreviewOverlayProvider>,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
expect(host.querySelector('[aria-hidden="true"]')).not.toBeNull();
|
|
48
|
+
|
|
49
|
+
const iframe = compositionFrame();
|
|
50
|
+
act(() => {
|
|
51
|
+
root.render(
|
|
52
|
+
<PreviewOverlayProvider iframe={iframe}>
|
|
53
|
+
<GridOverlay />
|
|
54
|
+
</PreviewOverlayProvider>,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
await act(async () => {
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const grid = host.querySelector<HTMLElement>('[aria-hidden="true"]');
|
|
62
|
+
expect([grid?.style.left, grid?.style.top, grid?.style.width, grid?.style.height]).toEqual([
|
|
63
|
+
"12px",
|
|
64
|
+
"18px",
|
|
65
|
+
"640px",
|
|
66
|
+
"360px",
|
|
67
|
+
]);
|
|
68
|
+
expect(grid?.style.backgroundSize).toBe("20px 20px");
|
|
69
|
+
act(() => root.unmount());
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("does not clear an iframe store entry owned elsewhere on unmount", () => {
|
|
73
|
+
const ownerIframe = compositionFrame();
|
|
74
|
+
usePreviewIframeStore.getState().setIframe(ownerIframe);
|
|
75
|
+
const host = document.createElement("div");
|
|
76
|
+
document.body.append(host);
|
|
77
|
+
const root = createRoot(host);
|
|
78
|
+
|
|
79
|
+
act(() => {
|
|
80
|
+
root.render(
|
|
81
|
+
<PreviewOverlayProvider iframe={null}>
|
|
82
|
+
<GridOverlay />
|
|
83
|
+
</PreviewOverlayProvider>,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
act(() => root.unmount());
|
|
87
|
+
|
|
88
|
+
expect(usePreviewIframeStore.getState().iframe).toBe(ownerIframe);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createContext, useContext, useRef, useState, type ReactNode, type RefObject } from "react";
|
|
2
|
+
import {
|
|
3
|
+
usePreviewCompositionRect,
|
|
4
|
+
type PreviewCompositionRect,
|
|
5
|
+
} from "./usePreviewCompositionRect";
|
|
6
|
+
import { usePreviewGuidesStore } from "./previewGuidesStore";
|
|
7
|
+
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
8
|
+
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
|
9
|
+
|
|
10
|
+
export interface PreviewSnapPreferences {
|
|
11
|
+
snapEnabled: boolean;
|
|
12
|
+
gridVisible: boolean;
|
|
13
|
+
gridSpacing: number;
|
|
14
|
+
snapToGrid: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface PreviewOverlayContextValue {
|
|
18
|
+
state: {
|
|
19
|
+
snapPrefs: PreviewSnapPreferences;
|
|
20
|
+
rulerVisible: boolean;
|
|
21
|
+
safeMarginsVisible: boolean;
|
|
22
|
+
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
23
|
+
compositionRect: PreviewCompositionRect;
|
|
24
|
+
};
|
|
25
|
+
actions: {
|
|
26
|
+
setSnapPrefs: (patch: Partial<PreviewSnapPreferences>) => void;
|
|
27
|
+
toggleRulers: () => void;
|
|
28
|
+
toggleSafeMargins: () => void;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const PreviewOverlayContext = createContext<PreviewOverlayContextValue | null>(null);
|
|
33
|
+
|
|
34
|
+
function readSnapPrefs(): PreviewSnapPreferences {
|
|
35
|
+
const prefs = readStudioUiPreferences();
|
|
36
|
+
return {
|
|
37
|
+
snapEnabled: prefs.snapEnabled ?? true,
|
|
38
|
+
gridVisible: prefs.gridVisible ?? false,
|
|
39
|
+
gridSpacing: prefs.gridSpacing ?? 50,
|
|
40
|
+
snapToGrid: prefs.snapToGrid ?? false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PreviewOverlayProviderProps {
|
|
45
|
+
iframe?: HTMLIFrameElement | null;
|
|
46
|
+
children: ReactNode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function PreviewOverlayProvider({ iframe, children }: PreviewOverlayProviderProps) {
|
|
50
|
+
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
51
|
+
const [snapPrefs, setSnapPrefs] = useState(readSnapPrefs);
|
|
52
|
+
const rulerVisible = usePreviewGuidesStore((state) => state.rulerVisible);
|
|
53
|
+
const safeMarginsVisible = usePreviewGuidesStore((state) => state.safeMarginsVisible);
|
|
54
|
+
const liveIframe = useLivePreviewIframe();
|
|
55
|
+
const resolvedIframe = iframe ?? liveIframe;
|
|
56
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(resolvedIframe);
|
|
57
|
+
iframeRef.current = resolvedIframe;
|
|
58
|
+
const compositionRect = usePreviewCompositionRect(overlayRef, resolvedIframe);
|
|
59
|
+
|
|
60
|
+
const contextValue: PreviewOverlayContextValue = {
|
|
61
|
+
state: { snapPrefs, rulerVisible, safeMarginsVisible, iframeRef, compositionRect },
|
|
62
|
+
actions: {
|
|
63
|
+
setSnapPrefs: (patch) => {
|
|
64
|
+
writeStudioUiPreferences(patch);
|
|
65
|
+
setSnapPrefs((current) => ({ ...current, ...patch }));
|
|
66
|
+
},
|
|
67
|
+
toggleRulers: () => usePreviewGuidesStore.getState().toggle("rulerVisible"),
|
|
68
|
+
toggleSafeMargins: () => usePreviewGuidesStore.getState().toggle("safeMarginsVisible"),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<PreviewOverlayContext.Provider value={contextValue}>
|
|
74
|
+
<div ref={overlayRef} className="absolute inset-0">
|
|
75
|
+
{children}
|
|
76
|
+
</div>
|
|
77
|
+
</PreviewOverlayContext.Provider>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function usePreviewOverlayContext(): PreviewOverlayContextValue {
|
|
82
|
+
const context = useContext(PreviewOverlayContext);
|
|
83
|
+
if (!context)
|
|
84
|
+
throw new Error("usePreviewOverlayContext must be used within PreviewOverlayProvider");
|
|
85
|
+
return context;
|
|
86
|
+
}
|
|
@@ -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
|
|
@@ -7,6 +7,7 @@ import { useAppHotkeys } from "../../hooks/useAppHotkeys";
|
|
|
7
7
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
8
|
import type { DomEditSelection } from "./domEditing";
|
|
9
9
|
import { SnapToolbar } from "./SnapToolbar";
|
|
10
|
+
import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
|
|
10
11
|
import { usePreviewGuidesStore } from "./previewGuidesStore";
|
|
11
12
|
|
|
12
13
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -17,14 +18,18 @@ afterEach(() => {
|
|
|
17
18
|
usePlayerStore.getState().reset();
|
|
18
19
|
});
|
|
19
20
|
|
|
20
|
-
function renderToolbar(
|
|
21
|
+
function renderToolbar() {
|
|
21
22
|
const host = document.createElement("div");
|
|
22
23
|
document.body.append(host);
|
|
23
24
|
const root = createRoot(host);
|
|
24
25
|
act(() => {
|
|
25
|
-
root.render(
|
|
26
|
+
root.render(
|
|
27
|
+
<PreviewOverlayProvider>
|
|
28
|
+
<SnapToolbar />
|
|
29
|
+
</PreviewOverlayProvider>,
|
|
30
|
+
);
|
|
26
31
|
});
|
|
27
|
-
return { root
|
|
32
|
+
return { root };
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
function AppHotkeyHarness() {
|
|
@@ -54,12 +59,13 @@ function AppHotkeyHarness() {
|
|
|
54
59
|
handleDuplicate: vi.fn(async () => false),
|
|
55
60
|
onResetKeyframes: vi.fn(() => false),
|
|
56
61
|
onDeleteSelectedKeyframes: vi.fn(),
|
|
62
|
+
readOnlyPreview: false,
|
|
57
63
|
});
|
|
58
64
|
|
|
59
65
|
return null;
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
function renderToolbarWithAppHotkeys(
|
|
68
|
+
function renderToolbarWithAppHotkeys() {
|
|
63
69
|
const host = document.createElement("div");
|
|
64
70
|
document.body.append(host);
|
|
65
71
|
const root = createRoot(host);
|
|
@@ -67,16 +73,18 @@ function renderToolbarWithAppHotkeys(onSnapChange = vi.fn()) {
|
|
|
67
73
|
root.render(
|
|
68
74
|
<>
|
|
69
75
|
<AppHotkeyHarness />
|
|
70
|
-
<
|
|
76
|
+
<PreviewOverlayProvider>
|
|
77
|
+
<SnapToolbar />
|
|
78
|
+
</PreviewOverlayProvider>
|
|
71
79
|
</>,
|
|
72
80
|
);
|
|
73
81
|
});
|
|
74
|
-
return { root
|
|
82
|
+
return { root };
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
describe("SnapToolbar keyboard shortcuts", () => {
|
|
78
86
|
it("toggles snap on an unclaimed S keypress", () => {
|
|
79
|
-
const { root
|
|
87
|
+
const { root } = renderToolbar();
|
|
80
88
|
|
|
81
89
|
act(() => {
|
|
82
90
|
document.dispatchEvent(
|
|
@@ -84,12 +92,14 @@ describe("SnapToolbar keyboard shortcuts", () => {
|
|
|
84
92
|
);
|
|
85
93
|
});
|
|
86
94
|
|
|
87
|
-
expect(
|
|
95
|
+
expect(
|
|
96
|
+
JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}").snapEnabled,
|
|
97
|
+
).toBe(false);
|
|
88
98
|
act(() => root.unmount());
|
|
89
99
|
});
|
|
90
100
|
|
|
91
101
|
it("does not toggle snap when another handler already prevented S", () => {
|
|
92
|
-
const { root
|
|
102
|
+
const { root } = renderToolbar();
|
|
93
103
|
const event = new KeyboardEvent("keydown", {
|
|
94
104
|
key: "s",
|
|
95
105
|
bubbles: true,
|
|
@@ -101,12 +111,14 @@ describe("SnapToolbar keyboard shortcuts", () => {
|
|
|
101
111
|
document.dispatchEvent(event);
|
|
102
112
|
});
|
|
103
113
|
|
|
104
|
-
expect(
|
|
114
|
+
expect(
|
|
115
|
+
JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}").snapEnabled,
|
|
116
|
+
).not.toBe(false);
|
|
105
117
|
act(() => root.unmount());
|
|
106
118
|
});
|
|
107
119
|
|
|
108
120
|
it("does not toggle snap when the app split shortcut claims S without a selected clip", () => {
|
|
109
|
-
const { root
|
|
121
|
+
const { root } = renderToolbarWithAppHotkeys();
|
|
110
122
|
|
|
111
123
|
act(() => {
|
|
112
124
|
document.dispatchEvent(
|
|
@@ -114,7 +126,9 @@ describe("SnapToolbar keyboard shortcuts", () => {
|
|
|
114
126
|
);
|
|
115
127
|
});
|
|
116
128
|
|
|
117
|
-
expect(
|
|
129
|
+
expect(
|
|
130
|
+
JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}").snapEnabled,
|
|
131
|
+
).not.toBe(false);
|
|
118
132
|
act(() => root.unmount());
|
|
119
133
|
});
|
|
120
134
|
});
|
|
@@ -1,59 +1,26 @@
|
|
|
1
1
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react";
|
|
3
|
-
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
4
3
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
const SNAP_DEFAULTS = {
|
|
8
|
-
snapEnabled: true,
|
|
9
|
-
gridVisible: false,
|
|
10
|
-
gridSpacing: 50,
|
|
11
|
-
snapToGrid: false,
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
// fallow-ignore-next-line complexity
|
|
15
|
-
function readSnapPrefs() {
|
|
16
|
-
const prefs = readStudioUiPreferences();
|
|
17
|
-
return {
|
|
18
|
-
snapEnabled: prefs.snapEnabled ?? SNAP_DEFAULTS.snapEnabled,
|
|
19
|
-
gridVisible: prefs.gridVisible ?? SNAP_DEFAULTS.gridVisible,
|
|
20
|
-
gridSpacing: prefs.gridSpacing ?? SNAP_DEFAULTS.gridSpacing,
|
|
21
|
-
snapToGrid: prefs.snapToGrid ?? SNAP_DEFAULTS.snapToGrid,
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface SnapToolbarProps {
|
|
26
|
-
onSnapChange?: (prefs: {
|
|
27
|
-
snapEnabled: boolean;
|
|
28
|
-
gridVisible: boolean;
|
|
29
|
-
gridSpacing: number;
|
|
30
|
-
snapToGrid: boolean;
|
|
31
|
-
}) => void;
|
|
32
|
-
}
|
|
4
|
+
import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
|
|
33
5
|
|
|
34
6
|
// fallow-ignore-next-line complexity
|
|
35
|
-
export const SnapToolbar = memo(function SnapToolbar(
|
|
36
|
-
const [prefs, setPrefs] = useState(readSnapPrefs);
|
|
7
|
+
export const SnapToolbar = memo(function SnapToolbar() {
|
|
37
8
|
const [gridPopoverOpen, setGridPopoverOpen] = useState(false);
|
|
9
|
+
const { state, actions } = usePreviewOverlayContext();
|
|
10
|
+
const { snapPrefs: prefs, rulerVisible, safeMarginsVisible } = state;
|
|
38
11
|
// Motion-path "set destination" toggle — shown only when the selected element
|
|
39
12
|
// can take a path; arms a single canvas click to place it (MotionPathOverlay).
|
|
40
13
|
const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable);
|
|
41
14
|
const motionPathArmed = usePlayerStore((s) => s.motionPathArmed);
|
|
42
15
|
const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
|
|
43
|
-
const guides = usePreviewGuidesStore();
|
|
44
16
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
45
17
|
const gridButtonRef = useRef<HTMLButtonElement>(null);
|
|
46
18
|
|
|
47
19
|
const updatePrefs = useCallback(
|
|
48
20
|
(patch: Partial<typeof prefs>) => {
|
|
49
|
-
|
|
50
|
-
const next = { ...prev, ...patch };
|
|
51
|
-
writeStudioUiPreferences(patch);
|
|
52
|
-
onSnapChange?.(next);
|
|
53
|
-
return next;
|
|
54
|
-
});
|
|
21
|
+
actions.setSnapPrefs(patch);
|
|
55
22
|
},
|
|
56
|
-
[
|
|
23
|
+
[actions],
|
|
57
24
|
);
|
|
58
25
|
|
|
59
26
|
const toggleSnap = useCallback(() => {
|
|
@@ -74,16 +41,16 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
|
|
74
41
|
if (t instanceof HTMLIFrameElement) return;
|
|
75
42
|
if (e.key === "s" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
76
43
|
e.preventDefault();
|
|
77
|
-
updatePrefs({ snapEnabled: !
|
|
44
|
+
updatePrefs({ snapEnabled: !prefs.snapEnabled });
|
|
78
45
|
}
|
|
79
46
|
if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
80
47
|
e.preventDefault();
|
|
81
|
-
updatePrefs({ gridVisible: !
|
|
48
|
+
updatePrefs({ gridVisible: !prefs.gridVisible });
|
|
82
49
|
}
|
|
83
50
|
};
|
|
84
51
|
document.addEventListener("keydown", handleKeyDown);
|
|
85
52
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
86
|
-
}, [updatePrefs]);
|
|
53
|
+
}, [prefs.gridVisible, prefs.snapEnabled, updatePrefs]);
|
|
87
54
|
|
|
88
55
|
useEffect(() => {
|
|
89
56
|
if (!gridPopoverOpen) return;
|
|
@@ -123,23 +90,27 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
|
|
123
90
|
["rulerVisible", "Ruler", Ruler],
|
|
124
91
|
["safeMarginsVisible", "Safe margins", FrameCorners],
|
|
125
92
|
] as const
|
|
126
|
-
).map(([key, label, Icon]) =>
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
93
|
+
).map(([key, label, Icon]) => {
|
|
94
|
+
const visible = key === "rulerVisible" ? rulerVisible : safeMarginsVisible;
|
|
95
|
+
const toggle = key === "rulerVisible" ? actions.toggleRulers : actions.toggleSafeMargins;
|
|
96
|
+
return (
|
|
97
|
+
<button
|
|
98
|
+
key={key}
|
|
99
|
+
type="button"
|
|
100
|
+
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
|
|
101
|
+
visible
|
|
102
|
+
? "bg-studio-accent/20 text-studio-accent"
|
|
103
|
+
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
|
104
|
+
}`}
|
|
105
|
+
onClick={toggle}
|
|
106
|
+
title={`${label} ${visible ? "on" : "off"}`}
|
|
107
|
+
aria-label={`Toggle ${label.toLowerCase()}`}
|
|
108
|
+
aria-pressed={visible}
|
|
109
|
+
>
|
|
110
|
+
<Icon size={16} weight={visible ? "fill" : "regular"} />
|
|
111
|
+
</button>
|
|
112
|
+
);
|
|
113
|
+
})}
|
|
143
114
|
<button
|
|
144
115
|
type="button"
|
|
145
116
|
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
|
|
@@ -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
|
+
}));
|