@hyperframes/studio 0.8.107 → 0.8.109

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 (183) hide show
  1. package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
  5. package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/chunk-6LUNLTHR.js +611 -0
  9. package/dist/chunk-6LUNLTHR.js.map +1 -0
  10. package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
  11. package/dist/chunk-PQFEBX32.js.map +1 -0
  12. package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
  13. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  14. package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  15. package/dist/index.d.ts +53 -4
  16. package/dist/index.html +2 -2
  17. package/dist/index.js +15746 -13173
  18. package/dist/index.js.map +1 -1
  19. package/dist/player.js +3 -3
  20. package/dist/styles.css +1 -1
  21. package/dist/ui.js +1 -1
  22. package/package.json +8 -8
  23. package/src/App.tsx +8 -11
  24. package/src/components/EditorShell.tsx +6 -0
  25. package/src/components/LinkedSelectionToggle.tsx +73 -0
  26. package/src/components/TimelineToolbar.test.tsx +31 -0
  27. package/src/components/TimelineToolbar.tsx +2 -0
  28. package/src/components/editor/CropPresetBar.tsx +69 -0
  29. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  30. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  31. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  32. package/src/components/editor/InlineTextCaret.tsx +211 -0
  33. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  34. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  35. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  36. package/src/components/editor/carveOutput.ts +54 -0
  37. package/src/components/editor/cropPresetStore.ts +33 -0
  38. package/src/components/editor/cropPresets.test.ts +41 -0
  39. package/src/components/editor/cropPresets.ts +24 -0
  40. package/src/components/editor/domEditInlineText.ts +6 -4
  41. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  42. package/src/components/editor/domEditingLayers.test.ts +21 -0
  43. package/src/components/editor/domEditingLayers.ts +2 -0
  44. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  45. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  46. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  47. package/src/components/editor/manualOffsetDrag.ts +11 -0
  48. package/src/components/editor/mediaAudioEdits.test.ts +79 -4
  49. package/src/components/editor/mediaAudioEdits.ts +66 -24
  50. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  51. package/src/components/editor/mediaLinkEdits.ts +282 -0
  52. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
  53. package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
  54. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
  55. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  56. package/src/components/editor/useDomEditNudge.ts +17 -12
  57. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  58. package/src/components/editor/useFxCarve.ts +3 -61
  59. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  60. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  61. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  62. package/src/components/editor/useVolumeAutomation.ts +29 -7
  63. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  64. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  65. package/src/components/nle/useTimelineEditCallbacks.ts +12 -0
  66. package/src/components/ui/Menu.test.tsx +3 -22
  67. package/src/components/ui/Menu.tsx +2 -2
  68. package/src/components/ui/Popover.tsx +2 -2
  69. package/src/components/ui/Select.tsx +2 -2
  70. package/src/components/ui/Tooltip.tsx +2 -2
  71. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  72. package/src/components/ui/valueControls.test.tsx +2 -21
  73. package/src/contexts/TimelineEditContext.tsx +4 -0
  74. package/src/hooks/appHotkeysDispatch.ts +2 -1
  75. package/src/hooks/linkShortcuts.test.ts +70 -0
  76. package/src/hooks/linkShortcuts.ts +41 -0
  77. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  78. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  79. package/src/hooks/timelineMoveAdapter.ts +13 -0
  80. package/src/hooks/timelineTimingSync.test.ts +59 -0
  81. package/src/hooks/timelineTimingSync.ts +22 -3
  82. package/src/hooks/useAppHotkeys.ts +9 -0
  83. package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
  84. package/src/hooks/useEditHistoryActions.ts +13 -4
  85. package/src/hooks/useElementPicker.test.ts +62 -13
  86. package/src/hooks/useElementPicker.ts +10 -2
  87. package/src/hooks/useFreezeFrame.test.ts +56 -0
  88. package/src/hooks/useFreezeFrame.ts +121 -0
  89. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  90. package/src/hooks/useGsapScriptCommits.ts +23 -3
  91. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  92. package/src/hooks/usePersistentEditHistory.ts +1 -1
  93. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  94. package/src/hooks/usePreviewPersistence.ts +15 -0
  95. package/src/hooks/useRazorSplit.test.tsx +48 -0
  96. package/src/hooks/useRazorSplit.ts +26 -2
  97. package/src/hooks/useRenderClipContent.test.ts +108 -4
  98. package/src/hooks/useRenderClipContent.ts +33 -23
  99. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  100. package/src/hooks/useTimelineEditGuard.ts +54 -0
  101. package/src/hooks/useTimelineEditing.ts +38 -57
  102. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  103. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  104. package/src/index.ts +2 -0
  105. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  106. package/src/player/components/AudioWaveform.test.tsx +30 -3
  107. package/src/player/components/AudioWaveform.tsx +6 -7
  108. package/src/player/components/ClipBadges.test.tsx +82 -0
  109. package/src/player/components/ClipBadges.tsx +67 -0
  110. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  111. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  112. package/src/player/components/ClipContextMenu.tsx +124 -77
  113. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  114. package/src/player/components/ClipPeakMarks.tsx +71 -0
  115. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  116. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  117. package/src/player/components/TimelineClip.test.tsx +17 -0
  118. package/src/player/components/TimelineClip.tsx +8 -0
  119. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  120. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  121. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  122. package/src/player/components/audioClipLink.test.ts +228 -31
  123. package/src/player/components/audioClipLink.ts +72 -25
  124. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  125. package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
  126. package/src/player/components/clipMenuAudioItems.tsx +116 -0
  127. package/src/player/components/clipMenuDuck.test.ts +117 -0
  128. package/src/player/components/clipMenuDuck.ts +147 -0
  129. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  130. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  131. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  132. package/src/player/components/clipMenuNormalize.ts +96 -0
  133. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  134. package/src/player/components/clipMenuToolItems.tsx +233 -0
  135. package/src/player/components/clipPeakRuns.test.ts +38 -0
  136. package/src/player/components/clipPeakRuns.ts +59 -0
  137. package/src/player/components/clipSync.test.ts +67 -0
  138. package/src/player/components/clipSync.ts +80 -0
  139. package/src/player/components/clipToolAttrs.test.ts +161 -0
  140. package/src/player/components/clipToolAttrs.ts +187 -0
  141. package/src/player/components/linkLabelColor.test.ts +20 -0
  142. package/src/player/components/linkLabelColor.ts +24 -0
  143. package/src/player/components/timelineCallbacks.ts +12 -0
  144. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  145. package/src/player/components/timelineClipDragTypes.ts +2 -0
  146. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  147. package/src/player/components/timelineLinkSelection.ts +23 -0
  148. package/src/player/components/useClipToolState.ts +92 -0
  149. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  150. package/src/player/components/useTimelineClipDrag.ts +21 -0
  151. package/src/player/components/useTimelineProviderState.tsx +3 -0
  152. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  153. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  154. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  155. package/src/player/lib/timelineDOM.test.ts +31 -0
  156. package/src/player/lib/timelineDOM.ts +13 -0
  157. package/src/player/lib/timelineElementHelpers.ts +9 -0
  158. package/src/player/store/timelineElement.ts +12 -0
  159. package/src/styles/components.css +27 -11
  160. package/src/styles/theme.css +0 -1
  161. package/src/timelineChromeExports.test.tsx +7 -0
  162. package/src/utils/audioMeterMath.test.ts +37 -0
  163. package/src/utils/audioMeterMath.ts +9 -3
  164. package/src/utils/exportPeakCeiling.ts +1 -0
  165. package/src/utils/gsapSoftReload.test.ts +238 -2
  166. package/src/utils/gsapSoftReload.ts +89 -74
  167. package/src/utils/gsapUndoRestore.test.ts +4 -3
  168. package/src/utils/gsapUndoRestore.ts +15 -0
  169. package/src/utils/linkedClipPreferences.ts +34 -0
  170. package/src/utils/previewRelativePath.ts +19 -0
  171. package/src/utils/softReloadTargets.ts +128 -0
  172. package/src/utils/sourcePatcher.ts +1 -1
  173. package/src/utils/studioPendingEdits.test.ts +66 -0
  174. package/src/utils/studioPendingEdits.ts +45 -11
  175. package/src/utils/studioUiPreferences.ts +8 -0
  176. package/src/utils/timelineInspector.test.ts +33 -2
  177. package/src/utils/timelineInspector.ts +16 -4
  178. package/dist/chunk-46XP7J3B.js.map +0 -1
  179. package/dist/chunk-4OZC426H.js.map +0 -1
  180. package/dist/chunk-DI4LCJHL.js +0 -102
  181. package/dist/chunk-DI4LCJHL.js.map +0 -1
  182. package/dist/chunk-NCUBAVGK.js.map +0 -1
  183. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -0,0 +1,150 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { cleanupMounted, trackedRoot } from "../ui/mountHost.testHelpers";
