@hyperframes/studio 0.8.107 → 0.8.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
- package/dist/assets/{index-D8GKc2D9.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-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +53 -4
- package/dist/index.html +2 -2
- package/dist/index.js +15746 -13173
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -11
- package/src/components/EditorShell.tsx +6 -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/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- 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/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +79 -4
- package/src/components/editor/mediaAudioEdits.ts +66 -24
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useDomEditNudge.ts +17 -12
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -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/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/useEditHistoryActions.paint.test.tsx +41 -0
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- 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 +26 -2
- package/src/hooks/useRenderClipContent.test.ts +108 -4
- package/src/hooks/useRenderClipContent.ts +33 -23
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +38 -57
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +2 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -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 +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- 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 +124 -77
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- 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 +8 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- 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 +168 -0
- package/src/player/components/clipMenuAudioItems.tsx +116 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +147 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +233 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -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 +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +12 -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/useClipToolState.ts +92 -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/lib/timelineElementHelpers.ts +9 -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/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- 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/previewRelativePath.ts +19 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioPendingEdits.test.ts +66 -0
- package/src/utils/studioPendingEdits.ts +45 -11
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-4OZC426H.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { cleanupMounted, trackedRoot } from "../ui/mountHost.testHelpers";
|
|
6
|
+
import { CARET_PX, InlineTextCaret } from "./InlineTextCaret";
|
|
7
|
+
import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
// Unmount before clearing the body: the caret is portaled there, so clearing first orphans React's node.
|
|
13
|
+
cleanupMounted();
|
|
14
|
+
document.body.innerHTML = "";
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** A frame showing the composition at a quarter of its width, from 100,50 on Studio's screen. */
|
|
19
|
+
function fakeFrame() {
|
|
20
|
+
const iframe = document.body.appendChild(document.createElement("iframe"));
|
|
21
|
+
iframe.getBoundingClientRect = () =>
|
|
22
|
+
({ left: 100, top: 50, width: window.innerWidth / 4 }) as DOMRect;
|
|
23
|
+
return iframe;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A text open for editing in a fake frame shown at a quarter of the composition's width, its caret after "Ti". */
|
|
27
|
+
function scene() {
|
|
28
|
+
document.body.innerHTML = `<h1 contenteditable="true" style="color: rgb(250, 250, 250)">Title</h1>`;
|
|
29
|
+
const element = document.body.firstElementChild as HTMLElement;
|
|
30
|
+
const iframe = fakeFrame();
|
|
31
|
+
vi.spyOn(Range.prototype, "getClientRects").mockReturnValue([
|
|
32
|
+
{ left: 400, top: 200, height: 120 },
|
|
33
|
+
] as unknown as DOMRectList);
|
|
34
|
+
element.focus();
|
|
35
|
+
const text = element.firstChild as Text;
|
|
36
|
+
window.getSelection()!.collapse(text, 2);
|
|
37
|
+
const session: InlineTextEditSession = {
|
|
38
|
+
element,
|
|
39
|
+
original: "Title",
|
|
40
|
+
outline: "",
|
|
41
|
+
outlineOffset: "",
|
|
42
|
+
};
|
|
43
|
+
return { element, iframe, session, text };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function render(session: InlineTextEditSession | null, iframe: HTMLIFrameElement | null) {
|
|
47
|
+
const root = trackedRoot(document.body.appendChild(document.createElement("div")));
|
|
48
|
+
act(() => root.render(<InlineTextCaret session={session} iframe={iframe} />));
|
|
49
|
+
return { root, caret: () => document.querySelector<HTMLElement>("[data-inline-text-caret]") };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fire = (target: EventTarget, type: string) =>
|
|
53
|
+
act(() => void target.dispatchEvent(new Event(type)));
|
|
54
|
+
|
|
55
|
+
describe("InlineTextCaret", () => {
|
|
56
|
+
it("draws a 2 px caret on Studio's screen where the composition's caret stands, in the text's colour", () => {
|
|
57
|
+
const { iframe, session } = scene();
|
|
58
|
+
const caret = render(session, iframe).caret();
|
|
59
|
+
expect(caret).not.toBeNull();
|
|
60
|
+
expect(caret!.style.left).toBe(`${100 + 400 / 4 - CARET_PX / 2}px`);
|
|
61
|
+
expect(caret!.style.top).toBe(`${50 + 200 / 4}px`);
|
|
62
|
+
expect(caret!.style.height).toBe(`${120 / 4}px`);
|
|
63
|
+
expect(caret!.style.width).toBe(`${CARET_PX}px`);
|
|
64
|
+
expect(caret!.style.background).toBe("rgb(250, 250, 250)");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("follows a pan or zoom of the preview, which moves the iframe with no event", async () => {
|
|
68
|
+
const { iframe, session } = scene();
|
|
69
|
+
const caret = render(session, iframe).caret;
|
|
70
|
+
iframe.getBoundingClientRect = () =>
|
|
71
|
+
({ left: 140, top: 50, width: window.innerWidth / 4 }) as DOMRect;
|
|
72
|
+
await act(() => new Promise((done) => requestAnimationFrame(() => done(undefined))));
|
|
73
|
+
expect(caret()!.style.left).toBe(`${140 + 400 / 4 - CARET_PX / 2}px`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("after End at a soft wrap the caret stands at the earlier line's end; after Home, at the next line's start", () => {
|
|
77
|
+
const { element, iframe, session } = scene();
|
|
78
|
+
// A collapsed range at a wrap point has the next line's box; the character before it ends the earlier line.
|
|
79
|
+
vi.spyOn(Range.prototype, "getClientRects").mockImplementation(function (this: Range) {
|
|
80
|
+
return (this.collapsed
|
|
81
|
+
? [{ left: 0, top: 320, height: 120 }]
|
|
82
|
+
: [{ left: 300, right: 400, top: 200, height: 120 }]) as unknown as DOMRectList;
|
|
83
|
+
});
|
|
84
|
+
const caret = render(session, iframe).caret;
|
|
85
|
+
const press = (key: string) => {
|
|
86
|
+
act(() => void element.dispatchEvent(new KeyboardEvent("keydown", { key })));
|
|
87
|
+
fire(document, "selectionchange");
|
|
88
|
+
};
|
|
89
|
+
press("End");
|
|
90
|
+
expect([caret()!.style.left, caret()!.style.top]).toEqual([
|
|
91
|
+
`${100 + 400 / 4 - CARET_PX / 2}px`,
|
|
92
|
+
`${50 + 200 / 4}px`,
|
|
93
|
+
]);
|
|
94
|
+
press("Shift");
|
|
95
|
+
expect(caret()!.style.top, "a modifier alone keeps the side").toBe(`${50 + 200 / 4}px`);
|
|
96
|
+
press("Home");
|
|
97
|
+
expect([caret()!.style.left, caret()!.style.top]).toEqual([
|
|
98
|
+
`${100 - CARET_PX / 2}px`,
|
|
99
|
+
`${50 + 320 / 4}px`,
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("shows no caret while a range is selected, and again once the selection collapses", () => {
|
|
104
|
+
const { iframe, session, text } = scene();
|
|
105
|
+
const { caret } = render(session, iframe);
|
|
106
|
+
act(() => window.getSelection()!.setBaseAndExtent(text, 0, text, 3));
|
|
107
|
+
fire(document, "selectionchange");
|
|
108
|
+
expect(caret()).toBeNull();
|
|
109
|
+
act(() => window.getSelection()!.collapse(text, 3));
|
|
110
|
+
fire(document, "selectionchange");
|
|
111
|
+
expect(caret()).not.toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("shows no caret while an input method composes, nor once the text loses focus", () => {
|
|
115
|
+
const { element, iframe, session } = scene();
|
|
116
|
+
const { caret } = render(session, iframe);
|
|
117
|
+
fire(element, "compositionstart");
|
|
118
|
+
expect(caret()).toBeNull();
|
|
119
|
+
fire(element, "compositionend");
|
|
120
|
+
expect(caret()).not.toBeNull();
|
|
121
|
+
act(() => element.blur());
|
|
122
|
+
fire(element, "blur");
|
|
123
|
+
expect(caret()).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("hides the browser's own caret while it draws one, and gives it back after", () => {
|
|
127
|
+
const { element, iframe, session } = scene();
|
|
128
|
+
element.style.caretColor = "red";
|
|
129
|
+
const { root } = render(session, iframe);
|
|
130
|
+
expect(element.style.caretColor).toBe("transparent");
|
|
131
|
+
act(() => root.render(<InlineTextCaret session={null} iframe={iframe} />));
|
|
132
|
+
expect(element.style.caretColor).toBe("red");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("stands at the end of a bold word when the caret is past it, where no box of its own is drawn", () => {
|
|
136
|
+
document.body.innerHTML = `<h1 contenteditable="true">Go <strong>bold</strong></h1>`;
|
|
137
|
+
const element = document.body.firstElementChild as HTMLElement;
|
|
138
|
+
const iframe = fakeFrame();
|
|
139
|
+
vi.spyOn(Range.prototype, "getClientRects").mockImplementation(function (this: Range) {
|
|
140
|
+
const rects = this.collapsed ? [] : [{ left: 300, right: 500, top: 200, height: 120 }];
|
|
141
|
+
return rects as unknown as DOMRectList;
|
|
142
|
+
});
|
|
143
|
+
element.focus();
|
|
144
|
+
window.getSelection()!.collapse(element, element.childNodes.length);
|
|
145
|
+
const session = { element, original: "", outline: "", outlineOffset: "" };
|
|
146
|
+
const caret = render(session, iframe).caret();
|
|
147
|
+
expect(caret!.style.left).toBe(`${100 + 500 / 4 - CARET_PX / 2}px`);
|
|
148
|
+
expect(caret!.style.top).toBe(`${50 + 200 / 4}px`);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
|
|
4
|
+
|
|
5
|
+
/** The drawn caret's width on screen, whatever the preview's scale. */
|
|
6
|
+
export const CARET_PX = 2;
|
|
7
|
+
|
|
8
|
+
interface CaretPlacement {
|
|
9
|
+
left: number;
|
|
10
|
+
top: number;
|
|
11
|
+
height: number;
|
|
12
|
+
color: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The caret of a text edited in place, drawn at screen size: the preview is scaled, so the browser's 1 px caret
|
|
17
|
+
* shrinks below a pixel. This one follows the real selection; the browser's own is transparent meanwhile.
|
|
18
|
+
*/
|
|
19
|
+
export function InlineTextCaret({
|
|
20
|
+
session,
|
|
21
|
+
iframe,
|
|
22
|
+
}: {
|
|
23
|
+
session: InlineTextEditSession | null;
|
|
24
|
+
iframe: HTMLIFrameElement | null;
|
|
25
|
+
}) {
|
|
26
|
+
const [placement, setPlacement] = useState<CaretPlacement | null>(null);
|
|
27
|
+
const element = session?.element ?? null;
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!element || !iframe) {
|
|
31
|
+
setPlacement(null);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const doc = element.ownerDocument;
|
|
35
|
+
const view = doc.defaultView;
|
|
36
|
+
const studio = iframe.ownerDocument.defaultView;
|
|
37
|
+
const color = caretColorOf(element);
|
|
38
|
+
const ownCaret = element.style.caretColor;
|
|
39
|
+
element.style.caretColor = "transparent";
|
|
40
|
+
let composing = false;
|
|
41
|
+
let side: CaretSide = "after";
|
|
42
|
+
const update = () =>
|
|
43
|
+
setPlacement(composing ? null : placeAtCaret(element, iframe, color, side));
|
|
44
|
+
// The DOM has no side for a soft-wrap point (one line's end is the next one's start): the key that moved there
|
|
45
|
+
// says which, as CodeMirror and ProseMirror keep it.
|
|
46
|
+
const onKey = ({ key }: KeyboardEvent) => {
|
|
47
|
+
if (!MODIFIERS.has(key)) side = SIDE_OF_KEY[key] ?? "after";
|
|
48
|
+
};
|
|
49
|
+
const onPress = () => void (side = "after");
|
|
50
|
+
const composition = (on: boolean) => () => {
|
|
51
|
+
composing = on;
|
|
52
|
+
update();
|
|
53
|
+
};
|
|
54
|
+
const start = composition(true);
|
|
55
|
+
const end = composition(false);
|
|
56
|
+
// The preview moves by transforms (the player's scale, a pan or zoom of the stage) that fire no event: the iframe's
|
|
57
|
+
// box is checked each frame and a move re-places the caret.
|
|
58
|
+
let box = "";
|
|
59
|
+
let frame = 0;
|
|
60
|
+
const follow = () => {
|
|
61
|
+
const { left, top, width, height } = iframe.getBoundingClientRect();
|
|
62
|
+
const now = `${left},${top},${width},${height}`;
|
|
63
|
+
if (now !== box) {
|
|
64
|
+
box = now;
|
|
65
|
+
update();
|
|
66
|
+
}
|
|
67
|
+
frame = studio?.requestAnimationFrame(follow) ?? 0;
|
|
68
|
+
};
|
|
69
|
+
doc.addEventListener("selectionchange", update);
|
|
70
|
+
element.addEventListener("keydown", onKey);
|
|
71
|
+
element.addEventListener("pointerdown", onPress);
|
|
72
|
+
element.addEventListener("input", update);
|
|
73
|
+
element.addEventListener("focus", update);
|
|
74
|
+
element.addEventListener("blur", update);
|
|
75
|
+
element.addEventListener("compositionstart", start);
|
|
76
|
+
element.addEventListener("compositionend", end);
|
|
77
|
+
view?.addEventListener("resize", update);
|
|
78
|
+
view?.addEventListener("scroll", update, true);
|
|
79
|
+
follow();
|
|
80
|
+
return () => {
|
|
81
|
+
studio?.cancelAnimationFrame(frame);
|
|
82
|
+
doc.removeEventListener("selectionchange", update);
|
|
83
|
+
element.removeEventListener("keydown", onKey);
|
|
84
|
+
element.removeEventListener("pointerdown", onPress);
|
|
85
|
+
element.removeEventListener("input", update);
|
|
86
|
+
element.removeEventListener("focus", update);
|
|
87
|
+
element.removeEventListener("blur", update);
|
|
88
|
+
element.removeEventListener("compositionstart", start);
|
|
89
|
+
element.removeEventListener("compositionend", end);
|
|
90
|
+
view?.removeEventListener("resize", update);
|
|
91
|
+
view?.removeEventListener("scroll", update, true);
|
|
92
|
+
element.style.caretColor = ownCaret;
|
|
93
|
+
};
|
|
94
|
+
}, [element, iframe]);
|
|
95
|
+
|
|
96
|
+
if (!placement || !iframe) return null;
|
|
97
|
+
// On Studio's body: a transformed ancestor in the canvas would make `fixed` relative to itself, not the viewport.
|
|
98
|
+
return createPortal(
|
|
99
|
+
<div
|
|
100
|
+
// Keyed on where it stands, so each move starts the blink lit, as the system caret does.
|
|
101
|
+
key={`${placement.left},${placement.top}`}
|
|
102
|
+
data-inline-text-caret="true"
|
|
103
|
+
aria-hidden="true"
|
|
104
|
+
className="hf-inline-text-caret pointer-events-none fixed z-200"
|
|
105
|
+
style={{
|
|
106
|
+
left: placement.left,
|
|
107
|
+
top: placement.top,
|
|
108
|
+
width: CARET_PX,
|
|
109
|
+
height: placement.height,
|
|
110
|
+
background: placement.color,
|
|
111
|
+
}}
|
|
112
|
+
/>,
|
|
113
|
+
iframe.ownerDocument.body,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The colour the browser would draw its caret in: `caret-color`, or the text's colour when that is `auto`. */
|
|
118
|
+
function caretColorOf(element: HTMLElement): string {
|
|
119
|
+
const style = element.ownerDocument.defaultView?.getComputedStyle(element);
|
|
120
|
+
if (!style) return "currentColor";
|
|
121
|
+
return style.caretColor && style.caretColor !== "auto" ? style.caretColor : style.color;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type CaretSide = "before" | "after";
|
|
125
|
+
const MODIFIERS = new Set(["Shift", "Control", "Alt", "Meta"]);
|
|
126
|
+
// Only End leaves Chrome's own caret at the earlier line's end; an arrow onto a wrap point lands on the next line.
|
|
127
|
+
const SIDE_OF_KEY: Record<string, CaretSide> = { End: "before" };
|
|
128
|
+
|
|
129
|
+
/** Where the caret stands on Studio's screen, or null when there is none to draw: a range selected, the text not
|
|
130
|
+
* focused, or the selection outside it. */
|
|
131
|
+
|
|
132
|
+
function placeAtCaret(
|
|
133
|
+
element: HTMLElement,
|
|
134
|
+
iframe: HTMLIFrameElement,
|
|
135
|
+
color: string,
|
|
136
|
+
side: CaretSide,
|
|
137
|
+
): CaretPlacement | null {
|
|
138
|
+
const view = element.ownerDocument.defaultView;
|
|
139
|
+
const range = view ? caretRange(element, view) : null;
|
|
140
|
+
if (!view || !range) return null;
|
|
141
|
+
const rect = (side === "before" && endOfCharBefore(range)) || caretRect(range, element, view);
|
|
142
|
+
const box = iframe.getBoundingClientRect();
|
|
143
|
+
const scale = view.innerWidth ? box.width / view.innerWidth : 1;
|
|
144
|
+
return {
|
|
145
|
+
left: box.left + rect.left * scale - CARET_PX / 2,
|
|
146
|
+
top: box.top + rect.top * scale,
|
|
147
|
+
height: rect.height * scale,
|
|
148
|
+
color,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The collapsed range's box on its line. A caret between two nodes has none, so it stands beside them; an empty
|
|
153
|
+
* text has neither, so it stands at the text's start, one line tall. */
|
|
154
|
+
function caretRect(range: Range, element: HTMLElement, view: Window) {
|
|
155
|
+
const last = [...range.getClientRects()].reverse().find((rect) => rect.height > 0);
|
|
156
|
+
if (last) return { left: last.left, top: last.top, height: last.height };
|
|
157
|
+
const beside = besideNode(range);
|
|
158
|
+
if (beside) return beside;
|
|
159
|
+
const style = view.getComputedStyle(element);
|
|
160
|
+
const box = element.getBoundingClientRect();
|
|
161
|
+
const fontSize = Number.parseFloat(style.fontSize) || 16;
|
|
162
|
+
const line = Number.parseFloat(style.lineHeight) || fontSize * 1.2;
|
|
163
|
+
return {
|
|
164
|
+
left: box.left + (Number.parseFloat(style.paddingLeft) || 0),
|
|
165
|
+
top: box.top + (Number.parseFloat(style.paddingTop) || 0),
|
|
166
|
+
height: line,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function caretRange(element: HTMLElement, view: Window): Range | null {
|
|
171
|
+
const doc = element.ownerDocument;
|
|
172
|
+
const selection = view.getSelection();
|
|
173
|
+
if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) return null;
|
|
174
|
+
if (!doc.hasFocus() || !element.contains(doc.activeElement)) return null;
|
|
175
|
+
const range = selection.getRangeAt(0);
|
|
176
|
+
return element.contains(range.startContainer) ? range : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The right edge of the character just before a collapsed range in a text node: where a caret that came from
|
|
180
|
+
* before a soft-wrap point stands, at the end of the earlier line. */
|
|
181
|
+
function endOfCharBefore({ startContainer: at, startOffset: offset }: Range) {
|
|
182
|
+
if (at.nodeType !== Node.TEXT_NODE || offset === 0) return null;
|
|
183
|
+
const char = at.ownerDocument!.createRange();
|
|
184
|
+
// An emoji's second half alone has no box: take the whole pair.
|
|
185
|
+
const low = /[\uDC00-\uDFFF]/.test((at as Text).data[offset - 1] ?? "");
|
|
186
|
+
char.setStart(at, offset - (low && offset > 1 ? 2 : 1));
|
|
187
|
+
char.setEnd(at, offset);
|
|
188
|
+
// A trailing space at a wrap has a box on each line; the earlier line's comes first.
|
|
189
|
+
const rect = [...char.getClientRects()].find((each) => each.height > 0);
|
|
190
|
+
return rect ? { left: rect.right, top: rect.top, height: rect.height } : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** At an element boundary: the end of the node before, or the start of the node after (a line break's own line). */
|
|
194
|
+
function besideNode(range: Range) {
|
|
195
|
+
const [before, after] = neighbours(range);
|
|
196
|
+
const node = before && before.nodeName !== "BR" ? before : (after ?? before);
|
|
197
|
+
if (!node) return null;
|
|
198
|
+
const around = range.cloneRange();
|
|
199
|
+
around.selectNode(node);
|
|
200
|
+
const rects = [...around.getClientRects()].filter((rect) => rect.height > 0);
|
|
201
|
+
const end = node === before;
|
|
202
|
+
const rect = end ? rects.at(-1) : rects[0];
|
|
203
|
+
return rect ? { left: end ? rect.right : rect.left, top: rect.top, height: rect.height } : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The nodes either side of a collapsed range; an empty text node is a boundary too, between its siblings. */
|
|
207
|
+
function neighbours({ startContainer: at, startOffset: offset }: Range) {
|
|
208
|
+
return at.nodeType === Node.TEXT_NODE
|
|
209
|
+
? [at.previousSibling, at.nextSibling]
|
|
210
|
+
: [at.childNodes[offset - 1] ?? null, at.childNodes[offset] ?? null];
|
|
211
|
+
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
|
-
import { createRoot } from "react-dom/client";
|
|
5
4
|
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { cleanupMounted, trackedRoot } from "../ui/mountHost.testHelpers";
|
|
6
6
|
import { InlineTextToolbar, swatchBackground } from "./InlineTextToolbar";
|
|
7
7
|
import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
|
|
8
8
|
|
|
9
9
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
10
|
|
|
11
11
|
afterEach(() => {
|
|
12
|
+
// Unmount before clearing the body: the bar is portaled there, so clearing first orphans React's node.
|
|
13
|
+
cleanupMounted();
|
|
12
14
|
document.body.innerHTML = "";
|
|
13
15
|
});
|
|
14
16
|
|
|
@@ -33,7 +35,7 @@ function scene(html: string) {
|
|
|
33
35
|
function render(session: InlineTextEditSession | null, iframe: HTMLIFrameElement | null) {
|
|
34
36
|
const host = document.createElement("div");
|
|
35
37
|
document.body.append(host);
|
|
36
|
-
const root =
|
|
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
|
),
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HF_AUDIO_FX_ATTR,
|
|
3
|
+
serializeAudioFxChain,
|
|
4
|
+
type HfAudioFxChain,
|
|
5
|
+
} from "@hyperframes/core/audio-fx";
|
|
6
|
+
import { type HfAutomation } from "@hyperframes/core/audio-automation";
|
|
7
|
+
import { isCarveVoiceElement } from "./useFxCarveGrouping.js";
|
|
8
|
+
import { automationAttrValue, HF_AUDIO_AUTOMATION_ATTR } from "./propertyPanelAutomation";
|
|
9
|
+
import type { CarveClip } from "./useFxCarveNodes.js";
|
|
10
|
+
import { readClipClock } from "./clipAudioClock.js";
|
|
11
|
+
|
|
12
|
+
/** Lanes belonging to nodes the carve generated, which a re-run replaces. */
|
|
13
|
+
export function withoutCarveLanes(automation: HfAutomation, chain: HfAudioFxChain): HfAutomation {
|
|
14
|
+
const prefixes = chain.nodes.filter((n) => n.fromCarve && n.id).map((n) => `fx.${n.id}.`);
|
|
15
|
+
if (prefixes.length === 0) return automation;
|
|
16
|
+
return {
|
|
17
|
+
version: automation.version,
|
|
18
|
+
lanes: automation.lanes.filter((lane) => !prefixes.some((p) => lane.target.startsWith(p))),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Every named voice still present with a src; a deleted source is skipped, not fatal. */
|
|
23
|
+
export function resolveCarveVoices(doc: Document, sources: readonly string[]): CarveClip[] {
|
|
24
|
+
const voices: CarveClip[] = [];
|
|
25
|
+
for (const id of sources) {
|
|
26
|
+
const el = doc.getElementById(id);
|
|
27
|
+
// By tag name: iframe-realm elements fail `instanceof HTMLAudioElement`.
|
|
28
|
+
if (!isCarveVoiceElement(el)) continue;
|
|
29
|
+
const src = el.getAttribute("src");
|
|
30
|
+
if (!src) continue;
|
|
31
|
+
const clock = readClipClock((name) => el.getAttribute(name));
|
|
32
|
+
voices.push({ src, start: el.getAttribute("data-start"), clock });
|
|
33
|
+
}
|
|
34
|
+
return voices;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Remove the filters and lanes a carve generated once it no longer names a voice. */
|
|
38
|
+
export async function dropCarveOutput(
|
|
39
|
+
chain: HfAudioFxChain,
|
|
40
|
+
automation: HfAutomation,
|
|
41
|
+
onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const carriedOver = withoutCarveLanes(automation, chain);
|
|
44
|
+
if (carriedOver.lanes.length !== automation.lanes.length) {
|
|
45
|
+
await onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(carriedOver) || null);
|
|
46
|
+
}
|
|
47
|
+
const kept = chain.nodes.filter((n) => !n.fromCarve);
|
|
48
|
+
if (kept.length !== chain.nodes.length) {
|
|
49
|
+
await onSetAttributeQuiet(
|
|
50
|
+
HF_AUDIO_FX_ATTR,
|
|
51
|
+
kept.length ? serializeAudioFxChain({ version: 1, nodes: kept }) : null,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
|
|
3
|
+
export interface CropPresetBarTarget {
|
|
4
|
+
hfId?: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface CropPresetBarState {
|
|
9
|
+
openFor: CropPresetBarTarget | null;
|
|
10
|
+
open: (target: CropPresetBarTarget) => void;
|
|
11
|
+
close: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Which clip the crop preset bar is open for; the canvas shows it while that element is selected. */
|
|
15
|
+
export const useCropPresetBarStore = create<CropPresetBarState>((set) => ({
|
|
16
|
+
openFor: null,
|
|
17
|
+
open: (target) => set({ openFor: target }),
|
|
18
|
+
close: () => set({ openFor: null }),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
export function isCropBarTarget(element: Element, target: CropPresetBarTarget | null): boolean {
|
|
22
|
+
if (!target) return false;
|
|
23
|
+
const hfId = element.getAttribute("data-hf-id");
|
|
24
|
+
if (target.hfId && hfId) return target.hfId === hfId;
|
|
25
|
+
return Boolean(target.id) && element.id === target.id;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The crop lift is a stylesheet rule, so the inline clip-path is still the committed one. */
|
|
29
|
+
export function committedClipPath(element: Element): string | null {
|
|
30
|
+
const style = element.getAttribute("style") ?? "";
|
|
31
|
+
const match = /(?:^|;)\s*clip-path\s*:\s*([^;]+)/i.exec(style);
|
|
32
|
+
return match?.[1]?.trim() ?? null;
|
|
33
|
+
}
|