@hyperframes/studio 0.8.108 → 0.8.110

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
  2. package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
  4. package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +64 -9
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +8216 -6268
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  24. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  25. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  26. package/src/components/editor/InlineTextCaret.tsx +211 -0
  27. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  28. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  29. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  30. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  31. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  32. package/src/components/editor/domEditInlineText.ts +6 -4
  33. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  34. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  35. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  36. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  37. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  38. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  39. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  40. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  41. package/src/components/editor/marqueeCommit.ts +16 -8
  42. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  43. package/src/components/editor/mediaAudioEdits.ts +27 -14
  44. package/src/components/editor/mediaLinkEdits.test.ts +334 -0
  45. package/src/components/editor/mediaLinkEdits.ts +303 -0
  46. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  47. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  48. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  49. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  50. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  51. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  52. package/src/components/editor/useVolumeAutomation.ts +29 -7
  53. package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
  54. package/src/components/ui/Menu.test.tsx +3 -22
  55. package/src/components/ui/Menu.tsx +2 -2
  56. package/src/components/ui/Popover.tsx +2 -2
  57. package/src/components/ui/Select.tsx +2 -2
  58. package/src/components/ui/Tooltip.tsx +2 -2
  59. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  60. package/src/components/ui/valueControls.test.tsx +2 -21
  61. package/src/contexts/TimelineEditContext.tsx +4 -0
  62. package/src/editorGesturesExports.test.tsx +1 -0
  63. package/src/hooks/appHotkeysDispatch.ts +11 -1
  64. package/src/hooks/linkShortcuts.test.ts +70 -0
  65. package/src/hooks/linkShortcuts.ts +41 -0
  66. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  67. package/src/hooks/timelineElementsAttribute.ts +113 -0
  68. package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
  69. package/src/hooks/timelineLinkEditPlan.ts +128 -0
  70. package/src/hooks/timelineMoveAdapter.ts +13 -0
  71. package/src/hooks/timelineTimingSync.test.ts +59 -0
  72. package/src/hooks/timelineTimingSync.ts +22 -3
  73. package/src/hooks/useAppHotkeys.test.ts +34 -0
  74. package/src/hooks/useAppHotkeys.ts +9 -0
  75. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  76. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  77. package/src/hooks/useGsapScriptCommits.ts +23 -3
  78. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  79. package/src/hooks/usePreviewPersistence.ts +15 -0
  80. package/src/hooks/useRazorSplit.test.tsx +48 -0
  81. package/src/hooks/useRazorSplit.ts +12 -1
  82. package/src/hooks/useRenderClipContent.test.ts +6 -4
  83. package/src/hooks/useRenderClipContent.ts +9 -8
  84. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  85. package/src/hooks/useTimelineEditGuard.ts +54 -0
  86. package/src/hooks/useTimelineEditing.ts +49 -55
  87. package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
  88. package/src/hooks/useTimelineLinkEditing.ts +158 -0
  89. package/src/index.ts +5 -0
  90. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  91. package/src/player/components/AudioGainDialog.tsx +226 -0
  92. package/src/player/components/AudioWaveform.test.tsx +30 -3
  93. package/src/player/components/AudioWaveform.tsx +6 -7
  94. package/src/player/components/ClipBadges.test.tsx +32 -8
  95. package/src/player/components/ClipBadges.tsx +37 -31
  96. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  97. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  98. package/src/player/components/ClipContextMenu.tsx +115 -90
  99. package/src/player/components/ClipPeakMarks.tsx +2 -14
  100. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  101. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  102. package/src/player/components/Timeline.test.ts +15 -0
  103. package/src/player/components/TimelineClip.test.tsx +58 -0
  104. package/src/player/components/TimelineClip.tsx +9 -2
  105. package/src/player/components/TimelineLanes.test.tsx +45 -2
  106. package/src/player/components/TimelineLanes.tsx +0 -2
  107. package/src/player/components/TimelineOverlays.tsx +2 -0
  108. package/src/player/components/audioClipLink.test.ts +288 -31
  109. package/src/player/components/audioClipLink.ts +151 -27
  110. package/src/player/components/audioGainApply.test.ts +87 -0
  111. package/src/player/components/audioGainApply.ts +57 -0
  112. package/src/player/components/audioGainDialogStore.ts +36 -0
  113. package/src/player/components/audioGainPlan.test.ts +82 -0
  114. package/src/player/components/audioGainPlan.ts +53 -0
  115. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
  116. package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
  117. package/src/player/components/clipMenuAudioItems.tsx +44 -57
  118. package/src/player/components/clipMenuDuck.ts +5 -0
  119. package/src/player/components/clipMenuLinkItems.test.ts +174 -0
  120. package/src/player/components/clipMenuLinkItems.tsx +166 -0
  121. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  122. package/src/player/components/clipMenuToolItems.tsx +80 -9
  123. package/src/player/components/clipPeakMap.ts +46 -0
  124. package/src/player/components/clipPeakRuns.test.ts +14 -1
  125. package/src/player/components/clipPeakRuns.ts +11 -0
  126. package/src/player/components/clipSync.test.ts +67 -0
  127. package/src/player/components/clipSync.ts +80 -0
  128. package/src/player/components/clipToolAttrs.test.ts +57 -41
  129. package/src/player/components/clipToolAttrs.ts +48 -45
  130. package/src/player/components/linkLabelColor.test.ts +20 -0
  131. package/src/player/components/linkLabelColor.ts +24 -0
  132. package/src/player/components/studioShortcuts.ts +2 -0
  133. package/src/player/components/timelineCallbacks.ts +17 -0
  134. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  135. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  136. package/src/player/components/timelineClipDragPreview.ts +32 -7
  137. package/src/player/components/timelineClipDragTypes.ts +2 -0
  138. package/src/player/components/timelineClipGestureHandlers.ts +29 -31
  139. package/src/player/components/timelineEditing.test.ts +0 -13
  140. package/src/player/components/timelineEditing.ts +0 -11
  141. package/src/player/components/timelineLaneProps.ts +0 -5
  142. package/src/player/components/timelineLinkSelection.ts +41 -0
  143. package/src/player/components/timelineViewportGeometry.ts +3 -0
  144. package/src/player/components/useClipToolState.ts +0 -5
  145. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  146. package/src/player/components/useTimelineClipDrag.ts +28 -0
  147. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  148. package/src/player/components/useTimelineProviderState.tsx +3 -2
  149. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  150. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  151. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  152. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  153. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  154. package/src/player/lib/timelineDOM.test.ts +31 -0
  155. package/src/player/lib/timelineDOM.ts +13 -0
  156. package/src/player/store/timelineElement.ts +12 -0
  157. package/src/styles/components.css +27 -11
  158. package/src/styles/theme.css +0 -1
  159. package/src/utils/gsapSoftReload.test.ts +238 -2
  160. package/src/utils/gsapSoftReload.ts +89 -74
  161. package/src/utils/gsapUndoRestore.test.ts +4 -3
  162. package/src/utils/gsapUndoRestore.ts +15 -0
  163. package/src/utils/linkedClipPreferences.ts +34 -0
  164. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  165. package/src/utils/softReloadTargets.ts +128 -0
  166. package/src/utils/studioUiPreferences.ts +8 -0
  167. package/src/utils/typingTarget.test.ts +8 -0
  168. package/src/utils/typingTarget.ts +1 -0
  169. package/dist/chunk-AOWVEVEU.js.map +0 -1
  170. package/dist/chunk-NCUBAVGK.js.map +0 -1
