@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
|
@@ -529,7 +529,7 @@ function titleSelection(): DomEditSelection {
|
|
|
529
529
|
return { id: "title", selector: "#title", element: el } as DomEditSelection;
|
|
530
530
|
}
|
|
531
531
|
|
|
532
|
-
it("
|
|
532
|
+
it("hands the size to the element's CSS when its only tween is a fade", async () => {
|
|
533
533
|
const fade = {
|
|
534
534
|
id: "#title-to-0-visual",
|
|
535
535
|
targetSelector: "#title",
|
|
@@ -550,14 +550,6 @@ it("writes a plain size on an element whose only tween is a fade", async () => {
|
|
|
550
550
|
commitMutation,
|
|
551
551
|
);
|
|
552
552
|
|
|
553
|
-
expect(handled).
|
|
554
|
-
|
|
555
|
-
expect(mutations.filter((m) => /keyframe/i.test(m.type))).toEqual([]);
|
|
556
|
-
expect(mutations).toContainEqual({
|
|
557
|
-
type: "add",
|
|
558
|
-
targetSelector: "#title",
|
|
559
|
-
method: "set",
|
|
560
|
-
position: 0,
|
|
561
|
-
properties: { width: 424, height: 237 },
|
|
562
|
-
});
|
|
553
|
+
expect(handled).toEqual({ status: "element-size" });
|
|
554
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
563
555
|
});
|
|
@@ -38,6 +38,7 @@ import { animationWritesAnyProperty, type GsapEditOutcome } from "./gsapEditOutc
|
|
|
38
38
|
import { preflightGsapResizeIntercept, resizeRoute } from "./gsapResizePreflight";
|
|
39
39
|
|
|
40
40
|
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
|
|
41
|
+
const SIZE_PROPS = new Set(["width", "height"]);
|
|
41
42
|
|
|
42
43
|
/** Build identity (zero / one) values for each property in `source`. */
|
|
43
44
|
function synthesizeIdentityProps(
|
|
@@ -101,7 +102,7 @@ export async function tryGsapResizeIntercept(
|
|
|
101
102
|
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
102
103
|
const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
|
|
103
104
|
if (outcome.status === "blocked") return outcome;
|
|
104
|
-
const { resizeGroup, resizeProperties, workingAnimations } = resizeRoute(
|
|
105
|
+
const { allKnownAnimations, resizeGroup, resizeProperties, workingAnimations } = resizeRoute(
|
|
105
106
|
animations,
|
|
106
107
|
fetchedAnimations,
|
|
107
108
|
);
|
|
@@ -131,6 +132,10 @@ export async function tryGsapResizeIntercept(
|
|
|
131
132
|
size,
|
|
132
133
|
});
|
|
133
134
|
if (!anim || isInstantHold(anim)) {
|
|
135
|
+
const scriptWritesSize = allKnownAnimations.some((a) =>
|
|
136
|
+
animationWritesAnyProperty(a, SIZE_PROPS),
|
|
137
|
+
);
|
|
138
|
+
if (!scriptWritesSize) return { status: "element-size" };
|
|
134
139
|
const sel = selectorFromSelection(selection) ?? writeTargetSelector(selection);
|
|
135
140
|
if (!sel) return { status: "blocked", reason: "no-selector" };
|
|
136
141
|
// A scale hold is not a size hold.
|
|
@@ -541,7 +546,6 @@ export async function tryGsapResizeIntercept(
|
|
|
541
546
|
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
|
|
542
547
|
}
|
|
543
548
|
|
|
544
|
-
const SIZE_PROPS = new Set(["width", "height"]);
|
|
545
549
|
const backfillDefaults: Record<string, number> = {};
|
|
546
550
|
for (const k of Object.keys(runtimeProps)) {
|
|
547
551
|
if (SIZE_PROPS.has(k)) continue;
|
|
@@ -163,6 +163,5 @@ it("leaves the drag offset to the caller when it commits size, not scale", async
|
|
|
163
163
|
async () => [scaleHold],
|
|
164
164
|
);
|
|
165
165
|
|
|
166
|
-
expect(outcome.status).toBe("
|
|
167
|
-
expect(outcome.status === "persisted" && outcome.ownsDragOffset).not.toBe(true);
|
|
166
|
+
expect(outcome.status).toBe("element-size");
|
|
168
167
|
});
|
|
@@ -567,3 +567,58 @@ describe("tryGsapDragIntercept — motion paths", () => {
|
|
|
567
567
|
);
|
|
568
568
|
});
|
|
569
569
|
});
|
|
570
|
+
|
|
571
|
+
describe("tryGsapDragIntercept — position self-heal", () => {
|
|
572
|
+
const xyTween = {
|
|
573
|
+
...stalePositionAnim,
|
|
574
|
+
id: "#puck-b-to-0-position",
|
|
575
|
+
position: 0,
|
|
576
|
+
resolvedStart: 0,
|
|
577
|
+
};
|
|
578
|
+
const centringSet = {
|
|
579
|
+
id: "#puck-b-set-0-position",
|
|
580
|
+
targetSelector: "#puck-b",
|
|
581
|
+
propertyGroup: "position",
|
|
582
|
+
method: "set",
|
|
583
|
+
properties: { xPercent: -50, yPercent: -50 },
|
|
584
|
+
position: 0,
|
|
585
|
+
duration: 0,
|
|
586
|
+
} as unknown as GsapAnimation;
|
|
587
|
+
const xySet = { ...centringSet, properties: { x: 10, y: 20 } } as GsapAnimation;
|
|
588
|
+
const liveTween = {
|
|
589
|
+
targets: () => [{ id: "puck-b" }],
|
|
590
|
+
vars: { x: -180, y: -60, duration: 2 },
|
|
591
|
+
duration: () => 2,
|
|
592
|
+
startTime: () => 0,
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
async function drag(second: GsapAnimation) {
|
|
596
|
+
usePlayerStore.setState({ currentTime: 1 });
|
|
597
|
+
const animations = [second, xyTween];
|
|
598
|
+
const commitMutation = vi.fn();
|
|
599
|
+
await tryGsapDragIntercept(
|
|
600
|
+
selection,
|
|
601
|
+
{ x: -50, y: 30 },
|
|
602
|
+
animations,
|
|
603
|
+
fakeIframe("puck-b", [liveTween]),
|
|
604
|
+
commitMutation,
|
|
605
|
+
async () => animations,
|
|
606
|
+
);
|
|
607
|
+
return commitMutation.mock.calls.map(([, mutation]) => mutation);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
it("keeps an xPercent/yPercent centring set and moves the x/y tween", async () => {
|
|
611
|
+
const mutations = await drag(centringSet);
|
|
612
|
+
expect(mutations.map((m) => m.type)).not.toContain("consolidate-position-writes");
|
|
613
|
+
expect(mutations.length).toBeGreaterThan(0);
|
|
614
|
+
expect(mutations.every((m) => m.animationId === xyTween.id)).toBe(true);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("consolidates a second x/y write", async () => {
|
|
618
|
+
const mutations = await drag(xySet);
|
|
619
|
+
expect(mutations[0]).toMatchObject({
|
|
620
|
+
type: "consolidate-position-writes",
|
|
621
|
+
keepAnimationId: xyTween.id,
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
});
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* easing, or seek position.
|
|
10
10
|
*/
|
|
11
11
|
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
12
|
+
import { isXYPositionWrite } from "@hyperframes/parsers/gsap-constants";
|
|
12
13
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
13
14
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
14
15
|
|
|
@@ -245,8 +246,7 @@ export async function tryGsapDragIntercept(
|
|
|
245
246
|
// the live keyframed/real tween if present (else any), strip the rest, so the
|
|
246
247
|
// commit below updates ONE write instead of fighting duplicates.
|
|
247
248
|
let workingAnimations = animations;
|
|
248
|
-
const isPosWrite = (a: GsapAnimation) =>
|
|
249
|
-
a.targetSelector === selector && a.propertyGroup === "position";
|
|
249
|
+
const isPosWrite = (a: GsapAnimation) => a.targetSelector === selector && isXYPositionWrite(a);
|
|
250
250
|
if (animations.filter(isPosWrite).length > 1 && fetchFallbackAnimations) {
|
|
251
251
|
const fresh = await fetchFallbackAnimations();
|
|
252
252
|
const dupes = fresh.filter(isPosWrite);
|
|
@@ -331,7 +331,9 @@ export async function tryGsapDragIntercept(
|
|
|
331
331
|
const fresh = await fetchFallbackAnimations();
|
|
332
332
|
const freshMatch = fresh.find(
|
|
333
333
|
(a) =>
|
|
334
|
-
a.targetSelector === posAnim!.targetSelector &&
|
|
334
|
+
a.targetSelector === posAnim!.targetSelector &&
|
|
335
|
+
a.propertyGroup === posAnim!.propertyGroup &&
|
|
336
|
+
isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
|
|
335
337
|
);
|
|
336
338
|
if (freshMatch && freshMatch.id !== posAnim.id) {
|
|
337
339
|
posAnim = freshMatch;
|
|
@@ -12,6 +12,7 @@ import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-
|
|
|
12
12
|
import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
|
|
13
13
|
import { roundTo3 } from "../utils/rounding";
|
|
14
14
|
import { BOX_SIZE_STYLE_PROPS } from "../components/editor/manualEditsDomPatches";
|
|
15
|
+
import { gsapRendersTransform } from "../components/editor/gsapAnimatesProperty";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* A GSAP tween's `vars` object — intentionally open: it mixes channel values
|
|
@@ -446,9 +447,6 @@ export const ROTATION_CHANNELS: string[] = [
|
|
|
446
447
|
...["rotate", "rotateX", "rotateY", "rotateZ"],
|
|
447
448
|
];
|
|
448
449
|
|
|
449
|
-
const gsapRendersTransform = (el: Element) =>
|
|
450
|
-
!!(el as { _gsap?: { renderTransform?: unknown } })._gsap?.renderTransform;
|
|
451
|
-
|
|
452
450
|
/** GSAP owns this element's position: a tween or hold writes it, or GSAP already renders its
|
|
453
451
|
* transform (a CSS translate would then apply twice). Everything else moves by plain CSS. */
|
|
454
452
|
export function gsapWritesPosition(el: Element): boolean {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
5
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
6
|
+
import {
|
|
7
|
+
makePreview,
|
|
8
|
+
mountPlayerWithPreview,
|
|
9
|
+
paintShadow,
|
|
10
|
+
resetPlayerStore,
|
|
11
|
+
} from "../player/hooks/timelinePlayerTestHarness";
|
|
12
|
+
import { useDomEditCommits } from "./useDomEditCommits";
|
|
13
|
+
|
|
14
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
15
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
16
|
+
vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({
|
|
17
|
+
...(await importOriginal<typeof import("../utils/gsapSoftReload")>()),
|
|
18
|
+
ensureMotionPathPluginLoaded: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
/** The project file; the patch route applies a colour to it, and each preview load serves it. */
|
|
22
|
+
let color = "red";
|
|
23
|
+
const card = () => `<div data-hf-id="hf-card" style="color: ${color}">Card</div>`;
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
document.body.innerHTML = "";
|
|
28
|
+
resetPlayerStore();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("an inspector save made while a reload loads is not undone when that reload goes on screen", async () => {
|
|
32
|
+
color = "red";
|
|
33
|
+
let land = () => {};
|
|
34
|
+
const landed = new Promise<void>((resolve) => (land = resolve));
|
|
35
|
+
vi.stubGlobal(
|
|
36
|
+
"fetch",
|
|
37
|
+
vi.fn(async (input: string, init?: RequestInit) => {
|
|
38
|
+
if (!input.includes("/file-mutations/patch-element/"))
|
|
39
|
+
return Response.json({ content: card() });
|
|
40
|
+
await landed;
|
|
41
|
+
const { operations } = JSON.parse(String(init?.body)) as { operations: { value: string }[] };
|
|
42
|
+
color = operations[0]!.value;
|
|
43
|
+
return Response.json({ ok: true, changed: true, matched: true, content: card() });
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
const live = makePreview(card());
|
|
47
|
+
const player = mountPlayerWithPreview(live);
|
|
48
|
+
const element = live.contentDocument!.querySelector<HTMLElement>("[data-hf-id]")!;
|
|
49
|
+
const selection = {
|
|
50
|
+
element,
|
|
51
|
+
label: "Card",
|
|
52
|
+
tagName: "div",
|
|
53
|
+
sourceFile: "index.html",
|
|
54
|
+
compositionPath: "index.html",
|
|
55
|
+
textFields: [],
|
|
56
|
+
inlineStyles: { color: "red" },
|
|
57
|
+
computedStyles: {},
|
|
58
|
+
dataAttributes: {},
|
|
59
|
+
capabilities: { canSelect: true, canEditStyles: true },
|
|
60
|
+
hfId: "hf-card",
|
|
61
|
+
selector: '[data-hf-id="hf-card"]',
|
|
62
|
+
selectorIndex: 0,
|
|
63
|
+
} as unknown as DomEditSelection;
|
|
64
|
+
let commits: ReturnType<typeof useDomEditCommits> | null = null;
|
|
65
|
+
function Inspector() {
|
|
66
|
+
commits = useDomEditCommits({
|
|
67
|
+
activeCompPath: "index.html",
|
|
68
|
+
previewIframeRef: player.getApi().iframeRef,
|
|
69
|
+
showToast: () => {},
|
|
70
|
+
queueDomEditSave: (save) => save(),
|
|
71
|
+
writeProjectFile: async () => {},
|
|
72
|
+
editHistory: { recordEdit: async () => {} },
|
|
73
|
+
fileTree: [],
|
|
74
|
+
importedFontAssetsRef: { current: [] },
|
|
75
|
+
projectId: "p1",
|
|
76
|
+
projectIdRef: { current: "p1" },
|
|
77
|
+
reloadPreview: () => {},
|
|
78
|
+
domEditSelection: selection,
|
|
79
|
+
applyDomSelection: () => {},
|
|
80
|
+
clearDomSelection: () => {},
|
|
81
|
+
refreshDomEditSelectionFromPreview: () => {},
|
|
82
|
+
buildDomSelectionFromTarget: async () => null,
|
|
83
|
+
readOnlyPreview: false,
|
|
84
|
+
});
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const root = createRoot(document.createElement("div"));
|
|
88
|
+
act(() => root.render(createElement(Inspector)));
|
|
89
|
+
|
|
90
|
+
act(() => player.getApi().refreshPlayer());
|
|
91
|
+
const beforeSave = makePreview(card(), "?_t=1");
|
|
92
|
+
let saved: Promise<unknown> = Promise.resolve();
|
|
93
|
+
act(() => void (saved = commits!.handleDomStyleCommit("color", "green")));
|
|
94
|
+
await act(async () => {
|
|
95
|
+
land();
|
|
96
|
+
await saved;
|
|
97
|
+
});
|
|
98
|
+
expect(color).toBe("green");
|
|
99
|
+
|
|
100
|
+
const requested = await paintShadow(player.getApi, beforeSave);
|
|
101
|
+
expect(player.getApi().iframeRef.current, "the file before the save").toBe(live);
|
|
102
|
+
const fresh = makePreview(card(), "?_t=2");
|
|
103
|
+
expect(await paintShadow(player.getApi, fresh)).toBeGreaterThan(requested);
|
|
104
|
+
expect(player.getApi().iframeRef.current).toBe(fresh);
|
|
105
|
+
act(() => root.unmount());
|
|
106
|
+
act(() => player.root.unmount());
|
|
107
|
+
});
|
|
@@ -16,6 +16,7 @@ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
|
16
16
|
const domDelete = vi.fn(async () => undefined);
|
|
17
17
|
const historyUndo = vi.fn(async () => ({ ok: false }));
|
|
18
18
|
const historyRedo = vi.fn(async () => ({ ok: false }));
|
|
19
|
+
const settle = vi.fn(async () => undefined);
|
|
19
20
|
let root: Root | null = null;
|
|
20
21
|
let sync: ((iframe: HTMLIFrameElement | null) => void) | null = null;
|
|
21
22
|
|
|
@@ -49,7 +50,7 @@ function Harness() {
|
|
|
49
50
|
writeProjectFile: vi.fn(async () => undefined),
|
|
50
51
|
showToast: vi.fn(),
|
|
51
52
|
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
|
52
|
-
|
|
53
|
+
settlePendingEdits: settle,
|
|
53
54
|
handleCopy: vi.fn(() => false),
|
|
54
55
|
handlePaste: vi.fn(() => false),
|
|
55
56
|
handleCut: vi.fn(() => false),
|
|
@@ -69,6 +70,8 @@ afterEach(() => {
|
|
|
69
70
|
domDelete.mockClear();
|
|
70
71
|
historyUndo.mockClear();
|
|
71
72
|
historyRedo.mockClear();
|
|
73
|
+
settle.mockReset();
|
|
74
|
+
settle.mockImplementation(async () => undefined);
|
|
72
75
|
vi.mocked(trackStudioEvent).mockClear();
|
|
73
76
|
});
|
|
74
77
|
|
|
@@ -122,6 +125,18 @@ describe("preview iframe hotkey forwarding", () => {
|
|
|
122
125
|
expect(trackStudioEvent).toHaveBeenCalledWith("keyboard_shortcut", { action: "undo" });
|
|
123
126
|
});
|
|
124
127
|
|
|
128
|
+
it("settles pending edits, failed ones included, before Cmd+Z steps the history", async () => {
|
|
129
|
+
let settled!: () => void;
|
|
130
|
+
settle.mockImplementation(
|
|
131
|
+
() => new Promise<undefined>((resolve) => (settled = () => resolve(undefined))),
|
|
132
|
+
);
|
|
133
|
+
await press(mountWithPreview(), { key: "z", ctrlKey: true });
|
|
134
|
+
expect(settle).toHaveBeenCalledTimes(1);
|
|
135
|
+
expect(historyUndo).not.toHaveBeenCalled();
|
|
136
|
+
await act(async () => settled());
|
|
137
|
+
expect(historyUndo).toHaveBeenCalledTimes(1);
|
|
138
|
+
});
|
|
139
|
+
|
|
125
140
|
it.each([
|
|
126
141
|
["Shift+Cmd+Z", { key: "Z", metaKey: true, shiftKey: true }],
|
|
127
142
|
["Ctrl+Y", { key: "y", ctrlKey: true }],
|
|
@@ -61,6 +61,9 @@ afterEach(() => {
|
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
describe("keys on a focused slider", () => {
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
document.body.innerHTML = "";
|
|
66
|
+
});
|
|
64
67
|
const slider = () => {
|
|
65
68
|
const el = document.createElement("div");
|
|
66
69
|
el.setAttribute("role", "slider");
|
|
@@ -85,6 +88,20 @@ describe("keys on a focused slider", () => {
|
|
|
85
88
|
expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
|
|
86
89
|
});
|
|
87
90
|
|
|
91
|
+
it.each(["video", "audio"])("leaves Delete to a focused <%s controls>", (tag) => {
|
|
92
|
+
usePlayerStore.setState({
|
|
93
|
+
elements: [bgmElement],
|
|
94
|
+
selectedElementId: "bgm",
|
|
95
|
+
selectedElementIds: new Set(["bgm"]),
|
|
96
|
+
});
|
|
97
|
+
const player = document.body.appendChild(document.createElement(tag));
|
|
98
|
+
player.setAttribute("controls", "");
|
|
99
|
+
const cb = callbacks();
|
|
100
|
+
dispatchPlainKey(from(player, { key: "Delete" }), "delete", cb);
|
|
101
|
+
expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
|
|
102
|
+
expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
88
105
|
it("still undoes with Cmd+Z while a slider has focus", () => {
|
|
89
106
|
const cb = callbacks();
|
|
90
107
|
dispatchModifierKey(from(slider(), { key: "z", metaKey: true }), "z", cb);
|
|
@@ -86,7 +86,7 @@ function Harness() {
|
|
|
86
86
|
writeProjectFile: vi.fn(async () => undefined),
|
|
87
87
|
showToast: vi.fn(),
|
|
88
88
|
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
|
89
|
-
|
|
89
|
+
settlePendingEdits: vi.fn(async () => undefined),
|
|
90
90
|
handleCopy: vi.fn(() => false),
|
|
91
91
|
handlePaste: vi.fn(async () => undefined),
|
|
92
92
|
handleCut: vi.fn(async () => false),
|
|
@@ -85,7 +85,7 @@ interface UseAppHotkeysParams {
|
|
|
85
85
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
86
86
|
syncHistoryPreviewAfterApply: UseEditHistoryActionsOptions["syncHistoryPreviewAfterApply"];
|
|
87
87
|
showHistoryRestoreNow?: UseEditHistoryActionsOptions["showHistoryRestoreNow"];
|
|
88
|
-
|
|
88
|
+
settlePendingEdits: () => Promise<void>;
|
|
89
89
|
handleCopy: () => boolean;
|
|
90
90
|
handlePaste: () => Promise<void>;
|
|
91
91
|
handleCut: () => Promise<boolean>;
|
|
@@ -124,7 +124,7 @@ export function useAppHotkeys({
|
|
|
124
124
|
showToast,
|
|
125
125
|
syncHistoryPreviewAfterApply,
|
|
126
126
|
showHistoryRestoreNow,
|
|
127
|
-
|
|
127
|
+
settlePendingEdits,
|
|
128
128
|
handleCopy,
|
|
129
129
|
handlePaste,
|
|
130
130
|
handleCut,
|
|
@@ -151,7 +151,7 @@ export function useAppHotkeys({
|
|
|
151
151
|
showToast,
|
|
152
152
|
syncHistoryPreviewAfterApply,
|
|
153
153
|
showHistoryRestoreNow,
|
|
154
|
-
waitForPendingDomEditSaves,
|
|
154
|
+
waitForPendingDomEditSaves: settlePendingEdits,
|
|
155
155
|
onAfterUndoRedo,
|
|
156
156
|
activeCompPath,
|
|
157
157
|
forceReloadSdkSession,
|
|
@@ -216,6 +216,23 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
216
216
|
input.remove();
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
+
describe("with a focused picker", () => {
|
|
220
|
+
const picker = document.createElement("button");
|
|
221
|
+
picker.setAttribute("role", "combobox");
|
|
222
|
+
afterEach(() => picker.remove());
|
|
223
|
+
|
|
224
|
+
it("is inert while a picker that owns typing has focus", () => {
|
|
225
|
+
usePlayerStore
|
|
226
|
+
.getState()
|
|
227
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }));
|
|
228
|
+
const { onCommit } = setup({});
|
|
229
|
+
document.body.append(picker);
|
|
230
|
+
picker.focus();
|
|
231
|
+
key("Delete");
|
|
232
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
219
236
|
it("Cmd+C copies the active selection", () => {
|
|
220
237
|
clearAutomationClipboard();
|
|
221
238
|
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type HfAutomationLane,
|
|
32
32
|
} from "@hyperframes/core/audio-automation";
|
|
33
33
|
import { clampNumber } from "../utils/studioHelpers";
|
|
34
|
+
import { isTypingTarget } from "../utils/typingTarget";
|
|
34
35
|
import type { AutomationSelection } from "../player/store/automationSelectionSlice";
|
|
35
36
|
import type {
|
|
36
37
|
AutomationLaneBinding,
|
|
@@ -39,13 +40,6 @@ import type {
|
|
|
39
40
|
|
|
40
41
|
type PlayerState = ReturnType<typeof usePlayerStore.getState>;
|
|
41
42
|
|
|
42
|
-
function isTextInput(el: Element | null): boolean {
|
|
43
|
-
if (!el) return false;
|
|
44
|
-
const tag = el.tagName;
|
|
45
|
-
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
46
|
-
return el instanceof HTMLElement && el.isContentEditable;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
43
|
/**
|
|
50
44
|
* A Cmd/Ctrl+<letter> chord. `e.key` is normalised because CapsLock makes it
|
|
51
45
|
* "V"/"C", and useAppHotkeys already lowercases — a raw `e.key === "v"` test
|
|
@@ -302,7 +296,7 @@ function handleCopy(
|
|
|
302
296
|
* dead key beats today's double write.
|
|
303
297
|
*/
|
|
304
298
|
export function automationOwnsKey(e: KeyboardEvent): boolean {
|
|
305
|
-
if (
|
|
299
|
+
if (isTypingTarget(document.activeElement)) return false;
|
|
306
300
|
const state = usePlayerStore.getState();
|
|
307
301
|
if (isChord(e, "c")) return state.automationSelection !== null;
|
|
308
302
|
if (!isChord(e, "v")) return false;
|
|
@@ -340,7 +334,7 @@ export function useAutomationSelectionKeyboard({
|
|
|
340
334
|
}): void {
|
|
341
335
|
useEffect(() => {
|
|
342
336
|
const handler = (e: KeyboardEvent): void => {
|
|
343
|
-
if (
|
|
337
|
+
if (isTypingTarget(document.activeElement)) return;
|
|
344
338
|
// Somebody upstream already claimed this key. useAppHotkeys is on
|
|
345
339
|
// window/capture so it always runs first, and it deliberately lets a
|
|
346
340
|
// keyframe selection outrank an automation range on Delete — without
|
|
@@ -5,8 +5,11 @@ import {
|
|
|
5
5
|
getDomEditTargetKey,
|
|
6
6
|
type DomEditSelection,
|
|
7
7
|
} from "../components/editor/domEditing";
|
|
8
|
-
import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
9
|
-
import {
|
|
8
|
+
import type { CommitDomAttributeBatch, PersistDomEditOperations } from "./domEditCommitTypes";
|
|
9
|
+
import {
|
|
10
|
+
DomEditPersistPreparedWriteError,
|
|
11
|
+
reportDomEditPersistFailure,
|
|
12
|
+
} from "./domEditPersistFailure";
|
|
10
13
|
import { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner";
|
|
11
14
|
import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
|
|
12
15
|
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
@@ -54,6 +57,15 @@ export function resolveOptimisticAttributeValue(attr: string, value: string | nu
|
|
|
54
57
|
return value === "false" && HTML_BOOLEAN_ATTRIBUTES.has(attr) ? null : value;
|
|
55
58
|
}
|
|
56
59
|
|
|
60
|
+
function previewAttributeName(op: PatchOperation): string {
|
|
61
|
+
return op.type === "attribute" ? resolveFullAttrName(op.property, true) : op.property;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function previewAttributeValue(op: PatchOperation): string | null {
|
|
65
|
+
if (op.type === "html-attribute") return resolveOptimisticAttributeValue(op.property, op.value);
|
|
66
|
+
return op.value === "" ? null : op.value;
|
|
67
|
+
}
|
|
68
|
+
|
|
57
69
|
function setOrRemovePreviewAttribute(
|
|
58
70
|
el: HTMLElement,
|
|
59
71
|
fullAttr: string,
|
|
@@ -209,7 +221,7 @@ export function useDomEditAttributeCommits({
|
|
|
209
221
|
],
|
|
210
222
|
);
|
|
211
223
|
|
|
212
|
-
// Commits several data-* attributes on the SAME element in ONE persist call
|
|
224
|
+
// Commits several data-* and HTML attributes on the SAME element in ONE persist call
|
|
213
225
|
// — needed when two attributes together describe a single logical value
|
|
214
226
|
// (e.g. a pinned timing range's start+duration): committing them through two
|
|
215
227
|
// separate sequential `commitDataAttribute` calls leaves a window where the
|
|
@@ -221,32 +233,30 @@ export function useDomEditAttributeCommits({
|
|
|
221
233
|
// explicit, caller-supplied `selection` (not the "current" one) closes both
|
|
222
234
|
// gaps — matching `onCommitAnimatedProperties`'s same-shaped fix for GSAP
|
|
223
235
|
// property batches.
|
|
224
|
-
const
|
|
236
|
+
const commitAttributeOps = useCallback(
|
|
225
237
|
async (
|
|
226
238
|
selection: DomEditSelection,
|
|
227
|
-
|
|
228
|
-
options: DataAttributeCommitOptions
|
|
229
|
-
|
|
239
|
+
ops: PatchOperation[],
|
|
240
|
+
options: DataAttributeCommitOptions & {
|
|
241
|
+
prepareContent?: (html: string) => string;
|
|
242
|
+
syncAutomation?: boolean;
|
|
243
|
+
},
|
|
244
|
+
): Promise<boolean> => {
|
|
230
245
|
const iframe = previewIframeRef.current;
|
|
231
|
-
const entries =
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
value,
|
|
246
|
+
const entries = ops.map((op) => ({
|
|
247
|
+
fullAttr: previewAttributeName(op),
|
|
248
|
+
value: previewAttributeValue(op),
|
|
235
249
|
}));
|
|
236
|
-
const commitKey = `${options.coalescePrefix}:${
|
|
237
|
-
.map((
|
|
250
|
+
const commitKey = `${options.coalescePrefix}:${ops
|
|
251
|
+
.map((op) => op.property)
|
|
238
252
|
.sort()
|
|
239
253
|
.join(",")}:${getDomEditTargetKey(selection)}`;
|
|
240
254
|
const isLatestCommit = bumpDomEditCommitMapVersion(
|
|
241
255
|
domAttributeCommitVersionRef.current,
|
|
242
256
|
commitKey,
|
|
243
257
|
);
|
|
244
|
-
const ops: PatchOperation[] = entries.map((entry) => ({
|
|
245
|
-
type: "attribute",
|
|
246
|
-
property: entry.attr,
|
|
247
|
-
value: entry.value,
|
|
248
|
-
}));
|
|
249
258
|
let captured: CapturedMultiAttributeElement | null = null;
|
|
259
|
+
let landed = false;
|
|
250
260
|
|
|
251
261
|
await runDomEditCommit({
|
|
252
262
|
capture: () => {
|
|
@@ -260,8 +270,7 @@ export function useDomEditAttributeCommits({
|
|
|
260
270
|
apply: () => {
|
|
261
271
|
if (!captured) return;
|
|
262
272
|
for (const entry of entries) {
|
|
263
|
-
|
|
264
|
-
setOrRemovePreviewAttribute(captured.element, entry.fullAttr, nextValue);
|
|
273
|
+
setOrRemovePreviewAttribute(captured.element, entry.fullAttr, entry.value);
|
|
265
274
|
}
|
|
266
275
|
},
|
|
267
276
|
persist: () =>
|
|
@@ -269,8 +278,10 @@ export function useDomEditAttributeCommits({
|
|
|
269
278
|
label: options.label,
|
|
270
279
|
coalesceKey: commitKey,
|
|
271
280
|
skipRefresh: options.skipRefresh,
|
|
281
|
+
prepareContent: options.prepareContent,
|
|
272
282
|
}),
|
|
273
|
-
shouldRevert: () =>
|
|
283
|
+
shouldRevert: (error) =>
|
|
284
|
+
isLatestCommit() && !(error instanceof DomEditPersistPreparedWriteError),
|
|
274
285
|
revert: () => {
|
|
275
286
|
if (!captured) return;
|
|
276
287
|
for (const entry of entries) {
|
|
@@ -283,10 +294,19 @@ export function useDomEditAttributeCommits({
|
|
|
283
294
|
},
|
|
284
295
|
onError: (error) => reportDomEditPersistFailure(selection, ops, error, showToast),
|
|
285
296
|
shouldResync: () => isLatestCommit() && !!options.refreshAfter,
|
|
286
|
-
resync: () =>
|
|
287
|
-
|
|
297
|
+
resync: () => {
|
|
298
|
+
refreshDomEditSelectionFromPreview(selection);
|
|
299
|
+
if (options.syncAutomation) {
|
|
300
|
+
syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
onSettled: (ok) => {
|
|
304
|
+
landed = ok;
|
|
305
|
+
options.onSettled?.(ok);
|
|
306
|
+
},
|
|
288
307
|
onFinally: isLatestCommit.release,
|
|
289
308
|
});
|
|
309
|
+
return landed;
|
|
290
310
|
},
|
|
291
311
|
[
|
|
292
312
|
activeCompPath,
|
|
@@ -299,14 +319,35 @@ export function useDomEditAttributeCommits({
|
|
|
299
319
|
|
|
300
320
|
const handleDomAttributesCommit = useCallback(
|
|
301
321
|
async (selection: DomEditSelection, attrs: Record<string, string>) => {
|
|
302
|
-
await
|
|
303
|
-
|
|
304
|
-
|
|
322
|
+
await commitAttributeOps(
|
|
323
|
+
selection,
|
|
324
|
+
Object.entries(attrs).map(([property, value]) => ({
|
|
325
|
+
type: "attribute",
|
|
326
|
+
property,
|
|
327
|
+
value,
|
|
328
|
+
})),
|
|
329
|
+
{
|
|
330
|
+
label: "Edit timing",
|
|
331
|
+
coalescePrefix: "attrs",
|
|
332
|
+
skipRefresh: false,
|
|
333
|
+
refreshAfter: true,
|
|
334
|
+
},
|
|
335
|
+
);
|
|
336
|
+
},
|
|
337
|
+
[commitAttributeOps],
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const handleDomAttributeBatchCommit: CommitDomAttributeBatch = useCallback(
|
|
341
|
+
(selection, ops, options) =>
|
|
342
|
+
commitAttributeOps(selection, ops, {
|
|
343
|
+
label: options.label,
|
|
344
|
+
coalescePrefix: "attr-batch",
|
|
305
345
|
skipRefresh: false,
|
|
306
346
|
refreshAfter: true,
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
347
|
+
syncAutomation: true,
|
|
348
|
+
prepareContent: options.prepareContent,
|
|
349
|
+
}),
|
|
350
|
+
[commitAttributeOps],
|
|
310
351
|
);
|
|
311
352
|
|
|
312
353
|
const handleDomAttributeCommit = useCallback(
|
|
@@ -438,5 +479,6 @@ export function useDomEditAttributeCommits({
|
|
|
438
479
|
handleDomAttributeQuietCommit,
|
|
439
480
|
handleDomHtmlAttributeCommit,
|
|
440
481
|
handleDomAttributesCommit,
|
|
482
|
+
handleDomAttributeBatchCommit,
|
|
441
483
|
};
|
|
442
484
|
}
|