@hyperframes/studio 0.8.82 → 0.8.83
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-zGIbZhLJ.js → hyperframes-player-BAerMCex.js} +1 -1
- package/dist/assets/{index-BiLCTunu.js → index-BUhcVhNy.js} +1 -1
- package/dist/assets/{index-DSazIt7R.js → index-BYhK_KMc.js} +193 -193
- package/dist/assets/{index-DStmfnpZ.js → index-Iv0HI7eM.js} +1 -1
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-LPZMAGS7.js +1871 -0
- package/dist/chunk-LPZMAGS7.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +669 -330
- package/dist/index.html +1 -1
- package/dist/index.js +20501 -23167
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +135 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +7 -43
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/useElementLifecycleOps.ts +1 -1
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/index.ts +45 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/playerEntry.ts +1 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { getPreviewState, iframeRef } from "../nle/PreviewOverlaysTestMocks";
|
|
7
|
+
import { PreviewOverlays } from "../nle/PreviewOverlays";
|
|
8
|
+
import { ConnectedDomEditOverlay } from "./ConnectedDomEditOverlay";
|
|
9
|
+
|
|
10
|
+
// Overlay prop -> session handler, as PreviewOverlays wired DomEditOverlay itself.
|
|
11
|
+
const WIRING = {
|
|
12
|
+
onCanvasMouseDown: "handlePreviewCanvasMouseDown",
|
|
13
|
+
onCanvasPointerMove: "handlePreviewCanvasPointerMove",
|
|
14
|
+
onCanvasPointerLeave: "handlePreviewCanvasPointerLeave",
|
|
15
|
+
onSelectionChange: "applyDomSelection",
|
|
16
|
+
onBlockedMove: "handleBlockedDomMove",
|
|
17
|
+
onManualDragStart: "handleDomManualDragStart",
|
|
18
|
+
onPathOffsetCommit: "handleDomPathOffsetCommit",
|
|
19
|
+
onGroupPathOffsetCommit: "handleDomGroupPathOffsetCommit",
|
|
20
|
+
onBoxSizeCommit: "handleDomBoxSizeCommit",
|
|
21
|
+
onRotationCommit: "handleDomRotationCommit",
|
|
22
|
+
onStyleCommit: "handleDomStyleCommit",
|
|
23
|
+
onDeleteSelection: "handleDomEditElementDelete",
|
|
24
|
+
onMarqueeSelect: "applyMarqueeSelection",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
const ctx = vi.hoisted(() => ({
|
|
28
|
+
overlayProps: null as Record<string, unknown> | null,
|
|
29
|
+
actions: {} as Record<string, unknown>,
|
|
30
|
+
selection: {
|
|
31
|
+
domEditHoverSelection: { label: "hover" },
|
|
32
|
+
domEditSelection: { label: "selected" },
|
|
33
|
+
domEditGroupSelections: [{ label: "member" }],
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
vi.mock("../../contexts/DomEditContext", () => ({
|
|
38
|
+
useDomEditSelectionContext: () => ctx.selection,
|
|
39
|
+
useDomEditActionsContext: () => ctx.actions,
|
|
40
|
+
}));
|
|
41
|
+
vi.mock("./DomEditOverlay", () => ({
|
|
42
|
+
DomEditOverlay: (props: Record<string, unknown>) => {
|
|
43
|
+
ctx.overlayProps = props;
|
|
44
|
+
return null;
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
vi.mock("./TopologyLens", () => ({ TopologyLens: () => null }));
|
|
48
|
+
vi.mock("./MotionPathOverlay", () => ({ MotionPathOverlay: () => null }));
|
|
49
|
+
vi.mock("./SnapToolbar", () => ({ SnapToolbar: () => null }));
|
|
50
|
+
vi.mock("./GridOverlay", () => ({ GridOverlay: () => null }));
|
|
51
|
+
|
|
52
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
53
|
+
|
|
54
|
+
let root: Root;
|
|
55
|
+
|
|
56
|
+
function mount(node: React.ReactNode): Record<string, unknown> {
|
|
57
|
+
ctx.actions = {
|
|
58
|
+
previewIframeRef: iframeRef,
|
|
59
|
+
handleDomZIndexReorderCommit: vi.fn(() => Promise.resolve(undefined)),
|
|
60
|
+
};
|
|
61
|
+
for (const name of Object.values(WIRING)) ctx.actions[name] = vi.fn();
|
|
62
|
+
const host = document.createElement("div");
|
|
63
|
+
document.body.append(host);
|
|
64
|
+
root = createRoot(host);
|
|
65
|
+
act(() => root.render(node));
|
|
66
|
+
return ctx.overlayProps!;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
act(() => root.unmount());
|
|
71
|
+
ctx.overlayProps = null;
|
|
72
|
+
getPreviewState().isPlaying = false;
|
|
73
|
+
document.body.replaceChildren();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("ConnectedDomEditOverlay", () => {
|
|
77
|
+
it("is what PreviewOverlays renders, with every handler from the session", () => {
|
|
78
|
+
const props = mount(
|
|
79
|
+
<PreviewOverlays
|
|
80
|
+
shouldShowMotionPath={false}
|
|
81
|
+
shouldShowSelectedDomBounds={true}
|
|
82
|
+
recordingState={"idle" as never}
|
|
83
|
+
onToggleRecording={vi.fn()}
|
|
84
|
+
/>,
|
|
85
|
+
);
|
|
86
|
+
// The overlay never read these, so the connected wrapper does not pass them.
|
|
87
|
+
expect(props).not.toHaveProperty("recordingState");
|
|
88
|
+
expect(props).not.toHaveProperty("onToggleRecording");
|
|
89
|
+
for (const [prop, handler] of Object.entries(WIRING)) {
|
|
90
|
+
expect(props[prop], prop).toBe(ctx.actions[handler]);
|
|
91
|
+
}
|
|
92
|
+
expect(props).toMatchObject({
|
|
93
|
+
iframeRef,
|
|
94
|
+
activeCompositionPath: "index.html",
|
|
95
|
+
hoverSelection: ctx.selection.domEditHoverSelection,
|
|
96
|
+
selection: ctx.selection.domEditSelection,
|
|
97
|
+
groupSelections: ctx.selection.domEditGroupSelections,
|
|
98
|
+
allowCanvasMovement: true,
|
|
99
|
+
canvasInput: undefined,
|
|
100
|
+
onTextEditingChange: undefined,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("shows no hover box while playback runs", () => {
|
|
105
|
+
getPreviewState().isPlaying = true;
|
|
106
|
+
const props = mount(
|
|
107
|
+
<PreviewOverlays shouldShowMotionPath={false} shouldShowSelectedDomBounds={true} />,
|
|
108
|
+
);
|
|
109
|
+
expect(props.hoverSelection).toBeNull();
|
|
110
|
+
expect(props.selection).toBe(ctx.selection.domEditSelection);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("hides the selection and stops movement while a gesture records", () => {
|
|
114
|
+
const props = mount(
|
|
115
|
+
<PreviewOverlays
|
|
116
|
+
shouldShowMotionPath={false}
|
|
117
|
+
shouldShowSelectedDomBounds={false}
|
|
118
|
+
isGestureRecording={true}
|
|
119
|
+
/>,
|
|
120
|
+
);
|
|
121
|
+
expect(props).toMatchObject({
|
|
122
|
+
selection: null,
|
|
123
|
+
groupSelections: [],
|
|
124
|
+
allowCanvasMovement: false,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("persists a z-order change through the session's reorder commit", () => {
|
|
129
|
+
const props = mount(
|
|
130
|
+
<PreviewOverlays shouldShowMotionPath={false} shouldShowSelectedDomBounds={true} />,
|
|
131
|
+
);
|
|
132
|
+
const element = document.createElement("div");
|
|
133
|
+
element.id = "card";
|
|
134
|
+
const sel = { element, id: "card", selector: "#card", sourceFile: "index.html" };
|
|
135
|
+
const apply = props.onApplyZIndex as (...args: unknown[]) => void;
|
|
136
|
+
act(() => apply(sel, [{ element, zIndex: 4 }], "bring-forward", null));
|
|
137
|
+
expect(ctx.actions.handleDomZIndexReorderCommit).toHaveBeenCalledWith(
|
|
138
|
+
[expect.objectContaining({ element, zIndex: 4, id: "card", sourceFile: "index.html" })],
|
|
139
|
+
expect.stringContaining("z-reorder:bring-forward"),
|
|
140
|
+
"bring-forward",
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("passes a host's canvas input, text-editing listener and hover gate to the overlay", () => {
|
|
145
|
+
const onTextEditingChange = vi.fn();
|
|
146
|
+
const props = mount(
|
|
147
|
+
<ConnectedDomEditOverlay
|
|
148
|
+
activeCompositionPath={null}
|
|
149
|
+
showHoverSelection={false}
|
|
150
|
+
shouldShowSelectedDomBounds={true}
|
|
151
|
+
canvasInput="host"
|
|
152
|
+
onTextEditingChange={onTextEditingChange}
|
|
153
|
+
/>,
|
|
154
|
+
);
|
|
155
|
+
expect(props).toMatchObject({
|
|
156
|
+
canvasInput: "host",
|
|
157
|
+
onTextEditingChange,
|
|
158
|
+
hoverSelection: null,
|
|
159
|
+
selection: ctx.selection.domEditSelection,
|
|
160
|
+
iframeRef,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { DomEditOverlay } from "./DomEditOverlay";
|
|
3
|
+
import {
|
|
4
|
+
useDomEditActionsContext,
|
|
5
|
+
useDomEditSelectionContext,
|
|
6
|
+
} from "../../contexts/DomEditContext";
|
|
7
|
+
import { readHfId, type DomEditSelection } from "./domEditing";
|
|
8
|
+
import { buildStableSelector } from "./domEditingDom";
|
|
9
|
+
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
|
|
10
|
+
import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
|
|
11
|
+
import { useCanvasZOrderTimelineMirror } from "../nle/useCanvasZOrderTimelineMirror";
|
|
12
|
+
import { runZLaneGesture } from "../nle/zLaneGesture";
|
|
13
|
+
|
|
14
|
+
type HostInputProps = Pick<
|
|
15
|
+
ComponentProps<typeof DomEditOverlay>,
|
|
16
|
+
"canvasInput" | "onSelectionBoxClick"
|
|
17
|
+
>;
|
|
18
|
+
|
|
19
|
+
export interface ConnectedDomEditOverlayProps extends HostInputProps {
|
|
20
|
+
activeCompositionPath: string | null;
|
|
21
|
+
showHoverSelection: boolean;
|
|
22
|
+
shouldShowSelectedDomBounds: boolean;
|
|
23
|
+
isGestureRecording?: boolean;
|
|
24
|
+
/** True when an in-place text edit opens its caret, false when it ends or unmounts. */
|
|
25
|
+
onTextEditingChange?: (editing: boolean) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ZIndexReorderEntry = {
|
|
29
|
+
element: HTMLElement;
|
|
30
|
+
zIndex: number;
|
|
31
|
+
id?: string;
|
|
32
|
+
selector?: string;
|
|
33
|
+
selectorIndex?: number;
|
|
34
|
+
sourceFile: string;
|
|
35
|
+
/** Timeline store key — lets the commit update the store zIndex synchronously. */
|
|
36
|
+
key?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Can this element be robustly re-targeted for a persisted z change? */
|
|
40
|
+
function canTargetZIndexElement(
|
|
41
|
+
element: HTMLElement,
|
|
42
|
+
id: string | undefined,
|
|
43
|
+
selector: string | undefined,
|
|
44
|
+
): boolean {
|
|
45
|
+
return Boolean(id || selector || readHfId(element));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The selected element carries its full selection identity. */
|
|
49
|
+
function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReorderEntry {
|
|
50
|
+
return {
|
|
51
|
+
element: sel.element,
|
|
52
|
+
zIndex,
|
|
53
|
+
id: sel.id ?? undefined,
|
|
54
|
+
selector: sel.selector,
|
|
55
|
+
selectorIndex: sel.selectorIndex,
|
|
56
|
+
sourceFile: sel.sourceFile,
|
|
57
|
+
key: deriveTimelineStoreKey({
|
|
58
|
+
domId: sel.id ?? undefined,
|
|
59
|
+
selector: sel.selector,
|
|
60
|
+
selectorIndex: sel.selectorIndex,
|
|
61
|
+
sourceFile: sel.sourceFile,
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A raw iframe sibling in the selection's file; null with no id or selector (z stays live). */
|
|
67
|
+
function siblingZIndexEntry(
|
|
68
|
+
element: HTMLElement,
|
|
69
|
+
zIndex: number,
|
|
70
|
+
sourceFile: string,
|
|
71
|
+
): ZIndexReorderEntry | null {
|
|
72
|
+
const id = element.id || undefined;
|
|
73
|
+
const selector = buildStableSelector(element);
|
|
74
|
+
if (!canTargetZIndexElement(element, id, selector)) return null;
|
|
75
|
+
return {
|
|
76
|
+
element,
|
|
77
|
+
zIndex,
|
|
78
|
+
id,
|
|
79
|
+
selector,
|
|
80
|
+
selectorIndex: undefined,
|
|
81
|
+
sourceFile,
|
|
82
|
+
key: deriveTimelineStoreKey({ domId: id, selector, sourceFile }),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Short human-readable label for a dropped sibling, for the console warning below. */
|
|
87
|
+
function describeZIndexElement(element: HTMLElement): string {
|
|
88
|
+
if (element.id) return `#${element.id}`;
|
|
89
|
+
const firstClass = element.classList.item(0);
|
|
90
|
+
return firstClass
|
|
91
|
+
? `${element.tagName.toLowerCase()}.${firstClass}`
|
|
92
|
+
: element.tagName.toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Resolve z-index patches into commit entries; a sibling with no stable
|
|
96
|
+
// id/selector can't be written to source, so it is returned as `dropped` for
|
|
97
|
+
// the revert-on-reload warning. Exported so tests can drive the menu → commit path.
|
|
98
|
+
export function resolveZIndexEntries(
|
|
99
|
+
sel: DomEditSelection,
|
|
100
|
+
patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
|
|
101
|
+
): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
|
|
102
|
+
const entries: ZIndexReorderEntry[] = [];
|
|
103
|
+
const dropped: Array<{ element: HTMLElement; zIndex: number }> = [];
|
|
104
|
+
for (const patch of patches) {
|
|
105
|
+
if (patch.element === sel.element) {
|
|
106
|
+
entries.push(selectedZIndexEntry(sel, patch.zIndex));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile);
|
|
110
|
+
if (entry) entries.push(entry);
|
|
111
|
+
else dropped.push(patch);
|
|
112
|
+
}
|
|
113
|
+
return { entries, dropped };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Studio's DomEditOverlay wired to the session in DomEditProvider, so a host
|
|
117
|
+
// outside EditorShell mounts the same canvas editing without its own callbacks.
|
|
118
|
+
export function ConnectedDomEditOverlay({
|
|
119
|
+
activeCompositionPath,
|
|
120
|
+
showHoverSelection,
|
|
121
|
+
shouldShowSelectedDomBounds,
|
|
122
|
+
isGestureRecording,
|
|
123
|
+
canvasInput,
|
|
124
|
+
onTextEditingChange,
|
|
125
|
+
onSelectionBoxClick,
|
|
126
|
+
}: ConnectedDomEditOverlayProps) {
|
|
127
|
+
const { domEditHoverSelection, domEditSelection, domEditGroupSelections } =
|
|
128
|
+
useDomEditSelectionContext();
|
|
129
|
+
const {
|
|
130
|
+
previewIframeRef,
|
|
131
|
+
handlePreviewCanvasMouseDown,
|
|
132
|
+
handlePreviewCanvasPointerMove,
|
|
133
|
+
handlePreviewCanvasPointerLeave,
|
|
134
|
+
applyDomSelection,
|
|
135
|
+
handleBlockedDomMove,
|
|
136
|
+
handleDomManualDragStart,
|
|
137
|
+
handleDomPathOffsetCommit,
|
|
138
|
+
handleDomGroupPathOffsetCommit,
|
|
139
|
+
handleDomBoxSizeCommit,
|
|
140
|
+
handleDomRotationCommit,
|
|
141
|
+
handleDomStyleCommit,
|
|
142
|
+
applyMarqueeSelection,
|
|
143
|
+
handleDomEditElementDelete,
|
|
144
|
+
handleDomZIndexReorderCommit,
|
|
145
|
+
} = useDomEditActionsContext();
|
|
146
|
+
const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror();
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<DomEditOverlay
|
|
150
|
+
iframeRef={previewIframeRef}
|
|
151
|
+
activeCompositionPath={activeCompositionPath}
|
|
152
|
+
hoverSelection={showHoverSelection ? domEditHoverSelection : null}
|
|
153
|
+
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
|
154
|
+
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
|
|
155
|
+
allowCanvasMovement={!isGestureRecording}
|
|
156
|
+
canvasInput={canvasInput}
|
|
157
|
+
onTextEditingChange={onTextEditingChange}
|
|
158
|
+
onSelectionBoxClick={onSelectionBoxClick}
|
|
159
|
+
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
160
|
+
onCanvasPointerMove={handlePreviewCanvasPointerMove}
|
|
161
|
+
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
|
|
162
|
+
onSelectionChange={applyDomSelection}
|
|
163
|
+
onBlockedMove={handleBlockedDomMove}
|
|
164
|
+
onManualDragStart={handleDomManualDragStart}
|
|
165
|
+
onPathOffsetCommit={handleDomPathOffsetCommit}
|
|
166
|
+
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
|
|
167
|
+
onBoxSizeCommit={handleDomBoxSizeCommit}
|
|
168
|
+
onRotationCommit={handleDomRotationCommit}
|
|
169
|
+
onStyleCommit={handleDomStyleCommit}
|
|
170
|
+
onDeleteSelection={handleDomEditElementDelete}
|
|
171
|
+
onApplyZIndex={(sel, patches, action, crossed) => {
|
|
172
|
+
const { entries, dropped } = resolveZIndexEntries(sel, patches);
|
|
173
|
+
if (dropped.length > 0) {
|
|
174
|
+
// These siblings can't be written to source. Apply their live z
|
|
175
|
+
// anyway so the resolved stacking order renders coherently — it
|
|
176
|
+
// just reverts to the prior order on the next reload.
|
|
177
|
+
for (const patch of dropped) patch.element.style.zIndex = String(patch.zIndex);
|
|
178
|
+
console.warn(
|
|
179
|
+
"[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
|
|
180
|
+
"(will revert on reload):",
|
|
181
|
+
dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (entries.length === 0) return;
|
|
185
|
+
// One coalesce key for the z persist AND the lane mirror folds both into one undo entry;
|
|
186
|
+
// passed explicitly so the mirror shares it by construction, not by formula duplication.
|
|
187
|
+
const coalesceKey = zReorderCoalesceKey(entries, action);
|
|
188
|
+
// One serialized z→lane transaction: the mirror runs only AFTER a durable z commit and
|
|
189
|
+
// no second gesture interleaves (see runZLaneGesture). A failed z commit has already
|
|
190
|
+
// toasted and rolled back, so the catch only keeps its rejection from going unhandled.
|
|
191
|
+
runZLaneGesture({
|
|
192
|
+
commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, action),
|
|
193
|
+
mirror: () =>
|
|
194
|
+
mirrorZOrderToTimeline({
|
|
195
|
+
selectionKey: entries.find((e) => e.element === sel.element)?.key,
|
|
196
|
+
action,
|
|
197
|
+
crossed,
|
|
198
|
+
sourceFile: sel.sourceFile,
|
|
199
|
+
coalesceKey,
|
|
200
|
+
}),
|
|
201
|
+
}).catch(() => undefined);
|
|
202
|
+
}}
|
|
203
|
+
onMarqueeSelect={applyMarqueeSelection}
|
|
204
|
+
/>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -54,6 +54,14 @@ interface DomEditOverlayProps {
|
|
|
54
54
|
groupSelections?: DomEditSelection[];
|
|
55
55
|
hoverSelection: DomEditSelection | null;
|
|
56
56
|
allowCanvasMovement?: boolean;
|
|
57
|
+
/** "host": no hover, marquee or box re-select; Enter with nothing focused still opens text. */
|
|
58
|
+
canvasInput?: "overlay" | "host";
|
|
59
|
+
onTextEditingChange?: (editing: boolean) => void;
|
|
60
|
+
/** A click on a single selection's box, in either mode; the event may be the pointerup. */
|
|
61
|
+
onSelectionBoxClick?: (
|
|
62
|
+
event: React.MouseEvent<HTMLDivElement>,
|
|
63
|
+
selection: DomEditSelection,
|
|
64
|
+
) => void;
|
|
57
65
|
onCanvasMouseDown: (
|
|
58
66
|
event: React.MouseEvent<HTMLDivElement>,
|
|
59
67
|
options?: PreviewMouseDownOptions,
|
|
@@ -117,7 +125,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
117
125
|
groupSelections = [],
|
|
118
126
|
hoverSelection,
|
|
119
127
|
allowCanvasMovement = true,
|
|
120
|
-
|
|
128
|
+
canvasInput = "overlay",
|
|
129
|
+
onTextEditingChange,
|
|
130
|
+
onSelectionBoxClick,
|
|
131
|
+
onCanvasMouseDown: onCanvasMouseDownProp,
|
|
121
132
|
onCanvasPointerMove,
|
|
122
133
|
onCanvasPointerLeave,
|
|
123
134
|
onSelectionChange,
|
|
@@ -133,6 +144,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
133
144
|
onApplyZIndex,
|
|
134
145
|
}: DomEditOverlayProps) {
|
|
135
146
|
const readOnly = usePreviewReadOnly();
|
|
147
|
+
const hostInput = canvasInput === "host";
|
|
136
148
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
137
149
|
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
138
150
|
const onMarqueeSelectRef = useRef(onMarqueeSelect);
|
|
@@ -150,6 +162,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
150
162
|
|
|
151
163
|
const selectionRef = useRef(selection);
|
|
152
164
|
selectionRef.current = selection;
|
|
165
|
+
const onCanvasMouseDown: typeof onCanvasMouseDownProp = (event, options) => {
|
|
166
|
+
const sel = selectionRef.current;
|
|
167
|
+
if (sel && boxRef.current?.contains(event.target as Node | null)) {
|
|
168
|
+
onSelectionBoxClick?.(event, sel);
|
|
169
|
+
}
|
|
170
|
+
if (!hostInput) onCanvasMouseDownProp(event, options);
|
|
171
|
+
};
|
|
153
172
|
|
|
154
173
|
// Brief highlight on the sibling a forward/backward z step crossed — drawn
|
|
155
174
|
// in this studio overlay, never in the iframe DOM (see useZOrderCrossedFlash).
|
|
@@ -163,7 +182,14 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
163
182
|
hoverSelectionRef.current = hoverSelection;
|
|
164
183
|
|
|
165
184
|
// Double-click an element to edit its text where it sits.
|
|
166
|
-
const inlineText = useInlineTextEditing(selectionRef);
|
|
185
|
+
const inlineText = useInlineTextEditing(selectionRef, { enterFromWindow: hostInput });
|
|
186
|
+
const onTextEditingChangeRef = useRef(onTextEditingChange);
|
|
187
|
+
onTextEditingChangeRef.current = onTextEditingChange;
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!inlineText.editing) return;
|
|
190
|
+
onTextEditingChangeRef.current?.(true);
|
|
191
|
+
return () => onTextEditingChangeRef.current?.(false);
|
|
192
|
+
}, [inlineText.editing]);
|
|
167
193
|
const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
|
|
168
194
|
onPathOffsetCommitRef.current = onPathOffsetCommit;
|
|
169
195
|
const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
|
|
@@ -322,7 +348,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
322
348
|
|
|
323
349
|
// fallow-ignore-next-line complexity
|
|
324
350
|
const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
325
|
-
if (!allowCanvasMovement || event.button !== 0) return;
|
|
351
|
+
if (!allowCanvasMovement || hostInput || event.button !== 0) return;
|
|
326
352
|
if (event.shiftKey) {
|
|
327
353
|
const shiftIframe = iframeRef.current;
|
|
328
354
|
const candidate = resolveShiftClickCandidate({
|
|
@@ -378,9 +404,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
378
404
|
)
|
|
379
405
|
: null;
|
|
380
406
|
if (freshTarget) return;
|
|
381
|
-
|
|
382
|
-
if (overlayEl) {
|
|
383
|
-
const oRect = overlayEl.getBoundingClientRect();
|
|
407
|
+
if (overlayRef.current) {
|
|
384
408
|
// Anywhere empty on the overlay starts one, not just inside the frame.
|
|
385
409
|
// An element dragged past the edge sits OUT there in the grey, and a
|
|
386
410
|
// rubber band that refuses to start there cannot reach it — which left
|
|
@@ -390,17 +414,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
390
414
|
event.preventDefault();
|
|
391
415
|
event.stopPropagation();
|
|
392
416
|
suppressNextOverlayMouseDownRef.current = true;
|
|
393
|
-
|
|
394
|
-
const cx = event.clientX - oRect.left;
|
|
395
|
-
const cy = event.clientY - oRect.top;
|
|
396
|
-
marquee.marqueeRef.current = {
|
|
397
|
-
startX: cx,
|
|
398
|
-
startY: cy,
|
|
399
|
-
currentX: cx,
|
|
400
|
-
currentY: cy,
|
|
401
|
-
pointerId: event.pointerId,
|
|
402
|
-
pastThreshold: false,
|
|
403
|
-
};
|
|
417
|
+
marquee.begin(event);
|
|
404
418
|
return;
|
|
405
419
|
}
|
|
406
420
|
}
|
|
@@ -440,7 +454,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
440
454
|
// Standing aside is the only way the caret below can be reached, and is
|
|
441
455
|
// what keeps selection, drag and marquee from firing mid-edit.
|
|
442
456
|
className={`absolute inset-0 z-10 outline-hidden ${
|
|
443
|
-
inlineText.editing ? "pointer-events-none" : "pointer-events-auto"
|
|
457
|
+
inlineText.editing || hostInput ? "pointer-events-none" : "pointer-events-auto"
|
|
444
458
|
}`}
|
|
445
459
|
data-editing-text={inlineText.editing ? "true" : undefined}
|
|
446
460
|
tabIndex={-1}
|
|
@@ -451,6 +465,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
451
465
|
// A pointer gesture supersedes a pending nudge burst — commit it first
|
|
452
466
|
// so the gesture's member snapshot starts from the nudged position.
|
|
453
467
|
flushNudge();
|
|
468
|
+
suppressNextBoxClickRef.current = false;
|
|
454
469
|
// Not while editing: taking focus back would send the keystroke nowhere.
|
|
455
470
|
if (!inlineText.editing) {
|
|
456
471
|
focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay);
|
|
@@ -467,9 +482,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
467
482
|
onPointerLeave={() => onCanvasPointerLeaveRef.current()}
|
|
468
483
|
onPointerUp={marquee.onPointerUp}
|
|
469
484
|
onPointerCancel={marquee.onPointerCancel}
|
|
470
|
-
onContextMenu={handleContextMenu}
|
|
485
|
+
onContextMenu={hostInput ? undefined : handleContextMenu}
|
|
471
486
|
>
|
|
472
|
-
{hoverSelection && hoverRect && compRect.width > 0 && (
|
|
487
|
+
{!hostInput && hoverSelection && hoverRect && compRect.width > 0 && (
|
|
473
488
|
<div
|
|
474
489
|
aria-hidden="true"
|
|
475
490
|
data-dom-edit-hover-box="true"
|
|
@@ -516,7 +531,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
516
531
|
editing session, which does. */}
|
|
517
532
|
{inlineText.toolbar}
|
|
518
533
|
<OffCanvasIndicators
|
|
519
|
-
rects={offCanvasRects}
|
|
534
|
+
rects={hostInput ? [] : offCanvasRects}
|
|
520
535
|
elements={offCanvasElementsRef}
|
|
521
536
|
compRect={compRect}
|
|
522
537
|
selection={selection}
|
|
@@ -343,7 +343,7 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
343
343
|
|
|
344
344
|
// ONE undo entry for the whole gesture: the z persist and the timeline
|
|
345
345
|
// lane mirror below share this per-gesture-unique key (same contract as
|
|
346
|
-
// the canvas menu's wiring in
|
|
346
|
+
// the canvas menu's wiring in ConnectedDomEditOverlay).
|
|
347
347
|
const coalesceKey = zReorderCoalesceKey(entries, "layer-drag");
|
|
348
348
|
const desiredOrderKeys = desiredBottomToTop.map(
|
|
349
349
|
(l) =>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Rect } from "../../utils/marqueeGeometry";
|
|
2
2
|
|
|
3
|
-
interface MarqueeOverlayProps {
|
|
3
|
+
export interface MarqueeOverlayProps {
|
|
4
4
|
/** Elements the marquee currently intersects — outlined live before mouse-up. */
|
|
5
5
|
candidateRects: Rect[];
|
|
6
6
|
/** The marquee drag rectangle itself, or null when not marquee-selecting. */
|
|
@@ -15,7 +15,7 @@ const GUIDE_COLOR = "rgba(255, 68, 204, 0.85)";
|
|
|
15
15
|
const SPACING_COLOR = "rgba(255, 68, 204, 0.6)";
|
|
16
16
|
const SPACING_BG = "rgba(255, 68, 204, 0.15)";
|
|
17
17
|
|
|
18
|
-
interface SnapGuideOverlayProps {
|
|
18
|
+
export interface SnapGuideOverlayProps {
|
|
19
19
|
snapGuidesRef: RefObject<SnapGuidesState | null>;
|
|
20
20
|
/** Composition rect in overlay space — guide lines span exactly this rect. */
|
|
21
21
|
compositionLeft: number;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { clipAudioOnItsClock, readClipClock } from "./clipAudioClock";
|
|
3
|
+
|
|
4
|
+
const RATE = 48000;
|
|
5
|
+
const sine = (seconds: number, hz: number) =>
|
|
6
|
+
Float32Array.from({ length: RATE * seconds }, (_, i) => Math.sin((2 * Math.PI * hz * i) / RATE));
|
|
7
|
+
const zeroCrossingsPerSecond = (x: Float32Array) => {
|
|
8
|
+
let n = 0;
|
|
9
|
+
for (let i = 1; i < x.length; i += 1) if (x[i - 1]! < 0 !== x[i]! < 0) n += 1;
|
|
10
|
+
return n / (x.length / RATE);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe("readClipClock", () => {
|
|
14
|
+
it("reads the in-point, the rate lane over the constant, and a positive duration", () => {
|
|
15
|
+
const attrs: Record<string, string> = {
|
|
16
|
+
"data-playback-start": "1.5",
|
|
17
|
+
"data-media-start": "9",
|
|
18
|
+
"data-playback-rate": "3",
|
|
19
|
+
"data-automation":
|
|
20
|
+
'{"version":1,"lanes":[{"target":"rate","points":[{"t":0,"v":2},{"t":4,"v":2}]}]}',
|
|
21
|
+
"data-duration": "4",
|
|
22
|
+
};
|
|
23
|
+
const clock = readClipClock((name) => attrs[name]);
|
|
24
|
+
expect({ ...clock, rate: typeof clock.rate }).toEqual({
|
|
25
|
+
inPoint: 1.5,
|
|
26
|
+
rate: "object",
|
|
27
|
+
duration: 4,
|
|
28
|
+
});
|
|
29
|
+
expect(readClipClock((name) => (name === "data-duration" ? "0" : null))).toEqual({
|
|
30
|
+
inPoint: 0,
|
|
31
|
+
rate: 1,
|
|
32
|
+
duration: null,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("clipAudioOnItsClock", () => {
|
|
38
|
+
it("is the exact slice the clip plays at rate 1", () => {
|
|
39
|
+
const samples = sine(4, 440);
|
|
40
|
+
const out = clipAudioOnItsClock(samples, RATE, { inPoint: 1, rate: 1, duration: 2 });
|
|
41
|
+
expect(Array.from(out)).toEqual(Array.from(samples.subarray(RATE, 3 * RATE)));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("lasts the clip's duration at 2x, consuming twice as much source", () => {
|
|
45
|
+
// Each sample holds its own source time, so the output reads back where it was taken from.
|
|
46
|
+
const samples = Float32Array.from({ length: RATE * 6 }, (_, i) => i / RATE);
|
|
47
|
+
const out = clipAudioOnItsClock(samples, RATE, { inPoint: 1, rate: 2, duration: 2 });
|
|
48
|
+
expect({ length: out.length, atOneSecond: Number(out[RATE]!.toFixed(1)) }).toEqual({
|
|
49
|
+
length: 2 * RATE,
|
|
50
|
+
atOneSecond: 3,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps the pitch at 2x and 0.5x", () => {
|
|
55
|
+
const samples = sine(4, 440);
|
|
56
|
+
for (const rate of [2, 0.5]) {
|
|
57
|
+
const out = clipAudioOnItsClock(samples, RATE, { inPoint: 0, rate, duration: 1.5 });
|
|
58
|
+
expect(zeroCrossingsPerSecond(out) / 880).toBeCloseTo(1, 1);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveRateSpec,
|
|
3
|
+
sourceTimeAt,
|
|
4
|
+
timeAtSourceTime,
|
|
5
|
+
type RateSpec,
|
|
6
|
+
} from "@hyperframes/core/speed-ramp";
|
|
7
|
+
import {
|
|
8
|
+
readDataDurationSeconds,
|
|
9
|
+
readMediaOffsetSeconds,
|
|
10
|
+
readPlaybackRate,
|
|
11
|
+
type AttrReader,
|
|
12
|
+
} from "@hyperframes/parsers/media-duration";
|
|
13
|
+
|
|
14
|
+
/** Where a clip's audio starts in its file, how fast it plays, and for how long (null: to the end). */
|
|
15
|
+
export interface ClipClock {
|
|
16
|
+
inPoint: number;
|
|
17
|
+
rate: RateSpec;
|
|
18
|
+
duration: number | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readClipClock(getAttr: AttrReader): ClipClock {
|
|
22
|
+
return {
|
|
23
|
+
inPoint: readMediaOffsetSeconds(getAttr),
|
|
24
|
+
rate: resolveRateSpec(getAttr("data-automation"), readPlaybackRate(getAttr)),
|
|
25
|
+
duration: readDataDurationSeconds(getAttr),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The source samples a clip plays, still on the file's clock. */
|
|
30
|
+
export function clipSourceWindow(
|
|
31
|
+
samples: Float32Array,
|
|
32
|
+
sampleRate: number,
|
|
33
|
+
clock: ClipClock,
|
|
34
|
+
): Float32Array {
|
|
35
|
+
const from = Math.min(samples.length, Math.floor(clock.inPoint * sampleRate));
|
|
36
|
+
const to = clock.duration
|
|
37
|
+
? Math.min(
|
|
38
|
+
samples.length,
|
|
39
|
+
from + Math.ceil(sourceTimeAt(clock.rate, clock.duration) * sampleRate),
|
|
40
|
+
)
|
|
41
|
+
: samples.length;
|
|
42
|
+
return samples.subarray(from, to);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const GRAIN = 4096;
|
|
46
|
+
const HOP = GRAIN / 2;
|
|
47
|
+
const HANN = Float32Array.from(
|
|
48
|
+
{ length: GRAIN },
|
|
49
|
+
(_, k) => 0.5 - 0.5 * Math.cos((2 * Math.PI * k) / GRAIN),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The audio heard over clip time [0, duration]. Off rate 1, Hann grains overlap-added at the source
|
|
54
|
+
* time each lands on: pitch kept, like export's atempo, close enough for band and level analysis.
|
|
55
|
+
*/
|
|
56
|
+
export function clipAudioOnItsClock(
|
|
57
|
+
samples: Float32Array,
|
|
58
|
+
sampleRate: number,
|
|
59
|
+
clock: ClipClock,
|
|
60
|
+
): Float32Array {
|
|
61
|
+
const source = clipSourceWindow(samples, sampleRate, clock);
|
|
62
|
+
if (clock.rate === 1) return source;
|
|
63
|
+
const out = new Float32Array(
|
|
64
|
+
Math.round(timeAtSourceTime(clock.rate, source.length / sampleRate) * sampleRate),
|
|
65
|
+
);
|
|
66
|
+
// Grains are not phase-aligned, so off rate 1 the level reads ~1.3 dB low (up to ~3 dB on
|
|
67
|
+
// tones); a WSOLA stretch would keep it.
|
|
68
|
+
for (let at = -HOP; at < out.length; at += HOP) {
|
|
69
|
+
const centre = Math.round(sourceTimeAt(clock.rate, (at + HOP) / sampleRate) * sampleRate);
|
|
70
|
+
for (let k = 0; k < GRAIN; k += 1) {
|
|
71
|
+
const o = at + k;
|
|
72
|
+
const s = centre - HOP + k;
|
|
73
|
+
if (o < 0 || o >= out.length || s < 0 || s >= source.length) continue;
|
|
74
|
+
out[o] = (out[o] ?? 0) + (HANN[k] ?? 0) * (source[s] ?? 0);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|