@hyperframes/studio 0.8.123 → 0.8.125
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-yABdb6yk.js → hyperframes-player-CmIh-wq9.js} +1 -1
- package/dist/assets/{index-D77MIjSJ.js → index-4LRR6o2F.js} +1 -1
- package/dist/assets/{index-Dt7lWtY7.js → index-CrYoEZ5p.js} +139 -139
- package/dist/assets/{index-LrNlT5Tg.css → index-D11atkBt.css} +1 -1
- package/dist/assets/{index-DWjF2Kxw.js → index-yCK8osBg.js} +1 -1
- package/dist/index.d.ts +43 -35
- package/dist/index.html +2 -2
- package/dist/index.js +283 -143
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/OpenInDesktopButton.dom.test.tsx +27 -10
- package/src/components/OpenInDesktopButton.tsx +22 -15
- package/src/components/editor/domEditOverlayGestures.ts +2 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +6 -0
- package/src/components/editor/dragUndoPaint.test.ts +170 -2
- package/src/components/editor/gestureUndoRevert.ts +104 -0
- package/src/components/editor/manualOffsetDrag.ts +1 -12
- package/src/components/editor/useDomEditNudge.ts +1 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +9 -5
- package/src/hooks/gestureTransaction.test.ts +60 -0
- package/src/hooks/gestureTransaction.ts +5 -2
- package/src/hooks/gsapGestureOutcome.ts +9 -2
- package/src/hooks/gsapResizeDropPoint.test.ts +39 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +27 -0
- package/src/hooks/gsapResizeIntercept.ts +55 -68
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +29 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
- package/src/hooks/useGsapAwareEditing.ts +21 -25
- package/src/hooks/useGsapAwareGroupMove.ts +4 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +42 -1
- package/src/hooks/useGsapScriptCommits.ts +12 -3
- package/src/player/components/AudibleVideoClipContent.test.tsx +67 -0
- package/src/player/components/AudibleVideoClipContent.tsx +5 -4
- package/src/player/components/AudioWaveform.tsx +2 -1
- package/src/player/components/ClipPeakMarks.tsx +5 -2
- package/src/player/components/OutOfSyncBadge.tsx +1 -1
- package/src/player/components/Timeline.trackPadding.test.tsx +1 -3
- package/src/player/components/TimelineCanvas.tsx +1 -0
- package/src/player/components/TimelineClip.filmstrip.test.tsx +27 -0
- package/src/player/components/TimelineClipFades.test.tsx +2 -1
- package/src/player/components/timelineClipDragPreview.test.ts +31 -0
- package/src/player/components/timelineClipDragPreview.ts +42 -37
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +11 -0
- package/src/styles/components.css +33 -3
- package/src/utils/studioPendingEdits.test.ts +52 -0
- package/src/utils/studioPendingEdits.ts +55 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.125",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/sdk": "0.8.
|
|
67
|
-
"@hyperframes/shader-transitions": "0.8.
|
|
68
|
-
"@hyperframes/studio-server": "0.8.
|
|
63
|
+
"@hyperframes/parsers": "0.8.125",
|
|
64
|
+
"@hyperframes/player": "0.8.125",
|
|
65
|
+
"@hyperframes/core": "0.8.125",
|
|
66
|
+
"@hyperframes/sdk": "0.8.125",
|
|
67
|
+
"@hyperframes/shader-transitions": "0.8.125",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.125"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.125"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* "Edit with Framey": shown
|
|
4
|
+
* "Edit with Framey": shown where the preview server has the app or its download to offer; a press opens the project in the app,
|
|
5
5
|
* or introduces Framey with the download while the app cannot take it or is missing.
|
|
6
6
|
*/
|
|
7
7
|
import React, { act } from "react";
|
|
@@ -45,7 +45,7 @@ async function mount(): Promise<HTMLElement> {
|
|
|
45
45
|
|
|
46
46
|
const downloadHref = () =>
|
|
47
47
|
[...document.querySelectorAll("a")]
|
|
48
|
-
.find((a) => a.textContent?.includes("Get
|
|
48
|
+
.find((a) => a.textContent?.includes("Get the desktop app"))
|
|
49
49
|
?.getAttribute("href");
|
|
50
50
|
|
|
51
51
|
const button = () =>
|
|
@@ -68,31 +68,48 @@ it("stays hidden where the server has no hand-off (the desktop's own Studio)", a
|
|
|
68
68
|
expect(button()).toBeNull();
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
it("stays hidden when the
|
|
72
|
-
serve(Response.json({ available: false }));
|
|
71
|
+
it("stays hidden when the app has no build for this machine (Windows)", async () => {
|
|
72
|
+
serve(Response.json({ available: false, handoff: true, downloadUrl: null }));
|
|
73
73
|
await mount();
|
|
74
74
|
expect(button()).toBeNull();
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
it("with the app installed but no download (Windows), shows only once the app can take the project", async () => {
|
|
78
|
+
serve(Response.json({ available: true, handoff: false, downloadUrl: null }));
|
|
79
|
+
await mount();
|
|
80
|
+
expect(button()).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("opens the project in an installed app that has no download (Windows)", async () => {
|
|
84
|
+
serve(Response.json({ available: true, handoff: true, downloadUrl: null }), {
|
|
85
|
+
opened: true,
|
|
86
|
+
app: "the HyperFrames desktop app",
|
|
87
|
+
});
|
|
88
|
+
await mount();
|
|
89
|
+
await act(async () => button()!.click());
|
|
90
|
+
expect(posts).toBe(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
77
93
|
it("while the app cannot take a project yet, introduces Framey with the download and opens nothing", async () => {
|
|
78
|
-
|
|
94
|
+
const linux = `${DOWNLOAD}?os=linux`;
|
|
95
|
+
serve(Response.json({ available: true, handoff: false, downloadUrl: linux }), { opened: true });
|
|
79
96
|
await mount();
|
|
80
97
|
await act(async () => button()!.click());
|
|
81
98
|
expect(posts).toBe(0);
|
|
82
99
|
expect(document.body.textContent).toContain("Meet Framey");
|
|
83
|
-
expect(downloadHref()).toBe(
|
|
100
|
+
expect(downloadHref()).toBe(linux);
|
|
84
101
|
});
|
|
85
102
|
|
|
86
103
|
it("opens the project in the app and says so", async () => {
|
|
87
|
-
serve(Response.json({ available: true, handoff: true }), {
|
|
104
|
+
serve(Response.json({ available: true, handoff: true, downloadUrl: DOWNLOAD }), {
|
|
88
105
|
opened: true,
|
|
89
|
-
|
|
106
|
+
app: "the HyperFrames desktop app",
|
|
90
107
|
});
|
|
91
108
|
await mount();
|
|
92
109
|
await act(async () => button()!.click());
|
|
93
110
|
expect(posts).toBe(1);
|
|
94
111
|
expect(showToast).toHaveBeenCalledWith(
|
|
95
|
-
"Framey is opening this project in HyperFrames
|
|
112
|
+
"Framey is opening this project in the HyperFrames desktop app",
|
|
96
113
|
"info",
|
|
97
114
|
);
|
|
98
115
|
expect(button()!.dataset.opening).toBe("true");
|
|
@@ -100,7 +117,7 @@ it("opens the project in the app and says so", async () => {
|
|
|
100
117
|
});
|
|
101
118
|
|
|
102
119
|
it("introduces Framey and offers the download when the app is missing", async () => {
|
|
103
|
-
serve(Response.json({ available: true, handoff: true }), {
|
|
120
|
+
serve(Response.json({ available: true, handoff: true, downloadUrl: DOWNLOAD }), {
|
|
104
121
|
opened: false,
|
|
105
122
|
reason: "not-installed",
|
|
106
123
|
downloadUrl: DOWNLOAD,
|
|
@@ -8,7 +8,6 @@ import { Button, buttonBase, buttonSizes, buttonVariants, cn, Popover } from "./
|
|
|
8
8
|
// "Edit with Framey": the preview server (`/api/open-in-desktop`) says whether to show it and whether the app can
|
|
9
9
|
// take the project yet (its gate is the CLI's HANDOFF_READY); until then a press introduces Framey with the download.
|
|
10
10
|
const ROUTE = "/api/open-in-desktop";
|
|
11
|
-
const DOWNLOAD_URL = "https://hyperframes.dev/studio/download";
|
|
12
11
|
/** How far the pupils travel towards the pointer, in the glyph's own units. */
|
|
13
12
|
const GAZE = 2.2;
|
|
14
13
|
/** Long enough for the flight to finish before the label comes back. */
|
|
@@ -16,15 +15,20 @@ const OPENING_MS = 1600;
|
|
|
16
15
|
|
|
17
16
|
type OpenResult = { opened: true } | { opened: false; reason: string; downloadUrl: string };
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
type DesktopRoute = { handoff: boolean; downloadUrl: string | null };
|
|
19
|
+
|
|
20
|
+
/** Null hides the button: no route (the app's own Studio), or nothing here to open or download. */
|
|
21
|
+
function useDesktopRoute(): DesktopRoute | null {
|
|
22
|
+
const [route, setRoute] = useState<DesktopRoute | null>(null);
|
|
22
23
|
useEffect(() => {
|
|
23
24
|
let live = true;
|
|
24
25
|
studioApiFetch(ROUTE)
|
|
25
26
|
.then((res) => (res.ok ? res.json() : null))
|
|
26
|
-
.then((body: { available?: unknown; handoff?: unknown } | null) => {
|
|
27
|
-
if (live
|
|
27
|
+
.then((body: { available?: unknown; handoff?: unknown; downloadUrl?: unknown } | null) => {
|
|
28
|
+
if (!live || body?.available !== true) return;
|
|
29
|
+
const handoff = body.handoff === true;
|
|
30
|
+
const downloadUrl = typeof body.downloadUrl === "string" ? body.downloadUrl : null;
|
|
31
|
+
if (handoff || downloadUrl) setRoute({ handoff, downloadUrl });
|
|
28
32
|
})
|
|
29
33
|
.catch(() => {});
|
|
30
34
|
return () => {
|
|
@@ -74,7 +78,7 @@ export function OpenInDesktopButton() {
|
|
|
74
78
|
const open = async () => {
|
|
75
79
|
if (!route.handoff) {
|
|
76
80
|
trackStudioEvent("toolbar_action", { action: "open_in_desktop" });
|
|
77
|
-
setDownloadUrl(
|
|
81
|
+
setDownloadUrl(route.downloadUrl);
|
|
78
82
|
return;
|
|
79
83
|
}
|
|
80
84
|
if (opening) return;
|
|
@@ -84,7 +88,7 @@ export function OpenInDesktopButton() {
|
|
|
84
88
|
const res = await studioApiFetch(ROUTE, { method: "POST" });
|
|
85
89
|
const result = (await res.json()) as OpenResult;
|
|
86
90
|
if (result.opened) {
|
|
87
|
-
showToast("Framey is opening this project in HyperFrames
|
|
91
|
+
showToast("Framey is opening this project in the HyperFrames desktop app", "info");
|
|
88
92
|
await new Promise((done) => setTimeout(done, OPENING_MS));
|
|
89
93
|
} else setDownloadUrl(result.downloadUrl);
|
|
90
94
|
} catch {
|
|
@@ -114,11 +118,16 @@ export function OpenInDesktopButton() {
|
|
|
114
118
|
variant="ghost"
|
|
115
119
|
data-testid="header-open-in-desktop"
|
|
116
120
|
data-opening={opening || undefined}
|
|
117
|
-
title="Open this project in HyperFrames
|
|
118
|
-
className="hf-framey-trigger gap-1.5 pl-1
|
|
119
|
-
icon={<FrameyGlyph size={
|
|
121
|
+
title="Open this project in the HyperFrames desktop app and edit it with Framey"
|
|
122
|
+
className="hf-framey-trigger gap-1.5 border border-accent/40 bg-accent/10 pl-1 font-semibold text-text-0 enabled:hover:bg-accent/20"
|
|
123
|
+
icon={<FrameyGlyph size={24} className="hf-framey" eyeRef={eye} />}
|
|
120
124
|
>
|
|
121
125
|
{opening ? "Opening in the app…" : "Edit with Framey"}
|
|
126
|
+
{!opening && (
|
|
127
|
+
<span className="rounded-full bg-accent px-1.5 text-[10px] leading-4 font-bold text-on-accent">
|
|
128
|
+
New
|
|
129
|
+
</span>
|
|
130
|
+
)}
|
|
122
131
|
</Button>
|
|
123
132
|
}
|
|
124
133
|
>
|
|
@@ -126,9 +135,7 @@ export function OpenInDesktopButton() {
|
|
|
126
135
|
<FrameyGlyph size={36} />
|
|
127
136
|
<p className="text-step-12 font-medium text-text-0">Meet Framey</p>
|
|
128
137
|
</div>
|
|
129
|
-
<p className="mt-2 text-text-3">
|
|
130
|
-
I live in HyperFrames Studio, the free desktop app for macOS:
|
|
131
|
-
</p>
|
|
138
|
+
<p className="mt-2 text-balance text-text-3">I live in the free HyperFrames desktop app:</p>
|
|
132
139
|
<ul className="mt-1.5 grid gap-1 text-text-1">
|
|
133
140
|
<li>Chat to change anything in the video</li>
|
|
134
141
|
<li>Draw on a frame to point at it</li>
|
|
@@ -145,7 +152,7 @@ export function OpenInDesktopButton() {
|
|
|
145
152
|
className={cn(buttonBase, buttonVariants.primary, buttonSizes.md)}
|
|
146
153
|
onClick={() => trackStudioEvent("toolbar_action", { action: "get_desktop_app" })}
|
|
147
154
|
>
|
|
148
|
-
Get
|
|
155
|
+
Get the desktop app
|
|
149
156
|
</a>
|
|
150
157
|
</div>
|
|
151
158
|
</Popover>
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
StudioRotationSnapshot,
|
|
7
7
|
} from "./manualEdits";
|
|
8
8
|
import type { ManualOffsetDragMember } from "./manualOffsetDrag";
|
|
9
|
+
import type { StudioElementLook } from "./gestureUndoRevert";
|
|
9
10
|
import type { CssRotationTarget, RotationCommit } from "./rotationDraft";
|
|
10
11
|
import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
|
|
11
12
|
import type { SnapContext } from "./snapTargetCollection";
|
|
@@ -57,6 +58,7 @@ export interface GestureState {
|
|
|
57
58
|
initialPathOffset: StudioPathOffsetSnapshot;
|
|
58
59
|
initialRotation: StudioRotationSnapshot;
|
|
59
60
|
initialBoxSize: StudioBoxSizeSnapshot;
|
|
61
|
+
initialLook: StudioElementLook;
|
|
60
62
|
pathOffsetMember?: ManualOffsetDragMember;
|
|
61
63
|
originLeft: number;
|
|
62
64
|
originTop: number;
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
restoreManualOffsetDragMembers,
|
|
10
10
|
type ManualOffsetDragMember,
|
|
11
11
|
} from "./manualOffsetDrag";
|
|
12
|
+
import { readElementLook } from "./gestureUndoRevert";
|
|
12
13
|
import { readCssRotationTarget, readRotationBase } from "./rotationDraft";
|
|
13
14
|
import {
|
|
14
15
|
beginStudioManualEditGesture,
|
|
@@ -179,6 +180,10 @@ export function startGesture(
|
|
|
179
180
|
Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1;
|
|
180
181
|
const contentScaleY =
|
|
181
182
|
Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1;
|
|
183
|
+
const initialLook = readElementLook(
|
|
184
|
+
sel.element,
|
|
185
|
+
kind !== "drag" && !editsPlainCss(sel.element, kind === "rotate" ? "rotate" : "resize"),
|
|
186
|
+
);
|
|
182
187
|
let initialPathOffset = captureStudioPathOffset(sel.element);
|
|
183
188
|
let manualEditDragToken: string | undefined;
|
|
184
189
|
let pathOffsetMember: ManualOffsetDragMember | undefined;
|
|
@@ -274,6 +279,7 @@ export function startGesture(
|
|
|
274
279
|
initialPathOffset,
|
|
275
280
|
initialRotation: captureStudioRotation(sel.element),
|
|
276
281
|
initialBoxSize: captureStudioBoxSize(sel.element),
|
|
282
|
+
initialLook,
|
|
277
283
|
pathOffsetMember,
|
|
278
284
|
originLeft: rect.left,
|
|
279
285
|
originTop: rect.top,
|
|
@@ -1,15 +1,40 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { afterEach, expect, it, vi } from "vitest";
|
|
3
|
+
import { savePlainRotation } from "../../hooks/plainRotation";
|
|
3
4
|
import type { DomEditSelection } from "./domEditing";
|
|
4
5
|
import type { GestureState } from "./domEditOverlayGestures";
|
|
5
6
|
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
6
7
|
import {
|
|
8
|
+
adoptingStudioPendingEdit,
|
|
7
9
|
hasStudioPendingEdits,
|
|
8
10
|
paintBackNewestStudioPendingEdit,
|
|
11
|
+
type StudioEditInFlight,
|
|
9
12
|
} from "../../utils/studioPendingEdits";
|
|
10
13
|
|
|
14
|
+
const gsapOwns = vi.hoisted(() => ({ on: false }));
|
|
15
|
+
vi.mock("../../hooks/gsapRuntimeKeyframes", async (importOriginal) => ({
|
|
16
|
+
...(await importOriginal<typeof import("../../hooks/gsapRuntimeKeyframes")>()),
|
|
17
|
+
editsPlainCss: () => !gsapOwns.on,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
/** GSAP's own transform values per element, as `gsap.set` / `gsap.getProperty` see them. */
|
|
21
|
+
function fakeGsap(start: Record<string, number>) {
|
|
22
|
+
const values = new Map<Element, Record<string, number>>();
|
|
23
|
+
const of = (el: Element) => values.get(el) ?? values.set(el, { ...start }).get(el)!;
|
|
24
|
+
gsapOwns.on = true;
|
|
25
|
+
Object.assign(window, {
|
|
26
|
+
gsap: {
|
|
27
|
+
set: (el: Element, props: Record<string, number>) => Object.assign(of(el), props),
|
|
28
|
+
getProperty: (el: Element, prop: string) => of(el)[prop] ?? 0,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
return of;
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
afterEach(() => {
|
|
12
35
|
document.body.innerHTML = "";
|
|
36
|
+
gsapOwns.on = false;
|
|
37
|
+
Reflect.deleteProperty(window, "gsap");
|
|
13
38
|
});
|
|
14
39
|
|
|
15
40
|
const ref = <T>(current: T) => ({ current });
|
|
@@ -24,7 +49,7 @@ const pointer = (x: number, y: number) => ({
|
|
|
24
49
|
});
|
|
25
50
|
|
|
26
51
|
/** Drags a box without GSAP 100 px right and 60 px down; its save waits for `save`. */
|
|
27
|
-
function dragWithSaveRunning(save: Promise<void>) {
|
|
52
|
+
function dragWithSaveRunning(save: Promise<void> | (() => Promise<void>)) {
|
|
28
53
|
const element = document.createElement("div");
|
|
29
54
|
element.style.setProperty("translate", "40px 30px");
|
|
30
55
|
document.body.append(element);
|
|
@@ -39,7 +64,7 @@ function dragWithSaveRunning(save: Promise<void>) {
|
|
|
39
64
|
rafPausedRef: ref(false),
|
|
40
65
|
onManualDragStartRef: ref(vi.fn()),
|
|
41
66
|
onBlockedMoveRef: ref(vi.fn()),
|
|
42
|
-
onPathOffsetCommitRef: ref(vi.fn(() => save)),
|
|
67
|
+
onPathOffsetCommitRef: ref(vi.fn(typeof save === "function" ? save : () => save)),
|
|
43
68
|
snapGuidesRef: ref(null),
|
|
44
69
|
groupGestureRef: ref(null),
|
|
45
70
|
blockedMoveRef: ref(null),
|
|
@@ -69,3 +94,146 @@ it("a drag whose save is still running can be painted back at once, and shown ag
|
|
|
69
94
|
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
70
95
|
expect(paintBackNewestStudioPendingEdit()).toBeNull();
|
|
71
96
|
});
|
|
97
|
+
|
|
98
|
+
/** Resizes or rotates a plain 240x160 box; the commit draws what it saves at once and its save waits for `save`. */
|
|
99
|
+
function gestureWithSaveRunning(kind: "resize" | "rotate", save: Promise<void>) {
|
|
100
|
+
const element = document.createElement("div");
|
|
101
|
+
element.setAttribute(
|
|
102
|
+
"style",
|
|
103
|
+
"position: absolute; width: 240px; height: 160px; clip-path: inset(10px)",
|
|
104
|
+
);
|
|
105
|
+
document.body.append(element);
|
|
106
|
+
const selection = {
|
|
107
|
+
element,
|
|
108
|
+
capabilities: { canApplyManualSize: true, canApplyManualRotation: true },
|
|
109
|
+
};
|
|
110
|
+
const handlers = createDomEditOverlayGestureHandlers({
|
|
111
|
+
selectionRef: ref(selection as unknown as DomEditSelection),
|
|
112
|
+
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
|
|
113
|
+
boxRef: ref(document.createElement("div")),
|
|
114
|
+
overlayRef: ref(null),
|
|
115
|
+
iframeRef: ref(null),
|
|
116
|
+
gestureRef: ref<GestureState | null>(null),
|
|
117
|
+
rafPausedRef: ref(false),
|
|
118
|
+
onBoxSizeCommitRef: ref(
|
|
119
|
+
vi.fn(() => {
|
|
120
|
+
element.style.setProperty("clip-path", "inset(15px)");
|
|
121
|
+
return save;
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
onRotationCommitRef: ref((sel: DomEditSelection, next: never) =>
|
|
125
|
+
savePlainRotation({ commitPositionPatchToHtml: () => save.then(() => undefined) }, sel, next),
|
|
126
|
+
),
|
|
127
|
+
snapGuidesRef: ref(null),
|
|
128
|
+
groupGestureRef: ref(null),
|
|
129
|
+
blockedMoveRef: ref(null),
|
|
130
|
+
setOverlayRect: vi.fn(),
|
|
131
|
+
suppressNextBoxClickRef: ref(false),
|
|
132
|
+
hoverSelectionRef: ref(null),
|
|
133
|
+
onCanvasMouseDown: vi.fn(),
|
|
134
|
+
} as never);
|
|
135
|
+
const start = kind === "resize" ? pointer(240, 160) : pointer(240, 80);
|
|
136
|
+
expect(handlers.startGesture(kind, start as never, { resizeHandle: "se" })).toBe(true);
|
|
137
|
+
const end = kind === "resize" ? pointer(300, 200) : pointer(120, 200);
|
|
138
|
+
handlers.onPointerMove(end as never);
|
|
139
|
+
handlers.onPointerUp(end as never);
|
|
140
|
+
return element;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
it.each(["resize", "rotate"] as const)(
|
|
144
|
+
"a %s whose save is still running can be painted back at once, and shown again",
|
|
145
|
+
async (kind) => {
|
|
146
|
+
let saved!: () => void;
|
|
147
|
+
const element = gestureWithSaveRunning(kind, new Promise<void>((resolve) => (saved = resolve)));
|
|
148
|
+
const before = "position: absolute; width: 240px; height: 160px; clip-path: inset(10px)";
|
|
149
|
+
const edited = element.getAttribute("style");
|
|
150
|
+
expect(edited).not.toBe(before);
|
|
151
|
+
|
|
152
|
+
const shown = paintBackNewestStudioPendingEdit();
|
|
153
|
+
expect(element.getAttribute("style")).toBe(before);
|
|
154
|
+
expect(paintBackNewestStudioPendingEdit()).toBeNull();
|
|
155
|
+
shown!.showAgain();
|
|
156
|
+
expect(element.getAttribute("style")).toBe(edited);
|
|
157
|
+
|
|
158
|
+
saved();
|
|
159
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
it("a drag of a box GSAP positions is painted back to where GSAP had it, and shown again", async () => {
|
|
164
|
+
const gsapOf = fakeGsap({ x: 5, y: 7 });
|
|
165
|
+
let saved!: () => void;
|
|
166
|
+
const element = dragWithSaveRunning(new Promise<void>((resolve) => (saved = resolve)));
|
|
167
|
+
const moved = { ...gsapOf(element) };
|
|
168
|
+
expect(moved).not.toMatchObject({ x: 5, y: 7 });
|
|
169
|
+
|
|
170
|
+
const shown = paintBackNewestStudioPendingEdit();
|
|
171
|
+
expect(gsapOf(element)).toMatchObject({ x: 5, y: 7 });
|
|
172
|
+
shown!.showAgain();
|
|
173
|
+
expect(gsapOf(element)).toMatchObject({ x: moved.x, y: moved.y });
|
|
174
|
+
|
|
175
|
+
saved();
|
|
176
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("a rotate of a box GSAP turns is painted back to GSAP's angle at press, and shown again", async () => {
|
|
180
|
+
const gsapOf = fakeGsap({ rotation: 10 });
|
|
181
|
+
let saved!: () => void;
|
|
182
|
+
const element = gestureWithSaveRunning(
|
|
183
|
+
"rotate",
|
|
184
|
+
new Promise<void>((resolve) => (saved = resolve)),
|
|
185
|
+
);
|
|
186
|
+
const turned = gsapOf(element).rotation;
|
|
187
|
+
expect(turned).not.toBe(10);
|
|
188
|
+
|
|
189
|
+
const shown = paintBackNewestStudioPendingEdit();
|
|
190
|
+
expect(gsapOf(element).rotation).toBe(10);
|
|
191
|
+
shown!.showAgain();
|
|
192
|
+
expect(gsapOf(element).rotation).toBe(turned);
|
|
193
|
+
|
|
194
|
+
saved();
|
|
195
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("a GSAP drag saved as a left/top offset stays painted back while that offset is drawn", async () => {
|
|
199
|
+
fakeGsap({ x: 5, y: 7 });
|
|
200
|
+
let saved!: () => void;
|
|
201
|
+
let drawn!: Promise<void>;
|
|
202
|
+
const element = dragWithSaveRunning(() => {
|
|
203
|
+
const edit = adoptingStudioPendingEdit()!;
|
|
204
|
+
drawn = new Promise<void>((resolve) => (saved = resolve)).then(() =>
|
|
205
|
+
edit.drawKeepingUndone(() => void element.style.setProperty("left", "99px")),
|
|
206
|
+
);
|
|
207
|
+
return drawn;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const shown = paintBackNewestStudioPendingEdit();
|
|
211
|
+
saved();
|
|
212
|
+
await drawn;
|
|
213
|
+
expect(element.style.getPropertyValue("left")).toBe("");
|
|
214
|
+
shown!.showAgain();
|
|
215
|
+
expect(element.style.getPropertyValue("left")).toBe("99px");
|
|
216
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("keeps a GSAP drag's base through every repaint of its undo, so its save adds the drag once", async () => {
|
|
220
|
+
const gsapOf = fakeGsap({ x: 5, y: 7 });
|
|
221
|
+
let edit!: StudioEditInFlight;
|
|
222
|
+
let release!: () => void;
|
|
223
|
+
const element = dragWithSaveRunning(() => {
|
|
224
|
+
edit = adoptingStudioPendingEdit()!;
|
|
225
|
+
return new Promise<void>((resolve) => (release = resolve));
|
|
226
|
+
});
|
|
227
|
+
const moved = { ...gsapOf(element) };
|
|
228
|
+
|
|
229
|
+
const shown = paintBackNewestStudioPendingEdit();
|
|
230
|
+
edit.drawKeepingUndone(() => undefined);
|
|
231
|
+
expect(gsapOf(element)).toMatchObject({ x: 5, y: 7 });
|
|
232
|
+
expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe("5");
|
|
233
|
+
shown!.showAgain();
|
|
234
|
+
expect(gsapOf(element)).toMatchObject({ x: moved.x, y: moved.y });
|
|
235
|
+
expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe("5");
|
|
236
|
+
|
|
237
|
+
release();
|
|
238
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
239
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
captureStudioPathOffset,
|
|
3
|
+
restoreStudioPathOffset,
|
|
4
|
+
type StudioPathOffsetSnapshot,
|
|
5
|
+
} from "./manualEdits";
|
|
6
|
+
import {
|
|
7
|
+
getOffsetDragGsap,
|
|
8
|
+
stampGestureBase,
|
|
9
|
+
type ManualOffsetDragMember,
|
|
10
|
+
} from "./manualOffsetDrag";
|
|
11
|
+
import type { StudioEditRevert } from "../../utils/studioPendingEdits";
|
|
12
|
+
|
|
13
|
+
interface MemberPosition {
|
|
14
|
+
offset: StudioPathOffsetSnapshot;
|
|
15
|
+
gsap: { x: number; y: number } | null;
|
|
16
|
+
placement: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Where an element-offset move draws the element when no transform channel can take it.
|
|
20
|
+
const PLACEMENT = ["position", "left", "top"];
|
|
21
|
+
const placementOf = (element: HTMLElement) =>
|
|
22
|
+
PLACEMENT.map((prop) => element.style.getPropertyValue(prop));
|
|
23
|
+
|
|
24
|
+
function showPlacement(element: HTMLElement, placement: string[]): void {
|
|
25
|
+
PLACEMENT.forEach((prop, i) => {
|
|
26
|
+
if (placement[i]) element.style.setProperty(prop, placement[i]!);
|
|
27
|
+
else element.style.removeProperty(prop);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readMemberPosition(member: ManualOffsetDragMember): MemberPosition {
|
|
32
|
+
const gsap = member.plainTranslate ? null : getOffsetDragGsap(member.element);
|
|
33
|
+
return {
|
|
34
|
+
offset: captureStudioPathOffset(member.element),
|
|
35
|
+
gsap: gsap && {
|
|
36
|
+
x: Number(gsap.getProperty(member.element, "x")),
|
|
37
|
+
y: Number(gsap.getProperty(member.element, "y")),
|
|
38
|
+
},
|
|
39
|
+
placement: placementOf(member.element),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function showMemberPosition(member: ManualOffsetDragMember, position: MemberPosition): void {
|
|
44
|
+
restoreStudioPathOffset(member.element, position.offset);
|
|
45
|
+
showPlacement(member.element, position.placement);
|
|
46
|
+
if (position.gsap) getOffsetDragGsap(member.element)?.set(member.element, { ...position.gsap });
|
|
47
|
+
// The restore drops the gesture's base stamps; its save still reads them.
|
|
48
|
+
if (!member.plainTranslate)
|
|
49
|
+
stampGestureBase(member.element, member.initialOffset, member.baseGsap);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Undo's live revert of a move: its members at gesture start. */
|
|
53
|
+
export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert {
|
|
54
|
+
const startPlacement = members.map((member) => placementOf(member.element));
|
|
55
|
+
return () => {
|
|
56
|
+
const shown = members.map(readMemberPosition);
|
|
57
|
+
members.forEach((member, i) =>
|
|
58
|
+
showMemberPosition(member, {
|
|
59
|
+
offset: member.initialPathOffset,
|
|
60
|
+
gsap: member.plainTranslate ? null : member.baseGsap,
|
|
61
|
+
placement: startPlacement[i]!,
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
return () => members.forEach((member, i) => showMemberPosition(member, shown[i]!));
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface StudioElementLook {
|
|
69
|
+
style: string | null;
|
|
70
|
+
gsap: Record<string, number> | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const GSAP_LOOK_PROPS = ["x", "y", "rotation", "scaleX", "scaleY"];
|
|
74
|
+
|
|
75
|
+
/** Reads GSAP only for an element it owns: reading a plain one makes GSAP bake its CSS into a transform. */
|
|
76
|
+
export function readElementLook(element: HTMLElement, gsapOwned: boolean): StudioElementLook {
|
|
77
|
+
const gsap = gsapOwned ? getOffsetDragGsap(element) : null;
|
|
78
|
+
return {
|
|
79
|
+
style: element.getAttribute("style"),
|
|
80
|
+
gsap:
|
|
81
|
+
gsap &&
|
|
82
|
+
Object.fromEntries(
|
|
83
|
+
GSAP_LOOK_PROPS.map((prop) => [prop, Number(gsap.getProperty(element, prop))]),
|
|
84
|
+
),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function showElementLook(element: HTMLElement, look: StudioElementLook): void {
|
|
89
|
+
if (look.style === null) element.removeAttribute("style");
|
|
90
|
+
else element.setAttribute("style", look.style);
|
|
91
|
+
if (look.gsap) getOffsetDragGsap(element)?.set(element, { ...look.gsap });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Undo's live revert of a resize or rotate: the element as it looked at press. */
|
|
95
|
+
export function elementLookRevert(
|
|
96
|
+
element: HTMLElement,
|
|
97
|
+
start: StudioElementLook,
|
|
98
|
+
): StudioEditRevert {
|
|
99
|
+
return () => {
|
|
100
|
+
const shown = readElementLook(element, start.gsap !== null);
|
|
101
|
+
showElementLook(element, start);
|
|
102
|
+
return () => showElementLook(element, shown);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
14
14
|
import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
|
|
15
15
|
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
|
|
16
|
-
import type { StudioEditRevert } from "../../utils/studioPendingEdits";
|
|
17
16
|
|
|
18
17
|
interface OffsetDragGsap {
|
|
19
18
|
set: (el: Element, vars: Record<string, number | string>) => void;
|
|
@@ -448,7 +447,7 @@ export function applyManualOffsetDragDraft(
|
|
|
448
447
|
* the element flies off-screen the instant you drop it. The member holds the
|
|
449
448
|
* true gesture-start values in JS, immune to the re-render.
|
|
450
449
|
*/
|
|
451
|
-
function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
|
|
450
|
+
export function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
|
|
452
451
|
el.setAttribute("data-hf-drag-gsap-base-x", String(baseGsap.x));
|
|
453
452
|
el.setAttribute("data-hf-drag-gsap-base-y", String(baseGsap.y));
|
|
454
453
|
el.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
|
|
@@ -510,16 +509,6 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
|
|
|
510
509
|
endStudioManualEditGesture(member.element, member.gestureToken);
|
|
511
510
|
}
|
|
512
511
|
|
|
513
|
-
/** Undo's live revert of a move: its members at gesture start. Null off the plain route, which GSAP draws. */
|
|
514
|
-
export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert | null {
|
|
515
|
-
if (!members.every((member) => member.plainTranslate)) return null;
|
|
516
|
-
return () => {
|
|
517
|
-
const shown = members.map((member) => captureStudioPathOffset(member.element));
|
|
518
|
-
for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
519
|
-
return () => members.forEach((member, i) => restoreStudioPathOffset(member.element, shown[i]!));
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
|
-
|
|
523
512
|
/** Roll back a FAILED drag to the exact gesture-start state. */
|
|
524
513
|
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
|
|
525
514
|
for (const member of members) {
|
|
@@ -32,10 +32,10 @@ import {
|
|
|
32
32
|
applyManualOffsetNudgeDraft,
|
|
33
33
|
createManualOffsetDragMember,
|
|
34
34
|
endManualOffsetDragMembers,
|
|
35
|
-
manualOffsetMoveRevert,
|
|
36
35
|
restoreManualOffsetDragMembers,
|
|
37
36
|
type ManualOffsetDragMember,
|
|
38
37
|
} from "./manualOffsetDrag";
|
|
38
|
+
import { manualOffsetMoveRevert } from "./gestureUndoRevert";
|
|
39
39
|
import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
|
|
40
40
|
import {
|
|
41
41
|
CANVAS_NUDGE_COMMIT_DEBOUNCE_MS,
|
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
applyManualOffsetDragDraft,
|
|
13
13
|
endManualOffsetDragMembers,
|
|
14
14
|
restoreManualOffsetDragMembers,
|
|
15
|
-
manualOffsetMoveRevert,
|
|
16
15
|
} from "./manualOffsetDrag";
|
|
16
|
+
import { manualOffsetMoveRevert, elementLookRevert } from "./gestureUndoRevert";
|
|
17
17
|
import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
|
|
18
18
|
import {
|
|
19
19
|
applyStudioBoxSize,
|
|
@@ -443,7 +443,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
443
443
|
// Hold the final angle while the commit lands.
|
|
444
444
|
applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
|
|
445
445
|
const commit = { ...finalRotation, plain: g.plainRotation };
|
|
446
|
-
|
|
446
|
+
const edit = beginStudioPendingEdit(elementLookRevert(sel.element, g.initialLook));
|
|
447
|
+
const saved = Promise.resolve(edit.adopt(() => opts.onRotationCommitRef.current(sel, commit)))
|
|
447
448
|
.then((result) => trackPreviewEditResult("rotate", "drag", result))
|
|
448
449
|
.catch((error) => {
|
|
449
450
|
logGestureCommitFailure("rotate commit failed", error);
|
|
@@ -454,6 +455,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
454
455
|
restoreRotation();
|
|
455
456
|
})
|
|
456
457
|
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
|
|
458
|
+
edit.settle(saved);
|
|
457
459
|
} else if (g.kind === "drag") {
|
|
458
460
|
// A moved drag (taps returned earlier) must not let the release click
|
|
459
461
|
// re-select whatever now sits under the pointer — dropping over a
|
|
@@ -535,9 +537,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
535
537
|
restoreStudioBoxSize(sel.element, g.initialBoxSize);
|
|
536
538
|
if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
|
|
537
539
|
};
|
|
538
|
-
|
|
539
|
-
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member)
|
|
540
|
-
)
|
|
540
|
+
const commitSize = () =>
|
|
541
|
+
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member);
|
|
542
|
+
const edit = beginStudioPendingEdit(elementLookRevert(sel.element, g.initialLook));
|
|
543
|
+
const saved = Promise.resolve(edit.adopt(commitSize))
|
|
541
544
|
.then((result) => trackPreviewEditResult("resize", "drag", result))
|
|
542
545
|
.catch((error) => {
|
|
543
546
|
logGestureCommitFailure("resize commit failed", error);
|
|
@@ -546,6 +549,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
546
549
|
if (member) endManualOffsetDragMembers([member]);
|
|
547
550
|
else endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
|
548
551
|
});
|
|
552
|
+
edit.settle(saved);
|
|
549
553
|
logResizeSettle(sel.element, "post-release");
|
|
550
554
|
}
|
|
551
555
|
};
|