@hyperframes/studio 0.8.82 → 0.8.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/assets/{hyperframes-player-zGIbZhLJ.js → hyperframes-player-Dd74fv7B.js} +3 -3
  2. package/dist/assets/{index-DStmfnpZ.js → index-D9dmtDap.js} +1 -1
  3. package/dist/assets/{index-BiLCTunu.js → index-DFcIBFM8.js} +1 -1
  4. package/dist/assets/{index-DSazIt7R.js → index-gjbSm-vt.js} +193 -193
  5. package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
  6. package/dist/chunk-3IOCVWB6.js.map +1 -0
  7. package/dist/chunk-62DDWOSH.js +189 -0
  8. package/dist/chunk-62DDWOSH.js.map +1 -0
  9. package/dist/chunk-7VDQIGOU.js +1871 -0
  10. package/dist/chunk-7VDQIGOU.js.map +1 -0
  11. package/dist/chunk-QZ6HNNFG.js +934 -0
  12. package/dist/chunk-QZ6HNNFG.js.map +1 -0
  13. package/dist/chunk-S65AQH6M.js +101 -0
  14. package/dist/chunk-S65AQH6M.js.map +1 -0
  15. package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
  16. package/dist/index.d.ts +669 -330
  17. package/dist/index.html +1 -1
  18. package/dist/index.js +20512 -23172
  19. package/dist/index.js.map +1 -1
  20. package/dist/player.d.ts +30 -0
  21. package/dist/player.js +10 -0
  22. package/dist/player.js.map +1 -0
  23. package/dist/ui.d.ts +294 -0
  24. package/dist/ui.js +66 -0
  25. package/dist/ui.js.map +1 -0
  26. package/package.json +16 -8
  27. package/src/components/StudioHeader.tsx +1 -121
  28. package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
  29. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  30. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
  31. package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
  32. package/src/components/editor/DomEditOverlay.tsx +36 -21
  33. package/src/components/editor/LayersPanel.tsx +1 -1
  34. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  35. package/src/components/editor/SnapGuideOverlay.tsx +1 -1
  36. package/src/components/editor/clipAudioClock.test.ts +61 -0
  37. package/src/components/editor/clipAudioClock.ts +78 -0
  38. package/src/components/editor/marqueeCommit.ts +100 -37
  39. package/src/components/editor/previewReadOnly.overlay.test.tsx +234 -17
  40. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +135 -0
  41. package/src/components/editor/useFxCarve.ts +11 -15
  42. package/src/components/editor/useFxCarveNodes.ts +27 -15
  43. package/src/components/editor/useFxLevelling.ts +7 -43
  44. package/src/components/editor/useInlineTextEditing.tsx +35 -19
  45. package/src/components/feedback/projectProvenance.ts +2 -3
  46. package/src/components/nle/PreviewOverlays.tsx +7 -188
  47. package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
  48. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
  49. package/src/components/ui/HyperframesLogo.tsx +120 -0
  50. package/src/domEditExports.test.tsx +69 -0
  51. package/src/editorGesturesExports.test.tsx +236 -0
  52. package/src/hooks/useDomEditCommits.ts +1 -0
  53. package/src/hooks/useDomEditTextCommits.test.tsx +36 -0
  54. package/src/hooks/useDomEditTextCommits.ts +8 -0
  55. package/src/hooks/useElementLifecycleOps.ts +1 -1
  56. package/src/hooks/useInlineTextEdit.test.tsx +39 -0
  57. package/src/hooks/useInlineTextEdit.ts +5 -9
  58. package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
  59. package/src/hooks/useKeyframeKeyboard.ts +9 -71
  60. package/src/hooks/usePreviewPersistence.ts +1 -1
  61. package/src/index.ts +45 -32
  62. package/src/lightEntries.test.ts +32 -0
  63. package/src/player/components/TimelineClipFades.test.tsx +31 -10
  64. package/src/player/components/TimelineClipFades.tsx +2 -1
  65. package/src/player/components/TimelineTypes.ts +4 -2
  66. package/src/player/components/studioShortcuts.ts +8 -5
  67. package/src/player/components/timelineClipChildren.test.ts +34 -1
  68. package/src/player/components/timelineClipChildren.tsx +7 -4
  69. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
  70. package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
  71. package/src/player/components/timelinePointerRelease.ts +4 -0
  72. package/src/player/components/useTimelineProviderState.tsx +4 -0
  73. package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
  74. package/src/player/components/useTimelineStackingSync.ts +18 -11
  75. package/src/player/hooks/timelinePlayerSync.test.ts +1 -0
  76. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  77. package/src/player/lib/timelineDOM.test.ts +41 -7
  78. package/src/player/lib/timelineElementHelpers.ts +7 -7
  79. package/src/playerEntry.ts +1 -0
  80. package/src/timelineStackingSyncExport.test.tsx +67 -0
  81. package/src/ui.ts +31 -0
  82. package/src/utils/mediaTypes.test.ts +33 -0
  83. package/src/utils/mediaTypes.ts +12 -5
  84. package/dist/chunk-I7XNAIOF.js.map +0 -1
  85. /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
- onCanvasMouseDown,
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
- const overlayEl = overlayRef.current;
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
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
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 PreviewOverlays).
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
+ }