@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,282 @@
|
|
|
1
|
+
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
|
2
|
+
import { MEDIA_LINK_ATTR, SYNC_ORIGIN_ATTR, mintLinkId } from "@hyperframes/core/media-link";
|
|
3
|
+
import { sameCompositionScope, type TimelineElement } from "../../player/store/timelineElement";
|
|
4
|
+
import { mediaAssetIdentity, sharesLinkGroup } from "../../player/components/audioClipLink";
|
|
5
|
+
import {
|
|
6
|
+
applyPatchByTarget,
|
|
7
|
+
findTagByTarget,
|
|
8
|
+
readAttributeByTarget,
|
|
9
|
+
type PatchOperation,
|
|
10
|
+
type PatchTarget,
|
|
11
|
+
} from "../../utils/sourcePatcher";
|
|
12
|
+
import { collectHtmlIds } from "../../utils/studioHelpers";
|
|
13
|
+
import { generateId } from "../../utils/generateId";
|
|
14
|
+
import {
|
|
15
|
+
COPIED_TIMING_ATTRS,
|
|
16
|
+
MOVED_SOUND_ATTRS,
|
|
17
|
+
firstFreeName,
|
|
18
|
+
formatAttrs,
|
|
19
|
+
insertBeforeTarget,
|
|
20
|
+
readAuthoredSrc,
|
|
21
|
+
splitAutomation,
|
|
22
|
+
} from "./mediaAudioEdits";
|
|
23
|
+
|
|
24
|
+
const LINK_PROPERTY = MEDIA_LINK_ATTR.slice("data-".length);
|
|
25
|
+
const HIDDEN_ATTR = "data-hidden";
|
|
26
|
+
const SYNC_ORIGIN_PROPERTY = SYNC_ORIGIN_ATTR.slice("data-".length);
|
|
27
|
+
const SYNC_TOLERANCE_S = 1e-3;
|
|
28
|
+
|
|
29
|
+
const dataOp = (property: string, value: string | null): PatchOperation => ({
|
|
30
|
+
type: "attribute",
|
|
31
|
+
property: property.startsWith("data-") ? property.slice("data-".length) : property,
|
|
32
|
+
value,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const htmlOp = (property: string, value: string | null): PatchOperation => ({
|
|
36
|
+
type: "html-attribute",
|
|
37
|
+
property,
|
|
38
|
+
value,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function applyOps(source: string, target: PatchTarget, ops: readonly PatchOperation[]): string {
|
|
42
|
+
return ops.reduce((html, op) => applyPatchByTarget(html, target, op), source);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mintLinkIdForSource(source: string): string {
|
|
46
|
+
const links = Array.from(
|
|
47
|
+
source.matchAll(/\bdata-(?:link|sync-origin)=["']([^"']+)["']/g),
|
|
48
|
+
(match) => match[1] ?? "",
|
|
49
|
+
);
|
|
50
|
+
return mintLinkId([...collectHtmlIds(source), ...links]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Write (or with `null`, remove) `data-link` on every target. */
|
|
54
|
+
export function setLinkInSource(
|
|
55
|
+
source: string,
|
|
56
|
+
targets: readonly PatchTarget[],
|
|
57
|
+
linkId: string | null,
|
|
58
|
+
): string {
|
|
59
|
+
return targets.reduce(
|
|
60
|
+
(html, target) => applyPatchByTarget(html, target, dataOp(LINK_PROPERTY, linkId)),
|
|
61
|
+
source,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Link the targets under one freshly minted id, which is also their sync origin. */
|
|
66
|
+
export function linkInSource(source: string, targets: readonly PatchTarget[]): string {
|
|
67
|
+
const linkId = mintLinkIdForSource(source);
|
|
68
|
+
return targets.reduce(
|
|
69
|
+
(html, target) =>
|
|
70
|
+
applyOps(html, target, [dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId)]),
|
|
71
|
+
source,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Remove one element (open tag through its matching close tag) and its line's indentation. */
|
|
76
|
+
export function removeElementInSource(source: string, target: PatchTarget): string {
|
|
77
|
+
const match = findTagByTarget(source, target);
|
|
78
|
+
if (!match) return source;
|
|
79
|
+
const tagName = /^<([a-zA-Z][\w-]*)/.exec(match.tag)?.[1]?.toLowerCase() ?? "";
|
|
80
|
+
const closing = `</${tagName}>`;
|
|
81
|
+
const closeAt = match.tag.endsWith("/>")
|
|
82
|
+
? -1
|
|
83
|
+
: source.toLowerCase().indexOf(closing, match.end + 1);
|
|
84
|
+
let end = closeAt >= 0 ? closeAt + closing.length : match.end + 1;
|
|
85
|
+
let start = match.start;
|
|
86
|
+
const lineStart = source.lastIndexOf("\n", start) + 1;
|
|
87
|
+
if (/^[ \t]*$/.test(source.slice(lineStart, start)) && source[end] === "\n") {
|
|
88
|
+
start = lineStart;
|
|
89
|
+
end += 1;
|
|
90
|
+
}
|
|
91
|
+
return source.slice(0, start) + source.slice(end);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hasBooleanAttr(source: string, target: PatchTarget, name: string): boolean {
|
|
95
|
+
const tag = findTagByTarget(source, target)?.tag ?? "";
|
|
96
|
+
return new RegExp(`\\s${name}(?=[\\s=/>]|$)`, "i").test(tag);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface DetachAudioResult {
|
|
100
|
+
html: string;
|
|
101
|
+
audioId: string;
|
|
102
|
+
linkId: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Detach audio: insert a linked `<audio>` over the same file and window, move the
|
|
107
|
+
* sound attributes onto it, and mute the video. Renders identically.
|
|
108
|
+
*/
|
|
109
|
+
export function detachAudioInSource(
|
|
110
|
+
source: string,
|
|
111
|
+
input: { target: PatchTarget; videoId: string | null; track: number },
|
|
112
|
+
): DetachAudioResult | null {
|
|
113
|
+
const { target } = input;
|
|
114
|
+
if (!findTagByTarget(source, target)) return null;
|
|
115
|
+
const linkId = mintLinkIdForSource(source);
|
|
116
|
+
const audioId = firstFreeName(
|
|
117
|
+
`${input.videoId || "video"}-audio`,
|
|
118
|
+
new Set(collectHtmlIds(source)),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const moved: Array<[string, string]> = [];
|
|
122
|
+
const videoOps: PatchOperation[] = [htmlOp("muted", "true"), dataOp("has-audio", null)];
|
|
123
|
+
for (const name of MOVED_SOUND_ATTRS) {
|
|
124
|
+
const value = readAttributeByTarget(source, target, name);
|
|
125
|
+
if (value === undefined) continue;
|
|
126
|
+
moved.push([name, value]);
|
|
127
|
+
videoOps.push(dataOp(name, null));
|
|
128
|
+
}
|
|
129
|
+
const automation = readAttributeByTarget(source, target, HF_AUDIO_AUTOMATION_ATTR);
|
|
130
|
+
if (automation !== undefined) {
|
|
131
|
+
const { videoKeeps, audioTakes } = splitAutomation(automation);
|
|
132
|
+
moved.push([HF_AUDIO_AUTOMATION_ATTR, audioTakes]);
|
|
133
|
+
videoOps.push(dataOp(HF_AUDIO_AUTOMATION_ATTR, videoKeeps));
|
|
134
|
+
}
|
|
135
|
+
videoOps.push(dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId));
|
|
136
|
+
|
|
137
|
+
const timing: Array<[string, string]> = [];
|
|
138
|
+
for (const name of COPIED_TIMING_ATTRS) {
|
|
139
|
+
const value = readAttributeByTarget(source, target, name);
|
|
140
|
+
if (value !== undefined) timing.push([`data-${name}`, value]);
|
|
141
|
+
}
|
|
142
|
+
const attrs: Array<[string, string]> = [
|
|
143
|
+
["id", audioId],
|
|
144
|
+
["data-hf-id", `hf-${generateId()}`],
|
|
145
|
+
["class", "clip"],
|
|
146
|
+
["src", readAuthoredSrc(source, target)],
|
|
147
|
+
...timing,
|
|
148
|
+
["data-track-index", String(input.track)],
|
|
149
|
+
[MEDIA_LINK_ATTR, linkId],
|
|
150
|
+
[SYNC_ORIGIN_ATTR, linkId],
|
|
151
|
+
...moved,
|
|
152
|
+
];
|
|
153
|
+
if (hasBooleanAttr(source, target, "loop")) attrs.push(["loop", ""]);
|
|
154
|
+
if (hasBooleanAttr(source, target, HIDDEN_ATTR)) attrs.push([HIDDEN_ATTR, ""]);
|
|
155
|
+
const inserted = insertBeforeTarget(source, target, `<audio ${formatAttrs(attrs)}></audio>`);
|
|
156
|
+
return { html: applyOps(inserted, target, videoOps), audioId, linkId };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Merge back: move the audio's sound attributes onto its video, unmute the
|
|
161
|
+
* video, drop the link, and delete the audio. The inverse of detach.
|
|
162
|
+
*/
|
|
163
|
+
export function mergeAudioInSource(
|
|
164
|
+
source: string,
|
|
165
|
+
input: { videoTarget: PatchTarget; audioTarget: PatchTarget },
|
|
166
|
+
): string | null {
|
|
167
|
+
const { videoTarget, audioTarget } = input;
|
|
168
|
+
if (!findTagByTarget(source, videoTarget) || !findTagByTarget(source, audioTarget)) return null;
|
|
169
|
+
const assetOf = (target: PatchTarget) =>
|
|
170
|
+
mediaAssetIdentity({ src: readAuthoredSrc(source, target) });
|
|
171
|
+
const videoAsset = assetOf(videoTarget);
|
|
172
|
+
if (!videoAsset || videoAsset !== assetOf(audioTarget)) return null;
|
|
173
|
+
const hiddenOf = (target: PatchTarget) => hasBooleanAttr(source, target, HIDDEN_ATTR);
|
|
174
|
+
if (hiddenOf(videoTarget) !== hiddenOf(audioTarget)) return null;
|
|
175
|
+
const videoOps: PatchOperation[] = [];
|
|
176
|
+
for (const name of MOVED_SOUND_ATTRS) {
|
|
177
|
+
const value = readAttributeByTarget(source, audioTarget, name);
|
|
178
|
+
videoOps.push(dataOp(name, value ?? null));
|
|
179
|
+
}
|
|
180
|
+
const automation = readAttributeByTarget(source, audioTarget, HF_AUDIO_AUTOMATION_ATTR);
|
|
181
|
+
if (automation !== undefined) videoOps.push(dataOp(HF_AUDIO_AUTOMATION_ATTR, automation));
|
|
182
|
+
videoOps.push(
|
|
183
|
+
htmlOp("muted", null),
|
|
184
|
+
dataOp(LINK_PROPERTY, null),
|
|
185
|
+
dataOp(SYNC_ORIGIN_PROPERTY, null),
|
|
186
|
+
dataOp("has-audio", "true"),
|
|
187
|
+
);
|
|
188
|
+
return applyOps(removeElementInSource(source, audioTarget), videoTarget, videoOps);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type TimedElement = Pick<
|
|
192
|
+
TimelineElement,
|
|
193
|
+
"id" | "key" | "tag" | "src" | "start" | "duration" | "playbackStart" | "playbackRate" | "link"
|
|
194
|
+
> &
|
|
195
|
+
Pick<TimelineElement, "muted" | "hasAudio" | "hidden" | "sourceFile" | "compositionScope">;
|
|
196
|
+
|
|
197
|
+
const tagOf = (el: Pick<TimelineElement, "tag">) => el.tag.trim().toLowerCase();
|
|
198
|
+
const keyOf = (el: Pick<TimelineElement, "id" | "key">) => el.key ?? el.id;
|
|
199
|
+
const near = (a: number, b: number) => Math.abs(a - b) <= SYNC_TOLERANCE_S;
|
|
200
|
+
|
|
201
|
+
function hasIdenticalTiming(a: TimedElement, b: TimedElement): boolean {
|
|
202
|
+
return (
|
|
203
|
+
near(a.start, b.start) &&
|
|
204
|
+
near(a.duration, b.duration) &&
|
|
205
|
+
near(a.playbackStart ?? 0, b.playbackStart ?? 0) &&
|
|
206
|
+
near(a.playbackRate ?? 1, b.playbackRate ?? 1)
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const sameAssetInScope = (a: TimedElement, b: TimedElement) => {
|
|
211
|
+
const asset = mediaAssetIdentity(a);
|
|
212
|
+
return asset !== null && asset === mediaAssetIdentity(b) && sameCompositionScope(a, b);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** A video whose sound is on the video itself: what Detach audio acts on. */
|
|
216
|
+
export function canDetachAudio(el: TimedElement): boolean {
|
|
217
|
+
return tagOf(el) === "video" && el.muted !== true && el.hasAudio === true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The (muted video, audio) pair Merge back acts on, found from either member:
|
|
222
|
+
* same file, and linked to each other or with identical timing.
|
|
223
|
+
*/
|
|
224
|
+
export function findMergePair<T extends TimedElement>(
|
|
225
|
+
element: T,
|
|
226
|
+
elements: readonly T[],
|
|
227
|
+
): { video: T; audio: T } | null {
|
|
228
|
+
const isVideo = tagOf(element) === "video";
|
|
229
|
+
if (!isVideo && tagOf(element) !== "audio") return null;
|
|
230
|
+
const partnerTag = isVideo ? "audio" : "video";
|
|
231
|
+
const candidates = elements.filter(
|
|
232
|
+
(el) =>
|
|
233
|
+
keyOf(el) !== keyOf(element) && tagOf(el) === partnerTag && sameAssetInScope(el, element),
|
|
234
|
+
);
|
|
235
|
+
const partner =
|
|
236
|
+
candidates.find((el) => sharesLinkGroup(el, element)) ??
|
|
237
|
+
candidates.find((el) => hasIdenticalTiming(el, element));
|
|
238
|
+
if (!partner) return null;
|
|
239
|
+
const video = isVideo ? element : partner;
|
|
240
|
+
const audio = isVideo ? partner : element;
|
|
241
|
+
if ((video.hidden === true) !== (audio.hidden === true)) return null;
|
|
242
|
+
return video.muted === true ? { video, audio } : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Exactly one video and one audio, same file, identical timing, not already linked together. */
|
|
246
|
+
export function canLinkPair(selected: readonly TimedElement[]): boolean {
|
|
247
|
+
if (selected.length !== 2) return false;
|
|
248
|
+
const [a, b] = selected;
|
|
249
|
+
if (!a || !b) return false;
|
|
250
|
+
const tags = new Set([tagOf(a), tagOf(b)]);
|
|
251
|
+
if (!tags.has("video") || !tags.has("audio")) return false;
|
|
252
|
+
if (sharesLinkGroup(a, b)) return false;
|
|
253
|
+
return sameAssetInScope(a, b) && hasIdenticalTiming(a, b);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
type TrackedElement = Pick<TimelineElement, "tag" | "track" | "start" | "duration"> & {
|
|
257
|
+
authoredTrack?: number;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The detached audio's track: the first audio-only track below every visual
|
|
262
|
+
* track that is free for the clip's window, else a new track at the bottom.
|
|
263
|
+
*/
|
|
264
|
+
export function pickDetachedAudioTrack(
|
|
265
|
+
elements: readonly TrackedElement[],
|
|
266
|
+
window: { start: number; duration: number },
|
|
267
|
+
): number {
|
|
268
|
+
const trackOf = (el: TrackedElement) => el.authoredTrack ?? el.track;
|
|
269
|
+
const visualTracks = elements.filter((el) => tagOf(el) !== "audio").map(trackOf);
|
|
270
|
+
const lowestAudio = Math.max(-1, ...visualTracks) + 1;
|
|
271
|
+
const end = window.start + window.duration;
|
|
272
|
+
const allTracks = elements.map(trackOf);
|
|
273
|
+
const bottom = Math.max(lowestAudio - 1, ...allTracks) + 1;
|
|
274
|
+
for (let track = lowestAudio; track < bottom; track += 1) {
|
|
275
|
+
const occupants = elements.filter((el) => trackOf(el) === track);
|
|
276
|
+
const free = occupants.every(
|
|
277
|
+
(el) => tagOf(el) === "audio" && (el.start >= end || el.start + el.duration <= window.start),
|
|
278
|
+
);
|
|
279
|
+
if (free) return track;
|
|
280
|
+
}
|
|
281
|
+
return bottom;
|
|
282
|
+
}
|
|
@@ -103,11 +103,11 @@ function mount(dataAttributes: Record<string, string>, alone = false, voices = 2
|
|
|
103
103
|
return { host, onSetAttributeQuiet, onSetAttributeLive };
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
function mountGroup(memberStart: number) {
|
|
106
|
+
function mountGroup(memberStart: number, memberTag: "audio" | "video" = "audio") {
|
|
107
107
|
const bus = document.createElement("hf-audio-group");
|
|
108
108
|
bus.id = "voiceover";
|
|
109
109
|
document.body.append(bus);
|
|
110
|
-
const member = document.createElement(
|
|
110
|
+
const member = document.createElement(memberTag);
|
|
111
111
|
member.id = "vo-1";
|
|
112
112
|
member.setAttribute("data-audio-group", "voiceover");
|
|
113
113
|
member.setAttribute("data-start", String(memberStart));
|
|
@@ -649,6 +649,16 @@ describe("AudioFxGroup dynamic carve", () => {
|
|
|
649
649
|
leaveShelf(host);
|
|
650
650
|
});
|
|
651
651
|
|
|
652
|
+
it("seeks a group audition to an audible video member's span", () => {
|
|
653
|
+
act(() =>
|
|
654
|
+
usePlayerStore.setState({ isPlaying: false, currentTime: 2, requestedSeekTime: null }),
|
|
655
|
+
);
|
|
656
|
+
const host = mountGroup(7, "video");
|
|
657
|
+
hoverPreset(host);
|
|
658
|
+
expect(store().requestedSeekTime).toBe(7);
|
|
659
|
+
leaveShelf(host);
|
|
660
|
+
});
|
|
661
|
+
|
|
652
662
|
it("leaves a transport the author started alone", () => {
|
|
653
663
|
// Stopping their playback because they passed over a preset would be the
|
|
654
664
|
// panel taking a decision nobody offered it.
|
|
@@ -1621,7 +1631,14 @@ describe("AudioFxGroup carve by default", () => {
|
|
|
1621
1631
|
describe("AudioFxGroup carve source list", () => {
|
|
1622
1632
|
/** Mount a bed alongside tracks named however the test needs. */
|
|
1623
1633
|
const mountWith = (
|
|
1624
|
-
tracks: {
|
|
1634
|
+
tracks: {
|
|
1635
|
+
id: string;
|
|
1636
|
+
src?: string;
|
|
1637
|
+
start?: string;
|
|
1638
|
+
duration?: string;
|
|
1639
|
+
tag?: "audio" | "video";
|
|
1640
|
+
muted?: boolean;
|
|
1641
|
+
}[],
|
|
1625
1642
|
bedAttrs: Record<string, string> = {},
|
|
1626
1643
|
) => {
|
|
1627
1644
|
const bed = document.createElement("audio");
|
|
@@ -1629,8 +1646,9 @@ describe("AudioFxGroup carve source list", () => {
|
|
|
1629
1646
|
for (const [k, v] of Object.entries(bedAttrs)) bed.setAttribute(`data-${k}`, v);
|
|
1630
1647
|
document.body.append(bed);
|
|
1631
1648
|
for (const t of tracks) {
|
|
1632
|
-
const el = document.createElement("audio");
|
|
1649
|
+
const el = document.createElement(t.tag ?? "audio");
|
|
1633
1650
|
el.id = t.id;
|
|
1651
|
+
if (t.muted) el.setAttribute("muted", "");
|
|
1634
1652
|
if (t.src) el.setAttribute("src", t.src);
|
|
1635
1653
|
if (t.start !== undefined) el.setAttribute("data-start", t.start);
|
|
1636
1654
|
if (t.duration !== undefined) el.setAttribute("data-duration", t.duration);
|
|
@@ -1660,6 +1678,16 @@ describe("AudioFxGroup carve source list", () => {
|
|
|
1660
1678
|
return { host, options, boxes, onSetAttributeQuiet };
|
|
1661
1679
|
};
|
|
1662
1680
|
|
|
1681
|
+
it("offers an audible video as a voice and never a muted one", () => {
|
|
1682
|
+
const { options } = mountWith([
|
|
1683
|
+
{ id: "a-roll", tag: "video" },
|
|
1684
|
+
{ id: "b-roll", tag: "video", muted: true },
|
|
1685
|
+
{ id: "narration" },
|
|
1686
|
+
]);
|
|
1687
|
+
expect(options).toContain("a-roll");
|
|
1688
|
+
expect(options).not.toContain("b-roll");
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1663
1691
|
it("reads the one voice out instead of offering a picker with one entry", () => {
|
|
1664
1692
|
// A question with one answer is not a question. It is also the common case: a
|
|
1665
1693
|
// narration and a bed, with a couple of stings that cannot be the voice.
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
resolveAudioGroups,
|
|
55
55
|
} from "@hyperframes/core/audio-groups";
|
|
56
56
|
import { useFxLevelling } from "./useFxLevelling.js";
|
|
57
|
+
import { isCarveVoiceElement } from "./useFxCarveGrouping.js";
|
|
57
58
|
|
|
58
59
|
function auditionSpan(startRaw: string | undefined, durationRaw: string | undefined) {
|
|
59
60
|
const start = Number.parseFloat(startRaw ?? "");
|
|
@@ -70,8 +71,9 @@ function auditionSpansFor(element: DomEditSelection): AuditionSpan[] {
|
|
|
70
71
|
if (element.tagName?.toLowerCase() !== HF_AUDIO_GROUP_TAG || !element.id) return [];
|
|
71
72
|
const doc = element.element?.ownerDocument;
|
|
72
73
|
if (!doc) return [];
|
|
73
|
-
return [...doc.querySelectorAll(`
|
|
74
|
+
return [...doc.querySelectorAll(`[${HF_AUDIO_GROUP_ATTR}]`)]
|
|
74
75
|
.filter((member) => member.getAttribute(HF_AUDIO_GROUP_ATTR) === element.id)
|
|
76
|
+
.filter(isCarveVoiceElement)
|
|
75
77
|
.flatMap((member) => {
|
|
76
78
|
const span = auditionSpan(
|
|
77
79
|
member.getAttribute("data-start") ?? undefined,
|
|
@@ -652,7 +652,12 @@ function makeHandlers() {
|
|
|
652
652
|
ops: PatchOperation[],
|
|
653
653
|
options: BatchOptions,
|
|
654
654
|
) => Promise<boolean>
|
|
655
|
-
>(async () =>
|
|
655
|
+
>(async (_selection, _ops, options) => {
|
|
656
|
+
options.prepareContent?.(
|
|
657
|
+
'<div data-composition-id="main"><video id="s1-bg" src="assets/intro-loop.mp4" data-start="2"></video></div>',
|
|
658
|
+
);
|
|
659
|
+
return true;
|
|
660
|
+
}),
|
|
656
661
|
};
|
|
657
662
|
}
|
|
658
663
|
|
|
@@ -871,14 +876,14 @@ describe.each<PanelKind>(["flat", "design"])("%s panel — cutout keeps the soun
|
|
|
871
876
|
const [selection, ops, options] = onSetAttributeBatch.mock.calls[0] ?? [];
|
|
872
877
|
expect(selection).toBe(element);
|
|
873
878
|
expect(ops).toEqual([
|
|
874
|
-
htmlOp("src", "assets/cut.webm"),
|
|
875
879
|
htmlOp("muted", "true"),
|
|
876
880
|
dataOp("has-audio", null),
|
|
877
881
|
dataOp("volume", null),
|
|
878
|
-
dataOp("link", "
|
|
882
|
+
dataOp("link", "lk-1"),
|
|
883
|
+
dataOp("sync-origin", "lk-1"),
|
|
879
884
|
]);
|
|
880
885
|
const source =
|
|
881
|
-
'<div data-composition-id="main"><video id="s1-bg" src="assets/
|
|
886
|
+
'<div data-composition-id="main"><video id="s1-bg" src="assets/intro-loop.mp4" muted data-start="2" data-duration="3" data-track-index="0" data-link="lk-1"></video></div>';
|
|
882
887
|
const prepared = options?.prepareContent?.(source) ?? "";
|
|
883
888
|
const audio = new DOMParser()
|
|
884
889
|
.parseFromString(prepared, "text/html")
|
|
@@ -886,7 +891,7 @@ describe.each<PanelKind>(["flat", "design"])("%s panel — cutout keeps the soun
|
|
|
886
891
|
expect(audio?.getAttribute("src")).toBe("assets/intro-loop.mp4");
|
|
887
892
|
expect(audio?.getAttribute("data-start")).toBe("2");
|
|
888
893
|
expect(audio?.getAttribute("data-duration")).toBe("3");
|
|
889
|
-
expect(audio?.getAttribute("data-link")).toBe("
|
|
894
|
+
expect(audio?.getAttribute("data-link")).toBe("lk-1");
|
|
890
895
|
expect(audio?.getAttribute("data-volume")).toBe("0.5");
|
|
891
896
|
expect(text).toContain("Background removed. Sound kept on a linked audio track.");
|
|
892
897
|
});
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
audioGainToText,
|
|
25
25
|
} from "@hyperframes/core/audio-gain";
|
|
26
26
|
import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
27
|
+
import { useLinkedSpeedCommit, withLinkedPlaybackRate } from "./linkedSpeedEdits";
|
|
27
28
|
import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
|
|
28
29
|
|
|
29
30
|
// fallow-ignore-next-line complexity
|
|
@@ -56,6 +57,10 @@ export function MediaSection({
|
|
|
56
57
|
) => Promise<BackgroundRemovalResult>;
|
|
57
58
|
}) {
|
|
58
59
|
const track = useTrackDesignInput();
|
|
60
|
+
const setRate = withLinkedPlaybackRate(
|
|
61
|
+
onSetAttribute,
|
|
62
|
+
useLinkedSpeedCommit(element, onSetAttributeBatch),
|
|
63
|
+
);
|
|
59
64
|
const isVideo = element.tagName === "video";
|
|
60
65
|
const isAudio = element.tagName === "audio";
|
|
61
66
|
const isImage = element.tagName === "img";
|
|
@@ -279,7 +284,7 @@ export function MediaSection({
|
|
|
279
284
|
displayValue={`${formatNumericValue(playbackRate)}x`}
|
|
280
285
|
formatDisplayValue={(next) => `${formatNumericValue(next / 100)}x`}
|
|
281
286
|
onCommit={(next) => {
|
|
282
|
-
void
|
|
287
|
+
void setRate("playback-rate", formatNumericValue(next / 100));
|
|
283
288
|
}}
|
|
284
289
|
/>
|
|
285
290
|
</div>
|
|
@@ -11,7 +11,7 @@ import { ownsPlainKeys } from "../../utils/typingTarget";
|
|
|
11
11
|
import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
|
|
12
12
|
import {
|
|
13
13
|
addStudioPendingEditFlushListener,
|
|
14
|
-
|
|
14
|
+
beginStudioPendingEdit,
|
|
15
15
|
} from "../../utils/studioPendingEdits";
|
|
16
16
|
import type { DomEditSelection } from "./domEditing";
|
|
17
17
|
import {
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
applyManualOffsetNudgeDraft,
|
|
32
32
|
createManualOffsetDragMember,
|
|
33
33
|
endManualOffsetDragMembers,
|
|
34
|
+
manualOffsetMoveRevert,
|
|
34
35
|
restoreManualOffsetDragMembers,
|
|
35
36
|
type ManualOffsetDragMember,
|
|
36
37
|
} from "./manualOffsetDrag";
|
|
@@ -47,7 +48,7 @@ interface NudgeSession {
|
|
|
47
48
|
/** Accumulated delta of the burst, in composition px. */
|
|
48
49
|
accum: { x: number; y: number };
|
|
49
50
|
timer: ReturnType<typeof setTimeout> | null;
|
|
50
|
-
|
|
51
|
+
edit: ReturnType<typeof beginStudioPendingEdit>;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export interface UseDomEditNudgeParams {
|
|
@@ -161,11 +162,16 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
161
162
|
next: applyManualOffsetNudgeCommit(member, session.accum),
|
|
162
163
|
plainTranslate: member.plainTranslate,
|
|
163
164
|
}));
|
|
164
|
-
const commit = session.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
165
|
+
const commit = session.edit.adopt(() =>
|
|
166
|
+
session.isGroup
|
|
167
|
+
? p.onGroupPathOffsetCommitRef.current(updates)
|
|
168
|
+
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
|
|
169
|
+
plainTranslate: updates[0].plainTranslate,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
if (session.edit.reverted())
|
|
173
|
+
for (const member of session.members)
|
|
174
|
+
restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
169
175
|
saved = Promise.resolve(commit)
|
|
170
176
|
.catch(() => {
|
|
171
177
|
for (const member of session.members) {
|
|
@@ -177,7 +183,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
177
183
|
.finally(() => endManualOffsetDragMembers(session.members));
|
|
178
184
|
return saved;
|
|
179
185
|
} finally {
|
|
180
|
-
session.
|
|
186
|
+
session.edit.settle(saved);
|
|
181
187
|
}
|
|
182
188
|
};
|
|
183
189
|
const commitSessionRef = useRef(commitSession);
|
|
@@ -190,7 +196,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
190
196
|
if (session.timer) clearTimeout(session.timer);
|
|
191
197
|
restoreManualOffsetDragMembers(session.members);
|
|
192
198
|
endManualOffsetDragMembers(session.members);
|
|
193
|
-
session.
|
|
199
|
+
session.edit.settle();
|
|
194
200
|
}, [params.allowCanvasMovement]);
|
|
195
201
|
|
|
196
202
|
// Build drag members for the current target set — the same member snapshot a
|
|
@@ -217,9 +223,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
217
223
|
if (members.length === 0) return null;
|
|
218
224
|
// Same side effect a drag start has (pauses preview playback).
|
|
219
225
|
p.onManualDragStartRef.current?.();
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, endPendingEdit };
|
|
226
|
+
const edit = beginStudioPendingEdit(manualOffsetMoveRevert(members));
|
|
227
|
+
return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, edit };
|
|
223
228
|
};
|
|
224
229
|
|
|
225
230
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
applyManualOffsetDragDraft,
|
|
12
12
|
endManualOffsetDragMembers,
|
|
13
13
|
restoreManualOffsetDragMembers,
|
|
14
|
+
manualOffsetMoveRevert,
|
|
14
15
|
} from "./manualOffsetDrag";
|
|
15
16
|
import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
|
|
16
17
|
import {
|
|
@@ -57,6 +58,7 @@ import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDeb
|
|
|
57
58
|
import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug";
|
|
58
59
|
import { createGroupDragMover } from "./groupDragMove";
|
|
59
60
|
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
|
|
61
|
+
import { beginStudioPendingEdit } from "../../utils/studioPendingEdits";
|
|
60
62
|
|
|
61
63
|
function isTap(g: { startX: number; startY: number; travelled?: boolean }, e: React.PointerEvent) {
|
|
62
64
|
return (
|
|
@@ -344,7 +346,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
344
346
|
),
|
|
345
347
|
at: readDragPositions(groupG.members),
|
|
346
348
|
});
|
|
347
|
-
|
|
349
|
+
const groupEdit = beginStudioPendingEdit(manualOffsetMoveRevert(groupG.members));
|
|
350
|
+
const groupSaved = Promise.resolve(
|
|
351
|
+
groupEdit.adopt(() => opts.onGroupPathOffsetCommitRef.current(updates)),
|
|
352
|
+
)
|
|
348
353
|
.catch(() => {
|
|
349
354
|
for (const member of groupG.members) {
|
|
350
355
|
if (
|
|
@@ -363,6 +368,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
363
368
|
// so this is where a snap-back would show.
|
|
364
369
|
logDragSettle("settle", groupG.members);
|
|
365
370
|
});
|
|
371
|
+
groupEdit.settle(groupSaved);
|
|
366
372
|
return;
|
|
367
373
|
}
|
|
368
374
|
|
|
@@ -469,11 +475,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
469
475
|
box.style.left = `${nextBoxLeft}px`;
|
|
470
476
|
box.style.top = `${nextBoxTop}px`;
|
|
471
477
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
478
|
+
const member = g.pathOffsetMember;
|
|
479
|
+
const edit = beginStudioPendingEdit(manualOffsetMoveRevert([member]));
|
|
480
|
+
const saved = Promise.resolve(
|
|
481
|
+
edit.adopt(() =>
|
|
482
|
+
opts.onPathOffsetCommitRef.current(sel, finalOffset, {
|
|
483
|
+
altKey: e.altKey,
|
|
484
|
+
plainTranslate: member.plainTranslate,
|
|
485
|
+
}),
|
|
486
|
+
),
|
|
477
487
|
)
|
|
478
488
|
.catch(() => {
|
|
479
489
|
if (
|
|
@@ -485,6 +495,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
485
495
|
.finally(() => {
|
|
486
496
|
if (g.pathOffsetMember) endManualOffsetDragMembers([g.pathOffsetMember]);
|
|
487
497
|
});
|
|
498
|
+
edit.settle(saved);
|
|
488
499
|
} else {
|
|
489
500
|
opts.suppressNextBoxClickRef.current = true;
|
|
490
501
|
const finalSize = readStudioBoxSize(sel.element);
|