6
+ import { CARET_PX, InlineTextCaret } from "./InlineTextCaret";
7
+ import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ afterEach(() => {
12
+ // Unmount before clearing the body: the caret is portaled there, so clearing first orphans React's node.
13
+ cleanupMounted();
14
+ document.body.innerHTML = "";
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ /** A frame showing the composition at a quarter of its width, from 100,50 on Studio's screen. */
19
+ function fakeFrame() {
20
+ const iframe = document.body.appendChild(document.createElement("iframe"));
21
+ iframe.getBoundingClientRect = () =>
22
+ ({ left: 100, top: 50, width: window.innerWidth / 4 }) as DOMRect;
23
+ return iframe;
24
+ }
25
+
26
+ /** A text open for editing in a fake frame shown at a quarter of the composition's width, its caret after "Ti". */
27
+ function scene() {
28
+ document.body.innerHTML = `<h1 contenteditable="true" style="color: rgb(250, 250, 250)">Title</h1>`;
29
+ const element = document.body.firstElementChild as HTMLElement;
30
+ const iframe = fakeFrame();
31
+ vi.spyOn(Range.prototype, "getClientRects").mockReturnValue([
32
+ { left: 400, top: 200, height: 120 },
33
+ ] as unknown as DOMRectList);
34
+ element.focus();
35
+ const text = element.firstChild as Text;
36
+ window.getSelection()!.collapse(text, 2);
37
+ const session: InlineTextEditSession = {
38
+ element,
39
+ original: "Title",
40
+ outline: "",
41
+ outlineOffset: "",
42
+ };
43
+ return { element, iframe, session, text };
44
+ }
45
+
46
+ function render(session: InlineTextEditSession | null, iframe: HTMLIFrameElement | null) {
47
+ const root = trackedRoot(document.body.appendChild(document.createElement("div")));
48
+ act(() => root.render(<InlineTextCaret session={session} iframe={iframe} />));
49
+ return { root, caret: () => document.querySelector<HTMLElement>("[data-inline-text-caret]") };
50
+ }
51
+
52
+ const fire = (target: EventTarget, type: string) =>
53
+ act(() => void target.dispatchEvent(new Event(type)));
54
+
55
+ describe("InlineTextCaret", () => {
56
+ it("draws a 2 px caret on Studio's screen where the composition's caret stands, in the text's colour", () => {
57
+ const { iframe, session } = scene();
58
+ const caret = render(session, iframe).caret();
59
+ expect(caret).not.toBeNull();
60
+ expect(caret!.style.left).toBe(`${100 + 400 / 4 - CARET_PX / 2}px`);
61
+ expect(caret!.style.top).toBe(`${50 + 200 / 4}px`);
62
+ expect(caret!.style.height).toBe(`${120 / 4}px`);
63
+ expect(caret!.style.width).toBe(`${CARET_PX}px`);
64
+ expect(caret!.style.background).toBe("rgb(250, 250, 250)");
65
+ });
66
+
67
+ it("follows a pan or zoom of the preview, which moves the iframe with no event", async () => {
68
+ const { iframe, session } = scene();
69
+ const caret = render(session, iframe).caret;
70
+ iframe.getBoundingClientRect = () =>
71
+ ({ left: 140, top: 50, width: window.innerWidth / 4 }) as DOMRect;
72
+ await act(() => new Promise((done) => requestAnimationFrame(() => done(undefined))));
73
+ expect(caret()!.style.left).toBe(`${140 + 400 / 4 - CARET_PX / 2}px`);
74
+ });
75
+
76
+ it("after End at a soft wrap the caret stands at the earlier line's end; after Home, at the next line's start", () => {
77
+ const { element, iframe, session } = scene();
78
+ // A collapsed range at a wrap point has the next line's box; the character before it ends the earlier line.
79
+ vi.spyOn(Range.prototype, "getClientRects").mockImplementation(function (this: Range) {
80
+ return (this.collapsed
81
+ ? [{ left: 0, top: 320, height: 120 }]
82
+ : [{ left: 300, right: 400, top: 200, height: 120 }]) as unknown as DOMRectList;
83
+ });
84
+ const caret = render(session, iframe).caret;
85
+ const press = (key: string) => {
86
+ act(() => void element.dispatchEvent(new KeyboardEvent("keydown", { key })));
87
+ fire(document, "selectionchange");
88
+ };
89
+ press("End");
90
+ expect([caret()!.style.left, caret()!.style.top]).toEqual([
91
+ `${100 + 400 / 4 - CARET_PX / 2}px`,
92
+ `${50 + 200 / 4}px`,
93
+ ]);
94
+ press("Shift");
95
+ expect(caret()!.style.top, "a modifier alone keeps the side").toBe(`${50 + 200 / 4}px`);
96
+ press("Home");
97
+ expect([caret()!.style.left, caret()!.style.top]).toEqual([
98
+ `${100 - CARET_PX / 2}px`,
99
+ `${50 + 320 / 4}px`,
100
+ ]);
101
+ });
102
+
103
+ it("shows no caret while a range is selected, and again once the selection collapses", () => {
104
+ const { iframe, session, text } = scene();
105
+ const { caret } = render(session, iframe);
106
+ act(() => window.getSelection()!.setBaseAndExtent(text, 0, text, 3));
107
+ fire(document, "selectionchange");
108
+ expect(caret()).toBeNull();
109
+ act(() => window.getSelection()!.collapse(text, 3));
110
+ fire(document, "selectionchange");
111
+ expect(caret()).not.toBeNull();
112
+ });
113
+
114
+ it("shows no caret while an input method composes, nor once the text loses focus", () => {
115
+ const { element, iframe, session } = scene();
116
+ const { caret } = render(session, iframe);
117
+ fire(element, "compositionstart");
118
+ expect(caret()).toBeNull();
119
+ fire(element, "compositionend");
120
+ expect(caret()).not.toBeNull();
121
+ act(() => element.blur());
122
+ fire(element, "blur");
123
+ expect(caret()).toBeNull();
124
+ });
125
+
126
+ it("hides the browser's own caret while it draws one, and gives it back after", () => {
127
+ const { element, iframe, session } = scene();
128
+ element.style.caretColor = "red";
129
+ const { root } = render(session, iframe);
130
+ expect(element.style.caretColor).toBe("transparent");
131
+ act(() => root.render(<InlineTextCaret session={null} iframe={iframe} />));
132
+ expect(element.style.caretColor).toBe("red");
133
+ });
134
+
135
+ it("stands at the end of a bold word when the caret is past it, where no box of its own is drawn", () => {
136
+ document.body.innerHTML = `<h1 contenteditable="true">Go <strong>bold</strong></h1>`;
137
+ const element = document.body.firstElementChild as HTMLElement;
138
+ const iframe = fakeFrame();
139
+ vi.spyOn(Range.prototype, "getClientRects").mockImplementation(function (this: Range) {
140
+ const rects = this.collapsed ? [] : [{ left: 300, right: 500, top: 200, height: 120 }];
141
+ return rects as unknown as DOMRectList;
142
+ });
143
+ element.focus();
144
+ window.getSelection()!.collapse(element, element.childNodes.length);
145
+ const session = { element, original: "", outline: "", outlineOffset: "" };
146
+ const caret = render(session, iframe).caret();
147
+ expect(caret!.style.left).toBe(`${100 + 500 / 4 - CARET_PX / 2}px`);
148
+ expect(caret!.style.top).toBe(`${50 + 200 / 4}px`);
149
+ });
150
+ });
@@ -0,0 +1,211 @@
1
+ import { useEffect, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
4
+
5
+ /** The drawn caret's width on screen, whatever the preview's scale. */
6
+ export const CARET_PX = 2;
7
+
8
+ interface CaretPlacement {
9
+ left: number;
10
+ top: number;
11
+ height: number;
12
+ color: string;
13
+ }
14
+
15
+ /**
16
+ * The caret of a text edited in place, drawn at screen size: the preview is scaled, so the browser's 1 px caret
17
+ * shrinks below a pixel. This one follows the real selection; the browser's own is transparent meanwhile.
18
+ */
19
+ export function InlineTextCaret({
20
+ session,
21
+ iframe,
22
+ }: {
23
+ session: InlineTextEditSession | null;
24
+ iframe: HTMLIFrameElement | null;
25
+ }) {
26
+ const [placement, setPlacement] = useState<CaretPlacement | null>(null);
27
+ const element = session?.element ?? null;
28
+
29
+ useEffect(() => {
30
+ if (!element || !iframe) {
31
+ setPlacement(null);
32
+ return;
33
+ }
34
+ const doc = element.ownerDocument;
35
+ const view = doc.defaultView;
36
+ const studio = iframe.ownerDocument.defaultView;
37
+ const color = caretColorOf(element);
38
+ const ownCaret = element.style.caretColor;
39
+ element.style.caretColor = "transparent";
40
+ let composing = false;
41
+ let side: CaretSide = "after";
42
+ const update = () =>
43
+ setPlacement(composing ? null : placeAtCaret(element, iframe, color, side));
44
+ // The DOM has no side for a soft-wrap point (one line's end is the next one's start): the key that moved there
45
+ // says which, as CodeMirror and ProseMirror keep it.
46
+ const onKey = ({ key }: KeyboardEvent) => {
47
+ if (!MODIFIERS.has(key)) side = SIDE_OF_KEY[key] ?? "after";
48
+ };
49
+ const onPress = () => void (side = "after");
50
+ const composition = (on: boolean) => () => {
51
+ composing = on;
52
+ update();
53
+ };
54
+ const start = composition(true);
55
+ const end = composition(false);
56
+ // The preview moves by transforms (the player's scale, a pan or zoom of the stage) that fire no event: the iframe's
57
+ // box is checked each frame and a move re-places the caret.
58
+ let box = "";
59
+ let frame = 0;
60
+ const follow = () => {
61
+ const { left, top, width, height } = iframe.getBoundingClientRect();
62
+ const now = `${left},${top},${width},${height}`;
63
+ if (now !== box) {
64
+ box = now;
65
+ update();
66
+ }
67
+ frame = studio?.requestAnimationFrame(follow) ?? 0;
68
+ };
69
+ doc.addEventListener("selectionchange", update);
70
+ element.addEventListener("keydown", onKey);
71
+ element.addEventListener("pointerdown", onPress);
72
+ element.addEventListener("input", update);
73
+ element.addEventListener("focus", update);
74
+ element.addEventListener("blur", update);
75
+ element.addEventListener("compositionstart", start);
76
+ element.addEventListener("compositionend", end);
77
+ view?.addEventListener("resize", update);
78
+ view?.addEventListener("scroll", update, true);
79
+ follow();
80
+ return () => {
81
+ studio?.cancelAnimationFrame(frame);
82
+ doc.removeEventListener("selectionchange", update);
83
+ element.removeEventListener("keydown", onKey);
84
+ element.removeEventListener("pointerdown", onPress);
85
+ element.removeEventListener("input", update);
86
+ element.removeEventListener("focus", update);
87
+ element.removeEventListener("blur", update);
88
+ element.removeEventListener("compositionstart", start);
89
+ element.removeEventListener("compositionend", end);
90
+ view?.removeEventListener("resize", update);
91
+ view?.removeEventListener("scroll", update, true);
92
+ element.style.caretColor = ownCaret;
93
+ };
94
+ }, [element, iframe]);
95
+
96
+ if (!placement || !iframe) return null;
97
+ // On Studio's body: a transformed ancestor in the canvas would make `fixed` relative to itself, not the viewport.
98
+ return createPortal(
99
+ <div
100
+ // Keyed on where it stands, so each move starts the blink lit, as the system caret does.
101
+ key={`${placement.left},${placement.top}`}
102
+ data-inline-text-caret="true"
103
+ aria-hidden="true"
104
+ className="hf-inline-text-caret pointer-events-none fixed z-200"
105
+ style={{
106
+ left: placement.left,
107
+ top: placement.top,
108
+ width: CARET_PX,
109
+ height: placement.height,
110
+ background: placement.color,
111
+ }}
112
+ />,
113
+ iframe.ownerDocument.body,
114
+ );
115
+ }
116
+
117
+ /** The colour the browser would draw its caret in: `caret-color`, or the text's colour when that is `auto`. */
118
+ function caretColorOf(element: HTMLElement): string {
119
+ const style = element.ownerDocument.defaultView?.getComputedStyle(element);
120
+ if (!style) return "currentColor";
121
+ return style.caretColor && style.caretColor !== "auto" ? style.caretColor : style.color;
122
+ }
123
+
124
+ type CaretSide = "before" | "after";
125
+ const MODIFIERS = new Set(["Shift", "Control", "Alt", "Meta"]);
126
+ // Only End leaves Chrome's own caret at the earlier line's end; an arrow onto a wrap point lands on the next line.
127
+ const SIDE_OF_KEY: Record<string, CaretSide> = { End: "before" };
128
+
129
+ /** Where the caret stands on Studio's screen, or null when there is none to draw: a range selected, the text not
130
+ * focused, or the selection outside it. */
131
+
132
+ function placeAtCaret(
133
+ element: HTMLElement,
134
+ iframe: HTMLIFrameElement,
135
+ color: string,
136
+ side: CaretSide,
137
+ ): CaretPlacement | null {
138
+ const view = element.ownerDocument.defaultView;
139
+ const range = view ? caretRange(element, view) : null;
140
+ if (!view || !range) return null;
141
+ const rect = (side === "before" && endOfCharBefore(range)) || caretRect(range, element, view);
142
+ const box = iframe.getBoundingClientRect();
143
+ const scale = view.innerWidth ? box.width / view.innerWidth : 1;
144
+ return {
145
+ left: box.left + rect.left * scale - CARET_PX / 2,
146
+ top: box.top + rect.top * scale,
147
+ height: rect.height * scale,
148
+ color,
149
+ };
150
+ }
151
+
152
+ /** The collapsed range's box on its line. A caret between two nodes has none, so it stands beside them; an empty
153
+ * text has neither, so it stands at the text's start, one line tall. */
154
+ function caretRect(range: Range, element: HTMLElement, view: Window) {
155
+ const last = [...range.getClientRects()].reverse().find((rect) => rect.height > 0);
156
+ if (last) return { left: last.left, top: last.top, height: last.height };
157
+ const beside = besideNode(range);
158
+ if (beside) return beside;
159
+ const style = view.getComputedStyle(element);
160
+ const box = element.getBoundingClientRect();
161
+ const fontSize = Number.parseFloat(style.fontSize) || 16;
162
+ const line = Number.parseFloat(style.lineHeight) || fontSize * 1.2;
163
+ return {
164
+ left: box.left + (Number.parseFloat(style.paddingLeft) || 0),
165
+ top: box.top + (Number.parseFloat(style.paddingTop) || 0),
166
+ height: line,
167
+ };
168
+ }
169
+
170
+ function caretRange(element: HTMLElement, view: Window): Range | null {
171
+ const doc = element.ownerDocument;
172
+ const selection = view.getSelection();
173
+ if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) return null;
174
+ if (!doc.hasFocus() || !element.contains(doc.activeElement)) return null;
175
+ const range = selection.getRangeAt(0);
176
+ return element.contains(range.startContainer) ? range : null;
177
+ }
178
+
179
+ /** The right edge of the character just before a collapsed range in a text node: where a caret that came from
180
+ * before a soft-wrap point stands, at the end of the earlier line. */
181
+ function endOfCharBefore({ startContainer: at, startOffset: offset }: Range) {
182
+ if (at.nodeType !== Node.TEXT_NODE || offset === 0) return null;
183
+ const char = at.ownerDocument!.createRange();
184
+ // An emoji's second half alone has no box: take the whole pair.
185
+ const low = /[\uDC00-\uDFFF]/.test((at as Text).data[offset - 1] ?? "");
186
+ char.setStart(at, offset - (low && offset > 1 ? 2 : 1));
187
+ char.setEnd(at, offset);
188
+ // A trailing space at a wrap has a box on each line; the earlier line's comes first.
189
+ const rect = [...char.getClientRects()].find((each) => each.height > 0);
190
+ return rect ? { left: rect.right, top: rect.top, height: rect.height } : null;
191
+ }
192
+
193
+ /** At an element boundary: the end of the node before, or the start of the node after (a line break's own line). */
194
+ function besideNode(range: Range) {
195
+ const [before, after] = neighbours(range);
196
+ const node = before && before.nodeName !== "BR" ? before : (after ?? before);
197
+ if (!node) return null;
198
+ const around = range.cloneRange();
199
+ around.selectNode(node);
200
+ const rects = [...around.getClientRects()].filter((rect) => rect.height > 0);
201
+ const end = node === before;
202
+ const rect = end ? rects.at(-1) : rects[0];
203
+ return rect ? { left: end ? rect.right : rect.left, top: rect.top, height: rect.height } : null;
204
+ }
205
+
206
+ /** The nodes either side of a collapsed range; an empty text node is a boundary too, between its siblings. */
207
+ function neighbours({ startContainer: at, startOffset: offset }: Range) {
208
+ return at.nodeType === Node.TEXT_NODE
209
+ ? [at.previousSibling, at.nextSibling]
210
+ : [at.childNodes[offset - 1] ?? null, at.childNodes[offset] ?? null];
211
+ }
@@ -1,14 +1,16 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import React, { act } from "react";
4
- import { createRoot } from "react-dom/client";
5
4
  import { afterEach, describe, expect, it } from "vitest";
5
+ import { cleanupMounted, trackedRoot } from "../ui/mountHost.testHelpers";
6
6
  import { InlineTextToolbar, swatchBackground } from "./InlineTextToolbar";
7
7
  import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
8
8
 
9
9
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
10
 
11
11
  afterEach(() => {
12
+ // Unmount before clearing the body: the bar is portaled there, so clearing first orphans React's node.
13
+ cleanupMounted();
12
14
  document.body.innerHTML = "";
13
15
  });
14
16
 
@@ -33,7 +35,7 @@ function scene(html: string) {
33
35
  function render(session: InlineTextEditSession | null, iframe: HTMLIFrameElement | null) {
34
36
  const host = document.createElement("div");
35
37
  document.body.append(host);
36
- const root = createRoot(host);
38
+ const root = trackedRoot(host);
37
39
  act(() => root.render(<InlineTextToolbar session={session} iframe={iframe} />));
38
40
  return { host, root, rerender: () => act(() => root.render(<div />)) };
39
41
  }
@@ -49,8 +51,21 @@ function selectAll(element: HTMLElement) {
49
51
  });
50
52
  }
51
53
 
54
+ function selectAt(element: HTMLElement, top: number) {
55
+ const range = document.createRange();
56
+ range.selectNodeContents(element);
57
+ range.getBoundingClientRect = () =>
58
+ ({ left: 20, top, width: 100, height: 10 }) as unknown as DOMRect;
59
+ const selection = document.getSelection()!;
60
+ act(() => {
61
+ selection.removeAllRanges();
62
+ selection.addRange(range);
63
+ document.dispatchEvent(new Event("selectionchange"));
64
+ });
65
+ }
66
+
52
67
  function toolbarIn(host: HTMLElement): HTMLElement | null {
53
- return host.querySelector<HTMLElement>('[data-inline-text-toolbar="true"]');
68
+ return host.ownerDocument.querySelector<HTMLElement>('[data-inline-text-toolbar="true"]');
54
69
  }
55
70
 
56
71
  function renderSelected(html = "hello world") {
@@ -120,7 +135,7 @@ describe("InlineTextToolbar", () => {
120
135
  // these are the handlers that would deselect the element.
121
136
  const host = document.createElement("div");
122
137
  document.body.append(host);
123
- const root = createRoot(host);
138
+ const root = trackedRoot(host);
124
139
  act(() =>
125
140
  root.render(
126
141
  <div
@@ -152,7 +167,7 @@ describe("InlineTextToolbar", () => {
152
167
  const { host } = render(session, iframe);
153
168
  selectAll(element);
154
169
 
155
- const input = host.querySelector<HTMLInputElement>('input[type="color"]')!;
170
+ const input = host.ownerDocument.querySelector<HTMLInputElement>('input[type="color"]')!;
156
171
  expect(input.className).toContain("w-full");
157
172
  expect(input.className).toContain("h-full");
158
173
  expect(input.className).toContain("min-w-0");
@@ -161,14 +176,14 @@ describe("InlineTextToolbar", () => {
161
176
  it("styles the selected characters when a control is used", () => {
162
177
  const { element, host } = renderSelected();
163
178
 
164
- act(() => host.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!.click());
179
+ act(() => host.ownerDocument.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!.click());
165
180
 
166
181
  expect(element.innerHTML).toBe('<span style="font-weight: 700">hello world</span>');
167
182
  });
168
183
 
169
184
  it("ignores a click when the selection disappeared before React hid the toolbar", () => {
170
185
  const { element, host } = renderSelected();
171
- const bold = host.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!;
186
+ const bold = host.ownerDocument.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!;
172
187
 
173
188
  expect(() => {
174
189
  act(() => {
@@ -185,8 +200,12 @@ describe("InlineTextToolbar", () => {
185
200
 
186
201
  selectAll(element);
187
202
 
188
- expect(host.querySelector('[aria-label="Italic"]')?.getAttribute("aria-pressed")).toBe("true");
189
- expect(host.querySelector('[aria-label="Bold"]')?.getAttribute("aria-pressed")).toBe("false");
203
+ expect(
204
+ host.ownerDocument.querySelector('[aria-label="Italic"]')?.getAttribute("aria-pressed"),
205
+ ).toBe("true");
206
+ expect(
207
+ host.ownerDocument.querySelector('[aria-label="Bold"]')?.getAttribute("aria-pressed"),
208
+ ).toBe("false");
190
209
  });
191
210
 
192
211
  it("turns a style back off when the control is used again", () => {
@@ -194,7 +213,7 @@ describe("InlineTextToolbar", () => {
194
213
  const { host } = render(session, iframe);
195
214
  selectAll(element);
196
215
 
197
- act(() => host.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!.click());
216
+ act(() => host.ownerDocument.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!.click());
198
217
 
199
218
  expect(element.innerHTML).toBe("words");
200
219
  });
@@ -202,16 +221,7 @@ describe("InlineTextToolbar", () => {
202
221
  it("places itself over the selection, mapped out of the scaled composition", () => {
203
222
  const { element, session, iframe } = scene("hello world");
204
223
  const { host } = render(session, iframe);
205
- const range = document.createRange();
206
- range.selectNodeContents(element);
207
- range.getBoundingClientRect = () =>
208
- ({ left: 20, top: 40, width: 100, height: 10 }) as unknown as DOMRect;
209
- const selection = document.getSelection()!;
210
- act(() => {
211
- selection.removeAllRanges();
212
- selection.addRange(range);
213
- document.dispatchEvent(new Event("selectionchange"));
214
- });
224
+ selectAt(element, 40);
215
225
 
216
226
  const toolbar = toolbarIn(host)!;
217
227
  // Frame at 100,50; scale 400/innerWidth; centre of the range, above it.
@@ -223,16 +233,7 @@ describe("InlineTextToolbar", () => {
223
233
  const { element, session, iframe } = scene("hello world");
224
234
  iframe.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400 }) as DOMRect;
225
235
  const { host } = render(session, iframe);
226
- const range = document.createRange();
227
- range.selectNodeContents(element);
228
- range.getBoundingClientRect = () =>
229
- ({ left: 20, top: 0, width: 100, height: 10 }) as unknown as DOMRect;
230
- const selection = document.getSelection()!;
231
- act(() => {
232
- selection.removeAllRanges();
233
- selection.addRange(range);
234
- document.dispatchEvent(new Event("selectionchange"));
235
- });
236
+ selectAt(element, 0);
236
237
 
237
238
  const toolbar = toolbarIn(host)!;
238
239
  const scale = 400 / window.innerWidth;
@@ -271,7 +272,9 @@ describe("InlineTextToolbar", () => {
271
272
 
272
273
  selectAll(element);
273
274
 
274
- expect(host.querySelector<HTMLInputElement>('input[type="color"]')?.value).toBe("#ff0000");
275
+ expect(host.ownerDocument.querySelector<HTMLInputElement>('input[type="color"]')?.value).toBe(
276
+ "#ff0000",
277
+ );
275
278
  });
276
279
  });
277
280
 
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
+ import { createPortal } from "react-dom";
2
3
  import { applyInlineStyle } from "./inlineTextStyleRange";
3
4
  import { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead";
4
5
  import { parseCssColor, toHexColor } from "./colorValue";
@@ -69,10 +70,10 @@ export function InlineTextToolbar({
69
70
  [session, refresh],
70
71
  );
71
72
 
72
- if (!placement) return null;
73
+ if (!placement || !iframe) return null;
73
74
  const styles = placement.styles;
74
75
 
75
- return (
76
+ return createPortal(
76
77
  <div
77
78
  data-inline-text-toolbar="true"
78
79
  role="toolbar"
@@ -89,8 +90,8 @@ export function InlineTextToolbar({
89
90
  // The default, because a press anywhere in Studio moves the focus, and
90
91
  // moving it out of the text collapses the selection being styled.
91
92
  //
92
- // The propagation, because this renders inside the canvas overlay: a
93
- // press that reaches the canvas is read as a click on the composition,
93
+ // The propagation, because React bubbles it through the canvas overlay
94
+ // that renders this bar: a press that reaches the canvas is read as a click on the composition,
94
95
  // which deselects the element and commits the edit out from under the
95
96
  // button that was just pressed.
96
97
  onPointerDown={swallow}
@@ -147,7 +148,8 @@ export function InlineTextToolbar({
147
148
  on={styles["text-decoration-line"] === "underline"}
148
149
  onToggle={(on) => apply({ "text-decoration-line": on ? "underline" : null })}
149
150
  />
150
- </div>
151
+ </div>,
152
+ iframe.ownerDocument.body,
151
153
  );
152
154
  }
153
155
 
@@ -5,6 +5,7 @@ import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext
5
5
  import { slugifyDesignInput } from "../../utils/designInputTracking";
6
6
  import { isTextEditableSelection } from "./domEditing";
7
7
  import type { PropertyPanelFlatProps } from "./propertyPanelFlatProps";
8
+ import { useLinkedSpeedCommit, withLinkedPlaybackRate } from "./linkedSpeedEdits";
8
9
  import { formatPxMetricValue } from "./propertyPanelHelpers";
9
10
  import { audioFxSummary } from "./audioFxSummary";
10
11
  import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
@@ -289,10 +290,12 @@ export function PropertyPanelFlat({
289
290
  const showMotionEffects = gsapEffectHandlers !== null && !audioSelection;
290
291
  const showMotionGroup = showMotionTiming || showMotionEffects;
291
292
 
293
+ const linkedSpeed = useLinkedSpeedCommit(element, forwardedProps.onSetAttributeBatch);
292
294
  const volumeAutomation = useVolumeAutomation(
293
295
  element,
294
296
  currentTime,
295
297
  onSetAttributeQuiet ?? onSetAttributeLive,
298
+ linkedSpeed,
296
299
  );
297
300
 
298
301
  // The group this clip belongs to, if any — the Audio FX summary reads
@@ -505,7 +508,7 @@ export function PropertyPanelFlat({
505
508
  element={element}
506
509
  styles={styles}
507
510
  onSetStyle={onSetStyle}
508
- onSetAttribute={onSetAttribute}
511
+ onSetAttribute={withLinkedPlaybackRate(onSetAttribute, linkedSpeed)}
509
512
  {...volumeAutomation}
510
513
  />
511
514
  ),
@@ -0,0 +1,54 @@
1
+ import {
2
+ HF_AUDIO_FX_ATTR,
3
+ serializeAudioFxChain,
4
+ type HfAudioFxChain,
5
+ } from "@hyperframes/core/audio-fx";
6
+ import { type HfAutomation } from "@hyperframes/core/audio-automation";
7
+ import { isCarveVoiceElement } from "./useFxCarveGrouping.js";
8
+ import { automationAttrValue, HF_AUDIO_AUTOMATION_ATTR } from "./propertyPanelAutomation";
9
+ import type { CarveClip } from "./useFxCarveNodes.js";
10
+ import { readClipClock } from "./clipAudioClock.js";
11
+
12
+ /** Lanes belonging to nodes the carve generated, which a re-run replaces. */
13
+ export function withoutCarveLanes(automation: HfAutomation, chain: HfAudioFxChain): HfAutomation {
14
+ const prefixes = chain.nodes.filter((n) => n.fromCarve && n.id).map((n) => `fx.${n.id}.`);
15
+ if (prefixes.length === 0) return automation;
16
+ return {
17
+ version: automation.version,
18
+ lanes: automation.lanes.filter((lane) => !prefixes.some((p) => lane.target.startsWith(p))),
19
+ };
20
+ }
21
+
22
+ /** Every named voice still present with a src; a deleted source is skipped, not fatal. */
23
+ export function resolveCarveVoices(doc: Document, sources: readonly string[]): CarveClip[] {
24
+ const voices: CarveClip[] = [];
25
+ for (const id of sources) {
26
+ const el = doc.getElementById(id);
27
+ // By tag name: iframe-realm elements fail `instanceof HTMLAudioElement`.
28
+ if (!isCarveVoiceElement(el)) continue;
29
+ const src = el.getAttribute("src");
30
+ if (!src) continue;
31
+ const clock = readClipClock((name) => el.getAttribute(name));
32
+ voices.push({ src, start: el.getAttribute("data-start"), clock });
33
+ }
34
+ return voices;
35
+ }
36
+
37
+ /** Remove the filters and lanes a carve generated once it no longer names a voice. */
38
+ export async function dropCarveOutput(
39
+ chain: HfAudioFxChain,
40
+ automation: HfAutomation,
41
+ onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
42
+ ): Promise<void> {
43
+ const carriedOver = withoutCarveLanes(automation, chain);
44
+ if (carriedOver.lanes.length !== automation.lanes.length) {
45
+ await onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(carriedOver) || null);
46
+ }
47
+ const kept = chain.nodes.filter((n) => !n.fromCarve);
48
+ if (kept.length !== chain.nodes.length) {
49
+ await onSetAttributeQuiet(
50
+ HF_AUDIO_FX_ATTR,
51
+ kept.length ? serializeAudioFxChain({ version: 1, nodes: kept }) : null,
52
+ );
53
+ }
54
+ }
@@ -0,0 +1,33 @@
1
+ import { create } from "zustand";
2
+
3
+ export interface CropPresetBarTarget {
4
+ hfId?: string;
5
+ id?: string;
6
+ }
7
+
8
+ interface CropPresetBarState {
9
+ openFor: CropPresetBarTarget | null;
10
+ open: (target: CropPresetBarTarget) => void;
11
+ close: () => void;
12
+ }
13
+
14
+ /** Which clip the crop preset bar is open for; the canvas shows it while that element is selected. */
15
+ export const useCropPresetBarStore = create<CropPresetBarState>((set) => ({
16
+ openFor: null,
17
+ open: (target) => set({ openFor: target }),
18
+ close: () => set({ openFor: null }),
19
+ }));
20
+
21
+ export function isCropBarTarget(element: Element, target: CropPresetBarTarget | null): boolean {
22
+ if (!target) return false;
23
+ const hfId = element.getAttribute("data-hf-id");
24
+ if (target.hfId && hfId) return target.hfId === hfId;
25
+ return Boolean(target.id) && element.id === target.id;
26
+ }
27
+
28
+ /** The crop lift is a stylesheet rule, so the inline clip-path is still the committed one. */
29
+ export function committedClipPath(element: Element): string | null {
30
+ const style = element.getAttribute("style") ?? "";
31
+ const match = /(?:^|;)\s*clip-path\s*:\s*([^;]+)/i.exec(style);
32
+ return match?.[1]?.trim() ?? null;
33
+ }