@@ -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
  ),
@@ -196,6 +196,7 @@ function evt(clientX: number, clientY: number) {
196
196
  clientY,
197
197
  pointerId: 1,
198
198
  button: 0,
199
+ buttons: 1,
199
200
  altKey: false,
200
201
  shiftKey: false,
201
202
  preventDefault() {},
@@ -182,7 +182,10 @@ async function settle() {
182
182
 
183
183
  const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
184
184
  act(() => {
185
- target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, ...init }));
185
+ const buttons = type === "pointerdown" || type === "pointermove" ? 1 : 0;
186
+ target.dispatchEvent(
187
+ new MouseEvent(type, { bubbles: true, cancelable: true, buttons, ...init }),
188
+ );
186
189
  });
187
190
  };
188
191
 
@@ -54,18 +54,20 @@ export function canEditElementTextInline(element: HTMLElement | null): boolean {
54
54
  }
55
55
 
56
56
  function hasOnlyFormattingChildren(element: HTMLElement): boolean {
57
- const HTMLElementClass = element.ownerDocument.defaultView?.HTMLElement;
58
- if (!HTMLElementClass) return false;
59
57
  for (const child of Array.from(element.children)) {
60
58
  if (!isRichTextFormattingTag(child.tagName)) return false;
61
- if (!(child instanceof HTMLElementClass)) return false;
59
+ if (!isHtmlFromAnyWindow(child)) return false;
62
60
  // Formatting nests, and a structural child hidden inside a span is still
63
61
  // structural.
64
- if (!hasOnlyFormattingChildren(child)) return false;
62
+ if (!hasOnlyFormattingChildren(child as HTMLElement)) return false;
65
63
  }
66
64
  return true;
67
65
  }
68
66
 
67
+ function isHtmlFromAnyWindow(node: Element): boolean {
68
+ return node.namespaceURI === "http://www.w3.org/1999/xhtml";
69
+ }
70
+
69
71
  /** Where and when a press landed, for recognising the next one as a pair. */
