@hyperframes/studio 0.7.105 → 0.7.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-Jx07OQgw.js → hyperframes-player-BLrfwq5o.js} +1 -1
- package/dist/assets/{index-CryZ0PGi.js → index-BA9yhzfR.js} +1 -1
- package/dist/assets/{index-Dqi2GmOp.js → index-BSBk5srs.js} +1 -1
- package/dist/assets/{index-DuxKGsHZ.js → index-dF-CmLZu.js} +218 -218
- package/dist/assets/index-zQ4JFwwB.css +1 -0
- package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
- package/dist/chunk-AZYHQC6V.js.map +1 -0
- package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +5335 -3954
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/EditorShell.tsx +0 -2
- package/src/components/StudioErrorBoundary.test.tsx +97 -0
- package/src/components/StudioErrorBoundary.tsx +7 -0
- package/src/components/StudioOverlays.tsx +15 -13
- package/src/components/editor/DomEditOverlay.tsx +36 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +119 -31
- package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
- package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
- package/src/components/editor/InlineTextToolbar.tsx +281 -0
- package/src/components/editor/OffCanvasIndicators.tsx +38 -0
- package/src/components/editor/domEditInlineText.test.ts +86 -0
- package/src/components/editor/domEditInlineText.ts +99 -0
- package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +136 -2
- package/src/components/editor/domEditOverlayCrop.ts +99 -15
- package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
- package/src/components/editor/domEditOverlayGeometry.ts +13 -10
- package/src/components/editor/domEditOverlayTransform.ts +88 -0
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.ts +7 -6
- package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
- package/src/components/editor/inlineTextStyleRange.ts +593 -0
- package/src/components/editor/inlineTextStyleRead.ts +47 -0
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useInlineTextEditing.tsx +119 -0
- package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
- package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
- package/src/components/feedback/feedbackTrigger.test.ts +168 -0
- package/src/components/feedback/feedbackTrigger.ts +269 -0
- package/src/components/feedback/projectProvenance.test.ts +120 -0
- package/src/components/feedback/projectProvenance.ts +83 -0
- package/src/components/renders/useRenderQueue.ts +62 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/domEditPersistFailure.ts +0 -7
- package/src/hooks/useAppHotkeys.ts +4 -4
- package/src/hooks/useDomEditCommits.test.tsx +103 -21
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.ts +110 -11
- package/src/hooks/useFileTree.ts +8 -3
- package/src/hooks/useInlineTextEdit.test.tsx +555 -0
- package/src/hooks/useInlineTextEdit.ts +320 -0
- package/src/player/lib/playbackShortcuts.ts +5 -4
- package/src/telemetry/breadcrumbs.test.ts +68 -0
- package/src/telemetry/breadcrumbs.ts +70 -0
- package/src/telemetry/client.ts +5 -0
- package/src/telemetry/events.ts +84 -3
- package/src/utils/sourcePatcher.ts +5 -1
- package/src/utils/studioHelpers.ts +2 -5
- package/src/utils/timelineDiscovery.ts +3 -24
- package/src/utils/typingTarget.test.ts +54 -0
- package/src/utils/typingTarget.ts +43 -0
- package/dist/assets/index-tBPidglp.css +0 -1
- package/dist/chunk-OBAG3GWK.js.map +0 -1
- package/src/components/StudioFeedbackBar.tsx +0 -217
- /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { DomEditSelection, DomEditTextField } from "./domEditingTypes";
|
|
5
|
+
|
|
6
|
+
const editable = vi.hoisted(() => ({ current: true }));
|
|
7
|
+
vi.mock("./domEditingLayers", () => ({
|
|
8
|
+
isTextEditableSelection: () => editable.current,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const { canEditTextInline, isDoublePress } = await import("./domEditInlineText");
|
|
12
|
+
|
|
13
|
+
function field(key: string): DomEditTextField {
|
|
14
|
+
return {
|
|
15
|
+
key,
|
|
16
|
+
label: key,
|
|
17
|
+
value: "text",
|
|
18
|
+
tagName: "SPAN",
|
|
19
|
+
attributes: [],
|
|
20
|
+
inlineStyles: {},
|
|
21
|
+
computedStyles: {},
|
|
22
|
+
source: "self",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function selection(partial: Partial<DomEditSelection> = {}): DomEditSelection {
|
|
27
|
+
return {
|
|
28
|
+
label: "Heading",
|
|
29
|
+
tagName: "H1",
|
|
30
|
+
isCompositionHost: false,
|
|
31
|
+
isInsideLockedComposition: false,
|
|
32
|
+
textFields: [field("self")],
|
|
33
|
+
...partial,
|
|
34
|
+
} as DomEditSelection;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("canEditTextInline", () => {
|
|
38
|
+
it("allows an element the panel would let you edit text on", () => {
|
|
39
|
+
editable.current = true;
|
|
40
|
+
expect(canEditTextInline(selection())).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// The bar is the panel's bar: nothing becomes editable here that is not
|
|
44
|
+
// editable there.
|
|
45
|
+
it("refuses an element whose text the panel cannot edit either", () => {
|
|
46
|
+
editable.current = false;
|
|
47
|
+
expect(canEditTextInline(selection())).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Editing the whole element would flatten its children into one string.
|
|
51
|
+
it("refuses an element with several text fields", () => {
|
|
52
|
+
editable.current = true;
|
|
53
|
+
expect(canEditTextInline(selection({ textFields: [field("a"), field("b")] }))).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("allows an element with no separate text fields", () => {
|
|
57
|
+
editable.current = true;
|
|
58
|
+
expect(canEditTextInline(selection({ textFields: [] }))).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("refuses the composition host, which is the document rather than copy", () => {
|
|
62
|
+
editable.current = true;
|
|
63
|
+
expect(canEditTextInline(selection({ isCompositionHost: true }))).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("refuses anything inside a locked composition", () => {
|
|
67
|
+
editable.current = true;
|
|
68
|
+
expect(canEditTextInline(selection({ isInsideLockedComposition: true }))).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("refuses nothing at all", () => {
|
|
72
|
+
editable.current = true;
|
|
73
|
+
expect(canEditTextInline(null)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("isDoublePress", () => {
|
|
78
|
+
it("pairs presses only when they land on the same element", () => {
|
|
79
|
+
const first = document.createElement("div");
|
|
80
|
+
const second = document.createElement("div");
|
|
81
|
+
const previous = { x: 10, y: 10, at: 100, element: first };
|
|
82
|
+
|
|
83
|
+
expect(isDoublePress(previous, { x: 12, y: 12, at: 200, element: first })).toBe(true);
|
|
84
|
+
expect(isDoublePress(previous, { x: 12, y: 12, at: 200, element: second })).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { isRichTextFormattingTag } from "@hyperframes/core/rich-text-sanitize";
|
|
2
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
3
|
+
import { isTextEditableSelection } from "./domEditingLayers";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether this element's text can be edited where it sits.
|
|
7
|
+
*
|
|
8
|
+
* Its own function rather than a condition inside a handler, because this is
|
|
9
|
+
* the rule most likely to change: it is the whole answer to "why did nothing
|
|
10
|
+
* happen when I double-clicked that".
|
|
11
|
+
*
|
|
12
|
+
* The bar is deliberately the same as the design panel's, plus one thing the
|
|
13
|
+
* panel can do that editing in place cannot. An element with several text
|
|
14
|
+
* fields is edited a field at a time there, and making the whole element
|
|
15
|
+
* editable would flatten its children into one string, so those keep the panel.
|
|
16
|
+
*/
|
|
17
|
+
export function canEditTextInline(selection: DomEditSelection | null): boolean {
|
|
18
|
+
if (!selection) return false;
|
|
19
|
+
if (!isTextEditableSelection(selection)) return false;
|
|
20
|
+
// The composition host is the document, not a piece of copy in it.
|
|
21
|
+
if (selection.isCompositionHost) return false;
|
|
22
|
+
if (selection.isInsideLockedComposition) return false;
|
|
23
|
+
if (selection.textFields.length <= 1) return true;
|
|
24
|
+
// A styled element reports one field per run of characters, but it is still
|
|
25
|
+
// one piece of copy and the caret edits all of it at once.
|
|
26
|
+
return canEditElementTextInline(selection.element);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether this element's text can be edited in place, judged from the element
|
|
31
|
+
* alone.
|
|
32
|
+
*
|
|
33
|
+
* The press path cannot use the selection-shaped gate above: building a
|
|
34
|
+
* selection is asynchronous, and a press has to decide now whether it is a
|
|
35
|
+
* text edit or the start of a drag. This asks the same question of the DOM.
|
|
36
|
+
*
|
|
37
|
+
* A structural child keeps an element out: those are separate text fields, the
|
|
38
|
+
* panel edits them one at a time, and making the whole element editable would
|
|
39
|
+
* flatten them into a single string.
|
|
40
|
+
*
|
|
41
|
+
* A formatting child does not. Styling a run of characters puts a span inside
|
|
42
|
+
* the element, so a rule of "no element children" would have let the editor
|
|
43
|
+
* lock every element it had ever styled out of itself, permanently, on the
|
|
44
|
+
* first colour change. What counts as formatting is the sanitiser's allowlist,
|
|
45
|
+
* so the editor and the thing that writes the file agree on it.
|
|
46
|
+
*/
|
|
47
|
+
export function canEditElementTextInline(element: HTMLElement | null): boolean {
|
|
48
|
+
if (!element) return false;
|
|
49
|
+
const tag = element.tagName;
|
|
50
|
+
if (tag === "BODY" || tag === "HTML") return false;
|
|
51
|
+
if (!hasOnlyFormattingChildren(element)) return false;
|
|
52
|
+
if (element.isContentEditable) return false;
|
|
53
|
+
return (element.textContent ?? "").trim().length > 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hasOnlyFormattingChildren(element: HTMLElement): boolean {
|
|
57
|
+
const HTMLElementClass = element.ownerDocument.defaultView?.HTMLElement;
|
|
58
|
+
if (!HTMLElementClass) return false;
|
|
59
|
+
for (const child of Array.from(element.children)) {
|
|
60
|
+
if (!isRichTextFormattingTag(child.tagName)) return false;
|
|
61
|
+
if (!(child instanceof HTMLElementClass)) return false;
|
|
62
|
+
// Formatting nests, and a structural child hidden inside a span is still
|
|
63
|
+
// structural.
|
|
64
|
+
if (!hasOnlyFormattingChildren(child)) return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Where and when a press landed, for recognising the next one as a pair. */
|
|
70
|
+
export interface PressMark {
|
|
71
|
+
x: number;
|
|
72
|
+
y: number;
|
|
73
|
+
at: number;
|
|
74
|
+
element: HTMLElement | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Long enough to be deliberate, short enough not to catch two separate clicks. */
|
|
78
|
+
const DOUBLE_PRESS_MS = 450;
|
|
79
|
+
/** A double press is two presses in the same place, not a tiny drag. */
|
|
80
|
+
const DOUBLE_PRESS_SLOP_PX = 6;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether this press pairs with the last one into a double press.
|
|
84
|
+
*
|
|
85
|
+
* Studio cannot use `dblclick` or a click count for this. The selection box
|
|
86
|
+
* takes pointer capture on the first press and prevents its default, which
|
|
87
|
+
* suppresses the compatibility mouse events and stops the browser pairing the
|
|
88
|
+
* two presses at all: no `dblclick` is dispatched, and `detail` stays 1.
|
|
89
|
+
*/
|
|
90
|
+
export function isDoublePress(previous: PressMark | null, next: PressMark): boolean {
|
|
91
|
+
if (!previous) return false;
|
|
92
|
+
return (
|
|
93
|
+
next.element !== null &&
|
|
94
|
+
previous.element === next.element &&
|
|
95
|
+
next.at - previous.at <= DOUBLE_PRESS_MS &&
|
|
96
|
+
Math.abs(next.x - previous.x) <= DOUBLE_PRESS_SLOP_PX &&
|
|
97
|
+
Math.abs(next.y - previous.y) <= DOUBLE_PRESS_SLOP_PX
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { canEditElementTextInline } from "./domEditInlineText";
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
document.body.innerHTML = "";
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
function mount(html: string): HTMLElement {
|
|
11
|
+
document.body.innerHTML = html;
|
|
12
|
+
return document.body.firstElementChild as HTMLElement;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("canEditElementTextInline", () => {
|
|
16
|
+
it("opens a plain piece of copy", () => {
|
|
17
|
+
expect(canEditElementTextInline(mount("<h1>hello world</h1>"))).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// The trap this exists for: styling a run of characters puts a span inside
|
|
21
|
+
// the element, and a rule of "no element children" would have let the editor
|
|
22
|
+
// lock every element it had ever styled out of itself, permanently.
|
|
23
|
+
it("still opens an element that has been styled", () => {
|
|
24
|
+
const element = mount('<h1>hell<span style="color: red">o</span> world</h1>');
|
|
25
|
+
expect(canEditElementTextInline(element)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("opens an element whose formatting is nested", () => {
|
|
29
|
+
const element = mount('<h1><b><span style="color: red">deep</span></b></h1>');
|
|
30
|
+
expect(canEditElementTextInline(element)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("keeps out an element with a structural child, which the panel edits field by field", () => {
|
|
34
|
+
expect(canEditElementTextInline(mount("<div><h1>a</h1><p>b</p></div>"))).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("keeps out an element hiding something structural inside its formatting", () => {
|
|
38
|
+
expect(canEditElementTextInline(mount("<h1><span><div>a</div></span></h1>"))).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keeps out the document itself", () => {
|
|
42
|
+
expect(canEditElementTextInline(document.body)).toBe(false);
|
|
43
|
+
expect(canEditElementTextInline(document.documentElement)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("keeps out an element that is already being edited", () => {
|
|
47
|
+
const element = mount("<h1>hello</h1>");
|
|
48
|
+
element.setAttribute("contenteditable", "true");
|
|
49
|
+
expect(canEditElementTextInline(element)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("keeps out an element with no words in it", () => {
|
|
53
|
+
expect(canEditElementTextInline(mount("<h1> </h1>"))).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps out nothing at all", () => {
|
|
57
|
+
expect(canEditElementTextInline(null)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
resolveCropInsetFromMoveDrag,
|
|
9
9
|
rotateDeltaIntoFrame,
|
|
10
10
|
} from "./domEditOverlayCrop";
|
|
11
|
+
import { individualRotateDegrees } from "./domEditOverlayTransform";
|
|
11
12
|
|
|
12
13
|
describe("resolveCropInsetFromEdgeDrag", () => {
|
|
13
14
|
const startInsets = { top: 10, right: 20, bottom: 30, left: 40 };
|
|
@@ -160,10 +161,15 @@ describe("readElementCropInsets tri-state", () => {
|
|
|
160
161
|
describe("readElementCropFrame", () => {
|
|
161
162
|
const overlayRect = { left: 100, top: 50, width: 220, height: 130, editScaleX: 1, editScaleY: 1 };
|
|
162
163
|
|
|
164
|
+
// Models an element well enough for the ancestor walk: it reports its own
|
|
165
|
+
// transform, claims no composition-root attribute, and has no parent, so the
|
|
166
|
+
// walk composes exactly one node.
|
|
163
167
|
const fakeEl = (transform: string, offsetWidth = 200, offsetHeight = 100) =>
|
|
164
168
|
({
|
|
165
169
|
offsetWidth,
|
|
166
170
|
offsetHeight,
|
|
171
|
+
parentElement: null,
|
|
172
|
+
hasAttribute: () => false,
|
|
167
173
|
ownerDocument: { defaultView: { getComputedStyle: () => ({ transform }) } },
|
|
168
174
|
}) as unknown as HTMLElement;
|
|
169
175
|
|
|
@@ -205,9 +211,43 @@ describe("readElementCropFrame", () => {
|
|
|
205
211
|
expect(frame.height).toBeCloseTo(200, 3);
|
|
206
212
|
});
|
|
207
213
|
|
|
208
|
-
|
|
214
|
+
/**
|
|
215
|
+
* A 3D matrix is not automatically unmeasurable. GSAP writes one for an
|
|
216
|
+
* ordinary 2D move or spin (force3D), so refusing every `matrix3d` drew the
|
|
217
|
+
* crop outline square on elements the rest of the chrome drew rotated.
|
|
218
|
+
* The identity here is a 2D transform written the long way.
|
|
219
|
+
*/
|
|
220
|
+
it("reads a planar matrix3d rather than giving up on it", () => {
|
|
221
|
+
// scale(1.5, 2) written the long way — planar, and not the identity.
|
|
209
222
|
const frame = readElementCropFrame(
|
|
210
|
-
fakeEl("matrix3d(1,0,0,0,0,
|
|
223
|
+
fakeEl("matrix3d(1.5,0,0,0,0,2,0,0,0,0,1,0,0,0,0,1)"),
|
|
224
|
+
overlayRect,
|
|
225
|
+
);
|
|
226
|
+
expect(frame.scaleX).toBeCloseTo(1.5, 3);
|
|
227
|
+
expect(frame.scaleY).toBeCloseTo(2, 3);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("reads a 2D rotation written as matrix3d", () => {
|
|
231
|
+
const frame = readElementCropFrame(
|
|
232
|
+
fakeEl("matrix3d(0.866025,0.5,0,0,-0.5,0.866025,0,0,0,0,1,0,0,0,0,1)"),
|
|
233
|
+
overlayRect,
|
|
234
|
+
);
|
|
235
|
+
expect(frame.angleDeg).toBeCloseTo(30, 3);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("reads a flipped element, which still has a real size", () => {
|
|
239
|
+
// Negative z scale — a composition that mirrors an element writes this.
|
|
240
|
+
const frame = readElementCropFrame(
|
|
241
|
+
fakeEl("matrix3d(-0.866025,-0.5,0,0,-0.5,0.866025,0,0,0,0,-1,0,0,0,0,1)"),
|
|
242
|
+
overlayRect,
|
|
243
|
+
);
|
|
244
|
+
expect(frame.width).toBeGreaterThan(0);
|
|
245
|
+
expect(frame.height).toBeGreaterThan(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("still falls back on a perspective transform, which no single angle describes", () => {
|
|
249
|
+
const frame = readElementCropFrame(
|
|
250
|
+
fakeEl("matrix3d(1,0,0,0.002,0,1,0,0,0,0,1,0,0,0,0,1)"),
|
|
211
251
|
overlayRect,
|
|
212
252
|
);
|
|
213
253
|
expect(frame).toEqual({
|
|
@@ -241,3 +281,97 @@ describe("rotateDeltaIntoFrame", () => {
|
|
|
241
281
|
expect(back.deltaY).toBeCloseTo(-3, 6);
|
|
242
282
|
});
|
|
243
283
|
});
|
|
284
|
+
|
|
285
|
+
describe("individualRotateDegrees", () => {
|
|
286
|
+
/**
|
|
287
|
+
* Studio's rotate handle writes the CSS `rotate` property, not `transform`.
|
|
288
|
+
* Everything that measured an element's angle read `transform` alone, so a
|
|
289
|
+
* turned element reported upright and the selection box, crop outline and
|
|
290
|
+
* child outlines all drew square across it.
|
|
291
|
+
*/
|
|
292
|
+
it("reads a plain angle", () => {
|
|
293
|
+
expect(individualRotateDegrees("-22deg")).toBeCloseTo(-22, 6);
|
|
294
|
+
expect(individualRotateDegrees("45deg")).toBeCloseTo(45, 6);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("reads an explicit z-axis rotation, honouring the axis sign", () => {
|
|
298
|
+
expect(individualRotateDegrees("0 0 1 30deg")).toBeCloseTo(30, 6);
|
|
299
|
+
expect(individualRotateDegrees("0 0 -1 30deg")).toBeCloseTo(-30, 6);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("reports nothing for a rotation that leaves the overlay's plane", () => {
|
|
303
|
+
// A 3D turn has no single in-plane angle. Reporting one would draw the
|
|
304
|
+
// chrome at a plausible-looking wrong angle instead of falling back square.
|
|
305
|
+
expect(individualRotateDegrees("1 0 0 45deg")).toBe(0);
|
|
306
|
+
expect(individualRotateDegrees("0 1 0 45deg")).toBe(0);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("reports nothing when the property is absent or unparseable", () => {
|
|
310
|
+
expect(individualRotateDegrees("none")).toBe(0);
|
|
311
|
+
expect(individualRotateDegrees(undefined)).toBe(0);
|
|
312
|
+
expect(individualRotateDegrees("")).toBe(0);
|
|
313
|
+
expect(individualRotateDegrees("12")).toBe(0);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe("readElementCropFrame — the composed walk", () => {
|
|
318
|
+
/**
|
|
319
|
+
* The case the crop outline got wrong: a text layer inside a rotated card.
|
|
320
|
+
* The layer carries its own spin and its parent turns it again, so the box
|
|
321
|
+
* belongs at the combination. Reading the element alone drew it across the
|
|
322
|
+
* text at roughly a right angle.
|
|
323
|
+
*/
|
|
324
|
+
const nested = (childTransform: string, parentTransform: string) => {
|
|
325
|
+
const style = (transform: string) => ({ transform }) as CSSStyleDeclaration;
|
|
326
|
+
const parent = {
|
|
327
|
+
offsetWidth: 400,
|
|
328
|
+
offsetHeight: 300,
|
|
329
|
+
parentElement: null,
|
|
330
|
+
hasAttribute: (name: string) => name === "data-composition-id",
|
|
331
|
+
ownerDocument: { defaultView: { getComputedStyle: () => style(parentTransform) } },
|
|
332
|
+
};
|
|
333
|
+
return {
|
|
334
|
+
offsetWidth: 200,
|
|
335
|
+
offsetHeight: 100,
|
|
336
|
+
parentElement: parent,
|
|
337
|
+
hasAttribute: () => false,
|
|
338
|
+
ownerDocument: {
|
|
339
|
+
defaultView: {
|
|
340
|
+
getComputedStyle: (node: unknown) =>
|
|
341
|
+
node === parent ? style(parentTransform) : style(childTransform),
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
} as unknown as HTMLElement;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const overlayRect = { left: 100, top: 50, width: 220, height: 130, editScaleX: 1, editScaleY: 1 };
|
|
348
|
+
|
|
349
|
+
it("adds the parent's rotation to the child's", () => {
|
|
350
|
+
// child 30deg inside a parent turned 60deg → the layer paints at 90.
|
|
351
|
+
const frame = readElementCropFrame(
|
|
352
|
+
nested(
|
|
353
|
+
"matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)",
|
|
354
|
+
"matrix(0.5, 0.8660254, -0.8660254, 0.5, 0, 0)",
|
|
355
|
+
),
|
|
356
|
+
overlayRect,
|
|
357
|
+
);
|
|
358
|
+
expect(frame.angleDeg).toBeCloseTo(90, 3);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("takes the parent's rotation when the child has none of its own", () => {
|
|
362
|
+
const frame = readElementCropFrame(
|
|
363
|
+
nested("none", "matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)"),
|
|
364
|
+
overlayRect,
|
|
365
|
+
);
|
|
366
|
+
expect(frame.angleDeg).toBeCloseTo(30, 3);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("stops at the composition root rather than walking the whole document", () => {
|
|
370
|
+
// The root itself is marked, so its own transform is the last one counted.
|
|
371
|
+
const frame = readElementCropFrame(
|
|
372
|
+
nested("none", "matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)"),
|
|
373
|
+
overlayRect,
|
|
374
|
+
);
|
|
375
|
+
expect(frame.angleDeg).toBeCloseTo(30, 3);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform";
|
|
1
2
|
import { parseInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
|
|
2
3
|
|
|
3
4
|
export type CropEdge = "top" | "right" | "bottom" | "left";
|
|
@@ -147,6 +148,96 @@ export interface CropFrame {
|
|
|
147
148
|
scaleY: number;
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The element's own 2D transform as matrix components, plus the `rotate`
|
|
153
|
+
* property's angle.
|
|
154
|
+
*
|
|
155
|
+
* `rotate` is a separate CSS property, not part of `transform`, and it is the
|
|
156
|
+
* one Studio's rotate handle writes — reading `transform` alone reported a
|
|
157
|
+
* turned element as upright, so the crop outline drew square across it.
|
|
158
|
+
*
|
|
159
|
+
* Null means there is nothing planar to draw against: no transform and no
|
|
160
|
+
* rotation, or a 3D/unparseable matrix. The caller falls back to the
|
|
161
|
+
* axis-aligned box rather than guessing an angle.
|
|
162
|
+
*/
|
|
163
|
+
const IDENTITY = { a: 1, b: 0, c: 0, d: 1 };
|
|
164
|
+
|
|
165
|
+
/** Perspective terms this far from zero mean the mapping is not affine. */
|
|
166
|
+
const PERSPECTIVE_EPSILON = 1e-6;
|
|
167
|
+
|
|
168
|
+
type Planar2D = { a: number; b: number; c: number; d: number };
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The 2D components of a computed transform, or null when it cannot be used.
|
|
172
|
+
*
|
|
173
|
+
* Accepts `matrix3d` as well as `matrix`, taking the same 2D projection the
|
|
174
|
+
* rest of the overlay reads through DOMMatrix. GSAP writes a 3D matrix for an
|
|
175
|
+
* ordinary 2D move or spin (force3D), and a composition that flips an element
|
|
176
|
+
* writes one with a negative z scale — treating either as unmeasurable left the
|
|
177
|
+
* crop outline square on an element every other piece of chrome drew rotated.
|
|
178
|
+
*
|
|
179
|
+
* Only a perspective term rules the matrix out, because that is where the
|
|
180
|
+
* mapping stops being affine and a single angle stops describing it.
|
|
181
|
+
*/
|
|
182
|
+
function parseMatrixComponents(transform: string): Planar2D | null {
|
|
183
|
+
const flat = /^matrix\(([^)]+)\)$/.exec(transform);
|
|
184
|
+
if (flat) {
|
|
185
|
+
const [a, b, c, d] = flat[1]!.split(",").map((v) => Number.parseFloat(v));
|
|
186
|
+
return [a, b, c, d].every(Number.isFinite) ? { a: a!, b: b!, c: c!, d: d! } : null;
|
|
187
|
+
}
|
|
188
|
+
const spatial = /^matrix3d\(([^)]+)\)$/.exec(transform);
|
|
189
|
+
if (!spatial) return null;
|
|
190
|
+
const m = spatial[1]!.split(",").map((v) => Number.parseFloat(v));
|
|
191
|
+
if (m.length !== 16 || !m.every(Number.isFinite)) return null;
|
|
192
|
+
const affine = [m[3], m[7], m[11]].every((v) => Math.abs(v!) < PERSPECTIVE_EPSILON);
|
|
193
|
+
if (!affine) return null;
|
|
194
|
+
return { a: m[0]!, b: m[1]!, c: m[4]!, d: m[5]! };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The crop frame only needs an angle and a scale, so it composes plain 2D components. */
|
|
198
|
+
const PLANAR_2D_OPS: PlanarTransformOps<Planar2D> = {
|
|
199
|
+
identity: () => IDENTITY,
|
|
200
|
+
fromTransform: parseMatrixComponents,
|
|
201
|
+
fromRotate: (degrees) => {
|
|
202
|
+
const rad = (degrees * Math.PI) / 180;
|
|
203
|
+
return { a: Math.cos(rad), b: Math.sin(rad), c: -Math.sin(rad), d: Math.cos(rad) };
|
|
204
|
+
},
|
|
205
|
+
compose: (outer, inner) => ({
|
|
206
|
+
a: outer.a * inner.a + outer.c * inner.b,
|
|
207
|
+
b: outer.b * inner.a + outer.d * inner.b,
|
|
208
|
+
c: outer.a * inner.c + outer.c * inner.d,
|
|
209
|
+
d: outer.b * inner.c + outer.d * inner.d,
|
|
210
|
+
}),
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** Whether the matrix leaves the box exactly as it found it. */
|
|
214
|
+
function isIdentity(m: Planar2D): boolean {
|
|
215
|
+
return (
|
|
216
|
+
Math.abs(m.a - 1) < PERSPECTIVE_EPSILON &&
|
|
217
|
+
Math.abs(m.b) < PERSPECTIVE_EPSILON &&
|
|
218
|
+
Math.abs(m.c) < PERSPECTIVE_EPSILON &&
|
|
219
|
+
Math.abs(m.d - 1) < PERSPECTIVE_EPSILON
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The transform the element paints under, in 2D components.
|
|
225
|
+
*
|
|
226
|
+
* Null when nothing up the chain transforms it: the caller's axis-aligned rect
|
|
227
|
+
* already describes it, and that comes from real layout rather than the
|
|
228
|
+
* element's untransformed box.
|
|
229
|
+
*/
|
|
230
|
+
function readPlanarTransform(element: HTMLElement): Planar2D | null {
|
|
231
|
+
const acc = composeElementTransform(element, PLANAR_2D_OPS, (node) => {
|
|
232
|
+
try {
|
|
233
|
+
return node.ownerDocument.defaultView?.getComputedStyle(node) ?? null;
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
return acc && !isIdentity(acc) ? acc : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
150
241
|
export function readElementCropFrame(
|
|
151
242
|
element: HTMLElement,
|
|
152
243
|
overlayRect: CropScreenRect & { editScaleX: number; editScaleY: number },
|
|
@@ -162,22 +253,15 @@ export function readElementCropFrame(
|
|
|
162
253
|
scaleX: editX,
|
|
163
254
|
scaleY: editY,
|
|
164
255
|
};
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const m = /^matrix\(([^)]+)\)$/.exec(transform);
|
|
173
|
-
if (!m) return aabb; // matrix3d or unparseable → axis-aligned fallback
|
|
174
|
-
const [a, b, c, d] = m[1]!.split(",").map((v) => Number.parseFloat(v));
|
|
175
|
-
if (![a, b, c, d].every(Number.isFinite)) return aabb;
|
|
176
|
-
const elScaleX = Math.hypot(a!, b!);
|
|
177
|
-
const det = a! * d! - b! * c!;
|
|
178
|
-
const elScaleY = elScaleX !== 0 ? det / elScaleX : 1;
|
|
256
|
+
const planar = readPlanarTransform(element);
|
|
257
|
+
if (!planar) return aabb;
|
|
258
|
+
const { a, b, c, d } = planar;
|
|
259
|
+
const elScaleX = Math.hypot(a, b);
|
|
260
|
+
const det = a * d - b * c;
|
|
261
|
+
// |det| : a flipped element (negative determinant) still has a real size.
|
|
262
|
+
const elScaleY = elScaleX !== 0 ? Math.abs(det) / elScaleX : 1;
|
|
179
263
|
if (elScaleX <= 0 || elScaleY <= 0) return aabb;
|
|
180
|
-
const angleDeg = (Math.atan2(b
|
|
264
|
+
const angleDeg = (Math.atan2(b, a) * 180) / Math.PI;
|
|
181
265
|
const scaleX = elScaleX * editX;
|
|
182
266
|
const scaleY = elScaleY * editY;
|
|
183
267
|
const width = element.offsetWidth * scaleX;
|
|
@@ -165,6 +165,19 @@ describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () =>
|
|
|
165
165
|
* a text layer inside a rotated card got an upright dashed box sitting across
|
|
166
166
|
* the rotated glyphs — the parent's chrome rotated and its children's did not.
|
|
167
167
|
*/
|
|
168
|
+
/**
|
|
169
|
+
* The selection box and the crop outline compose the same ancestor walk, so
|
|
170
|
+
* this asserts the geometry side of the case the crop test covers: a child
|
|
171
|
+
* inside a rotated parent reports the angle it paints at, not its own.
|
|
172
|
+
*/
|
|
173
|
+
it("composes the parent's rotation into the child's angle", () => {
|
|
174
|
+
const { overlayEl, iframe, el } = buildHarness();
|
|
175
|
+
el.parentElement!.style.transform = ROTATE_30DEG_MATRIX;
|
|
176
|
+
const rect = orientedOverlayRect(overlayEl, iframe, el);
|
|
177
|
+
expect(rect).not.toBeNull();
|
|
178
|
+
expect(rect!.angle).toBeCloseTo(30, 3);
|
|
179
|
+
});
|
|
180
|
+
|
|
168
181
|
it("child outlines carry the element's angle, so they can co-rotate with it", () => {
|
|
169
182
|
const { overlayEl, iframe, el } = buildHarness();
|
|
170
183
|
el.style.transform = ROTATE_30DEG_MATRIX;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type DomEditSelection, findElementForSelection } from "./domEditing";
|
|
2
2
|
import { isElementVisibleThroughAncestors } from "./domEditingDom";
|
|
3
3
|
import { hugRectForElement } from "./domEditOverlayCrop";
|
|
4
|
+
import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform";
|
|
4
5
|
|
|
5
6
|
export interface OverlayRect {
|
|
6
7
|
left: number;
|
|
@@ -146,17 +147,19 @@ function readElementTransformSnapshot(
|
|
|
146
147
|
const DOMMatrixCtor = (win as Window & typeof globalThis).DOMMatrix;
|
|
147
148
|
if (!DOMMatrixCtor) return null;
|
|
148
149
|
const cs = win.getComputedStyle(element);
|
|
150
|
+
// The corner math transforms points, so this algebra keeps the full matrix,
|
|
151
|
+
// translation included, where the crop frame's keeps only 2D components.
|
|
152
|
+
const ops: PlanarTransformOps<DOMMatrix> = {
|
|
153
|
+
identity: () => new DOMMatrixCtor(),
|
|
154
|
+
fromTransform: (value) => new DOMMatrixCtor(value),
|
|
155
|
+
fromRotate: (degrees) => new DOMMatrixCtor().rotateSelf(degrees),
|
|
156
|
+
compose: (outer, inner) => outer.multiply(inner),
|
|
157
|
+
};
|
|
149
158
|
try {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// An ancestor applies outside, so it multiplies on the left.
|
|
155
|
-
matrix = new DOMMatrixCtor(transform).multiply(matrix);
|
|
156
|
-
}
|
|
157
|
-
if (node.hasAttribute("data-composition-id")) break;
|
|
158
|
-
}
|
|
159
|
-
return { matrix, cs };
|
|
159
|
+
const matrix = composeElementTransform(element, ops, (node) =>
|
|
160
|
+
node === element ? cs : win.getComputedStyle(node),
|
|
161
|
+
);
|
|
162
|
+
return matrix ? { matrix, cs } : null;
|
|
160
163
|
} catch {
|
|
161
164
|
return null;
|
|
162
165
|
}
|