@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.
Files changed (75) hide show
  1. package/dist/assets/{hyperframes-player-Biu5_W0l.js → hyperframes-player-Rml34FRW.js} +1 -1
  2. package/dist/assets/{index-BdV5T-QI.js → index-CVDArg3m.js} +188 -188
  3. package/dist/assets/{index-B5Z-1RW9.js → index-C_fi-jTe.js} +1 -1
  4. package/dist/assets/{index-CWuozokW.js → index-D9YzDuBx.js} +1 -1
  5. package/dist/index.d.ts +16 -4
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +758 -607
  8. package/dist/index.js.map +1 -1
  9. package/package.json +10 -9
  10. package/src/components/EditorShell.tsx +7 -0
  11. package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
  12. package/src/components/editor/DomEditCropHandles.tsx +30 -62
  13. package/src/components/editor/DomEditOverlay.test.ts +2 -2
  14. package/src/components/editor/DomEditOverlay.tsx +0 -27
  15. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
  16. package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
  17. package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
  18. package/src/components/editor/cropResize.test.ts +129 -0
  19. package/src/components/editor/cropResize.ts +143 -0
  20. package/src/components/editor/domEditOverlayCrop.ts +65 -15
  21. package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
  22. package/src/components/editor/domEditOverlayGestures.ts +2 -5
  23. package/src/components/editor/elementPositionOffset.ts +5 -4
  24. package/src/components/editor/manualEdits.test.ts +22 -8
  25. package/src/components/editor/manualEditsDom.ts +27 -11
  26. package/src/components/editor/manualEditsParsing.ts +0 -4
  27. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  28. package/src/components/editor/manualOffsetDrag.test.ts +14 -0
  29. package/src/components/editor/propertyPanelFont.test.tsx +48 -5
  30. package/src/components/editor/propertyPanelFont.tsx +2 -1
  31. package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
  32. package/src/components/nle/TimelinePane.tsx +4 -0
  33. package/src/hooks/domSelectionTestHarness.ts +8 -0
  34. package/src/hooks/draggedGsapPosition.ts +4 -2
  35. package/src/hooks/gsapDragCommit.ts +6 -6
  36. package/src/hooks/gsapEditOutcome.ts +6 -0
  37. package/src/hooks/gsapResizeIntercept.ts +5 -5
  38. package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
  39. package/src/hooks/gsapRuntimeBridge.ts +2 -1
  40. package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
  41. package/src/hooks/gsapRuntimeKeyframes.ts +34 -21
  42. package/src/hooks/timelineTimingSync.ts +40 -4
  43. package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
  44. package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
  45. package/src/hooks/useCommitPreflightCapabilities.ts +3 -11
  46. package/src/hooks/useCropOverlay.ts +8 -2
  47. package/src/hooks/useDomEditCommits.test.tsx +6 -1
  48. package/src/hooks/useDomEditCommits.ts +1 -0
  49. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
  50. package/src/hooks/useDomEditSession.ts +2 -0
  51. package/src/hooks/useDomGeometryCommit.ts +1 -0
  52. package/src/hooks/useDomGeometryCommits.test.tsx +112 -0
  53. package/src/hooks/useDomGeometryCommits.ts +5 -0
  54. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
  55. package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
  56. package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
  57. package/src/hooks/useGsapAwareEditing.test.tsx +101 -5
  58. package/src/hooks/useGsapAwareEditing.ts +37 -23
  59. package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
  60. package/src/hooks/useGsapTweenCache.ts +0 -24
  61. package/src/hooks/useTimelineEditing.test.tsx +190 -135
  62. package/src/hooks/useTimelineEditing.ts +13 -13
  63. package/src/hooks/useTimelineGroupEditing.ts +13 -9
  64. package/src/index.ts +4 -1
  65. package/src/player/components/ClipContextMenu.test.tsx +182 -0
  66. package/src/player/components/ClipContextMenu.tsx +52 -10
  67. package/src/player/components/TimelineOverlays.test.ts +30 -7
  68. package/src/player/components/TimelineOverlays.tsx +7 -1
  69. package/src/player/components/TimelineProvider.tsx +1 -0
  70. package/src/player/components/TimelineTypes.ts +10 -0
  71. package/src/player/components/menuKeyboardNav.ts +12 -5
  72. package/src/player/components/useTimelineProviderState.tsx +2 -0
  73. package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
  74. package/src/utils/rounding.ts +5 -0
  75. 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 { parseInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
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
- * Current inset crop of an element (inline first, computed fallback).
34
- * Zeros = no clip (croppable, nothing cropped yet). `null` = the element
35
- * carries a clip-path this tool cannot represent (circle/polygon/non-px
36
- * inset) — croppers must not lift, edit, or restore it, or the clip gets
37
- * silently replaced or destroyed on deselect.
38
- */
39
- export function readElementCropInsets(
40
- element: HTMLElement,
41
- ): (ClipPathInsetSides & { radius: number }) | null {
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 value =
44
- inline || element.ownerDocument.defaultView?.getComputedStyle(element).clipPath.trim() || "";
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 clip (null) can't be hugged — show the full element rect.
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
- : roundAngle(angle),
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 = Math.round(gestureOffset.x - readDragStart(el, "x"));
34
- const dy = Math.round(gestureOffset.y - readDragStart(el, "y"));
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 = `${Math.round(baseLeft) + dx}px`;
45
- el.style.top = `${Math.round(baseTop) + dy}px`;
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.24 });
205
+ applyStudioRotation(card, { angle: 24.2468 });
192
206
 
193
- expect(readStudioRotation(card)).toEqual({ angle: 24.2 });
194
- expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.2deg");
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.26 });
200
- expect(readStudioRotation(card)).toEqual({ angle: -12.3 });
201
- expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.3deg)");
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.3 });
208
- expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.3deg)");
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