70
72
  export interface PressMark {
71
73
  x: number;
@@ -30,6 +30,13 @@ describe("canEditElementTextInline", () => {
30
30
  expect(canEditElementTextInline(element)).toBe(true);
31
31
  });
32
32
 
33
+ it("opens styled copy whose formatting comes from another window", () => {
34
+ const element = mount("<h1>a <strong>bold</strong></h1>");
35
+ // happy-dom shares classes across windows; drop this window's HTMLElement as a foreign realm would.
36
+ Object.setPrototypeOf(element.firstElementChild, Element.prototype);
37
+ expect(canEditElementTextInline(element)).toBe(true);
38
+ });
39
+
33
40
  it("keeps out an element with a structural child, which the panel edits field by field", () => {
34
41
  expect(canEditElementTextInline(mount("<div><h1>a</h1><p>b</p></div>"))).toBe(false);
35
42
  });
@@ -49,6 +49,7 @@ export interface GestureState {
49
49
  kind: GestureKind;
50
50
  mode: "path-offset" | "box-size" | "rotation";
51
51
  selection: DomEditSelection;
52
+ pointerId: number;
52
53
  startX: number;
53
54
  startY: number;
54
55
  centerX: number;
@@ -93,6 +94,7 @@ export interface GestureState {
93
94
  }
94
95
 
95
96
  export interface GroupGestureState {
97
+ pointerId: number;
96
98
  startX: number;
97
99
  startY: number;
98
100
  originItems: GroupOverlayItem[];
@@ -103,6 +105,14 @@ export interface GroupGestureState {
103
105
  travelled?: boolean;
104
106
  }
105
107
 
108
+ /** Only the pressing pointer's moves with its button held drive a gesture, not Chromium's buttonless resends. */
109
+ export function movesGesture(
110
+ gesture: { pointerId: number },
111
+ e: { pointerId: number; buttons: number },
112
+ ): boolean {
113
+ return e.pointerId === gesture.pointerId && (e.buttons & 1) === 1;
114
+ }
115
+
106
116
  export interface BlockedMoveState {
107
117
  pointerId: number;
108
118
  startX: number;
@@ -177,6 +177,7 @@ describe("a rotate on a page that loads GSAP", () => {
177
177
  clientY,
178
178
  pointerId: 1,
179
179
  button: 0,
180
+ buttons: 1,
180
181
  shiftKey: false,
181
182
  preventDefault() {},
182
183
  stopPropagation() {},
@@ -119,6 +119,7 @@ export function startGroupDrag(
119
119
  e.currentTarget.setPointerCapture(e.pointerId);
120
120
  opts.rafPausedRef.current = true;
121
121
  opts.groupGestureRef.current = {
122
+ pointerId: e.pointerId,
122
123
  startX: e.clientX,
123
124
  startY: e.clientY,
124
125
  originItems: items,
@@ -265,6 +266,7 @@ export function startGesture(
265
266
  kind,
266
267
  mode,
267
268
  selection: sel,
269
+ pointerId: e.pointerId,
268
270
  startX: e.clientX,
269
271
  startY: e.clientY,
270
272
  centerX,
@@ -66,8 +66,24 @@ const byId = (iframe: HTMLIFrameElement | null, id: string) =>
66
66
  iframe!.contentDocument!.getElementById(id) as HTMLElement;
67
67
  const api = (): TimelinePlayerApi => player.getApi();
68
68
 
69
- function pointer(target: Element, type: string, x: number, y: number) {
70
- const init = { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: x, clientY: y };
69
+ function pointer(
70
+ target: Element,
71
+ type: string,
72
+ x: number,
73
+ y: number,
74
+ extra: PointerEventInit = {},
75
+ ) {
76
+ const buttons = type === "pointerdown" || type === "pointermove" ? 1 : 0;
77
+ const init = {
78
+ bubbles: true,
79
+ cancelable: true,
80
+ button: 0,
81
+ buttons,
82
+ pointerId: 1,
83
+ clientX: x,
84
+ clientY: y,
85
+ ...extra,
86
+ };
71
87
  act(() => void target.dispatchEvent(new PointerEvent(type, init)));
72
88
  }
73
89
 
@@ -252,6 +268,27 @@ describe("a reload during a drag", () => {
252
268
  });
253
269
  });
254
270
 
271
+ describe("a drag moves only with its own pointer pressed", () => {
272
+ it.each([
273
+ ["no button held, as Chromium sends after a layout change", { buttons: 0 }],
274
+ ["another pointer", { pointerId: 2 }],
275
+ ])(
276
+ "a move with %s at a fixed point leaves the drop where the pointer let go",
277
+ async (_, stray) => {
278
+ const { live, overlay, box } = mountEditor(false);
279
+ pointer(box, "pointerdown", 150, 150);
280
+ pointer(overlay, "pointermove", 170, 160);
281
+ pointer(overlay, "pointermove", 20, 20, stray);
282
+ pointer(overlay, "pointermove", 190, 170);
283
+ pointer(overlay, "pointermove", 20, 20, stray);
284
+ expect(byId(live, "title").style.getPropertyValue("translate")).toBe("40px 20px");
285
+ pointer(overlay, "pointerup", 190, 170);
286
+ await settle();
287
+ expect(file.title).toBe("translate: 40px 20px");
288
+ },
289
+ );
290
+ });
291
+
255
292
  type Editor = ReturnType<typeof mountEditor>;
256
293
 
257
294
  describe("every way a drag ends without a drop clears its mark and promotes the held reload", () => {