@hyperframes/studio 0.8.106 → 0.8.108
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-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
- package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
- package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
- package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
- package/dist/chunk-AOWVEVEU.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +377 -12
- package/dist/index.html +2 -2
- package/dist/index.js +13445 -11874
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +6 -0
- package/src/components/EditorShell.tsx +2 -0
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- 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/DomEditOverlay.tsx +12 -18
- package/src/components/editor/PropertyPanel.tsx +1 -14
- package/src/components/editor/PropertyPanelFlat.tsx +3 -6
- package/src/components/editor/SnapToolbar.test.tsx +32 -1
- package/src/components/editor/SnapToolbar.tsx +3 -4
- 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/domEditOverlayStartGesture.ts +5 -2
- 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/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +28 -5
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +15 -1
- package/src/components/editor/mediaAudioEdits.test.ts +271 -0
- package/src/components/editor/mediaAudioEdits.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 +290 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useDomEditNudge.test.tsx +134 -2
- package/src/components/editor/useDomEditNudge.ts +46 -23
- 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/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
- package/src/components/panels/SlideshowPanel.test.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +12 -7
- package/src/contexts/DomEditContext.test.tsx +62 -0
- package/src/contexts/DomEditContext.tsx +94 -77
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/canvasQuickSaves.test.tsx +72 -7
- package/src/hooks/domEditCommitTypes.ts +10 -0
- package/src/hooks/domEditPersistFailure.ts +12 -1
- package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
- package/src/hooks/domStyleCommit.ts +2 -8
- package/src/hooks/gsapDragCommit.ts +3 -8
- package/src/hooks/gsapDragPositionCommit.ts +6 -1
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
- package/src/hooks/gsapResizeIntercept.test.ts +3 -11
- package/src/hooks/gsapResizeIntercept.ts +6 -2
- package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
- package/src/hooks/inspectorSaveReload.test.tsx +107 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +17 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +3 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useDomEditAttributeCommits.ts +71 -29
- package/src/hooks/useDomEditCommits.test.tsx +172 -92
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
- package/src/hooks/useDomEditPersist.ts +28 -13
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -3
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
- 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/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +9 -7
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/usePreviewPersistence.ts +6 -0
- package/src/hooks/useRazorSplit.ts +14 -1
- package/src/hooks/useRenderClipContent.test.ts +102 -0
- package/src/hooks/useRenderClipContent.ts +30 -23
- package/src/hooks/useTimelineEditing.ts +2 -2
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +8 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.tsx +22 -0
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.tsx +2 -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/clipMenuAudioItems.test.tsx +125 -0
- package/src/player/components/clipMenuAudioItems.tsx +108 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +142 -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 +235 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +25 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- 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.ts +3 -2
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
- package/src/utils/gsapUndoRestore.test.ts +145 -1
- package/src/utils/gsapUndoRestore.ts +70 -21
- package/src/utils/previewRelativePath.ts +19 -0
- package/src/utils/sourcePatcher.ts +2 -2
- package/src/utils/studioFontHelpers.ts +2 -23
- package/src/utils/studioHelpers.test.ts +30 -0
- package/src/utils/studioHelpers.ts +18 -4
- package/src/utils/studioPendingEdits.test.ts +129 -0
- package/src/utils/studioPendingEdits.ts +61 -18
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/src/utils/typingTarget.test.ts +13 -0
- package/src/utils/typingTarget.ts +5 -2
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-ICTZJPQQ.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -58,7 +58,6 @@ export {
|
|
|
58
58
|
export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelProps) {
|
|
59
59
|
const {
|
|
60
60
|
projectId,
|
|
61
|
-
projectDir,
|
|
62
61
|
assets,
|
|
63
62
|
element,
|
|
64
63
|
multiSelectCount = 0,
|
|
@@ -72,8 +71,6 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
72
71
|
onSetAttribute,
|
|
73
72
|
onSetAttributeLive,
|
|
74
73
|
onApplyColorGradingScope,
|
|
75
|
-
onSetHtmlAttribute,
|
|
76
|
-
onRemoveBackground,
|
|
77
74
|
onSetManualOffset,
|
|
78
75
|
onSetManualSize,
|
|
79
76
|
onSetManualRotation,
|
|
@@ -374,17 +371,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
374
371
|
/>
|
|
375
372
|
)}
|
|
376
373
|
|
|
377
|
-
{sections.media &&
|
|
378
|
-
<MediaSection
|
|
379
|
-
projectDir={projectDir}
|
|
380
|
-
element={element}
|
|
381
|
-
styles={styles}
|
|
382
|
-
onSetStyle={onSetStyle}
|
|
383
|
-
onSetAttribute={onSetAttribute}
|
|
384
|
-
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
385
|
-
onRemoveBackground={onRemoveBackground}
|
|
386
|
-
/>
|
|
387
|
-
)}
|
|
374
|
+
{sections.media && <MediaSection {...props} element={element} styles={styles} />}
|
|
388
375
|
|
|
389
376
|
{sections.layout && (
|
|
390
377
|
<Section title="Layout" icon={<Move size={15} />}>
|
|
@@ -61,7 +61,6 @@ export function PropertyPanelFlat({
|
|
|
61
61
|
clipboardCopied,
|
|
62
62
|
onCopyElementInfo,
|
|
63
63
|
projectId,
|
|
64
|
-
projectDir,
|
|
65
64
|
assets,
|
|
66
65
|
previewIframeRef,
|
|
67
66
|
onClearSelection,
|
|
@@ -73,8 +72,6 @@ export function PropertyPanelFlat({
|
|
|
73
72
|
onSetAttributeLive,
|
|
74
73
|
onSetAttributeQuiet,
|
|
75
74
|
onApplyColorGradingScope,
|
|
76
|
-
onSetHtmlAttribute,
|
|
77
|
-
onRemoveBackground,
|
|
78
75
|
onSetText,
|
|
79
76
|
onSetTextFieldStyle,
|
|
80
77
|
onPreviewTextFieldStyle,
|
|
@@ -130,6 +127,7 @@ export function PropertyPanelFlat({
|
|
|
130
127
|
onUpdateKeyframeEase,
|
|
131
128
|
onUpdateSegmentEase,
|
|
132
129
|
onSetAllKeyframeEases,
|
|
130
|
+
...forwardedProps
|
|
133
131
|
}: PropertyPanelFlatProps) {
|
|
134
132
|
// PropertyPanel keys this component by selection, so the default is per element.
|
|
135
133
|
const [openGroupId, setOpenGroupId] = useState<string>(() =>
|
|
@@ -502,13 +500,12 @@ export function PropertyPanelFlat({
|
|
|
502
500
|
summary: element.tagName,
|
|
503
501
|
content: (
|
|
504
502
|
<FlatMediaSection
|
|
505
|
-
|
|
503
|
+
{...forwardedProps}
|
|
504
|
+
projectId={projectId}
|
|
506
505
|
element={element}
|
|
507
506
|
styles={styles}
|
|
508
507
|
onSetStyle={onSetStyle}
|
|
509
508
|
onSetAttribute={onSetAttribute}
|
|
510
|
-
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
511
|
-
onRemoveBackground={onRemoveBackground}
|
|
512
509
|
{...volumeAutomation}
|
|
513
510
|
/>
|
|
514
511
|
),
|
|
@@ -52,7 +52,7 @@ function AppHotkeyHarness() {
|
|
|
52
52
|
writeProjectFile: vi.fn(async () => undefined),
|
|
53
53
|
showToast: vi.fn(),
|
|
54
54
|
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
|
55
|
-
|
|
55
|
+
settlePendingEdits: vi.fn(async () => undefined),
|
|
56
56
|
handleCopy: vi.fn(() => false),
|
|
57
57
|
handlePaste: vi.fn(async () => undefined),
|
|
58
58
|
handleCut: vi.fn(async () => false),
|
|
@@ -133,6 +133,37 @@ describe("SnapToolbar keyboard shortcuts", () => {
|
|
|
133
133
|
});
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
describe("SnapToolbar keys and a focused control", () => {
|
|
137
|
+
let root: ReturnType<typeof createRoot> | null = null;
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
act(() => root?.unmount());
|
|
140
|
+
root = null;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const controls: Array<[string, string, string, string]> = [
|
|
144
|
+
["a combobox", "button", "role", "combobox"],
|
|
145
|
+
["a select", "select", "name", "font"],
|
|
146
|
+
["a switch", "button", "role", "switch"],
|
|
147
|
+
["a video player", "video", "controls", ""],
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
it.each(controls)("leaves S and G alone while %s has focus", (_name, tag, attr, value) => {
|
|
151
|
+
root = renderToolbar().root;
|
|
152
|
+
const control = document.body.appendChild(document.createElement(tag));
|
|
153
|
+
control.setAttribute(attr, value);
|
|
154
|
+
act(() => {
|
|
155
|
+
for (const key of ["s", "g"])
|
|
156
|
+
control.dispatchEvent(
|
|
157
|
+
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const prefs = JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}");
|
|
162
|
+
expect(prefs.snapEnabled).not.toBe(false);
|
|
163
|
+
expect(prefs.gridVisible).not.toBe(true);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
136
167
|
describe("SnapToolbar ruler and safe-margin toggles", () => {
|
|
137
168
|
it.each([
|
|
138
169
|
["Toggle ruler", "rulerVisible"],
|
|
@@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
|
2
2
|
import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react";
|
|
3
3
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
4
4
|
import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
|
|
5
|
+
import { ownsPlainKeys } from "../../utils/typingTarget";
|
|
5
6
|
|
|
6
7
|
// fallow-ignore-next-line complexity
|
|
7
8
|
export const SnapToolbar = memo(function SnapToolbar() {
|
|
@@ -35,10 +36,8 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
35
36
|
// fallow-ignore-next-line complexity
|
|
36
37
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
37
38
|
if (e.defaultPrevented) return;
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
if (t instanceof HTMLElement && t.isContentEditable) return;
|
|
41
|
-
if (t instanceof HTMLIFrameElement) return;
|
|
39
|
+
if (ownsPlainKeys(e.target)) return;
|
|
40
|
+
if (e.target instanceof HTMLIFrameElement) return;
|
|
42
41
|
if (e.key === "s" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
43
42
|
e.preventDefault();
|
|
44
43
|
updatePrefs({ snapEnabled: !prefs.snapEnabled });
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { CROP_ASPECT_PRESETS, centredCropInsets } from "./cropPresets";
|
|
3
|
+
|
|
4
|
+
describe("centredCropInsets", () => {
|
|
5
|
+
it("trims the sides of a wide frame to 9:16, centred", () => {
|
|
6
|
+
const insets = centredCropInsets(1920, 1080, 9 / 16);
|
|
7
|
+
expect(insets.top).toBe(0);
|
|
8
|
+
expect(insets.bottom).toBe(0);
|
|
9
|
+
expect(insets.left).toBeCloseTo((1920 - 607.5) / 2, 6);
|
|
10
|
+
expect(insets.right).toBeCloseTo(insets.left, 6);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("trims top and bottom of a tall frame to 16:9", () => {
|
|
14
|
+
const insets = centredCropInsets(1080, 1920, 16 / 9);
|
|
15
|
+
expect(insets.left).toBe(0);
|
|
16
|
+
expect(insets.right).toBe(0);
|
|
17
|
+
expect(insets.top).toBeCloseTo((1920 - 607.5) / 2, 6);
|
|
18
|
+
expect(insets.bottom).toBeCloseTo(insets.top, 6);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("leaves the visible box in the requested ratio", () => {
|
|
22
|
+
for (const [w, h] of [
|
|
23
|
+
[1920, 1080],
|
|
24
|
+
[1080, 1920],
|
|
25
|
+
[800, 800],
|
|
26
|
+
]) {
|
|
27
|
+
for (const { ratio } of CROP_ASPECT_PRESETS) {
|
|
28
|
+
const i = centredCropInsets(w, h, ratio);
|
|
29
|
+
expect((w - i.left - i.right) / (h - i.top - i.bottom)).toBeCloseTo(ratio, 6);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("is no crop when the frame already has the ratio", () => {
|
|
35
|
+
expect(centredCropInsets(1600, 900, 16 / 9)).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("offers the four spec ratios", () => {
|
|
39
|
+
expect(CROP_ASPECT_PRESETS.map((p) => p.label)).toEqual(["16:9", "9:16", "1:1", "4:5"]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ClipPathInsetSides } from "./clipPathHelpers";
|
|
2
|
+
|
|
3
|
+
export const CROP_ASPECT_PRESETS = [
|
|
4
|
+
{ label: "16:9", ratio: 16 / 9 },
|
|
5
|
+
{ label: "9:16", ratio: 9 / 16 },
|
|
6
|
+
{ label: "1:1", ratio: 1 },
|
|
7
|
+
{ label: "4:5", ratio: 4 / 5 },
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
/** Insets (element CSS px) that leave the largest centred box of `ratio` (width / height). */
|
|
11
|
+
export function centredCropInsets(
|
|
12
|
+
width: number,
|
|
13
|
+
height: number,
|
|
14
|
+
ratio: number,
|
|
15
|
+
): ClipPathInsetSides {
|
|
16
|
+
const none = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
17
|
+
if (width <= 0 || height <= 0 || ratio <= 0) return none;
|
|
18
|
+
if (width / height > ratio) {
|
|
19
|
+
const side = (width - height * ratio) / 2;
|
|
20
|
+
return side > 1e-9 ? { ...none, left: side, right: side } : none;
|
|
21
|
+
}
|
|
22
|
+
const band = (height - width / ratio) / 2;
|
|
23
|
+
return band > 1e-9 ? { ...none, top: band, bottom: band } : none;
|
|
24
|
+
}
|
|
@@ -219,10 +219,13 @@ export function startGesture(
|
|
|
219
219
|
// Hold a % translate as the same px now, so a growing box can't drag it along mid-frame.
|
|
220
220
|
if (result.member.plainTranslate) applyManualOffsetDragDraft(result.member, 0, 0);
|
|
221
221
|
} else {
|
|
222
|
-
manualEditDragToken = beginStudioManualEditGesture(sel.element);
|
|
222
|
+
manualEditDragToken = beginStudioManualEditGesture(sel.element, "resize");
|
|
223
223
|
}
|
|
224
224
|
} else {
|
|
225
|
-
manualEditDragToken = beginStudioManualEditGesture(
|
|
225
|
+
manualEditDragToken = beginStudioManualEditGesture(
|
|
226
|
+
sel.element,
|
|
227
|
+
kind === "rotate" ? "rotate" : "resize",
|
|
228
|
+
);
|
|
226
229
|
}
|
|
227
230
|
}
|
|
228
231
|
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
buildTextFieldChildLocator,
|
|
8
8
|
readHfId,
|
|
9
9
|
liveLayerElement,
|
|
10
|
+
domEditSelectionToFacts,
|
|
10
11
|
} from "./domEditingLayers";
|
|
11
12
|
import type { DomEditTextField } from "./domEditingTypes";
|
|
12
13
|
|
|
@@ -332,3 +333,23 @@ describe("liveLayerElement", () => {
|
|
|
332
333
|
).toBe("strip");
|
|
333
334
|
});
|
|
334
335
|
});
|
|
336
|
+
|
|
337
|
+
describe("domEditSelectionToFacts hasAudio", () => {
|
|
338
|
+
async function factsFor(html: string) {
|
|
339
|
+
document.body.innerHTML = html;
|
|
340
|
+
const node = document.body.firstElementChild;
|
|
341
|
+
if (!(node instanceof HTMLElement)) throw new Error("expected element");
|
|
342
|
+
const selection = await resolveDomEditSelection(node, opts);
|
|
343
|
+
document.body.innerHTML = "";
|
|
344
|
+
if (!selection) throw new Error("expected selection");
|
|
345
|
+
return domEditSelectionToFacts(selection);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
it("is true for an unmuted video, false for muted or data-has-audio=false", async () => {
|
|
349
|
+
expect((await factsFor(`<video id="v1"></video>`)).hasAudio).toBe(true);
|
|
350
|
+
expect((await factsFor(`<video id="v2" data-has-audio="true"></video>`)).hasAudio).toBe(true);
|
|
351
|
+
expect((await factsFor(`<video id="v3" muted></video>`)).hasAudio).toBe(false);
|
|
352
|
+
expect((await factsFor(`<video id="v4" data-has-audio="false"></video>`)).hasAudio).toBe(false);
|
|
353
|
+
expect((await factsFor(`<div id="d1"></div>`)).hasAudio).toBe(false);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { probeSourceElement } from "./probeSourceElement";
|
|
2
|
+
import { isAudibleVideoNode } from "../../player/lib/timelineElementHelpers";
|
|
2
3
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
3
4
|
import {
|
|
4
5
|
resolveEditingAffordances,
|
|
@@ -248,6 +249,7 @@ export function domEditSelectionToFacts(
|
|
|
248
249
|
hasEditableText: selection.textFields.length > 0,
|
|
249
250
|
hasTimingStart: selection.dataAttributes.start != null,
|
|
250
251
|
animationCount,
|
|
252
|
+
hasAudio: isAudibleVideoNode(selection.element),
|
|
251
253
|
};
|
|
252
254
|
}
|
|
253
255
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
3
|
+
import type { DomEditSelection } from "./domEditing";
|
|
4
|
+
import type { GestureState } from "./domEditOverlayGestures";
|
|
5
|
+
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
6
|
+
import {
|
|
7
|
+
hasStudioPendingEdits,
|
|
8
|
+
revertNewestStudioPendingEdit,
|
|
9
|
+
} from "../../utils/studioPendingEdits";
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const ref = <T>(current: T) => ({ current });
|
|
16
|
+
const pointer = (x: number, y: number) => ({
|
|
17
|
+
clientX: x,
|
|
18
|
+
clientY: y,
|
|
19
|
+
pointerId: 1,
|
|
20
|
+
button: 0,
|
|
21
|
+
preventDefault() {},
|
|
22
|
+
stopPropagation() {},
|
|
23
|
+
currentTarget: { setPointerCapture() {}, releasePointerCapture() {} },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** Drags a box without GSAP 100 px right and 60 px down; its save waits for `save`. */
|
|
27
|
+
function dragWithSaveRunning(save: Promise<void>) {
|
|
28
|
+
const element = document.createElement("div");
|
|
29
|
+
element.style.setProperty("translate", "40px 30px");
|
|
30
|
+
document.body.append(element);
|
|
31
|
+
const selection = { element, capabilities: { canApplyManualOffset: true } };
|
|
32
|
+
const handlers = createDomEditOverlayGestureHandlers({
|
|
33
|
+
selectionRef: ref(selection as unknown as DomEditSelection),
|
|
34
|
+
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
|
|
35
|
+
boxRef: ref(document.createElement("div")),
|
|
36
|
+
overlayRef: ref(null),
|
|
37
|
+
iframeRef: ref(null),
|
|
38
|
+
gestureRef: ref<GestureState | null>(null),
|
|
39
|
+
rafPausedRef: ref(false),
|
|
40
|
+
onManualDragStartRef: ref(vi.fn()),
|
|
41
|
+
onBlockedMoveRef: ref(vi.fn()),
|
|
42
|
+
onPathOffsetCommitRef: ref(vi.fn(() => save)),
|
|
43
|
+
snapGuidesRef: ref(null),
|
|
44
|
+
groupGestureRef: ref(null),
|
|
45
|
+
blockedMoveRef: ref(null),
|
|
46
|
+
setOverlayRect: vi.fn(),
|
|
47
|
+
suppressNextBoxClickRef: ref(false),
|
|
48
|
+
hoverSelectionRef: ref(null),
|
|
49
|
+
onCanvasMouseDown: vi.fn(),
|
|
50
|
+
} as never);
|
|
51
|
+
expect(handlers.startGesture("drag", pointer(10, 10) as never)).toBe(true);
|
|
52
|
+
handlers.onPointerUp(pointer(110, 70) as never);
|
|
53
|
+
return element;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
it("a drag whose save is still running can be painted back at once, and shown again", async () => {
|
|
57
|
+
let saved!: () => void;
|
|
58
|
+
const element = dragWithSaveRunning(new Promise<void>((resolve) => (saved = resolve)));
|
|
59
|
+
const moved = element.style.getPropertyValue("translate");
|
|
60
|
+
expect(moved).not.toBe("40px 30px");
|
|
61
|
+
|
|
62
|
+
const showAgain = revertNewestStudioPendingEdit();
|
|
63
|
+
expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
|
|
64
|
+
expect(revertNewestStudioPendingEdit()).toBeNull();
|
|
65
|
+
showAgain!();
|
|
66
|
+
expect(element.style.getPropertyValue("translate")).toBe(moved);
|
|
67
|
+
|
|
68
|
+
saved();
|
|
69
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
70
|
+
expect(revertNewestStudioPendingEdit()).toBeNull();
|
|
71
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { expect, it } from "vitest";
|
|
3
|
-
import {
|
|
3
|
+
import { importedFontFaceCssFor, injectPreviewImportedFont } from "../../utils/studioFontHelpers";
|
|
4
4
|
import { importedFontFaceCss, type ImportedFontAsset } from "./fontAssets";
|
|
5
5
|
|
|
6
6
|
const face = (file: string, extra: Partial<ImportedFontAsset> = {}): ImportedFontAsset => ({
|
|
@@ -30,24 +30,25 @@ it("a face says which weight and style it draws, a variable file its weight rang
|
|
|
30
30
|
);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
it("two weights of one family are both
|
|
33
|
+
it("two weights of one family are both drawn in the preview", () => {
|
|
34
34
|
const bold = face("Poppins-Bold.ttf", { weight: "700", style: "normal" });
|
|
35
35
|
const regular = face("Poppins-Regular.ttf", { weight: "400", style: "normal" });
|
|
36
|
-
const html = "<html><head></head><body></body></html>";
|
|
37
|
-
const saved = ensureImportedFontFace(
|
|
38
|
-
ensureImportedFontFace(html, bold, "index.html"),
|
|
39
|
-
regular,
|
|
40
|
-
"index.html",
|
|
41
|
-
);
|
|
42
|
-
expect(saved).toContain("font-weight: 700;");
|
|
43
|
-
expect(saved).toContain("font-weight: 400;");
|
|
44
|
-
expect(ensureImportedFontFace(saved, bold, "index.html")).toBe(saved);
|
|
45
36
|
injectPreviewImportedFont(document, bold);
|
|
46
37
|
injectPreviewImportedFont(document, regular);
|
|
47
38
|
injectPreviewImportedFont(document, bold);
|
|
48
39
|
expect(document.head.querySelectorAll("style")).toHaveLength(2);
|
|
49
40
|
});
|
|
50
41
|
|
|
42
|
+
it("the saved rule points at the font from the file that uses it", () => {
|
|
43
|
+
const bold = face("Poppins-Bold.ttf");
|
|
44
|
+
expect(importedFontFaceCssFor(bold, "index.html")).toContain(
|
|
45
|
+
'url("assets/fonts/Poppins/Poppins-Bold.ttf")',
|
|
46
|
+
);
|
|
47
|
+
expect(importedFontFaceCssFor(bold, "compositions/intro.html")).toContain(
|
|
48
|
+
'url("../assets/fonts/Poppins/Poppins-Bold.ttf")',
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
51
52
|
it("a weight the browser would drop is left out, and a family cannot close the style block", () => {
|
|
52
53
|
for (const weight of ["0", "5000", "bold", "700;}"])
|
|
53
54
|
expect(importedFontFaceCss(face("x.ttf", { weight }))).not.toContain("font-weight");
|
|
@@ -56,14 +57,3 @@ it("a weight the browser would drop is left out, and a family cannot close the s
|
|
|
56
57
|
"</style>",
|
|
57
58
|
);
|
|
58
59
|
});
|
|
59
|
-
|
|
60
|
-
it("a file name with a replacement pattern is saved as written, not expanded into the page", () => {
|
|
61
|
-
const html = "<html><head></head><body>rest</body></html>";
|
|
62
|
-
const odd = { family: "Cash", path: "assets/Cash$'Font.ttf", url: "/Cash$'Font.ttf" };
|
|
63
|
-
const saved = ensureImportedFontFace(html, odd, "index.html");
|
|
64
|
-
expect(saved).toContain("Cash$'Font.ttf");
|
|
65
|
-
expect(saved.match(/<body>/g)).toHaveLength(1);
|
|
66
|
-
const again = ensureImportedFontFace(saved, { ...odd, path: "assets/Cash$&.ttf" }, "index.html");
|
|
67
|
-
expect(again).toContain("Cash$&.ttf");
|
|
68
|
-
expect(again.match(/<body>/g)).toHaveLength(1);
|
|
69
|
-
});
|
|
@@ -72,18 +72,22 @@ function pointer(target: Element, type: string, x: number, y: number) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/** The real player with a two-layer preview, and the real overlay editing it. */
|
|
75
|
-
function mountEditor(
|
|
75
|
+
function mountEditor(
|
|
76
|
+
group: boolean,
|
|
77
|
+
saving: { landed: Promise<void> } = { landed: Promise.resolve() },
|
|
78
|
+
) {
|
|
76
79
|
file = { title: "", sub: "" };
|
|
77
80
|
const live = makePreview(TWO_LAYERS);
|
|
78
81
|
player = mountPlayerWithPreview(live);
|
|
79
82
|
const selections = ["title", "sub"].map((id) => makeSelection(id, byId(live, id)));
|
|
83
|
+
for (const s of selections) s.capabilities.canApplyManualRotation = true;
|
|
84
|
+
const saveStyle = (sel: DomEditSelection | null, style: string) =>
|
|
85
|
+
saving.landed.then(() => void (file = { ...file, [sel?.id ?? "title"]: style }));
|
|
80
86
|
layout.group = group
|
|
81
87
|
? selections.map((s) => ({ key: s.id, selection: s, element: s.element, rect: RECT }))
|
|
82
88
|
: [];
|
|
83
|
-
const write = (sel: DomEditSelection, next: { x: number; y: number }) =>
|
|
84
|
-
|
|
85
|
-
return Promise.resolve();
|
|
86
|
-
};
|
|
89
|
+
const write = (sel: DomEditSelection, next: { x: number; y: number }) =>
|
|
90
|
+
saveStyle(sel, `translate: ${next.x}px ${next.y}px`);
|
|
87
91
|
const onPathOffsetCommit = vi.fn(write);
|
|
88
92
|
const onGroupPathOffsetCommit = vi.fn(
|
|
89
93
|
async (updates: { selection: DomEditSelection; next: { x: number; y: number } }[]) => {
|
|
@@ -109,8 +113,9 @@ function mountEditor(group: boolean) {
|
|
|
109
113
|
onBlockedMove={() => undefined}
|
|
110
114
|
onPathOffsetCommit={onPathOffsetCommit}
|
|
111
115
|
onGroupPathOffsetCommit={onGroupPathOffsetCommit}
|
|
112
|
-
onBoxSizeCommit={() =>
|
|
113
|
-
onRotationCommit={() =>
|
|
116
|
+
onBoxSizeCommit={(sel, size) => saveStyle(sel, `width: ${size.width}px`)}
|
|
117
|
+
onRotationCommit={(sel, next) => saveStyle(sel, `rotate: ${next.angle}deg`)}
|
|
118
|
+
onStyleCommit={(property, value) => saveStyle(selections[0]!, `${property}: ${value}`)}
|
|
114
119
|
/>
|
|
115
120
|
</PreviewReadOnlyProvider>,
|
|
116
121
|
),
|
|
@@ -177,18 +182,70 @@ describe("a reload during a drag", () => {
|
|
|
177
182
|
},
|
|
178
183
|
);
|
|
179
184
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
185
|
+
const handle = (overlay: HTMLElement, selector: string) => overlay.querySelector(selector)!;
|
|
186
|
+
const corner = (overlay: HTMLElement) =>
|
|
187
|
+
[...overlay.querySelectorAll<HTMLElement>("div.h-4.w-4")].reduce((a, b) =>
|
|
188
|
+
parseFloat(b.style.left) + parseFloat(b.style.top) >
|
|
189
|
+
parseFloat(a.style.left) + parseFloat(a.style.top)
|
|
190
|
+
? b
|
|
191
|
+
: a,
|
|
192
|
+
);
|
|
193
|
+
// Each edit presses at `from`, drags to `to`, and releases on `on`.
|
|
194
|
+
const edits = {
|
|
195
|
+
move: (o: HTMLElement) => ({ press: handle(o, BOX), on: o, from: [150, 150], to: [190, 170] }),
|
|
196
|
+
resize: (o: HTMLElement) => ({ press: corner(o), on: o, from: [300, 200], to: [340, 220] }),
|
|
197
|
+
rotate: (o: HTMLElement) => ({
|
|
198
|
+
press: handle(o, '[aria-label="Rotate selection"]'),
|
|
199
|
+
on: o,
|
|
200
|
+
from: [200, 80],
|
|
201
|
+
to: [270, 150],
|
|
202
|
+
}),
|
|
203
|
+
crop: (o: HTMLElement) => {
|
|
204
|
+
const edge = handle(o, '[aria-label="Crop left"]');
|
|
205
|
+
return { press: edge, on: edge, from: [100, 150], to: [140, 150] };
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
function drag(edit: { press: Element; on: Element; from: number[]; to: number[] }) {
|
|
210
|
+
pointer(edit.press, "pointerdown", edit.from[0]!, edit.from[1]!);
|
|
211
|
+
pointer(edit.on, "pointermove", edit.to[0]!, edit.to[1]!);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
it.each(Object.keys(edits) as (keyof typeof edits)[])(
|
|
215
|
+
"that paints after a %s shows it from a fresh load, not the file before it",
|
|
216
|
+
async (kind) => {
|
|
217
|
+
const { live, overlay } = mountEditor(false);
|
|
218
|
+
const edit = edits[kind](overlay);
|
|
219
|
+
drag(edit);
|
|
220
|
+
act(() => api().refreshPlayer());
|
|
221
|
+
const beforeDrop = served("?_t=1");
|
|
222
|
+
pointer(edit.on, "pointerup", edit.to[0]!, edit.to[1]!);
|
|
223
|
+
await settle();
|
|
224
|
+
expect(file.title, "the edit saved").not.toBe("");
|
|
225
|
+
|
|
226
|
+
const requested = await paintShadow(api, beforeDrop);
|
|
227
|
+
expect(api().iframeRef.current, "the file before the edit").toBe(live);
|
|
228
|
+
const fresh = served("?_t=2");
|
|
229
|
+
expect(await paintShadow(api, fresh)).toBeGreaterThan(requested);
|
|
230
|
+
expect(byId(fresh, "title").getAttribute("style")).toBe(file.title);
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
it("requested while the drop is still saving shows the drop from a fresh load", async () => {
|
|
235
|
+
let land: () => void = () => {};
|
|
236
|
+
const { live, overlay } = mountEditor(false, { landed: new Promise((r) => (land = r)) });
|
|
237
|
+
const edit = edits.move(overlay);
|
|
238
|
+
drag(edit);
|
|
239
|
+
pointer(edit.on, "pointerup", edit.to[0]!, edit.to[1]!);
|
|
240
|
+
await settle();
|
|
184
241
|
act(() => api().refreshPlayer());
|
|
185
|
-
const
|
|
186
|
-
|
|
242
|
+
const beforeSave = served("?_t=1");
|
|
243
|
+
await act(async () => land());
|
|
187
244
|
await settle();
|
|
188
245
|
expect(file.title).toBe("translate: 40px 20px");
|
|
189
246
|
|
|
190
|
-
const requested = await paintShadow(api,
|
|
191
|
-
expect(api().iframeRef.current, "the file before the
|
|
247
|
+
const requested = await paintShadow(api, beforeSave);
|
|
248
|
+
expect(api().iframeRef.current, "the file before the save").toBe(live);
|
|
192
249
|
const fresh = served("?_t=2");
|
|
193
250
|
expect(await paintShadow(api, fresh)).toBeGreaterThan(requested);
|
|
194
251
|
expect(byId(fresh, "title").style.getPropertyValue("translate")).toBe("40px 20px");
|
|
@@ -222,24 +279,30 @@ describe("every way a drag ends without a drop clears its mark and promotes the
|
|
|
222
279
|
);
|
|
223
280
|
});
|
|
224
281
|
|
|
225
|
-
it(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
282
|
+
it.each([
|
|
283
|
+
["one layer", false],
|
|
284
|
+
["a group", true],
|
|
285
|
+
])(
|
|
286
|
+
"a press on %s that throws before the drag is armed leaves no mark behind",
|
|
287
|
+
async (_, group) => {
|
|
288
|
+
const editor = mountEditor(group);
|
|
289
|
+
HTMLElement.prototype.setPointerCapture = () => {
|
|
290
|
+
throw new Error("capture refused");
|
|
291
|
+
};
|
|
292
|
+
const swallow = (event: ErrorEvent) => event.preventDefault();
|
|
293
|
+
window.addEventListener("error", swallow);
|
|
294
|
+
try {
|
|
295
|
+
pointer(editor.box, "pointerdown", 150, 150);
|
|
296
|
+
} catch {
|
|
297
|
+
// React may rethrow the handler's error; either way the mark must be gone.
|
|
298
|
+
} finally {
|
|
299
|
+
window.removeEventListener("error", swallow);
|
|
300
|
+
}
|
|
301
|
+
expect(marked(editor.live.contentDocument!)).toHaveLength(0);
|
|
302
|
+
const { shadow } = await reloadMidGesture();
|
|
303
|
+
expect(api().iframeRef.current).toBe(shadow);
|
|
304
|
+
},
|
|
305
|
+
);
|
|
243
306
|
});
|
|
244
307
|
|
|
245
308
|
describe("an inline text edit", () => {
|