@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.
- package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
- package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/index.d.ts +64 -9
- package/dist/index.html +2 -2
- package/dist/index.js +8216 -6268
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +7 -11
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditOverlayGestures.ts +10 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +334 -0
- package/src/components/editor/mediaLinkEdits.ts +303 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +11 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
- package/src/hooks/timelineLinkEditPlan.ts +128 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +12 -1
- package/src/hooks/useRenderClipContent.test.ts +6 -4
- package/src/hooks/useRenderClipContent.ts +9 -8
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +49 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
- package/src/hooks/useTimelineLinkEditing.ts +158 -0
- package/src/index.ts +5 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +32 -8
- package/src/player/components/ClipBadges.tsx +37 -31
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +115 -90
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +58 -0
- package/src/player/components/TimelineClip.tsx +9 -2
- package/src/player/components/TimelineLanes.test.tsx +45 -2
- package/src/player/components/TimelineLanes.tsx +0 -2
- package/src/player/components/TimelineOverlays.tsx +2 -0
- package/src/player/components/audioClipLink.test.ts +288 -31
- package/src/player/components/audioClipLink.ts +151 -27
- package/src/player/components/audioGainApply.test.ts +87 -0
- package/src/player/components/audioGainApply.ts +57 -0
- package/src/player/components/audioGainDialogStore.ts +36 -0
- package/src/player/components/audioGainPlan.test.ts +82 -0
- package/src/player/components/audioGainPlan.ts +53 -0
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
- package/src/player/components/clipMenuAudioItems.tsx +44 -57
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +174 -0
- package/src/player/components/clipMenuLinkItems.tsx +166 -0
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +80 -9
- package/src/player/components/clipPeakMap.ts +46 -0
- package/src/player/components/clipPeakRuns.test.ts +14 -1
- package/src/player/components/clipPeakRuns.ts +11 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +17 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +29 -31
- package/src/player/components/timelineEditing.test.ts +0 -13
- package/src/player/components/timelineEditing.ts +0 -11
- package/src/player/components/timelineLaneProps.ts +0 -5
- package/src/player/components/timelineLinkSelection.ts +41 -0
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +28 -0
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +3 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- 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 =
|
|
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 =
|
|
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(
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
),
|
|
@@ -182,7 +182,10 @@ async function settle() {
|
|
|
182
182
|
|
|
183
183
|
const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
|
|
184
184
|
act(() => {
|
|
185
|
-
|
|
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
|
|
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;
|
|
@@ -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(
|
|
70
|
-
|
|
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", () => {
|