@hyperframes/studio 0.8.105 → 0.8.107
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-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
- package/dist/assets/index-CM1xA_jD.js +394 -0
- package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
- package/dist/assets/index-lrT-gfHa.css +1 -0
- package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
- package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
- package/dist/chunk-4OZC426H.js.map +1 -0
- package/dist/index.d.ts +381 -14
- package/dist/index.html +11 -2
- package/dist/index.js +9974 -9045
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +9 -9
- package/src/App.tsx +5 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +2 -1
- package/src/components/StudioHeader.dom.test.tsx +31 -9
- package/src/components/StudioHeader.tsx +4 -1
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/ThemeToggle.tsx +99 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- package/src/components/editor/DomEditOverlay.tsx +31 -5
- 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/SourceEditor.tsx +2 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
- package/src/components/editor/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +58 -2
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +4 -1
- package/src/components/editor/marqueeCommit.test.tsx +69 -1
- package/src/components/editor/marqueeCommit.ts +6 -1
- package/src/components/editor/mediaAudioEdits.test.ts +212 -0
- package/src/components/editor/mediaAudioEdits.ts +253 -0
- package/src/components/editor/overlayFrameLoop.test.ts +27 -0
- package/src/components/editor/overlayFrameLoop.ts +21 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.test.tsx +80 -3
- package/src/components/editor/propertyPanelFont.tsx +69 -46
- package/src/components/editor/propertyPanelHelpers.ts +1 -2
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/studioCodeTheme.test.ts +20 -0
- package/src/components/editor/studioCodeTheme.ts +99 -0
- package/src/components/editor/useDomEditNudge.test.tsx +165 -2
- package/src/components/editor/useDomEditNudge.ts +43 -25
- package/src/components/editor/useInlineTextEditing.tsx +1 -1
- package/src/components/editor/useMotionPathData.test.tsx +64 -0
- package/src/components/editor/useMotionPathData.ts +5 -1
- 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/hooks/appHotkeysDispatch.ts +2 -1
- 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/previewPromotionSelection.test.tsx +180 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +49 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +4 -5
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useClipboard.source.test.ts +99 -19
- package/src/hooks/useClipboard.ts +145 -124
- 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.test.tsx +76 -72
- package/src/hooks/useDomEditSession.ts +9 -46
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
- 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/useGsapTweenCache.ts +1 -2
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +21 -5
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/useLivePreviewIframe.ts +18 -0
- package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
- package/src/hooks/usePreviewPersistence.ts +8 -5
- package/src/hooks/useRenderClipContent.ts +3 -3
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +9 -0
- package/src/livePreviewIframeExport.test.tsx +33 -0
- package/src/main.tsx +4 -1
- package/src/player/components/AudioWaveform.test.tsx +36 -15
- package/src/player/components/AudioWaveform.tsx +24 -7
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.test.tsx +38 -0
- package/src/player/components/TimelineClip.tsx +14 -6
- package/src/player/components/TimelineClipFades.test.tsx +376 -74
- package/src/player/components/TimelineClipFades.tsx +368 -137
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useTimelineKeyboardActor.ts +1 -1
- package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/hooks/useShadowPreviewReload.ts +72 -5
- package/src/player/lib/automationStoreSync.test.ts +18 -1
- package/src/player/lib/automationStoreSync.ts +29 -17
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +16 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- package/src/player/sceneSwap.ts +18 -0
- package/src/styles/components.css +26 -0
- package/src/styles/studio.css +6 -0
- package/src/styles/theme.css +10 -0
- package/src/utils/clipboardPayload.ts +10 -1
- 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/sourcePatcher.ts +1 -1
- 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 +63 -0
- package/src/utils/studioPendingEdits.ts +37 -7
- package/src/utils/studioTheme.test.tsx +141 -0
- package/src/utils/studioTheme.ts +34 -0
- package/src/utils/studioUiPreferences.ts +3 -0
- package/src/utils/typingTarget.test.ts +21 -1
- package/src/utils/typingTarget.ts +8 -0
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/assets/index-Cre9X4RS.css +0 -1
- package/dist/assets/index-DEDhPmPD.js +0 -399
- package/dist/chunk-QADHJFDF.js.map +0 -1
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { makeSelection } from "../../hooks/domSelectionTestHarness";
|
|
7
|
+
import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
|
|
8
|
+
import {
|
|
9
|
+
makePreview,
|
|
10
|
+
mountPlayerWithPreview,
|
|
11
|
+
paintShadow,
|
|
12
|
+
resetPlayerStore,
|
|
13
|
+
type TimelinePlayerApi,
|
|
14
|
+
} from "../../player/hooks/timelinePlayerTestHarness";
|
|
15
|
+
import type { DomEditSelection } from "./domEditing";
|
|
16
|
+
import "./domEditOverlayTestMocks";
|
|
17
|
+
import { DomEditOverlay } from "./DomEditOverlay";
|
|
18
|
+
import { PreviewReadOnlyProvider } from "./previewReadOnlyContext";
|
|
19
|
+
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "./manualEditsTypes";
|
|
20
|
+
|
|
21
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
22
|
+
|
|
23
|
+
const layout = vi.hoisted(() => ({
|
|
24
|
+
group: [] as unknown[],
|
|
25
|
+
rect: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
|
|
26
|
+
}));
|
|
27
|
+
vi.mock("./useDomEditOverlayRects", () => ({
|
|
28
|
+
useDomEditOverlayRects: () => {
|
|
29
|
+
const { rect, group } = layout;
|
|
30
|
+
const groupOverlayItemsRef = { current: group };
|
|
31
|
+
const noop = () => undefined;
|
|
32
|
+
return {
|
|
33
|
+
overlayRect: rect,
|
|
34
|
+
overlayRectRef: { current: rect },
|
|
35
|
+
groupOverlayItems: group,
|
|
36
|
+
groupOverlayItemsRef,
|
|
37
|
+
hoverRect: null,
|
|
38
|
+
childRects: [],
|
|
39
|
+
setOverlayRect: noop,
|
|
40
|
+
setGroupOverlayItems: noop,
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
vi.mock("../../utils/gsapSoftReload", async (importOriginal) => ({
|
|
45
|
+
...(await importOriginal<typeof import("../../utils/gsapSoftReload")>()),
|
|
46
|
+
ensureMotionPathPluginLoaded: vi.fn(),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const BOX = '[data-dom-edit-selection-box="true"]';
|
|
50
|
+
const RECT = layout.rect;
|
|
51
|
+
const TWO_LAYERS = '<h1 id="title">Title</h1><p id="sub">Sub</p>';
|
|
52
|
+
|
|
53
|
+
/** What the server serves: the file as the last commit left it. */
|
|
54
|
+
let file = { title: "", sub: "" };
|
|
55
|
+
const served = (query: string) =>
|
|
56
|
+
makePreview(
|
|
57
|
+
`<h1 id="title" style="${file.title}">Title</h1><p id="sub" style="${file.sub}">Sub</p>`,
|
|
58
|
+
query,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
let overlayRoot: Root;
|
|
62
|
+
let player: ReturnType<typeof mountPlayerWithPreview>;
|
|
63
|
+
|
|
64
|
+
const marked = (doc: Document) => doc.querySelectorAll(`[${STUDIO_MANUAL_EDIT_GESTURE_ATTR}]`);
|
|
65
|
+
const byId = (iframe: HTMLIFrameElement | null, id: string) =>
|
|
66
|
+
iframe!.contentDocument!.getElementById(id) as HTMLElement;
|
|
67
|
+
const api = (): TimelinePlayerApi => player.getApi();
|
|
68
|
+
|
|
69
|
+
function pointer(target: Element, type: string, x: number, y: number) {
|
|
70
|
+
const init = { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: x, clientY: y };
|
|
71
|
+
act(() => void target.dispatchEvent(new PointerEvent(type, init)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The real player with a two-layer preview, and the real overlay editing it. */
|
|
75
|
+
function mountEditor(
|
|
76
|
+
group: boolean,
|
|
77
|
+
saving: { landed: Promise<void> } = { landed: Promise.resolve() },
|
|
78
|
+
) {
|
|
79
|
+
file = { title: "", sub: "" };
|
|
80
|
+
const live = makePreview(TWO_LAYERS);
|
|
81
|
+
player = mountPlayerWithPreview(live);
|
|
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 }));
|
|
86
|
+
layout.group = group
|
|
87
|
+
? selections.map((s) => ({ key: s.id, selection: s, element: s.element, rect: RECT }))
|
|
88
|
+
: [];
|
|
89
|
+
const write = (sel: DomEditSelection, next: { x: number; y: number }) =>
|
|
90
|
+
saveStyle(sel, `translate: ${next.x}px ${next.y}px`);
|
|
91
|
+
const onPathOffsetCommit = vi.fn(write);
|
|
92
|
+
const onGroupPathOffsetCommit = vi.fn(
|
|
93
|
+
async (updates: { selection: DomEditSelection; next: { x: number; y: number } }[]) => {
|
|
94
|
+
for (const { selection, next } of updates) await write(selection, next);
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
const host = document.body.appendChild(document.createElement("div"));
|
|
98
|
+
overlayRoot = createRoot(host);
|
|
99
|
+
const render = (readOnly: boolean) =>
|
|
100
|
+
act(() =>
|
|
101
|
+
overlayRoot.render(
|
|
102
|
+
<PreviewReadOnlyProvider readOnly={readOnly}>
|
|
103
|
+
<DomEditOverlay
|
|
104
|
+
iframeRef={api().iframeRef}
|
|
105
|
+
activeCompositionPath={null}
|
|
106
|
+
selection={group ? null : selections[0]!}
|
|
107
|
+
groupSelections={group ? selections : []}
|
|
108
|
+
hoverSelection={null}
|
|
109
|
+
onCanvasMouseDown={() => undefined}
|
|
110
|
+
onCanvasPointerMove={() => Promise.resolve(null)}
|
|
111
|
+
onCanvasPointerLeave={() => undefined}
|
|
112
|
+
onSelectionChange={() => undefined}
|
|
113
|
+
onBlockedMove={() => undefined}
|
|
114
|
+
onPathOffsetCommit={onPathOffsetCommit}
|
|
115
|
+
onGroupPathOffsetCommit={onGroupPathOffsetCommit}
|
|
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}`)}
|
|
119
|
+
/>
|
|
120
|
+
</PreviewReadOnlyProvider>,
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
render(false);
|
|
124
|
+
const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLElement;
|
|
125
|
+
return { live, overlay, box: overlay.querySelector(BOX)!, onPathOffsetCommit, render };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A reload whose shadow paints while the gesture is still live. */
|
|
129
|
+
async function reloadMidGesture() {
|
|
130
|
+
act(() => api().refreshPlayer());
|
|
131
|
+
const shadow = served("?_t=1");
|
|
132
|
+
const gen = await paintShadow(api, shadow);
|
|
133
|
+
return { shadow, gen };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function settle() {
|
|
137
|
+
await act(async () => {
|
|
138
|
+
for (let tick = 0; tick < 10; tick++) await Promise.resolve();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
HTMLElement.prototype.setPointerCapture = () => undefined;
|
|
144
|
+
HTMLElement.prototype.releasePointerCapture = () => undefined;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
act(() => overlayRoot?.unmount());
|
|
149
|
+
act(() => player?.root.unmount());
|
|
150
|
+
layout.group = [];
|
|
151
|
+
document.body.innerHTML = "";
|
|
152
|
+
resetPlayerStore();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("a reload during a drag", () => {
|
|
156
|
+
it.each([
|
|
157
|
+
["one layer", false],
|
|
158
|
+
["a group", true],
|
|
159
|
+
])(
|
|
160
|
+
"that paints before the drop of %s waits, then shows the drop from a fresh load",
|
|
161
|
+
async (_, group) => {
|
|
162
|
+
const { live, overlay, box } = mountEditor(group);
|
|
163
|
+
pointer(box, "pointerdown", 150, 150);
|
|
164
|
+
pointer(overlay, "pointermove", 170, 160);
|
|
165
|
+
pointer(overlay, "pointermove", 190, 170);
|
|
166
|
+
const held = await reloadMidGesture();
|
|
167
|
+
expect(api().iframeRef.current, "promoted under the pointer").toBe(live);
|
|
168
|
+
|
|
169
|
+
pointer(overlay, "pointerup", 190, 170);
|
|
170
|
+
await settle();
|
|
171
|
+
expect(byId(live, "title").style.getPropertyValue("translate")).toBe("40px 20px");
|
|
172
|
+
expect(file.title).toBe("translate: 40px 20px");
|
|
173
|
+
if (group) expect(file.sub).toBe("translate: 40px 20px");
|
|
174
|
+
expect(marked(live.contentDocument!)).toHaveLength(0);
|
|
175
|
+
expect(api().iframeRef.current, "the shadow loaded before the drop").toBe(live);
|
|
176
|
+
|
|
177
|
+
const fresh = served("?_t=2");
|
|
178
|
+
expect(await paintShadow(api, fresh)).toBeGreaterThan(held.gen);
|
|
179
|
+
expect(api().iframeRef.current).toBe(fresh);
|
|
180
|
+
expect(byId(fresh, "title").style.getPropertyValue("translate")).toBe("40px 20px");
|
|
181
|
+
if (group) expect(byId(fresh, "sub").style.getPropertyValue("translate")).toBe("40px 20px");
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
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();
|
|
241
|
+
act(() => api().refreshPlayer());
|
|
242
|
+
const beforeSave = served("?_t=1");
|
|
243
|
+
await act(async () => land());
|
|
244
|
+
await settle();
|
|
245
|
+
expect(file.title).toBe("translate: 40px 20px");
|
|
246
|
+
|
|
247
|
+
const requested = await paintShadow(api, beforeSave);
|
|
248
|
+
expect(api().iframeRef.current, "the file before the save").toBe(live);
|
|
249
|
+
const fresh = served("?_t=2");
|
|
250
|
+
expect(await paintShadow(api, fresh)).toBeGreaterThan(requested);
|
|
251
|
+
expect(byId(fresh, "title").style.getPropertyValue("translate")).toBe("40px 20px");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
type Editor = ReturnType<typeof mountEditor>;
|
|
256
|
+
|
|
257
|
+
describe("every way a drag ends without a drop clears its mark and promotes the held reload", () => {
|
|
258
|
+
it.each([
|
|
259
|
+
["pointercancel", (e: Editor) => pointer(e.overlay, "pointercancel", 0, 0)],
|
|
260
|
+
["lostpointercapture", (e: Editor) => pointer(e.box, "lostpointercapture", 0, 0)],
|
|
261
|
+
["window blur", () => act(() => void window.dispatchEvent(new Event("blur")))],
|
|
262
|
+
["a switch to read-only", (e: Editor) => e.render(true)],
|
|
263
|
+
["overlay unmount", () => act(() => overlayRoot.unmount())],
|
|
264
|
+
])("%s", async (_, end) => {
|
|
265
|
+
const editor = mountEditor(false);
|
|
266
|
+
pointer(editor.box, "pointerdown", 150, 150);
|
|
267
|
+
pointer(editor.overlay, "pointermove", 190, 170);
|
|
268
|
+
expect(marked(editor.live.contentDocument!)).toHaveLength(1);
|
|
269
|
+
const held = await reloadMidGesture();
|
|
270
|
+
expect(api().iframeRef.current).toBe(editor.live);
|
|
271
|
+
|
|
272
|
+
end(editor);
|
|
273
|
+
await settle();
|
|
274
|
+
expect(marked(editor.live.contentDocument!)).toHaveLength(0);
|
|
275
|
+
expect(byId(editor.live, "title").style.getPropertyValue("translate")).not.toBe("40px 20px");
|
|
276
|
+
expect(editor.onPathOffsetCommit).not.toHaveBeenCalled();
|
|
277
|
+
expect(api().iframeRef.current, "nothing was saved, so the held copy is current").toBe(
|
|
278
|
+
held.shadow,
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
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
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("an inline text edit", () => {
|
|
309
|
+
it("holds a reload while open, and the reload after it shows the saved text", async () => {
|
|
310
|
+
const live = makePreview(TWO_LAYERS);
|
|
311
|
+
player = mountPlayerWithPreview(live);
|
|
312
|
+
let land: () => void = () => {};
|
|
313
|
+
const onCommit = vi.fn(() => new Promise<void>((resolve) => (land = resolve)));
|
|
314
|
+
let controls: ReturnType<typeof useInlineTextEdit> | null = null;
|
|
315
|
+
function Editor() {
|
|
316
|
+
controls = useInlineTextEdit({ onCommit });
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
overlayRoot = createRoot(document.body.appendChild(document.createElement("div")));
|
|
320
|
+
act(() => overlayRoot.render(<Editor />));
|
|
321
|
+
act(() => void controls!.start(byId(live, "title")));
|
|
322
|
+
const held = await reloadMidGesture();
|
|
323
|
+
expect(api().iframeRef.current).toBe(live);
|
|
324
|
+
|
|
325
|
+
act(() => controls!.commit());
|
|
326
|
+
await settle();
|
|
327
|
+
expect(onCommit).toHaveBeenCalledTimes(1);
|
|
328
|
+
expect(marked(live.contentDocument!), "closed, though the save is still out").toHaveLength(0);
|
|
329
|
+
expect(api().iframeRef.current, "the held copy predates the text").toBe(live);
|
|
330
|
+
expect(api().previewSlots.find((slot) => slot.role === "shadow")?.gen).toBe(held.gen);
|
|
331
|
+
|
|
332
|
+
await act(async () => land());
|
|
333
|
+
await settle();
|
|
334
|
+
const afterSave = served("?_t=2");
|
|
335
|
+
expect(await paintShadow(api, afterSave)).toBeGreaterThan(held.gen);
|
|
336
|
+
expect(api().iframeRef.current).toBe(afterSave);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export const gsapRendersTransform = (el: Element) =>
|
|
2
|
+
!!(el as { _gsap?: { renderTransform?: unknown } })._gsap?.renderTransform;
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Checks whether GSAP actively animates one or more CSS/GSAP properties on
|
|
3
6
|
* the given element by inspecting all registered `__timelines`.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// fallow-ignore-file code-duplication
|
|
1
2
|
import { describe, expect, it, vi } from "vitest";
|
|
2
3
|
import { Window } from "happy-dom";
|
|
3
4
|
import {
|
|
@@ -285,7 +286,7 @@ describe("studio manual edits", () => {
|
|
|
285
286
|
expect(set).not.toHaveBeenCalled();
|
|
286
287
|
expect(tickerTick).not.toHaveBeenCalled();
|
|
287
288
|
|
|
288
|
-
beginStudioManualEditGesture(card);
|
|
289
|
+
beginStudioManualEditGesture(card, "move");
|
|
289
290
|
applyStudioPathOffsetDraft(card, { x: 35, y: -6 });
|
|
290
291
|
|
|
291
292
|
expect(readStudioPathOffset(card)).toEqual({ x: 35, y: -6 });
|
|
@@ -395,8 +396,8 @@ describe("studio manual edits", () => {
|
|
|
395
396
|
const card = document.getElementById("card") as HTMLElement;
|
|
396
397
|
|
|
397
398
|
applyStudioPathOffset(card, { x: 40, y: 24 });
|
|
398
|
-
const firstToken = beginStudioManualEditGesture(card);
|
|
399
|
-
const secondToken = beginStudioManualEditGesture(card);
|
|
399
|
+
const firstToken = beginStudioManualEditGesture(card, "move");
|
|
400
|
+
const secondToken = beginStudioManualEditGesture(card, "move");
|
|
400
401
|
endStudioManualEditGesture(card, firstToken);
|
|
401
402
|
|
|
402
403
|
// Gesture still active — offset should remain
|
|
@@ -35,20 +35,76 @@ import {
|
|
|
35
35
|
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
|
|
36
36
|
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
|
37
37
|
import { roundTo3, roundToLayoutPx } from "../../utils/rounding";
|
|
38
|
+
import { BOX_SIZE_STYLE_PROPS } from "./manualEditsDomPatches";
|
|
38
39
|
|
|
39
40
|
/* ── Gesture tracking ─────────────────────────────────────────────── */
|
|
40
41
|
let studioManualEditGestureId = 0;
|
|
41
42
|
|
|
42
|
-
export
|
|
43
|
+
export type StudioGestureDraws = "move" | "resize" | "rotate" | "edit";
|
|
44
|
+
const MOVE_DRAWS = ["translate", STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP];
|
|
45
|
+
const GESTURE_DRAWS: Record<StudioGestureDraws, readonly string[]> = {
|
|
46
|
+
move: MOVE_DRAWS,
|
|
47
|
+
resize: [...MOVE_DRAWS, STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP, ...BOX_SIZE_STYLE_PROPS],
|
|
48
|
+
rotate: ["rotate", "transform", "transform-origin", "display", STUDIO_ROTATION_PROP],
|
|
49
|
+
edit: [],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function beginStudioManualEditGesture(
|
|
53
|
+
element: HTMLElement,
|
|
54
|
+
draws: StudioGestureDraws,
|
|
55
|
+
): string {
|
|
43
56
|
studioManualEditGestureId += 1;
|
|
44
|
-
const token = `gesture-${studioManualEditGestureId}`;
|
|
57
|
+
const token = `gesture-${studioManualEditGestureId}:${draws}`;
|
|
45
58
|
element.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, token);
|
|
46
59
|
return token;
|
|
47
60
|
}
|
|
48
61
|
|
|
62
|
+
const GESTURE_ENDED = "hf-manual-edit-gesture-ended";
|
|
63
|
+
|
|
49
64
|
export function endStudioManualEditGesture(element: HTMLElement, token?: string): void {
|
|
50
65
|
if (token && element.getAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR) !== token) return;
|
|
66
|
+
if (!element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
|
|
51
67
|
element.removeAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
|
|
68
|
+
const doc = element.ownerDocument;
|
|
69
|
+
doc.dispatchEvent(new (doc.defaultView?.Event ?? Event)(GESTURE_ENDED));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isStudioManualEditGestureLiveIn(doc: Document): boolean {
|
|
73
|
+
return doc.querySelector(`[${STUDIO_MANUAL_EDIT_GESTURE_ATTR}]`) !== null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Runs `run` once the last gesture in `doc` ends; the returned function stops waiting. */
|
|
77
|
+
export function afterStudioManualEditGestures(doc: Document, run: () => void): () => void {
|
|
78
|
+
const onEnded = () => {
|
|
79
|
+
if (isStudioManualEditGestureLiveIn(doc)) return;
|
|
80
|
+
stop();
|
|
81
|
+
run();
|
|
82
|
+
};
|
|
83
|
+
const stop = () => doc.removeEventListener(GESTURE_ENDED, onEnded);
|
|
84
|
+
doc.addEventListener(GESTURE_ENDED, onEnded);
|
|
85
|
+
return stop;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const gestureSaves = new WeakMap<Document, number>();
|
|
89
|
+
|
|
90
|
+
export function studioManualEditSavesIn(doc: Document): number {
|
|
91
|
+
return gestureSaves.get(doc) ?? 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Runs a gesture's save, counted as it starts and as it settles: a reload requested before shows the old file. */
|
|
95
|
+
export function countStudioManualEditSave<R>(element: HTMLElement, save: () => R): R {
|
|
96
|
+
const doc = element.ownerDocument;
|
|
97
|
+
const count = () => void gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
|
|
98
|
+
count();
|
|
99
|
+
const result = save();
|
|
100
|
+
void Promise.resolve(result).then(count, count);
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function studioGestureDraws(element: Element): readonly string[] | null {
|
|
105
|
+
const token = element.getAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
|
|
106
|
+
if (token === null) return null;
|
|
107
|
+
return GESTURE_DRAWS[token.split(":")[1] as StudioGestureDraws] ?? [];
|
|
52
108
|
}
|
|
53
109
|
|
|
54
110
|
function isStudioManualEditGestureActive(element: HTMLElement): boolean {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
2
|
+
import { gsapRendersTransform } from "./gsapAnimatesProperty";
|
|
2
3
|
import {
|
|
3
4
|
STUDIO_OFFSET_X_PROP,
|
|
4
5
|
STUDIO_OFFSET_Y_PROP,
|
|
@@ -127,7 +128,10 @@ const BOX_SIZE_ORIG_ATTRS: ReadonlyArray<[string, string]> = [
|
|
|
127
128
|
export function buildBoxSizePatches(element: HTMLElement): PatchOperation[] {
|
|
128
129
|
const ops: PatchOperation[] = [];
|
|
129
130
|
collectInlineStyleOps(element, [STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP], ops);
|
|
130
|
-
|
|
131
|
+
const withoutGsapScaleMask = gsapRendersTransform(element)
|
|
132
|
+
? BOX_SIZE_STYLE_PROPS.filter((prop) => prop !== "scale")
|
|
133
|
+
: BOX_SIZE_STYLE_PROPS;
|
|
134
|
+
collectInlineStyleOps(element, withoutGsapScaleMask, ops);
|
|
131
135
|
ops.push({ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" });
|
|
132
136
|
collectAttributeOps(
|
|
133
137
|
element,
|
|
@@ -356,7 +356,10 @@ export function createManualOffsetDragMember(input: {
|
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
const initialPathOffset = captureStudioPathOffset(input.element);
|
|
359
|
-
const gestureToken = beginStudioManualEditGesture(
|
|
359
|
+
const gestureToken = beginStudioManualEditGesture(
|
|
360
|
+
input.element,
|
|
361
|
+
input.gesture === "resize" ? "resize" : "move",
|
|
362
|
+
);
|
|
360
363
|
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset, {
|
|
361
364
|
scaleX: input.rect.editScaleX,
|
|
362
365
|
scaleY: input.rect.editScaleY,
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { act, useRef } from "react";
|
|
3
3
|
import type { Root } from "react-dom/client";
|
|
4
|
-
import { afterEach, beforeEach, expect, it } from "vitest";
|
|
4
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
5
5
|
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
|
6
6
|
import { useMarqueeGestures } from "./marqueeCommit";
|
|
7
7
|
|
|
8
|
+
// Layout stands in for a real preview: every element is visible and sits at the band's start.
|
|
9
|
+
vi.mock("./domEditingElement", async (importOriginal) => ({
|
|
10
|
+
...(await importOriginal<typeof import("./domEditingElement")>()),
|
|
11
|
+
isElementComputedVisible: () => true,
|
|
12
|
+
}));
|
|
13
|
+
vi.mock("./domEditOverlayGeometry", async (importOriginal) => ({
|
|
14
|
+
...(await importOriginal<typeof import("./domEditOverlayGeometry")>()),
|
|
15
|
+
toVisibleOverlayRect: () => ({ left: 10, top: 10, width: 40, height: 40 }),
|
|
16
|
+
}));
|
|
17
|
+
|
|
8
18
|
installReactActEnvironment();
|
|
9
19
|
HTMLElement.prototype.setPointerCapture ??= () => {};
|
|
10
20
|
HTMLElement.prototype.releasePointerCapture ??= () => {};
|
|
@@ -82,3 +92,61 @@ it("an escape with no band in flight still reaches the host", () => {
|
|
|
82
92
|
expect(event.defaultPrevented).toBe(false);
|
|
83
93
|
expect(hostEscapes).toHaveLength(1);
|
|
84
94
|
});
|
|
95
|
+
|
|
96
|
+
it("a reload promoted mid-band selects from the preview on screen, not the retired one", async () => {
|
|
97
|
+
const preview = () => {
|
|
98
|
+
const iframe = document.createElement("iframe");
|
|
99
|
+
const doc = document.implementation.createHTMLDocument("preview");
|
|
100
|
+
doc.body.innerHTML = '<div data-composition-id="main"><h1 id="title">Title</h1></div>';
|
|
101
|
+
Object.defineProperty(iframe, "contentDocument", { value: doc });
|
|
102
|
+
return iframe;
|
|
103
|
+
};
|
|
104
|
+
const [retired, live] = [preview(), preview()];
|
|
105
|
+
const picked: HTMLElement[][] = [];
|
|
106
|
+
const iframeRef = { current: retired as HTMLIFrameElement | null };
|
|
107
|
+
function Band() {
|
|
108
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
109
|
+
const marquee = useMarqueeGestures({
|
|
110
|
+
iframeRef,
|
|
111
|
+
overlayRef,
|
|
112
|
+
activeCompositionPathRef: useRef<string | null>("index.html"),
|
|
113
|
+
onMarqueeSelectRef: useRef(() => undefined),
|
|
114
|
+
resolveHits: async (elements: HTMLElement[]) => {
|
|
115
|
+
picked.push(elements);
|
|
116
|
+
return [];
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
ref={overlayRef}
|
|
122
|
+
data-band-overlay
|
|
123
|
+
onPointerDown={marquee.begin}
|
|
124
|
+
onPointerMove={marquee.onPointerMove}
|
|
125
|
+
onPointerUp={marquee.onPointerUp}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const band = mountReactHarness(<Band />);
|
|
130
|
+
const fire = (type: string, x: number, y: number) =>
|
|
131
|
+
act(() => {
|
|
132
|
+
document.querySelector("[data-band-overlay]")!.dispatchEvent(
|
|
133
|
+
new PointerEvent(type, {
|
|
134
|
+
bubbles: true,
|
|
135
|
+
button: 0,
|
|
136
|
+
pointerId: 1,
|
|
137
|
+
clientX: x,
|
|
138
|
+
clientY: y,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
fire("pointerdown", 0, 0);
|
|
144
|
+
fire("pointermove", 120, 90);
|
|
145
|
+
iframeRef.current = live;
|
|
146
|
+
await act(async () => fire("pointerup", 120, 90));
|
|
147
|
+
expect(picked.at(-1)?.length).toBeGreaterThan(0);
|
|
148
|
+
for (const element of picked.at(-1)!) expect(element.ownerDocument).toBe(live.contentDocument);
|
|
149
|
+
} finally {
|
|
150
|
+
act(() => band.unmount());
|
|
151
|
+
}
|
|
152
|
+
});
|
|
@@ -159,7 +159,12 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
|
|
|
159
159
|
const overlay = deps.overlayRef.current;
|
|
160
160
|
if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
|
|
161
161
|
const acp = deps.activeCompositionPathRef.current ?? "index.html";
|
|
162
|
-
const
|
|
162
|
+
const cached = candidatesRef.current;
|
|
163
|
+
const measuredOnScreen = cached?.every(
|
|
164
|
+
(hit) => hit.element.ownerDocument === iframe.contentDocument,
|
|
165
|
+
);
|
|
166
|
+
const candidates =
|
|
167
|
+
cached && measuredOnScreen ? cached : collectMarqueeCandidates(iframe, overlay, acp);
|
|
163
168
|
const elements = hitsWithin(rect, candidates).map((hit) => hit.element);
|
|
164
169
|
const resolveHits = deps.resolveHits;
|
|
165
170
|
const picks = resolveHits
|