@hyperframes/studio 0.8.106 → 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-dl6K6ABp.js → hyperframes-player-h4mktSdm.js} +1 -1
- package/dist/assets/{index-CMiYVGv5.js → index-CM1xA_jD.js} +195 -200
- package/dist/assets/{index-ooaZZhKf.js → index-D8GKc2D9.js} +1 -1
- package/dist/assets/{index-DKT5NF1Y.js → index-zoqPmBF6.js} +1 -1
- package/dist/{chunk-ICTZJPQQ.js → chunk-4OZC426H.js} +12 -3
- package/dist/chunk-4OZC426H.js.map +1 -0
- package/dist/index.d.ts +367 -11
- package/dist/index.html +1 -1
- package/dist/index.js +1064 -714
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +5 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +6 -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/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/domEditOverlayStartGesture.ts +5 -2
- 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 +4 -1
- package/src/components/editor/mediaAudioEdits.test.ts +212 -0
- package/src/components/editor/mediaAudioEdits.ts +253 -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/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 +41 -23
- 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/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 +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/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/usePreviewPersistence.ts +6 -0
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +7 -0
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/timelineLayout.ts +0 -28
- 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 +16 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -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/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 +16 -7
- 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-ICTZJPQQ.js.map +0 -1
|
@@ -2,85 +2,88 @@
|
|
|
2
2
|
import type { useDomEditSession } from "../hooks/useDomEditSession";
|
|
3
3
|
import { useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
|
|
4
4
|
import { createStableContext } from "../utils/hmrStableContext";
|
|
5
|
+
import { trackedStudioEdit } from "../utils/studioPendingEdits";
|
|
5
6
|
|
|
6
7
|
type DomEditValue = ReturnType<typeof useDomEditSession>;
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
9
|
+
const SAVES_AN_EDIT = {
|
|
10
|
+
handleTimelineElementSelect: false,
|
|
11
|
+
handlePreviewCanvasMouseDown: false,
|
|
12
|
+
handlePreviewCanvasPointerMove: false,
|
|
13
|
+
handlePreviewCanvasPointerLeave: false,
|
|
14
|
+
applyDomSelection: false,
|
|
15
|
+
clearDomSelection: false,
|
|
16
|
+
refreshDomEditSelectionFromPreview: false,
|
|
17
|
+
handleDomStyleCommit: true,
|
|
18
|
+
handleDomStyleCommitForSelection: true,
|
|
19
|
+
handleDomAttributeCommit: true,
|
|
20
|
+
handleDomAttributeLiveCommit: false,
|
|
21
|
+
handleDomAttributeQuietCommit: true,
|
|
22
|
+
handleDomHtmlAttributeCommit: true,
|
|
23
|
+
handleDomAttributesCommit: true,
|
|
24
|
+
handleDomAttributeBatchCommit: true,
|
|
25
|
+
handleDomPathOffsetCommit: true,
|
|
26
|
+
handleDomGroupPathOffsetCommit: true,
|
|
27
|
+
handleDomZIndexReorderCommit: true,
|
|
28
|
+
handleDomBoxSizeCommit: true,
|
|
29
|
+
handleDomRotationCommit: true,
|
|
30
|
+
handleDomManualEditsReset: true,
|
|
31
|
+
handleDomTextCommit: true,
|
|
32
|
+
handleDomTextCommitForSelection: true,
|
|
33
|
+
handleDomRichTextCommit: true,
|
|
34
|
+
handleDomTextFieldStyleCommit: true,
|
|
35
|
+
handleDomAddTextField: true,
|
|
36
|
+
handleDomRemoveTextField: true,
|
|
37
|
+
getGsapAnimationsForSelection: false,
|
|
38
|
+
handleAskAgent: false,
|
|
39
|
+
handleAgentModalSubmit: false,
|
|
40
|
+
handleBlockedDomMove: false,
|
|
41
|
+
handleDomManualDragStart: false,
|
|
42
|
+
handleDomEditElementDelete: true,
|
|
43
|
+
handleGroupSelection: true,
|
|
44
|
+
handleUngroupSelection: true,
|
|
45
|
+
setActiveGroupElement: false,
|
|
46
|
+
buildDomSelectionFromTarget: false,
|
|
47
|
+
buildDomSelectionForTimelineElement: false,
|
|
48
|
+
updateDomEditHoverSelection: false,
|
|
49
|
+
resolveImportedFontAsset: false,
|
|
50
|
+
setAgentModalOpen: false,
|
|
51
|
+
setAgentPromptSelectionContext: false,
|
|
52
|
+
setAgentModalAnchorPoint: false,
|
|
53
|
+
handleGsapUpdateProperty: true,
|
|
54
|
+
handleGsapUpdateMeta: true,
|
|
55
|
+
handleGsapDeleteAnimation: true,
|
|
56
|
+
handleGsapDeleteAllForElement: true,
|
|
57
|
+
handleGsapAddAnimation: true,
|
|
58
|
+
handleGsapAddProperty: true,
|
|
59
|
+
handleGsapRemoveProperty: true,
|
|
60
|
+
handleGsapUpdateFromProperty: true,
|
|
61
|
+
handleGsapAddFromProperty: true,
|
|
62
|
+
handleGsapRemoveFromProperty: true,
|
|
63
|
+
handleGsapAddKeyframe: true,
|
|
64
|
+
handleGsapAddKeyframeBatch: true,
|
|
65
|
+
handleGsapRemoveKeyframe: true,
|
|
66
|
+
handleGsapMoveKeyframeToPlayhead: true,
|
|
67
|
+
handleGsapMoveKeyframe: true,
|
|
68
|
+
handleGsapResizeKeyframedTween: true,
|
|
69
|
+
handleGsapConvertToKeyframes: true,
|
|
70
|
+
handleGsapRemoveAllKeyframes: true,
|
|
71
|
+
handleResetSelectedElementKeyframes: true,
|
|
72
|
+
commitAnimatedProperty: true,
|
|
73
|
+
commitAnimatedProperties: true,
|
|
74
|
+
handleSetArcPath: true,
|
|
75
|
+
handleUpdateArcSegment: true,
|
|
76
|
+
handleUnroll: true,
|
|
77
|
+
invalidateGsapCache: false,
|
|
78
|
+
previewIframeRef: false,
|
|
79
|
+
commitMutation: true,
|
|
80
|
+
applyMarqueeSelection: false,
|
|
81
|
+
handleUpdateKeyframeEase: true,
|
|
82
|
+
handleUpdateSegmentEase: true,
|
|
83
|
+
handleSetAllKeyframeEases: true,
|
|
84
|
+
} as const satisfies Partial<Record<keyof DomEditValue, boolean>>;
|
|
85
|
+
|
|
86
|
+
export interface DomEditActionsValue extends Pick<DomEditValue, keyof typeof SAVES_AN_EDIT> {}
|
|
84
87
|
|
|
85
88
|
export interface DomEditSelectionValue extends Pick<
|
|
86
89
|
DomEditValue,
|
|
@@ -98,6 +101,15 @@ export interface DomEditSelectionValue extends Pick<
|
|
|
98
101
|
| "agentPromptSelectionContext"
|
|
99
102
|
> {}
|
|
100
103
|
|
|
104
|
+
function trackEditCommits(actions: DomEditActionsValue): DomEditActionsValue {
|
|
105
|
+
const tracked: Record<string, unknown> = { ...actions };
|
|
106
|
+
for (const key of Object.keys(SAVES_AN_EDIT) as Array<keyof typeof SAVES_AN_EDIT>) {
|
|
107
|
+
if (!SAVES_AN_EDIT[key]) continue;
|
|
108
|
+
tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown);
|
|
109
|
+
}
|
|
110
|
+
return tracked as unknown as DomEditActionsValue;
|
|
111
|
+
}
|
|
112
|
+
|
|
101
113
|
const DomEditActionsContext = createStableContext<DomEditActionsValue | null>(
|
|
102
114
|
"DomEditActionsContext",
|
|
103
115
|
null,
|
|
@@ -164,6 +176,7 @@ export function DomEditProvider({
|
|
|
164
176
|
handleDomAttributeQuietCommit,
|
|
165
177
|
handleDomHtmlAttributeCommit,
|
|
166
178
|
handleDomAttributesCommit,
|
|
179
|
+
handleDomAttributeBatchCommit,
|
|
167
180
|
handleDomPathOffsetCommit,
|
|
168
181
|
handleDomGroupPathOffsetCommit,
|
|
169
182
|
handleDomZIndexReorderCommit,
|
|
@@ -242,7 +255,7 @@ export function DomEditProvider({
|
|
|
242
255
|
[],
|
|
243
256
|
);
|
|
244
257
|
|
|
245
|
-
const
|
|
258
|
+
const untrackedActions = useMemo<DomEditActionsValue>(
|
|
246
259
|
() => ({
|
|
247
260
|
handleTimelineElementSelect,
|
|
248
261
|
handlePreviewCanvasMouseDown,
|
|
@@ -258,6 +271,7 @@ export function DomEditProvider({
|
|
|
258
271
|
handleDomAttributeQuietCommit,
|
|
259
272
|
handleDomHtmlAttributeCommit,
|
|
260
273
|
handleDomAttributesCommit,
|
|
274
|
+
handleDomAttributeBatchCommit,
|
|
261
275
|
handleDomPathOffsetCommit,
|
|
262
276
|
handleDomGroupPathOffsetCommit,
|
|
263
277
|
handleDomZIndexReorderCommit,
|
|
@@ -333,6 +347,7 @@ export function DomEditProvider({
|
|
|
333
347
|
handleDomAttributeQuietCommit,
|
|
334
348
|
handleDomHtmlAttributeCommit,
|
|
335
349
|
handleDomAttributesCommit,
|
|
350
|
+
handleDomAttributeBatchCommit,
|
|
336
351
|
handleDomPathOffsetCommit,
|
|
337
352
|
handleDomGroupPathOffsetCommit,
|
|
338
353
|
handleDomZIndexReorderCommit,
|
|
@@ -395,6 +410,8 @@ export function DomEditProvider({
|
|
|
395
410
|
],
|
|
396
411
|
);
|
|
397
412
|
|
|
413
|
+
const actions = useMemo(() => trackEditCommits(untrackedActions), [untrackedActions]);
|
|
414
|
+
|
|
398
415
|
const selection = useMemo<DomEditSelectionValue>(
|
|
399
416
|
() => ({
|
|
400
417
|
domEditSelection,
|
|
@@ -14,38 +14,56 @@ const SOURCE =
|
|
|
14
14
|
'<!doctype html><html><head></head><body><div data-hf-id="hf-card" style="color: red">Card</div>' +
|
|
15
15
|
'<div id="clip" class="clip" data-start="1" data-duration="5" data-track-index="0"></div></body></html>';
|
|
16
16
|
|
|
17
|
-
// The server patches whatever is on disk; the writer refuses a stale base
|
|
18
|
-
// The first write can be held mid-flight.
|
|
17
|
+
// The server patches whatever is on disk, a font rule included; the writer refuses a stale base
|
|
18
|
+
// like If-Match does. The first write, a patch or a whole file, can be held mid-flight.
|
|
19
19
|
function fakeProject() {
|
|
20
20
|
let disk = SOURCE;
|
|
21
21
|
const refused: string[] = [];
|
|
22
22
|
let writes = 0;
|
|
23
|
+
let wholeFileWrites = 0;
|
|
23
24
|
let release = () => {};
|
|
24
25
|
const firstWrite = new Promise<void>((resolve) => (release = resolve));
|
|
26
|
+
const holdFirst = async () => {
|
|
27
|
+
if (++writes === 1) await firstWrite;
|
|
28
|
+
};
|
|
25
29
|
vi.stubGlobal(
|
|
26
30
|
"fetch",
|
|
27
31
|
vi.fn(async (input: string, init?: RequestInit) => {
|
|
28
32
|
if (input.includes("/file-mutations/patch-element/")) {
|
|
29
|
-
|
|
33
|
+
await holdFirst();
|
|
34
|
+
const { operations, fontFaceCss } = JSON.parse(String(init?.body)) as {
|
|
30
35
|
operations: Array<{ property?: string; value?: string }>;
|
|
36
|
+
fontFaceCss?: string;
|
|
31
37
|
};
|
|
32
38
|
for (const op of operations) {
|
|
33
39
|
if (op.property === "color") disk = disk.replace(/color: \w+/, `color: ${op.value}`);
|
|
34
40
|
}
|
|
41
|
+
if (fontFaceCss && !disk.includes(fontFaceCss))
|
|
42
|
+
disk = disk.replace(
|
|
43
|
+
"</head>",
|
|
44
|
+
`<style data-hf-studio-fonts="true">${fontFaceCss}</style></head>`,
|
|
45
|
+
);
|
|
35
46
|
return Response.json({ ok: true, changed: true, matched: true, content: disk });
|
|
36
47
|
}
|
|
37
48
|
return Response.json({ content: disk });
|
|
38
49
|
}),
|
|
39
50
|
);
|
|
40
51
|
const writeProjectFile = async (path: string, content: string, expected?: string) => {
|
|
41
|
-
|
|
52
|
+
wholeFileWrites += 1;
|
|
53
|
+
await holdFirst();
|
|
42
54
|
if (expected !== undefined && expected !== disk && content !== disk) {
|
|
43
55
|
refused.push(path);
|
|
44
56
|
throw new Error(`409 conflict on ${path}`);
|
|
45
57
|
}
|
|
46
58
|
disk = content;
|
|
47
59
|
};
|
|
48
|
-
return {
|
|
60
|
+
return {
|
|
61
|
+
writeProjectFile,
|
|
62
|
+
refused,
|
|
63
|
+
release: () => release(),
|
|
64
|
+
read: () => disk,
|
|
65
|
+
wholeFileWrites: () => wholeFileWrites,
|
|
66
|
+
};
|
|
49
67
|
}
|
|
50
68
|
|
|
51
69
|
type Project = ReturnType<typeof fakeProject>;
|
|
@@ -158,7 +176,7 @@ afterEach(() => {
|
|
|
158
176
|
});
|
|
159
177
|
|
|
160
178
|
describe("canvas edit commits share the file queue", () => {
|
|
161
|
-
it("lands a second canvas commit started while the first one's font
|
|
179
|
+
it("lands a second canvas commit started while the first one's font save is held", async () => {
|
|
162
180
|
const project = fakeProject();
|
|
163
181
|
const { hook, selection, toasts } = mountCanvasCommits(project);
|
|
164
182
|
const font = {
|
|
@@ -174,8 +192,55 @@ describe("canvas edit commits share the file queue", () => {
|
|
|
174
192
|
);
|
|
175
193
|
|
|
176
194
|
expect(toasts).toEqual([]);
|
|
177
|
-
expect(project.read()
|
|
195
|
+
expect(project.read().match(/@font-face/g)).toHaveLength(1);
|
|
178
196
|
expect(project.read()).toContain("color: green");
|
|
197
|
+
expect(project.wholeFileWrites(), "the font rides the edit's own patch").toBe(0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("writes a batch's prepared content over the patch and saves it once", async () => {
|
|
201
|
+
const project = fakeProject();
|
|
202
|
+
const { hook, selection, toasts } = mountCanvasCommits(project);
|
|
203
|
+
project.release();
|
|
204
|
+
|
|
205
|
+
const saved = await hook.handleDomAttributeBatchCommit(
|
|
206
|
+
selection,
|
|
207
|
+
[{ type: "attribute", property: "data-note", value: "1" }],
|
|
208
|
+
{
|
|
209
|
+
label: "Edit with prepared content",
|
|
210
|
+
prepareContent: (html) => html.replace("</head>", '<meta data-prepared="1"></head>'),
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(saved).toBe(true);
|
|
215
|
+
expect(toasts).toEqual([]);
|
|
216
|
+
expect(project.read().match(/data-prepared="1"/g)).toHaveLength(1);
|
|
217
|
+
expect(project.wholeFileWrites()).toBe(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("reports a failed follow-up write as a failed commit, not as saved", async () => {
|
|
221
|
+
const project = fakeProject();
|
|
222
|
+
project.release();
|
|
223
|
+
const failing = {
|
|
224
|
+
...project,
|
|
225
|
+
writeProjectFile: async () => {
|
|
226
|
+
throw new Error("409 conflict");
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
const { hook, selection, toasts } = mountCanvasCommits(failing);
|
|
230
|
+
|
|
231
|
+
const saved = await hook.handleDomAttributeBatchCommit(
|
|
232
|
+
selection,
|
|
233
|
+
[{ type: "attribute", property: "data-note", value: "1" }],
|
|
234
|
+
{
|
|
235
|
+
label: "Edit with prepared content",
|
|
236
|
+
prepareContent: (html) => html.replace("</head>", '<meta data-prepared="1"></head>'),
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
expect(saved).toBe(false);
|
|
241
|
+
expect(toasts).toHaveLength(1);
|
|
242
|
+
expect(toasts[0]).toContain("couldn't finish updating");
|
|
243
|
+
expect(project.read()).not.toContain("data-prepared");
|
|
179
244
|
});
|
|
180
245
|
|
|
181
246
|
it("lands a canvas commit started while a timeline save is writing", async () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
2
|
+
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
2
3
|
import type { PatchOperation, PatchTarget } from "../utils/sourcePatcher";
|
|
3
4
|
|
|
4
5
|
export interface DomEditPatchBatch {
|
|
@@ -53,7 +54,16 @@ export type PersistDomEditOperations = (
|
|
|
53
54
|
coalesceKey?: string;
|
|
54
55
|
coalesceMs?: number;
|
|
55
56
|
skipRefresh?: boolean;
|
|
57
|
+
deferRender?: boolean;
|
|
58
|
+
importedFont?: ImportedFontAsset;
|
|
56
59
|
prepareContent?: (html: string, sourceFile: string) => string;
|
|
57
60
|
shouldSave?: () => boolean;
|
|
58
61
|
},
|
|
59
62
|
) => Promise<DomEditPersistOutcome | undefined>;
|
|
63
|
+
|
|
64
|
+
/** Several data-* and HTML attribute ops on one element as ONE persist and ONE undo entry. */
|
|
65
|
+
export type CommitDomAttributeBatch = (
|
|
66
|
+
selection: DomEditSelection,
|
|
67
|
+
operations: PatchOperation[],
|
|
68
|
+
options: { label: string; prepareContent?: (html: string) => string },
|
|
69
|
+
) => Promise<boolean>;
|
|
@@ -19,6 +19,15 @@ export class DomEditPersistUnsafeValueError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export class DomEditPersistPreparedWriteError extends Error {
|
|
23
|
+
readonly alreadyToasted = true;
|
|
24
|
+
|
|
25
|
+
constructor(targetPath: string) {
|
|
26
|
+
super(`The edit saved but its follow-up write to ${targetPath} did not`);
|
|
27
|
+
this.name = "DomEditPersistPreparedWriteError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
export type DomEditPersistFailureSelection = Pick<
|
|
23
32
|
DomEditSelection,
|
|
24
33
|
"label" | "hfId" | "id" | "selector" | "selectorIndex" | "sourceFile"
|
|
@@ -48,7 +57,9 @@ function getSelectionLabel(selection: DomEditPersistFailureSelection): string {
|
|
|
48
57
|
|
|
49
58
|
export function wasAlreadyToasted(error: unknown): boolean {
|
|
50
59
|
return (
|
|
51
|
-
(error instanceof DomEditPersistUnsafeValueError ||
|
|
60
|
+
(error instanceof DomEditPersistUnsafeValueError ||
|
|
61
|
+
error instanceof DomEditPersistPreparedWriteError ||
|
|
62
|
+
error instanceof StudioSaveHttpError) &&
|
|
52
63
|
error.alreadyToasted
|
|
53
64
|
);
|
|
54
65
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { expect, it } from "vitest";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
4
4
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
5
|
+
import { importedFontFaceCssFor } from "../utils/studioFontHelpers";
|
|
5
6
|
import { commitDomStyles, type DomStyleCommitContext } from "./domStyleCommit";
|
|
6
7
|
|
|
7
8
|
const selection = (id: string): DomEditSelection =>
|
|
@@ -28,7 +29,7 @@ const poppins = (file: string, weight: string): ImportedFontAsset => ({
|
|
|
28
29
|
});
|
|
29
30
|
|
|
30
31
|
it("each font a host hands over is saved as its own face, with the weight and style it draws", async () => {
|
|
31
|
-
|
|
32
|
+
const faces: string[] = [];
|
|
32
33
|
let listed: ImportedFontAsset | null = null;
|
|
33
34
|
const context: DomStyleCommitContext = {
|
|
34
35
|
activeCompPath: "index.html",
|
|
@@ -39,16 +40,16 @@ it("each font a host hands over is saved as its own face, with the weight and st
|
|
|
39
40
|
persistDomEditOperations: (async (
|
|
40
41
|
_selection: DomEditSelection,
|
|
41
42
|
_operations: unknown,
|
|
42
|
-
options?: {
|
|
43
|
+
options?: { importedFont?: ImportedFontAsset },
|
|
43
44
|
) => {
|
|
44
|
-
|
|
45
|
+
if (options?.importedFont)
|
|
46
|
+
faces.push(importedFontFaceCssFor(options.importedFont, "index.html"));
|
|
45
47
|
}) as unknown as DomStyleCommitContext["persistDomEditOperations"],
|
|
46
48
|
};
|
|
47
49
|
listed = poppins("Poppins-SemiBold.ttf", "600");
|
|
48
50
|
await commitDomStyles(context, selection("title"), { "font-family": "Poppins" });
|
|
49
51
|
listed = poppins("Poppins-Regular.ttf", "400");
|
|
50
52
|
await commitDomStyles(context, selection("subtitle"), { "font-family": "Poppins" });
|
|
51
|
-
const faces = html.match(/@font-face[^}]*}/g) ?? [];
|
|
52
53
|
expect(faces).toHaveLength(2);
|
|
53
54
|
expect(faces[0]).toContain('Poppins-SemiBold.ttf"); font-weight: 600; font-style: normal;');
|
|
54
55
|
expect(faces[1]).toContain('Poppins-Regular.ttf"); font-weight: 400; font-style: normal;');
|
|
@@ -4,11 +4,7 @@ import {
|
|
|
4
4
|
isManualGeometryStyleProperty,
|
|
5
5
|
normalizeDomEditStyleValue,
|
|
6
6
|
} from "../utils/studioHelpers";
|
|
7
|
-
import {
|
|
8
|
-
injectPreviewGoogleFont,
|
|
9
|
-
injectPreviewImportedFont,
|
|
10
|
-
ensureImportedFontFace,
|
|
11
|
-
} from "../utils/studioFontHelpers";
|
|
7
|
+
import { injectPreviewGoogleFont, injectPreviewImportedFont } from "../utils/studioFontHelpers";
|
|
12
8
|
import {
|
|
13
9
|
buildDomEditStylePatchOperation,
|
|
14
10
|
findElementForSelection,
|
|
@@ -105,9 +101,7 @@ export async function commitDomStyles(
|
|
|
105
101
|
label: "Edit layer style",
|
|
106
102
|
// Inline styles are already live, so a reload would only blank the preview.
|
|
107
103
|
skipRefresh: true,
|
|
108
|
-
|
|
109
|
-
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
110
|
-
: undefined,
|
|
104
|
+
importedFont: importedFont ?? undefined,
|
|
111
105
|
}),
|
|
112
106
|
shouldRevert: ownsAny,
|
|
113
107
|
revert: () => {
|
|
@@ -273,14 +273,9 @@ export async function commitStaticGsapRotation(
|
|
|
273
273
|
}
|
|
274
274
|
|
|
275
275
|
/**
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
* tween: one keyframe at the playhead % renders NaN/0 at every other frame, so
|
|
280
|
-
* the element collapses/disappears (worst when resized off the 0% mark). A `set`
|
|
281
|
-
* holds the size at all times. Re-resizing an element that already has a size
|
|
282
|
-
* `set` UPDATES it in place with one `update-properties`; a new element
|
|
283
|
-
* gets one `add` with `method:"set"`.
|
|
276
|
+
* A static resize as `tl.set("#el",{width,height})`, only where the script already writes the size
|
|
277
|
+
* (else it is CSS). A set, not a one-stop keyframe tween, which renders NaN/0 off its keyframe.
|
|
278
|
+
* Updates an existing size set in place, else adds one.
|
|
284
279
|
*/
|
|
285
280
|
export async function commitStaticGsapSize(
|
|
286
281
|
selection: DomEditSelection,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import { PROPERTY_GROUPS } from "@hyperframes/parsers/gsap-constants";
|
|
2
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
4
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
5
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
@@ -179,7 +180,11 @@ async function commitFlatViaKeyframes(
|
|
|
179
180
|
if (runtime && ts !== null) {
|
|
180
181
|
const { gsapLib, el, mainTl } = runtime;
|
|
181
182
|
const draggedValues: Record<string, number> = {};
|
|
182
|
-
|
|
183
|
+
const channelsTheClearWipes = new Set([
|
|
184
|
+
...Object.keys(properties),
|
|
185
|
+
...PROPERTY_GROUPS.position,
|
|
186
|
+
]);
|
|
187
|
+
for (const key of channelsTheClearWipes) {
|
|
183
188
|
const v = Number(gsapLib.getProperty(el, key));
|
|
184
189
|
if (Number.isFinite(v)) draggedValues[key] = v;
|
|
185
190
|
}
|
|
@@ -44,7 +44,8 @@ export type GsapEditOutcome =
|
|
|
44
44
|
ownsDragOffset?: boolean;
|
|
45
45
|
}
|
|
46
46
|
| { status: "blocked"; reason: GsapEditBlockReason; detail?: GsapEditBlockDetail }
|
|
47
|
-
| { status: "element-offset" }
|
|
47
|
+
| { status: "element-offset" }
|
|
48
|
+
| { status: "element-size" };
|
|
48
49
|
|
|
49
50
|
export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
|
|
50
51
|
"no-selector": "This layer needs a stable selector before Studio can save the edit.",
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
|
+
import { applyStudioBoxSizeDraft } from "../components/editor/manualEdits";
|
|
8
|
+
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
9
|
+
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
|
10
|
+
import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
|
11
|
+
|
|
12
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
document.body.innerHTML = "";
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// The bench's hold fixture: an off-timeline `gsap.set` positions #target; its size is stylesheet CSS.
|
|
19
|
+
const positionHold = {
|
|
20
|
+
id: "#target-set-0-position",
|
|
21
|
+
targetSelector: "#target",
|
|
22
|
+
propertyGroup: "position",
|
|
23
|
+
method: "set",
|
|
24
|
+
properties: { x: 40, y: 20 },
|
|
25
|
+
position: 0,
|
|
26
|
+
resolvedStart: 0,
|
|
27
|
+
duration: 0,
|
|
28
|
+
global: true,
|
|
29
|
+
} as unknown as GsapAnimation;
|
|
30
|
+
|
|
31
|
+
const widthTween = {
|
|
32
|
+
id: "#target-to-0-size",
|
|
33
|
+
targetSelector: "#target",
|
|
34
|
+
propertyGroup: "size",
|
|
35
|
+
method: "to",
|
|
36
|
+
properties: { width: 400, height: 300 },
|
|
37
|
+
position: 0,
|
|
38
|
+
resolvedStart: 0,
|
|
39
|
+
duration: 4,
|
|
40
|
+
} as unknown as GsapAnimation;
|
|
41
|
+
|
|
42
|
+
function mount(animations: GsapAnimation[]) {
|
|
43
|
+
const element = Object.assign(document.createElement("div"), {
|
|
44
|
+
id: "target",
|
|
45
|
+
_gsap: { renderTransform: () => {} },
|
|
46
|
+
});
|
|
47
|
+
// GSAP's own inline mask on an element whose transform it renders.
|
|
48
|
+
element.style.setProperty("scale", "none");
|
|
49
|
+
document.body.append(element);
|
|
50
|
+
const gsap = { getProperty: () => 0, set: vi.fn() };
|
|
51
|
+
const iframe = {
|
|
52
|
+
contentWindow: { gsap, __timelines: {} },
|
|
53
|
+
contentDocument: document,
|
|
54
|
+
} as unknown as HTMLIFrameElement;
|
|
55
|
+
const selection = { element, id: "target", selector: "#target" } as unknown as DomEditSelection;
|
|
56
|
+
const commitMutation = vi.fn().mockResolvedValue(undefined);
|
|
57
|
+
const commitPatch = vi.fn().mockResolvedValue(undefined);
|
|
58
|
+
let resize!: ReturnType<typeof useGsapAwareEditing>["handleGsapAwareBoxSizeCommit"];
|
|
59
|
+
function Harness() {
|
|
60
|
+
const { stageElementPositionOffset, handleDomBoxSizeCommit } = useDomGeometryCommits({
|
|
61
|
+
showToast: vi.fn(),
|
|
62
|
+
commitPositionPatchToHtml: commitPatch,
|
|
63
|
+
readOnlyPreview: false,
|
|
64
|
+
});
|
|
65
|
+
resize = useGsapAwareEditing({
|
|
66
|
+
domEditSelection: selection,
|
|
67
|
+
selectedGsapAnimations: animations,
|
|
68
|
+
gsapCommitMutation: commitMutation,
|
|
69
|
+
previewIframeRef: { current: iframe },
|
|
70
|
+
showToast: vi.fn(),
|
|
71
|
+
bumpGsapCache: vi.fn(),
|
|
72
|
+
makeFetchFallback: () => async () => animations,
|
|
73
|
+
trackGsapInteractionFailure: vi.fn(),
|
|
74
|
+
stageElementPositionOffset,
|
|
75
|
+
handleDomBoxSizeCommit,
|
|
76
|
+
handleDomRotationCommit: vi.fn(),
|
|
77
|
+
commitPositionPatchToHtml: commitPatch,
|
|
78
|
+
addGsapAnimation: vi.fn(),
|
|
79
|
+
convertToKeyframes: vi.fn(),
|
|
80
|
+
setArcPath: vi.fn(),
|
|
81
|
+
updateArcSegment: vi.fn(),
|
|
82
|
+
}).handleGsapAwareBoxSizeCommit;
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const root = mountReactHarness(<Harness />);
|
|
86
|
+
const size = { width: 340, height: 227 };
|
|
87
|
+
applyStudioBoxSizeDraft(element, size);
|
|
88
|
+
return { selection, size, commitMutation, commitPatch, resize, root };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const writesSize = (mutation: { properties?: object; keyframes?: Array<{ properties: object }> }) =>
|
|
92
|
+
[mutation.properties, ...(mutation.keyframes ?? []).map((k) => k.properties)].some(
|
|
93
|
+
(props) => !!props && ("width" in props || "height" in props),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
describe("resizing an element GSAP positions", () => {
|
|
97
|
+
it("saves a size the script never writes as the element's CSS width/height, not a tl.set", async () => {
|
|
98
|
+
const h = mount([positionHold]);
|
|
99
|
+
|
|
100
|
+
await act(() => h.resize(h.selection, h.size, { x: -50, y: -33.5 }));
|
|
101
|
+
|
|
102
|
+
const mutations = h.commitMutation.mock.calls.map((call) => call[1]);
|
|
103
|
+
expect(mutations.filter(writesSize)).toEqual([]);
|
|
104
|
+
// The anchor still moves the GSAP hold, in the same undo step as the CSS size.
|
|
105
|
+
expect(mutations.some((m) => m.animationId === positionHold.id)).toBe(true);
|
|
106
|
+
const [, patches, options] = h.commitPatch.mock.calls.at(-1)!;
|
|
107
|
+
expect(patches).toEqual(
|
|
108
|
+
expect.arrayContaining([
|
|
109
|
+
{ type: "inline-style", property: "width", value: "340px" },
|
|
110
|
+
{ type: "inline-style", property: "height", value: "227px" },
|
|
111
|
+
]),
|
|
112
|
+
);
|
|
113
|
+
expect(patches.filter((p: { property: string }) => p.property === "scale")).toEqual([]);
|
|
114
|
+
expect(options.coalesceKey).toBe(h.commitMutation.mock.calls[0]![2].coalesceKey);
|
|
115
|
+
// Saved before the GSAP write and without its own render, so the GSAP reload renders last.
|
|
116
|
+
expect(options.deferRender).toBe(true);
|
|
117
|
+
expect(h.commitPatch.mock.invocationCallOrder.at(-1)).toBeLessThan(
|
|
118
|
+
h.commitMutation.mock.invocationCallOrder[0]!,
|
|
119
|
+
);
|
|
120
|
+
act(() => h.root.unmount());
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("still saves the size through GSAP when a tween animates it", async () => {
|
|
124
|
+
const h = mount([positionHold, widthTween]);
|
|
125
|
+
|
|
126
|
+
await act(() => h.resize(h.selection, h.size, { x: -50, y: -33.5 }));
|
|
127
|
+
|
|
128
|
+
const mutations = h.commitMutation.mock.calls.map((call) => call[1]);
|
|
129
|
+
expect(mutations.filter(writesSize).length).toBeGreaterThan(0);
|
|
130
|
+
const cssSize = h.commitPatch.mock.calls
|
|
131
|
+
.flatMap((call) => call[1])
|
|
132
|
+
.filter((p: { property: string }) => p.property === "width" || p.property === "height");
|
|
133
|
+
expect(cssSize).toEqual([]);
|
|
134
|
+
act(() => h.root.unmount());
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -238,7 +238,9 @@ async function runCase(testCase: Case): Promise<string[]> {
|
|
|
238
238
|
async () => animations,
|
|
239
239
|
);
|
|
240
240
|
|
|
241
|
-
const { scale, size } = committed(commitMutation.mock.calls);
|
|
241
|
+
const { scale, size: written } = committed(commitMutation.mock.calls);
|
|
242
|
+
const size =
|
|
243
|
+
outcome.status === "element-size" ? { w: testCase.drop.w, h: testCase.drop.h } : written;
|
|
242
244
|
const settled = renderRect(
|
|
243
245
|
{ box: size ?? testCase.box, pos: { ...live.pos }, scale: scale ?? testCase.liveScale },
|
|
244
246
|
testCase.rotation,
|