@hyperframes/studio 0.8.97 → 0.8.99
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-Biu5_W0l.js → hyperframes-player-Rml34FRW.js} +1 -1
- package/dist/assets/{index-BdV5T-QI.js → index-CVDArg3m.js} +188 -188
- package/dist/assets/{index-B5Z-1RW9.js → index-C_fi-jTe.js} +1 -1
- package/dist/assets/{index-CWuozokW.js → index-D9YzDuBx.js} +1 -1
- package/dist/index.d.ts +16 -4
- package/dist/index.html +1 -1
- package/dist/index.js +758 -607
- package/dist/index.js.map +1 -1
- package/package.json +10 -9
- package/src/components/EditorShell.tsx +7 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
- package/src/components/editor/DomEditCropHandles.tsx +30 -62
- package/src/components/editor/DomEditOverlay.test.ts +2 -2
- package/src/components/editor/DomEditOverlay.tsx +0 -27
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
- package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
- package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
- package/src/components/editor/cropResize.test.ts +129 -0
- package/src/components/editor/cropResize.ts +143 -0
- package/src/components/editor/domEditOverlayCrop.ts +65 -15
- package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
- package/src/components/editor/domEditOverlayGestures.ts +2 -5
- package/src/components/editor/elementPositionOffset.ts +5 -4
- package/src/components/editor/manualEdits.test.ts +22 -8
- package/src/components/editor/manualEditsDom.ts +27 -11
- package/src/components/editor/manualEditsParsing.ts +0 -4
- package/src/components/editor/manualEditsSnapshot.ts +3 -0
- package/src/components/editor/manualOffsetDrag.test.ts +14 -0
- package/src/components/editor/propertyPanelFont.test.tsx +48 -5
- package/src/components/editor/propertyPanelFont.tsx +2 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
- package/src/components/nle/TimelinePane.tsx +4 -0
- package/src/hooks/domSelectionTestHarness.ts +8 -0
- package/src/hooks/draggedGsapPosition.ts +4 -2
- package/src/hooks/gsapDragCommit.ts +6 -6
- package/src/hooks/gsapEditOutcome.ts +6 -0
- package/src/hooks/gsapResizeIntercept.ts +5 -5
- package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
- package/src/hooks/gsapRuntimeBridge.ts +2 -1
- package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +34 -21
- package/src/hooks/timelineTimingSync.ts +40 -4
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
- package/src/hooks/useCommitPreflightCapabilities.ts +3 -11
- package/src/hooks/useCropOverlay.ts +8 -2
- package/src/hooks/useDomEditCommits.test.tsx +6 -1
- package/src/hooks/useDomEditCommits.ts +1 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommit.ts +1 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +112 -0
- package/src/hooks/useDomGeometryCommits.ts +5 -0
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
- package/src/hooks/useGsapAwareEditing.test.tsx +101 -5
- package/src/hooks/useGsapAwareEditing.ts +37 -23
- package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
- package/src/hooks/useGsapTweenCache.ts +0 -24
- package/src/hooks/useTimelineEditing.test.tsx +190 -135
- package/src/hooks/useTimelineEditing.ts +13 -13
- package/src/hooks/useTimelineGroupEditing.ts +13 -9
- package/src/index.ts +4 -1
- package/src/player/components/ClipContextMenu.test.tsx +182 -0
- package/src/player/components/ClipContextMenu.tsx +52 -10
- package/src/player/components/TimelineOverlays.test.ts +30 -7
- package/src/player/components/TimelineOverlays.tsx +7 -1
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTypes.ts +10 -0
- package/src/player/components/menuKeyboardNav.ts +12 -5
- package/src/player/components/useTimelineProviderState.tsx +2 -0
- package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
- package/src/utils/rounding.ts +5 -0
- package/src/utils/sdkEditTransaction.ts +10 -3
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
6
|
+
import { makeSelection } from "../../hooks/domSelectionTestHarness";
|
|
7
|
+
import type { DomEditSelection } from "./domEditing";
|
|
8
|
+
import "./domEditOverlayTestMocks";
|
|
9
|
+
import { DomEditOverlay } from "./DomEditOverlay";
|
|
10
|
+
|
|
11
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
|
+
HTMLElement.prototype.setPointerCapture ??= () => {};
|
|
13
|
+
HTMLElement.prototype.releasePointerCapture ??= () => {};
|
|
14
|
+
|
|
15
|
+
const pointTarget = vi.hoisted(() => ({ current: null as HTMLElement | null }));
|
|
16
|
+
const rect = vi.hoisted(() => ({ current: null as Record<string, number> | null }));
|
|
17
|
+
vi.mock("../../utils/studioPreviewHelpers", async (importOriginal) => ({
|
|
18
|
+
...(await importOriginal<typeof import("../../utils/studioPreviewHelpers")>()),
|
|
19
|
+
getPreviewTargetFromPointer: () => pointTarget.current,
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("./useDomEditOverlayRects", () => ({
|
|
22
|
+
useDomEditOverlayRects: () => ({
|
|
23
|
+
overlayRect: rect.current,
|
|
24
|
+
overlayRectRef: rect,
|
|
25
|
+
setOverlayRect: () => undefined,
|
|
26
|
+
hoverRect: null,
|
|
27
|
+
groupOverlayItems: [],
|
|
28
|
+
groupOverlayItemsRef: { current: [] },
|
|
29
|
+
setGroupOverlayItems: () => undefined,
|
|
30
|
+
childRects: [],
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
let root: Root;
|
|
35
|
+
const onCanvasMouseDown = vi.fn();
|
|
36
|
+
const onSelectionChange = vi.fn();
|
|
37
|
+
|
|
38
|
+
function mount() {
|
|
39
|
+
const host = document.createElement("div");
|
|
40
|
+
document.body.append(host);
|
|
41
|
+
root = createRoot(host);
|
|
42
|
+
const element = document.createElement("h1");
|
|
43
|
+
document.body.append(element);
|
|
44
|
+
return { host, element, hover: makeSelection("Title", element) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function render(
|
|
48
|
+
hoverSelection: DomEditSelection | null,
|
|
49
|
+
selection: DomEditSelection | null = null,
|
|
50
|
+
) {
|
|
51
|
+
act(() =>
|
|
52
|
+
root.render(
|
|
53
|
+
<DomEditOverlay
|
|
54
|
+
iframeRef={{ current: document.createElement("iframe") }}
|
|
55
|
+
activeCompositionPath={null}
|
|
56
|
+
selection={selection}
|
|
57
|
+
hoverSelection={hoverSelection}
|
|
58
|
+
onCanvasMouseDown={onCanvasMouseDown}
|
|
59
|
+
onCanvasPointerMove={() => Promise.resolve(null)}
|
|
60
|
+
onCanvasPointerLeave={() => undefined}
|
|
61
|
+
onSelectionChange={onSelectionChange}
|
|
62
|
+
onBlockedMove={() => undefined}
|
|
63
|
+
onPathOffsetCommit={() => undefined}
|
|
64
|
+
onGroupPathOffsetCommit={() => undefined}
|
|
65
|
+
onBoxSizeCommit={() => undefined}
|
|
66
|
+
onRotationCommit={() => undefined}
|
|
67
|
+
onMarqueeSelect={() => undefined}
|
|
68
|
+
/>,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Like Chrome: a default-prevented pointerdown sends no compatibility mousedown.
|
|
74
|
+
function press(target: Element, shiftKey = false) {
|
|
75
|
+
const init = { bubbles: true, cancelable: true, button: 0, pointerId: 1, shiftKey };
|
|
76
|
+
const down = new PointerEvent("pointerdown", init);
|
|
77
|
+
act(() => void target.dispatchEvent(down));
|
|
78
|
+
if (!down.defaultPrevented)
|
|
79
|
+
act(() => void target.dispatchEvent(new MouseEvent("mousedown", init)));
|
|
80
|
+
act(() => void target.dispatchEvent(new PointerEvent("pointerup", init)));
|
|
81
|
+
act(() => void target.dispatchEvent(new MouseEvent("click", init)));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
act(() => root.unmount());
|
|
86
|
+
onCanvasMouseDown.mockClear();
|
|
87
|
+
onSelectionChange.mockClear();
|
|
88
|
+
pointTarget.current = null;
|
|
89
|
+
rect.current = null;
|
|
90
|
+
document.body.innerHTML = "";
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it.each([
|
|
94
|
+
["a press on empty canvas", false],
|
|
95
|
+
["a shift+click add", true],
|
|
96
|
+
])("after %s, the next click on an element selects it", (_, shiftFirst) => {
|
|
97
|
+
const { host, element, hover } = mount();
|
|
98
|
+
const overlay = () => host.firstElementChild!;
|
|
99
|
+
|
|
100
|
+
pointTarget.current = shiftFirst ? element : null;
|
|
101
|
+
render(shiftFirst ? hover : null);
|
|
102
|
+
press(overlay(), shiftFirst);
|
|
103
|
+
expect(onCanvasMouseDown).not.toHaveBeenCalled();
|
|
104
|
+
|
|
105
|
+
pointTarget.current = element;
|
|
106
|
+
render(hover);
|
|
107
|
+
press(overlay());
|
|
108
|
+
expect(onCanvasMouseDown).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("a shift+click on a selected box that cannot move toggles it once", () => {
|
|
112
|
+
const { host, element, hover } = mount();
|
|
113
|
+
hover.capabilities.canApplyManualOffset = false;
|
|
114
|
+
rect.current = { left: 20, top: 30, width: 100, height: 40, editScaleX: 1, editScaleY: 1 };
|
|
115
|
+
pointTarget.current = element;
|
|
116
|
+
render(hover, hover);
|
|
117
|
+
|
|
118
|
+
press(host.querySelector('[data-dom-edit-selection-box="true"]')!, true);
|
|
119
|
+
|
|
120
|
+
expect(onSelectionChange).toHaveBeenCalledTimes(1);
|
|
121
|
+
expect(onCanvasMouseDown).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { prepareCropResize, readCropFollowingResize, saveCropResize } from "./cropResize";
|
|
4
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
5
|
+
import {
|
|
6
|
+
applyStudioBoxSizeDraft,
|
|
7
|
+
captureStudioBoxSize,
|
|
8
|
+
clearStudioBoxSize,
|
|
9
|
+
restoreStudioBoxSize,
|
|
10
|
+
} from "./manualEdits";
|
|
11
|
+
import { withInlineLayoutBox } from "../../hooks/domSelectionTestHarness";
|
|
12
|
+
|
|
13
|
+
function sizedElement(width: number, height: number, clip: string): HTMLElement {
|
|
14
|
+
const el = withInlineLayoutBox(document.createElement("div"));
|
|
15
|
+
el.style.cssText = `width: ${width}px; height: ${height}px; clip-path: ${clip}`;
|
|
16
|
+
return el;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("crop during a resize", () => {
|
|
20
|
+
it("follows the box only while a resize draft is live", () => {
|
|
21
|
+
const el = sizedElement(300, 200, "inset(10px 60px 20px 30px round 8px)");
|
|
22
|
+
const before = captureStudioBoxSize(el);
|
|
23
|
+
const crop = (top: number, right: number, bottom: number, left: number) => ({
|
|
24
|
+
top,
|
|
25
|
+
right,
|
|
26
|
+
bottom,
|
|
27
|
+
left,
|
|
28
|
+
radius: 8,
|
|
29
|
+
});
|
|
30
|
+
applyStudioBoxSizeDraft(el, { width: 600, height: 400 });
|
|
31
|
+
expect(readCropFollowingResize(el)).toEqual(crop(20, 120, 40, 60));
|
|
32
|
+
restoreStudioBoxSize(el, before);
|
|
33
|
+
// An animated width is not a resize: the crop keeps its pixels.
|
|
34
|
+
el.style.width = "600px";
|
|
35
|
+
expect(readCropFollowingResize(el)).toEqual(crop(10, 60, 20, 30));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("leaves the crop alone when the box kept its size (a resize saved as scale)", () => {
|
|
39
|
+
const el = sizedElement(300, 200, "inset(0px 60px 0px 0px)");
|
|
40
|
+
applyStudioBoxSizeDraft(el, { width: 450, height: 300 });
|
|
41
|
+
const stage = prepareCropResize(el);
|
|
42
|
+
clearStudioBoxSize(el);
|
|
43
|
+
el.style.width = "300px";
|
|
44
|
+
el.style.height = "200px";
|
|
45
|
+
expect(stage()).toBeNull();
|
|
46
|
+
expect(el.style.getPropertyValue("clip-path")).toBe("inset(0px 60px 0px 0px)");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("puts the crop back, !important and all, when its save fails", async () => {
|
|
50
|
+
const el = sizedElement(300, 200, "none");
|
|
51
|
+
el.style.setProperty("clip-path", "inset(0px 60px 0px 0px)", "important");
|
|
52
|
+
const stage = prepareCropResize(el);
|
|
53
|
+
el.style.width = "450px";
|
|
54
|
+
let priorityWhileSaving = "";
|
|
55
|
+
const commit = vi.fn(() => {
|
|
56
|
+
priorityWhileSaving = el.style.getPropertyPriority("clip-path");
|
|
57
|
+
return Promise.reject(new Error("save failed"));
|
|
58
|
+
});
|
|
59
|
+
const selection = { element: el } as unknown as DomEditSelection;
|
|
60
|
+
await expect(saveCropResize(stage, selection, commit, "undo-key")).rejects.toThrow(
|
|
61
|
+
"save failed",
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const value = "inset(0px 90px 0px 0px) !important";
|
|
65
|
+
expect(commit.mock.calls[0]![1]).toEqual([
|
|
66
|
+
{ type: "inline-style", property: "clip-path", value },
|
|
67
|
+
]);
|
|
68
|
+
expect(priorityWhileSaving).toBe("important");
|
|
69
|
+
expect(el.style.getPropertyValue("clip-path")).toBe("inset(0px 60px 0px 0px)");
|
|
70
|
+
expect(el.style.getPropertyPriority("clip-path")).toBe("important");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("scales the crop once: the stage ends the draft", () => {
|
|
74
|
+
const el = sizedElement(300, 200, "inset(0px 60px 0px 0px)");
|
|
75
|
+
applyStudioBoxSizeDraft(el, { width: 450, height: 300 });
|
|
76
|
+
prepareCropResize(el)();
|
|
77
|
+
expect(readCropFollowingResize(el)).toMatchObject({ right: 90 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("leaves a crop edited while the size saved, and a tweened crop, alone", () => {
|
|
81
|
+
const edited = sizedElement(300, 200, "inset(0px 60px 0px 0px)");
|
|
82
|
+
const stageEdited = prepareCropResize(edited);
|
|
83
|
+
edited.style.width = "450px";
|
|
84
|
+
edited.style.setProperty("clip-path", "inset(0px 100px 0px 0px)");
|
|
85
|
+
expect(stageEdited()).toBeNull();
|
|
86
|
+
|
|
87
|
+
const tweened = sizedElement(300, 200, "inset(0px 60px 0px 0px)");
|
|
88
|
+
const child = { targets: () => [tweened], vars: { clipPath: "inset(0px 120px 0px 0px)" } };
|
|
89
|
+
Object.assign(window, { __timelines: { main: { getChildren: () => [child] } } });
|
|
90
|
+
const stageTweened = prepareCropResize(tweened);
|
|
91
|
+
tweened.style.width = "450px";
|
|
92
|
+
expect(stageTweened()).toBeNull();
|
|
93
|
+
Object.assign(window, { __timelines: undefined });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("decides before the write, which may add a width tween of its own", () => {
|
|
97
|
+
const el = sizedElement(300, 200, "inset(0px 60px 0px 0px)");
|
|
98
|
+
const stage = prepareCropResize(el);
|
|
99
|
+
const tween = { targets: () => [el], vars: { width: 600 }, duration: () => 4 };
|
|
100
|
+
Object.assign(window, { __timelines: { main: { getChildren: () => [tween] } } });
|
|
101
|
+
el.style.width = "600px";
|
|
102
|
+
expect(stage()?.patch.value).toBe("inset(0px 120px 0px 0px)");
|
|
103
|
+
Object.assign(window, { __timelines: undefined });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("follows only the axes GSAP does not tween, while dragging and once saved", () => {
|
|
107
|
+
const resize = (vars: object, duration: number) => {
|
|
108
|
+
const el = sizedElement(300, 200, "inset(20px 60px 20px 0px)");
|
|
109
|
+
const tween = { targets: () => [el], vars, duration: () => duration };
|
|
110
|
+
// The tween's timeline is not the first key, as after a soft reload.
|
|
111
|
+
const timelines = { sub: { getChildren: () => [] }, main: { getChildren: () => [tween] } };
|
|
112
|
+
Object.assign(window, { __timelines: timelines });
|
|
113
|
+
applyStudioBoxSizeDraft(el, { width: 600, height: 400 });
|
|
114
|
+
const { top, right } = readCropFollowingResize(el)!;
|
|
115
|
+
const saved = prepareCropResize(el)()?.patch.value ?? null;
|
|
116
|
+
Object.assign(window, { __timelines: undefined });
|
|
117
|
+
return [[top, right], saved];
|
|
118
|
+
};
|
|
119
|
+
// A held width still scales; a height tween leaves the width free.
|
|
120
|
+
expect(resize({ width: 473 }, 0)).toEqual([[40, 120], "inset(40px 120px 40px 0px)"]);
|
|
121
|
+
expect(resize({ height: 300 }, 4)).toEqual([[20, 120], "inset(20px 120px 20px 0px)"]);
|
|
122
|
+
// A width tween, in either keyframe form, leaves the height free.
|
|
123
|
+
const widthOnly = [[40, 60], "inset(40px 60px 40px 0px)"];
|
|
124
|
+
const percent = { "0%": { width: 300 }, "100%": { width: 600 } };
|
|
125
|
+
expect(resize({ keyframes: percent }, 4)).toEqual(widthOnly);
|
|
126
|
+
expect(resize({ keyframes: { width: [300, 600] } }, 4)).toEqual(widthOnly);
|
|
127
|
+
expect(resize({ width: 600, height: 400 }, 4)).toEqual([[20, 60], null]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
2
|
+
import type { CommitMutation } from "../../hooks/gsapScriptCommitTypes";
|
|
3
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
4
|
+
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
|
|
5
|
+
import { elementHasNonHoldTween } from "../../hooks/gsapRuntimeKeyframes";
|
|
6
|
+
import { buildInsetClipPathSides, type ParsedInsetClipPathSides } from "./clipPathHelpers";
|
|
7
|
+
import { hasCropInsets, readElementCropInsets } from "./domEditOverlayCrop";
|
|
8
|
+
import { forgetStudioBoxSizeDraftBase, readStudioBoxSizeDraftBase } from "./manualEditsDom";
|
|
9
|
+
|
|
10
|
+
interface Box {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const boxOf = (element: HTMLElement): Box => ({
|
|
16
|
+
width: element.offsetWidth,
|
|
17
|
+
height: element.offsetHeight,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
type Follows = Record<keyof Box, boolean>;
|
|
21
|
+
|
|
22
|
+
/** Per axis, whether a crop follows a resize: not when GSAP drives the clip or tweens that axis's size. */
|
|
23
|
+
function cropFollows(element: HTMLElement): Follows {
|
|
24
|
+
const clip = gsapAnimatesProperty(element, "clipPath");
|
|
25
|
+
return {
|
|
26
|
+
width: !clip && !elementHasNonHoldTween(element, ["width"]),
|
|
27
|
+
height: !clip && !elementHasNonHoldTween(element, ["height"]),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function scaleCrop(
|
|
32
|
+
crop: ParsedInsetClipPathSides,
|
|
33
|
+
from: Box,
|
|
34
|
+
to: Box,
|
|
35
|
+
follows: Follows,
|
|
36
|
+
): ParsedInsetClipPathSides {
|
|
37
|
+
const sx = follows.width && from.width > 0 ? to.width / from.width : 1;
|
|
38
|
+
const sy = follows.height && from.height > 0 ? to.height / from.height : 1;
|
|
39
|
+
return {
|
|
40
|
+
top: crop.top * sy,
|
|
41
|
+
right: crop.right * sx,
|
|
42
|
+
bottom: crop.bottom * sy,
|
|
43
|
+
left: crop.left * sx,
|
|
44
|
+
radius: crop.radius,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The element's crop as the crop UI draws it: it follows the box while a resize draft is live. */
|
|
49
|
+
export function readCropFollowingResize(element: HTMLElement): ParsedInsetClipPathSides | null {
|
|
50
|
+
const crop = readElementCropInsets(element);
|
|
51
|
+
const base = readStudioBoxSizeDraftBase(element);
|
|
52
|
+
return crop && base ? scaleCrop(crop, base, boxOf(element), cropFollows(element)) : crop;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CropResize {
|
|
56
|
+
patch: PatchOperation;
|
|
57
|
+
revert: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Call before a resize lands. Once the new size is live, the stage writes the crop that keeps the
|
|
61
|
+
* same part of the element in view and returns its patch, or null when nothing needs rescaling. */
|
|
62
|
+
export function prepareCropResize(element: HTMLElement): () => CropResize | null {
|
|
63
|
+
const from = readStudioBoxSizeDraftBase(element) ?? boxOf(element);
|
|
64
|
+
const before = element.style.getPropertyValue("clip-path");
|
|
65
|
+
const priority = element.style.getPropertyPriority("clip-path");
|
|
66
|
+
// Decided before the write lands: a W edit may add a width keyframe of its own.
|
|
67
|
+
const follows = cropFollows(element);
|
|
68
|
+
const crop = readElementCropInsets(element);
|
|
69
|
+
return () => {
|
|
70
|
+
forgetStudioBoxSizeDraftBase(element);
|
|
71
|
+
const to = boxOf(element);
|
|
72
|
+
// A crop edited while the size saved was drawn in the new box already.
|
|
73
|
+
const untouched = element.style.getPropertyValue("clip-path") === before;
|
|
74
|
+
if (!crop || !hasCropInsets(crop) || !untouched || !(to.width > 0 && to.height > 0))
|
|
75
|
+
return null;
|
|
76
|
+
const clip = buildInsetClipPathSides(scaleCrop(crop, from, to, follows), crop.radius);
|
|
77
|
+
// Unchanged when the box is (a resize saved as a `scale`) or no resized axis follows.
|
|
78
|
+
if (clip === buildInsetClipPathSides(crop, crop.radius)) return null;
|
|
79
|
+
element.style.setProperty("clip-path", clip, priority);
|
|
80
|
+
return {
|
|
81
|
+
patch: {
|
|
82
|
+
type: "inline-style",
|
|
83
|
+
property: "clip-path",
|
|
84
|
+
value: priority ? `${clip} !important` : clip,
|
|
85
|
+
},
|
|
86
|
+
revert: () => {
|
|
87
|
+
if (before) element.style.setProperty("clip-path", before, priority);
|
|
88
|
+
else element.style.removeProperty("clip-path");
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type PatchCommit = (
|
|
95
|
+
selection: DomEditSelection,
|
|
96
|
+
patches: PatchOperation[],
|
|
97
|
+
options: { label: string; coalesceKey: string; coalesceMs?: number },
|
|
98
|
+
) => Promise<void>;
|
|
99
|
+
|
|
100
|
+
/** Stage the crop and save it under the resize's undo key, taking it back off the element if that fails. */
|
|
101
|
+
export async function saveCropResize(
|
|
102
|
+
stage: () => CropResize | null,
|
|
103
|
+
selection: DomEditSelection,
|
|
104
|
+
commit: PatchCommit,
|
|
105
|
+
coalesceKey: string,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const crop = stage();
|
|
108
|
+
if (!crop) return;
|
|
109
|
+
try {
|
|
110
|
+
await commit(selection, [crop.patch], {
|
|
111
|
+
label: "Resize layer",
|
|
112
|
+
coalesceKey,
|
|
113
|
+
coalesceMs: Number.POSITIVE_INFINITY,
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
crop.revert();
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let sizeWriteCounter = 0;
|
|
122
|
+
|
|
123
|
+
/** A property write that may size the element (a W/H field on an animated element): when it
|
|
124
|
+
* does, its writes and the rescaled crop share one undo step. */
|
|
125
|
+
export async function writeSizeWithCrop(
|
|
126
|
+
selection: DomEditSelection,
|
|
127
|
+
properties: Record<string, unknown>,
|
|
128
|
+
mutation: CommitMutation | null,
|
|
129
|
+
commit: PatchCommit,
|
|
130
|
+
write: (keyed?: CommitMutation) => Promise<void>,
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
if (!mutation || !("width" in properties || "height" in properties)) return write();
|
|
133
|
+
const stage = prepareCropResize(selection.element);
|
|
134
|
+
const coalesceKey = `size-write:${++sizeWriteCounter}`;
|
|
135
|
+
const undoStep = { coalesceKey, coalesceMs: Number.POSITIVE_INFINITY };
|
|
136
|
+
const keyed: CommitMutation = (s, m, options) => mutation(s, m, { ...options, ...undoStep });
|
|
137
|
+
if (mutation.batch) {
|
|
138
|
+
const batch = mutation.batch;
|
|
139
|
+
keyed.batch = (calls, options) => batch(calls, { ...options, ...undoStep });
|
|
140
|
+
}
|
|
141
|
+
await write(keyed);
|
|
142
|
+
await saveCropResize(stage, selection, commit, coalesceKey);
|
|
143
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
parseInsetClipPathSides,
|
|
4
|
+
type ClipPathInsetSides,
|
|
5
|
+
type ParsedInsetClipPathSides,
|
|
6
|
+
} from "./clipPathHelpers";
|
|
3
7
|
|
|
4
8
|
export type CropEdge = "top" | "right" | "bottom" | "left";
|
|
5
9
|
|
|
@@ -29,23 +33,69 @@ export function cropRectFromInsets(
|
|
|
29
33
|
};
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
element:
|
|
41
|
-
|
|
36
|
+
// A selected croppable element shows uncropped so the crop UI can dim what is cut away. A rule keyed on its
|
|
37
|
+
// identity does that, so the element's own clip-path stays the one record undo, redo and panel edits rewrite.
|
|
38
|
+
const cropLifts = new WeakMap<HTMLElement, { rule: HTMLStyleElement; sheetClip?: string }>();
|
|
39
|
+
|
|
40
|
+
function liftSelector(element: HTMLElement): string | null {
|
|
41
|
+
const quote = (value: string) => `"${value.replace(/["\\]/g, "\\$&")}"`;
|
|
42
|
+
const hfId = element.getAttribute("data-hf-id");
|
|
43
|
+
if (hfId) return `[data-hf-id=${quote(hfId)}]`;
|
|
44
|
+
return element.id ? `[id=${quote(element.id)}]` : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function liftElementCrop(element: HTMLElement): void {
|
|
48
|
+
const selector = liftSelector(element);
|
|
49
|
+
if (!selector || cropLifts.has(element)) return;
|
|
50
|
+
const doc = element.ownerDocument;
|
|
51
|
+
const rule = doc.createElement("style");
|
|
52
|
+
rule.textContent = `${selector}{clip-path:none!important}`;
|
|
53
|
+
(doc.head ?? doc.documentElement).append(rule);
|
|
54
|
+
cropLifts.set(element, { rule });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function dropElementCropLift(element: HTMLElement): void {
|
|
58
|
+
cropLifts.get(element)?.rule.remove();
|
|
59
|
+
cropLifts.delete(element);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isElementCropLifted(element: HTMLElement): boolean {
|
|
63
|
+
return cropLifts.has(element);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const computedClipPath = (element: HTMLElement) =>
|
|
67
|
+
element.ownerDocument.defaultView?.getComputedStyle(element).clipPath.trim() || "";
|
|
68
|
+
|
|
69
|
+
/** The element's clip-path, inline first, then the stylesheet's as it is without the lift. */
|
|
70
|
+
function readElementClipPath(element: HTMLElement): string {
|
|
42
71
|
const inline = element.style.getPropertyValue("clip-path").trim();
|
|
43
|
-
const
|
|
44
|
-
|
|
72
|
+
const lift = cropLifts.get(element);
|
|
73
|
+
if (inline || !lift) return inline || computedClipPath(element);
|
|
74
|
+
// Only inline writes happen while selected, so the stylesheet's clip is read once per lift.
|
|
75
|
+
if (lift.sheetClip === undefined) {
|
|
76
|
+
const { parentNode, nextSibling } = lift.rule;
|
|
77
|
+
lift.rule.remove();
|
|
78
|
+
lift.sheetClip = computedClipPath(element);
|
|
79
|
+
parentNode?.insertBefore(lift.rule, nextSibling);
|
|
80
|
+
}
|
|
81
|
+
return lift.sheetClip;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Zeros = no clip yet. `null` = a clip this tool cannot represent (circle/polygon/non-px inset):
|
|
85
|
+
* croppers must not lift, edit, or restore it, or deselect silently replaces or destroys it. */
|
|
86
|
+
function parseCropClipPath(value: string): ParsedInsetClipPathSides | null {
|
|
45
87
|
if (!value || value === "none") return { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
|
|
46
88
|
return parseInsetClipPathSides(value);
|
|
47
89
|
}
|
|
48
90
|
|
|
91
|
+
export function readElementCropInsets(element: HTMLElement): ParsedInsetClipPathSides | null {
|
|
92
|
+
return parseCropClipPath(readElementClipPath(element));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function hasCropInsets(insets: ClipPathInsetSides): boolean {
|
|
96
|
+
return insets.top > 0 || insets.right > 0 || insets.bottom > 0 || insets.left > 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
49
99
|
export interface CropInsetDragInput {
|
|
50
100
|
edge: CropEdge;
|
|
51
101
|
startInsets: ClipPathInsetSides;
|
|
@@ -117,8 +167,8 @@ export function hugRectForElement(
|
|
|
117
167
|
rect: CropScreenRect & { editScaleX: number; editScaleY: number },
|
|
118
168
|
element: HTMLElement,
|
|
119
169
|
): CropScreenRect {
|
|
120
|
-
const insets = readElementCropInsets(element);
|
|
121
|
-
// Uneditable
|
|
170
|
+
const insets = isElementCropLifted(element) ? null : readElementCropInsets(element);
|
|
171
|
+
// Uneditable or lifted clip — show the full element rect.
|
|
122
172
|
if (!insets || (insets.top <= 0 && insets.right <= 0 && insets.bottom <= 0 && insets.left <= 0))
|
|
123
173
|
return rect;
|
|
124
174
|
return cropRectFromInsets(rect, insets, rect.editScaleX, rect.editScaleY);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
dropElementCropLift,
|
|
5
|
+
hugRectForElement,
|
|
6
|
+
liftElementCrop,
|
|
7
|
+
readElementCropInsets,
|
|
8
|
+
} from "./domEditOverlayCrop";
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.head.innerHTML = "";
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function cropped(style: string): HTMLElement {
|
|
16
|
+
const el = document.createElement("div");
|
|
17
|
+
el.setAttribute("data-hf-id", "hf-a");
|
|
18
|
+
el.style.cssText = style;
|
|
19
|
+
document.body.append(el);
|
|
20
|
+
return el;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("crop lift", () => {
|
|
24
|
+
it("shows the element uncropped over its inline clip, and drop takes the rule away", () => {
|
|
25
|
+
const el = cropped("clip-path: inset(0px 60px 0px 0px)");
|
|
26
|
+
liftElementCrop(el);
|
|
27
|
+
expect(getComputedStyle(el).clipPath).toBe("none");
|
|
28
|
+
expect(el.style.getPropertyValue("clip-path")).toBe("inset(0px 60px 0px 0px)");
|
|
29
|
+
dropElementCropLift(el);
|
|
30
|
+
expect(document.head.querySelectorAll("style")).toHaveLength(0);
|
|
31
|
+
expect(getComputedStyle(el).clipPath).toBe("inset(0px 60px 0px 0px)");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("still reads a stylesheet crop while lifted, and the hover hug shows the full box", () => {
|
|
35
|
+
const sheet = document.createElement("style");
|
|
36
|
+
sheet.textContent = '[data-hf-id="hf-a"] { clip-path: inset(0px 60px 0px 0px); }';
|
|
37
|
+
document.head.append(sheet);
|
|
38
|
+
const el = cropped("width: 300px");
|
|
39
|
+
liftElementCrop(el);
|
|
40
|
+
expect(readElementCropInsets(el)).toMatchObject({ right: 60 });
|
|
41
|
+
const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 };
|
|
42
|
+
expect(hugRectForElement(rect, el)).toEqual(rect);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -11,6 +11,7 @@ import type { SnapContext } from "./snapTargetCollection";
|
|
|
11
11
|
import type { SnapGuidesState } from "./SnapGuideOverlay";
|
|
12
12
|
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
|
|
13
13
|
import { logSelect } from "../../utils/selectDebug";
|
|
14
|
+
import { roundTo3 } from "../../utils/rounding";
|
|
14
15
|
|
|
15
16
|
export type GestureKind = "drag" | "resize" | "rotate";
|
|
16
17
|
|
|
@@ -197,10 +198,6 @@ function normalizeAngleDelta(delta: number): number {
|
|
|
197
198
|
return ((((delta + 180) % 360) + 360) % 360) - 180;
|
|
198
199
|
}
|
|
199
200
|
|
|
200
|
-
function roundAngle(angle: number): number {
|
|
201
|
-
return Math.round(angle * 10) / 10;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
201
|
export function resolveDomEditRotationGesture(input: {
|
|
205
202
|
centerX: number;
|
|
206
203
|
centerY: number;
|
|
@@ -223,7 +220,7 @@ export function resolveDomEditRotationGesture(input: {
|
|
|
223
220
|
return {
|
|
224
221
|
angle: input.snap
|
|
225
222
|
? Math.round(angle / ROTATION_SNAP_DEGREES) * ROTATION_SNAP_DEGREES
|
|
226
|
-
:
|
|
223
|
+
: roundTo3(angle),
|
|
227
224
|
};
|
|
228
225
|
}
|
|
229
226
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
2
2
|
import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../../player/lib/timelineElementHelpers";
|
|
3
|
+
import { roundTo3 } from "../../utils/rounding";
|
|
3
4
|
|
|
4
5
|
interface LayoutBox {
|
|
5
6
|
left: number;
|
|
@@ -30,8 +31,8 @@ export function applyElementPositionOffset(
|
|
|
30
31
|
): PatchOperation[] | ElementOffsetRefusal {
|
|
31
32
|
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
32
33
|
if (!cs) return "anchored";
|
|
33
|
-
const dx =
|
|
34
|
-
const dy =
|
|
34
|
+
const dx = roundTo3(gestureOffset.x - readDragStart(el, "x"));
|
|
35
|
+
const dy = roundTo3(gestureOffset.y - readDragStart(el, "y"));
|
|
35
36
|
// A Layers-panel pick lifts a static element to relative for display only.
|
|
36
37
|
const lifted = el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR) === "static";
|
|
37
38
|
const isStatic = lifted || !/^(relative|absolute|fixed|sticky)$/.test(cs.position);
|
|
@@ -41,8 +42,8 @@ export function applyElementPositionOffset(
|
|
|
41
42
|
const previous = { position: el.style.position, left: el.style.left, top: el.style.top };
|
|
42
43
|
const before = layoutBox(el);
|
|
43
44
|
if (isStatic) el.style.position = "relative";
|
|
44
|
-
el.style.left = `${
|
|
45
|
-
el.style.top = `${
|
|
45
|
+
el.style.left = `${roundTo3(baseLeft + dx)}px`;
|
|
46
|
+
el.style.top = `${roundTo3(baseTop + dy)}px`;
|
|
46
47
|
const after = layoutBox(el);
|
|
47
48
|
const shiftedExactly =
|
|
48
49
|
Math.abs(after.left - before.left - dx) <= 1 &&
|
|
@@ -146,6 +146,20 @@ describe("studio manual edits", () => {
|
|
|
146
146
|
expect(card.style.getPropertyValue("rotate")).toBe(`var(${STUDIO_ROTATION_PROP}, 0deg)`);
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
it("keeps sub-pixel offsets, and box sizes on whole px so GSAP reads a -50% centring exactly", () => {
|
|
150
|
+
const document = createDocument(`<div id="card" style="width: 160px; height: 90px"></div>`);
|
|
151
|
+
const card = document.getElementById("card") as HTMLElement;
|
|
152
|
+
mockBoundingRect(card, 160, 90);
|
|
153
|
+
|
|
154
|
+
applyStudioPathOffsetDraft(card, { x: 14.25, y: -8.5 });
|
|
155
|
+
applyStudioBoxSizeDraft(card, { width: 240.4, height: 135.6 });
|
|
156
|
+
|
|
157
|
+
expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("14.25px");
|
|
158
|
+
expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("-8.5px");
|
|
159
|
+
expect(card.style.getPropertyValue("width")).toBe("240px");
|
|
160
|
+
expect(card.style.getPropertyValue("height")).toBe("136px");
|
|
161
|
+
});
|
|
162
|
+
|
|
149
163
|
it("applies box sizes through CSS dimensions and flex sizing overrides", () => {
|
|
150
164
|
const document = createDocument(`
|
|
151
165
|
<div style="display: flex; flex-direction: row">
|
|
@@ -188,24 +202,24 @@ describe("studio manual edits", () => {
|
|
|
188
202
|
);
|
|
189
203
|
const card = document.getElementById("card") as HTMLElement;
|
|
190
204
|
|
|
191
|
-
applyStudioRotation(card, { angle: 24.
|
|
205
|
+
applyStudioRotation(card, { angle: 24.2468 });
|
|
192
206
|
|
|
193
|
-
expect(readStudioRotation(card)).toEqual({ angle: 24.
|
|
194
|
-
expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.
|
|
207
|
+
expect(readStudioRotation(card)).toEqual({ angle: 24.247 });
|
|
208
|
+
expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.247deg");
|
|
195
209
|
expect(card.style.getPropertyValue("rotate")).toContain("8deg");
|
|
196
210
|
expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
|
|
197
211
|
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
|
198
212
|
|
|
199
|
-
applyStudioRotationDraft(card, { angle: -12.
|
|
200
|
-
expect(readStudioRotation(card)).toEqual({ angle: -12.
|
|
201
|
-
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.
|
|
213
|
+
applyStudioRotationDraft(card, { angle: -12.2604 });
|
|
214
|
+
expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
|
|
215
|
+
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
|
|
202
216
|
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
|
203
217
|
|
|
204
218
|
const snapshot = captureStudioRotation(card);
|
|
205
219
|
applyStudioRotationDraft(card, { angle: 45 });
|
|
206
220
|
restoreStudioRotation(card, snapshot);
|
|
207
|
-
expect(readStudioRotation(card)).toEqual({ angle: -12.
|
|
208
|
-
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.
|
|
221
|
+
expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
|
|
222
|
+
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
|
|
209
223
|
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
|
210
224
|
});
|
|
211
225
|
|