@hyperframes/studio 0.8.108 → 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.
- package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-CYP6dMd7.js} +208 -206
- package/dist/assets/{index-C5dyUHwk.js → index-HTFVHUJ4.js} +1 -1
- 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 +44 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7021 -5669
- 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/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/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +8 -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 +3 -0
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.ts +123 -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.ts +9 -0
- 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 +6 -3
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +36 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +1 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- 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/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/TimelineClip.test.tsx +17 -0
- package/src/player/components/TimelineClip.tsx +6 -0
- package/src/player/components/audioClipLink.test.ts +228 -31
- package/src/player/components/audioClipLink.ts +72 -25
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +52 -9
- package/src/player/components/clipMenuAudioItems.tsx +31 -23
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuToolItems.tsx +1 -3
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +11 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +4 -1
- package/src/player/components/timelineLinkSelection.ts +23 -0
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +3 -0
- 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/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
package/dist/ui.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.109",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/core": "0.8.
|
|
64
|
-
"@hyperframes/parsers": "0.8.
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.109",
|
|
64
|
+
"@hyperframes/parsers": "0.8.109",
|
|
65
|
+
"@hyperframes/sdk": "0.8.109",
|
|
66
|
+
"@hyperframes/shader-transitions": "0.8.109",
|
|
67
|
+
"@hyperframes/player": "0.8.109",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.109"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.109"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -17,11 +17,7 @@ import { useFileManager } from "./hooks/useFileManager";
|
|
|
17
17
|
import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
|
18
18
|
import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion";
|
|
19
19
|
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
|
20
|
-
import {
|
|
21
|
-
persistTimelineMoveEditsAtomically,
|
|
22
|
-
type TimelineMoveEditsHandler,
|
|
23
|
-
type TimelineMoveOperation,
|
|
24
|
-
} from "./hooks/timelineMoveAdapter";
|
|
20
|
+
import { useTimelineMoveEditsHandler } from "./hooks/timelineMoveAdapter";
|
|
25
21
|
import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
|
|
26
22
|
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
|
|
27
23
|
import { useDomEditSession } from "./hooks/useDomEditSession";
|
|
@@ -179,12 +175,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
|
|
179
175
|
invalidateGsapCache,
|
|
180
176
|
handleDomZIndexReorderCommitRef,
|
|
181
177
|
});
|
|
182
|
-
const handleTimelineElementsMove
|
|
183
|
-
|
|
184
|
-
const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove };
|
|
185
|
-
await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
|
|
186
|
-
},
|
|
187
|
-
[timelineEditing.handleTimelineGroupMove],
|
|
178
|
+
const handleTimelineElementsMove = useTimelineMoveEditsHandler(
|
|
179
|
+
timelineEditing.handleTimelineGroupMove,
|
|
188
180
|
);
|
|
189
181
|
const {
|
|
190
182
|
addAssetAtPlayhead: handleAddAssetAtPlayhead,
|
|
@@ -246,6 +238,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
|
|
246
238
|
});
|
|
247
239
|
const appHotkeys = useAppHotkeys({
|
|
248
240
|
handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
|
|
241
|
+
handleLinkEdit: timelineEditing.handleLinkEdit,
|
|
242
|
+
handleTimelineElementDeleteOnly: timelineEditing.handleTimelineElementDeleteOnly,
|
|
249
243
|
handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit,
|
|
250
244
|
handleDomEditElementDelete: domEditDeleteBridge,
|
|
251
245
|
domEditSelectionRef: domEditSelectionBridgeRef,
|
|
@@ -553,6 +547,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
|
|
553
547
|
handleRazorSplit={timelineEditing.handleRazorSplit}
|
|
554
548
|
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
|
|
555
549
|
handleFreezeFrame={timelineEditing.handleFreezeFrame}
|
|
550
|
+
handleLinkEdit={timelineEditing.handleLinkEdit}
|
|
551
|
+
handleTimelineElementDeleteOnly={timelineEditing.handleTimelineElementDeleteOnly}
|
|
556
552
|
onCopyClip={handleCopy}
|
|
557
553
|
onPasteClip={handlePaste}
|
|
558
554
|
onDuplicateClip={handleDuplicate}
|
|
@@ -104,6 +104,8 @@ export function EditorShell({
|
|
|
104
104
|
handleRazorSplit,
|
|
105
105
|
handleRazorSplitAll,
|
|
106
106
|
handleFreezeFrame,
|
|
107
|
+
handleLinkEdit,
|
|
108
|
+
handleTimelineElementDeleteOnly,
|
|
107
109
|
onCopyClip,
|
|
108
110
|
onPasteClip,
|
|
109
111
|
onDuplicateClip,
|
|
@@ -165,6 +167,8 @@ export function EditorShell({
|
|
|
165
167
|
handleRazorSplit,
|
|
166
168
|
handleRazorSplitAll,
|
|
167
169
|
handleFreezeFrame,
|
|
170
|
+
handleLinkEdit,
|
|
171
|
+
handleTimelineElementDeleteOnly,
|
|
168
172
|
});
|
|
169
173
|
|
|
170
174
|
return (
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { LinkSimple } from "@phosphor-icons/react";
|
|
4
|
+
import { useContextMenuDismiss } from "../hooks/useContextMenuDismiss";
|
|
5
|
+
import { useMenuKeyboardNav } from "../player/components/menuKeyboardNav";
|
|
6
|
+
import { useLinkedClipPreferences } from "../utils/linkedClipPreferences";
|
|
7
|
+
import { Tooltip } from "./ui";
|
|
8
|
+
import { flatActive, flatIdle } from "./timelineToolbarStyles";
|
|
9
|
+
|
|
10
|
+
function SyncIndicatorMenu({ x, y, onClose }: { x: number; y: number; onClose: () => void }) {
|
|
11
|
+
const menuRef = useContextMenuDismiss(onClose);
|
|
12
|
+
useMenuKeyboardNav(menuRef);
|
|
13
|
+
const visible = useLinkedClipPreferences((s) => s.syncIndicatorsVisible);
|
|
14
|
+
const setVisible = useLinkedClipPreferences((s) => s.setSyncIndicatorsVisible);
|
|
15
|
+
return createPortal(
|
|
16
|
+
<div
|
|
17
|
+
ref={menuRef}
|
|
18
|
+
role="menu"
|
|
19
|
+
aria-label="Linked clips"
|
|
20
|
+
className="fixed z-200 min-w-[200px] rounded-sm border border-neutral-700 bg-neutral-900 py-1 shadow-lg"
|
|
21
|
+
style={{ left: x, top: y }}
|
|
22
|
+
>
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
role="menuitemcheckbox"
|
|
26
|
+
aria-checked={visible}
|
|
27
|
+
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-neutral-300 outline-hidden hover:bg-neutral-800 focus-visible:bg-neutral-800"
|
|
28
|
+
onClick={() => {
|
|
29
|
+
setVisible(!visible);
|
|
30
|
+
onClose();
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<span className="w-3 text-studio-accent">{visible ? "✓" : ""}</span>
|
|
34
|
+
Show out-of-sync indicators
|
|
35
|
+
</button>
|
|
36
|
+
</div>,
|
|
37
|
+
document.body,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Premiere's Linked Selection; right-click holds the out-of-sync indicator preference. */
|
|
42
|
+
export function LinkedSelectionToggle() {
|
|
43
|
+
const linkedSelection = useLinkedClipPreferences((s) => s.linkedSelection);
|
|
44
|
+
const setLinkedSelection = useLinkedClipPreferences((s) => s.setLinkedSelection);
|
|
45
|
+
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
|
46
|
+
const closeMenu = useCallback(() => setMenuAt(null), []);
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<Tooltip
|
|
50
|
+
label={
|
|
51
|
+
linkedSelection
|
|
52
|
+
? "Linked Selection on — a click selects both halves (⌥-click for one)"
|
|
53
|
+
: "Linked Selection off — a click selects one clip"
|
|
54
|
+
}
|
|
55
|
+
>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={() => setLinkedSelection(!linkedSelection)}
|
|
59
|
+
onContextMenu={(e) => {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
setMenuAt({ x: e.clientX, y: e.clientY });
|
|
62
|
+
}}
|
|
63
|
+
aria-label="Linked Selection"
|
|
64
|
+
aria-pressed={linkedSelection}
|
|
65
|
+
className={linkedSelection ? flatActive : flatIdle}
|
|
66
|
+
>
|
|
67
|
+
<LinkSimple size={16} weight="bold" aria-hidden="true" />
|
|
68
|
+
</button>
|
|
69
|
+
</Tooltip>
|
|
70
|
+
{menuAt && <SyncIndicatorMenu x={menuAt.x} y={menuAt.y} onClose={closeMenu} />}
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -391,3 +391,34 @@ describe("TimelineToolbar add beat", () => {
|
|
|
391
391
|
act(() => root.unmount());
|
|
392
392
|
});
|
|
393
393
|
});
|
|
394
|
+
|
|
395
|
+
describe("TimelineToolbar Linked Selection", () => {
|
|
396
|
+
it("is on by default, first among the editing toggles, and persists a click", async () => {
|
|
397
|
+
const { useLinkedClipPreferences } = await import("../utils/linkedClipPreferences");
|
|
398
|
+
const { host } = renderToolbar();
|
|
399
|
+
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Linked Selection"]');
|
|
400
|
+
expect(button?.getAttribute("aria-pressed")).toBe("true");
|
|
401
|
+
const snapping = host.querySelector('button[aria-label="Toggle timeline snapping"]');
|
|
402
|
+
expect(snapping && button?.compareDocumentPosition(snapping)).toBe(
|
|
403
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
404
|
+
);
|
|
405
|
+
act(() => button?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
406
|
+
expect(button?.getAttribute("aria-pressed")).toBe("false");
|
|
407
|
+
expect(readStudioUiPreferences().linkedSelectionEnabled).toBe(false);
|
|
408
|
+
act(() => useLinkedClipPreferences.getState().setLinkedSelection(true));
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("right-click toggles the out-of-sync indicator preference", async () => {
|
|
412
|
+
const { useLinkedClipPreferences } = await import("../utils/linkedClipPreferences");
|
|
413
|
+
const { host } = renderToolbar();
|
|
414
|
+
const button = host.querySelector('button[aria-label="Linked Selection"]');
|
|
415
|
+
act(() => button?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })));
|
|
416
|
+
const item = document.querySelector<HTMLButtonElement>('[role="menuitemcheckbox"]');
|
|
417
|
+
expect(item?.textContent).toContain("Show out-of-sync indicators");
|
|
418
|
+
expect(item?.getAttribute("aria-checked")).toBe("true");
|
|
419
|
+
act(() => item?.click());
|
|
420
|
+
expect(useLinkedClipPreferences.getState().syncIndicatorsVisible).toBe(false);
|
|
421
|
+
expect(readStudioUiPreferences().syncIndicatorsVisible).toBe(false);
|
|
422
|
+
act(() => useLinkedClipPreferences.getState().setSyncIndicatorsVisible(true));
|
|
423
|
+
});
|
|
424
|
+
});
|
|
@@ -31,6 +31,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
31
31
|
import type { DomEditSelection } from "./editor/domEditingTypes";
|
|
32
32
|
import { canSplitElement } from "../utils/timelineElementSplit";
|
|
33
33
|
import { useAudioMetersVisible } from "../utils/audioMeterVisibility";
|
|
34
|
+
import { LinkedSelectionToggle } from "./LinkedSelectionToggle";
|
|
34
35
|
import { useProjectHasAudio } from "../utils/audioMeterMath";
|
|
35
36
|
import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions";
|
|
36
37
|
import { isTypingTarget } from "../utils/typingTarget";
|
|
@@ -218,6 +219,7 @@ export function TimelineToolbar({
|
|
|
218
219
|
<TimelineToolPicker showSelectAroundPlayhead={showSelectAroundPlayhead} />
|
|
219
220
|
{/* Divider: tool-mode | editing-actions */}
|
|
220
221
|
<div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
|
|
222
|
+
<LinkedSelectionToggle />
|
|
221
223
|
<Tooltip label={timelineSnapEnabled ? "Snapping on (N)" : "Snapping off (N)"}>
|
|
222
224
|
<button
|
|
223
225
|
type="button"
|
|
@@ -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
|
+
}
|