@hyperframes/studio 0.8.112 → 0.8.114
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-CBpg41JM.js → hyperframes-player-aXNJKiIg.js} +1 -1
- package/dist/assets/index-BYnsJnJV.css +1 -0
- package/dist/assets/{index-BFitquwi.js → index-Bn2RJNTJ.js} +1 -1
- package/dist/assets/{index-CZfrSyU9.js → index-C9N-DGjC.js} +1 -1
- package/dist/assets/{index-BKy_birQ.js → index-Dz7doz9l.js} +199 -199
- package/dist/index.d.ts +3 -3
- package/dist/index.html +2 -2
- package/dist/index.js +1605 -1536
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +9 -8
- package/src/App.tsx +82 -84
- package/src/components/EditorShell.tsx +3 -3
- package/src/components/StudioHeader.tsx +4 -4
- package/src/components/StudioLeftPanels.tsx +3 -3
- package/src/components/StudioOverlays.tsx +4 -4
- package/src/components/StudioRightPanels.tsx +14 -9
- package/src/components/StudioToast.tsx +1 -1
- package/src/components/TimelineToolbar.tsx +3 -3
- package/src/components/editor/InlineTextToolbar.tsx +11 -27
- package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
- package/src/components/editor/colorValue.test.ts +16 -0
- package/src/components/editor/colorValue.ts +100 -24
- package/src/components/editor/cropResize.test.ts +11 -0
- package/src/components/editor/cropResize.ts +2 -3
- package/src/components/editor/gradientValue.ts +28 -36
- package/src/components/editor/gsapAnimatesProperty.ts +0 -54
- package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
- package/src/components/editor/inlineTextStyleRange.ts +5 -2
- package/src/components/editor/inlineTextStyleRead.ts +9 -0
- package/src/components/editor/manualEditsDom.ts +2 -2
- package/src/components/editor/manualEditsSeekReapply.ts +2 -2
- package/src/components/editor/propertyPanelColor.tsx +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
- package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
- package/src/hooks/gsapRuntimeReaders.ts +3 -90
- package/src/hooks/useAppHotkeys.ts +11 -5
- package/src/hooks/useFrameCapture.ts +15 -8
- package/src/hooks/useGestureCommit.ts +4 -1
- package/src/hooks/useGestureTrailOverlay.tsx +33 -0
- package/src/hooks/usePanelLayout.ts +5 -1
- package/src/hooks/useShellSlices.ts +53 -0
- package/src/hooks/useStableHandlers.test.tsx +77 -0
- package/src/hooks/useStableHandlers.ts +44 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -1
- package/src/hooks/useStudioSidePanels.test.tsx +44 -0
- package/src/hooks/useStudioSidePanels.tsx +23 -0
- package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
- package/src/hooks/useTimelineEditing.ts +33 -27
- package/src/utils/frameCapture.ts +3 -1
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
- package/dist/assets/index-DbfKVTId.css +0 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const CHROME_147_FILL_STYLE: Record<string, string> = {
|
|
4
|
+
transparent: "rgba(0, 0, 0, 0)",
|
|
5
|
+
white: "#ffffff",
|
|
6
|
+
red: "#ff0000",
|
|
7
|
+
currentcolor: "#000000",
|
|
8
|
+
"hsl(210 40% 50%)": "#4d80b3",
|
|
9
|
+
"rgb(100%, 0%, 0%)": "#ff0000",
|
|
10
|
+
"hsl(0 100% 50% / 0.001)": "rgba(255, 0, 0, 0)",
|
|
11
|
+
"oklch(0.7 0.15 200)": "oklch(0.7 0.15 200)",
|
|
12
|
+
"oklch(0.7 0.15 200 / 0.25)": "oklch(0.7 0.15 200 / 0.25)",
|
|
13
|
+
"color-mix(in oklab, oklch(0.7 0.15 200), red 50%)": "oklab(0.663983 0.0419629 0.0372782)",
|
|
14
|
+
"color-mix(in srgb, #ff000000, #0000ffff 50%)": "color(srgb 0 0 1 / 0.5)",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const CHROME_147_RELATIVE_SRGB: Record<string, string> = {
|
|
18
|
+
currentcolor: "color(srgb 0 0 0)",
|
|
19
|
+
"hsl(0 100% 50% / 0.001)": "color(srgb 1 0 0 / 0.001)",
|
|
20
|
+
"oklch(0.7 0.15 200)": "color(srgb -0.316663 0.724435 0.764448)",
|
|
21
|
+
"oklch(0.7 0.15 200 / 0.25)": "color(srgb -0.316663 0.724435 0.764448 / 0.25)",
|
|
22
|
+
"color-mix(in oklab, oklch(0.7 0.15 200), red 50%)": "color(srgb 0.698277 0.535308 0.473819)",
|
|
23
|
+
"color-mix(in srgb, #ff000000, #0000ffff 50%)": "color(srgb 0 0 1 / 0.5)",
|
|
24
|
+
"color-mix(in srgb, hwb(0 0% 0%), hwb(240 0% 0%) 50%)": "color(srgb 0.5 0 0.5)",
|
|
25
|
+
"color-mix(in oklab, red, blue 50%)": "color(srgb 0.550402 0.325634 0.636508)",
|
|
26
|
+
"color-mix(in srgb, red, blue 50%)": "color(srgb 0.5 0 0.5)",
|
|
27
|
+
"light-dark(white, red)": "color(srgb 1 1 1)",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
async function loadColorModulesWithChromeCanvas() {
|
|
31
|
+
let fillStyle = "#000000";
|
|
32
|
+
const context = {
|
|
33
|
+
get fillStyle() {
|
|
34
|
+
return fillStyle;
|
|
35
|
+
},
|
|
36
|
+
set fillStyle(next: string) {
|
|
37
|
+
const relative = /^color\(from (.+) srgb r g b \/ alpha\)$/.exec(next);
|
|
38
|
+
fillStyle =
|
|
39
|
+
(relative ? CHROME_147_RELATIVE_SRGB[relative[1]] : CHROME_147_FILL_STYLE[next]) ??
|
|
40
|
+
fillStyle;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
vi.stubGlobal("CSS", { supports: () => true });
|
|
44
|
+
vi.stubGlobal("document", { createElement: () => ({ getContext: () => context }) });
|
|
45
|
+
vi.resetModules();
|
|
46
|
+
return { ...(await import("./colorValue")), ...(await import("./gradientValue")) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("parseCssColor with a browser canvas", () => {
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
vi.unstubAllGlobals();
|
|
52
|
+
vi.resetModules();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it.each([
|
|
56
|
+
["red", { red: 255, green: 0, blue: 0, alpha: 1 }],
|
|
57
|
+
["hsl(210 40% 50%)", { red: 77, green: 128, blue: 179, alpha: 1 }],
|
|
58
|
+
["rgb(100%, 0%, 0%)", { red: 255, green: 0, blue: 0, alpha: 1 }],
|
|
59
|
+
["oklch(0.7 0.15 200)", { red: 0, green: 185, blue: 195, alpha: 1 }],
|
|
60
|
+
["oklch(0.7 0.15 200 / 0.25)", { red: 0, green: 185, blue: 195, alpha: 0.25 }],
|
|
61
|
+
["hsl(0 100% 50% / 0.001)", { red: 255, green: 0, blue: 0, alpha: 0.001 }],
|
|
62
|
+
["inherit", null],
|
|
63
|
+
["currentcolor", null],
|
|
64
|
+
["light-dark(white, red)", null],
|
|
65
|
+
])("resolves %s through the canvas, or null if it must not", async (input, expected) => {
|
|
66
|
+
const { parseCssColor: parseFresh } = await loadColorModulesWithChromeCanvas();
|
|
67
|
+
expect(parseFresh(input)).toEqual(expected);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("insertGradientStop with a browser canvas", () => {
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
vi.unstubAllGlobals();
|
|
74
|
+
vi.resetModules();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it.each([
|
|
78
|
+
["oklch(0.7 0.15 200)", "red", "#B28979"],
|
|
79
|
+
["#ff000000", "#0000ffff", "rgba(0, 0, 255, 0.5)"],
|
|
80
|
+
])("gives %s to %s the midpoint the browser paints", async (left, right, expected) => {
|
|
81
|
+
const { parseGradient: parse, insertGradientStop: insert } =
|
|
82
|
+
await loadColorModulesWithChromeCanvas();
|
|
83
|
+
const model = parse(`linear-gradient(90deg, ${left} 0%, ${right} 100%)`);
|
|
84
|
+
expect(model).not.toBeNull();
|
|
85
|
+
expect(insert(model!, 50).stops[1].color).toBe(expected);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it.each([
|
|
89
|
+
["hwb stops mix in sRGB like rgb()", "hwb(0 0% 0%) 0%, hwb(240 0% 0%) 100%", 50, "#800080"],
|
|
90
|
+
[
|
|
91
|
+
"one modern stop moves every interval to Oklab",
|
|
92
|
+
"red 0%, blue 50%, oklch(0.7 0.15 200) 100%",
|
|
93
|
+
25,
|
|
94
|
+
"#8C53A2",
|
|
95
|
+
],
|
|
96
|
+
[
|
|
97
|
+
"an unresolved var() stop leaves a legacy interval in sRGB",
|
|
98
|
+
"red 0%, blue 50%, var(--third, red) 100%",
|
|
99
|
+
25,
|
|
100
|
+
"#800080",
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
"a modern stop with a var() inside still moves every interval to Oklab",
|
|
104
|
+
"red 0%, blue 50%, oklch(var(--l, 0.7) 0.1 200) 100%",
|
|
105
|
+
25,
|
|
106
|
+
"#8C53A2",
|
|
107
|
+
],
|
|
108
|
+
])("%s", async (_case, stops, position, expected) => {
|
|
109
|
+
const { parseGradient: parse, insertGradientStop: insert } =
|
|
110
|
+
await loadColorModulesWithChromeCanvas();
|
|
111
|
+
const model = parse(`linear-gradient(90deg, ${stops})`);
|
|
112
|
+
expect(model).not.toBeNull();
|
|
113
|
+
const inserted = insert(model!, position).stops.find((stop) => stop.position === position);
|
|
114
|
+
expect(inserted?.color).toBe(expected);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -28,6 +28,22 @@ describe("parseCssColor", () => {
|
|
|
28
28
|
});
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
it.each([
|
|
32
|
+
["#0f172acc", { red: 15, green: 23, blue: 42, alpha: 0.8 }],
|
|
33
|
+
["#f008", { red: 255, green: 0, blue: 0, alpha: 136 / 255 }],
|
|
34
|
+
["rgb(255 0 0 / 50%)", { red: 255, green: 0, blue: 0, alpha: 0.5 }],
|
|
35
|
+
["rgb(100% 0% 0% / 0.001)", { red: 255, green: 0, blue: 0, alpha: 0.001 }],
|
|
36
|
+
])("parses %s without a browser", (input, expected) => {
|
|
37
|
+
expect(parseCssColor(input)).toEqual(expected);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it.each(["", "#12", "notacolor", "currentcolor", "none", "rgb(1..2, 3, 4)", "rgb(1. 2 3)"])(
|
|
41
|
+
"rejects %s without a browser",
|
|
42
|
+
(input) => {
|
|
43
|
+
expect(parseCssColor(input)).toBeNull();
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
31
47
|
it("parses transparent", () => {
|
|
32
48
|
expect(parseCssColor("transparent")).toEqual({
|
|
33
49
|
red: 0,
|
|
@@ -29,49 +29,125 @@ function formatAlpha(value: number): string {
|
|
|
29
29
|
return `${roundToCenti(clampAlpha(value))}`;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
function parseComponent(value: string, scale: number): number {
|
|
33
|
+
return value.endsWith("%") ? (Number(value.slice(0, -1)) * scale) / 100 : Number(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const NUMBER = String.raw`(?:\d+(?:\.\d+)?|\.\d+)`;
|
|
37
|
+
const COMMA_RGB = new RegExp(
|
|
38
|
+
String.raw`^rgba?\(\s*(${NUMBER})\s*,\s*(${NUMBER})\s*,\s*(${NUMBER})(?:\s*,\s*(${NUMBER}))?\s*\)$`,
|
|
39
|
+
);
|
|
40
|
+
const SPACE_RGB = new RegExp(
|
|
41
|
+
String.raw`^rgba?\(\s*(${NUMBER}%?)\s+(${NUMBER}%?)\s+(${NUMBER}%?)(?:\s*\/\s*(${NUMBER}%?))?\s*\)$`,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function parseSerializedColor(value: string): ParsedColor | null {
|
|
33
45
|
const trimmed = value.trim().toLowerCase();
|
|
34
46
|
if (!trimmed) return null;
|
|
35
47
|
if (trimmed === "transparent") {
|
|
36
48
|
return { red: 0, green: 0, blue: 0, alpha: 0 };
|
|
37
49
|
}
|
|
38
50
|
|
|
39
|
-
const
|
|
40
|
-
if (shortHex) {
|
|
41
|
-
const [r, g, b] = shortHex[1].split("");
|
|
42
|
-
return {
|
|
43
|
-
red: Number.parseInt(r + r, 16),
|
|
44
|
-
green: Number.parseInt(g + g, 16),
|
|
45
|
-
blue: Number.parseInt(b + b, 16),
|
|
46
|
-
alpha: 1,
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const hex = trimmed.match(/^#([0-9a-f]{6})$/i);
|
|
51
|
+
const hex = trimmed.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
|
|
51
52
|
if (hex) {
|
|
53
|
+
const digits = hex[1].length <= 4 ? [...hex[1]].map((digit) => digit + digit).join("") : hex[1];
|
|
52
54
|
return {
|
|
53
|
-
red: Number.parseInt(
|
|
54
|
-
green: Number.parseInt(
|
|
55
|
-
blue: Number.parseInt(
|
|
56
|
-
alpha: 1,
|
|
55
|
+
red: Number.parseInt(digits.slice(0, 2), 16),
|
|
56
|
+
green: Number.parseInt(digits.slice(2, 4), 16),
|
|
57
|
+
blue: Number.parseInt(digits.slice(4, 6), 16),
|
|
58
|
+
alpha: digits.length === 8 ? Number.parseInt(digits.slice(6, 8), 16) / 255 : 1,
|
|
57
59
|
};
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
const rgba = trimmed.match(
|
|
61
|
-
/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i,
|
|
62
|
-
);
|
|
62
|
+
const rgba = trimmed.match(COMMA_RGB) ?? trimmed.match(SPACE_RGB);
|
|
63
63
|
if (rgba) {
|
|
64
64
|
return {
|
|
65
|
-
red: clampChannel(
|
|
66
|
-
green: clampChannel(
|
|
67
|
-
blue: clampChannel(
|
|
68
|
-
alpha: clampAlpha(rgba[4] != null ?
|
|
65
|
+
red: clampChannel(parseComponent(rgba[1], 255)),
|
|
66
|
+
green: clampChannel(parseComponent(rgba[2], 255)),
|
|
67
|
+
blue: clampChannel(parseComponent(rgba[3], 255)),
|
|
68
|
+
alpha: clampAlpha(rgba[4] != null ? parseComponent(rgba[4], 1) : 1),
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
return null;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
let colorContext: CanvasRenderingContext2D | null = null;
|
|
76
|
+
|
|
77
|
+
function canResolveInBrowser(value: string): boolean {
|
|
78
|
+
return (
|
|
79
|
+
typeof document !== "undefined" &&
|
|
80
|
+
typeof CSS !== "undefined" &&
|
|
81
|
+
CSS.supports("color", value) &&
|
|
82
|
+
!/\bcurrentcolor\b|\bvar\s*\(|\blight-dark\s*\(/i.test(value)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseBrowserColor(value: string): ParsedColor | null {
|
|
87
|
+
try {
|
|
88
|
+
if (!canResolveInBrowser(value)) return null;
|
|
89
|
+
colorContext ??= document.createElement("canvas").getContext("2d");
|
|
90
|
+
if (!colorContext) return null;
|
|
91
|
+
return (
|
|
92
|
+
parseCanvasColor(
|
|
93
|
+
colorContext,
|
|
94
|
+
`color(from ${value} srgb r g b / alpha)`,
|
|
95
|
+
parseSrgbSerialization,
|
|
96
|
+
) ?? parseCanvasColor(colorContext, value, parseSerializedColor)
|
|
97
|
+
);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseCanvasColor(
|
|
104
|
+
context: CanvasRenderingContext2D,
|
|
105
|
+
value: string,
|
|
106
|
+
parse: (serialized: string) => ParsedColor | null,
|
|
107
|
+
): ParsedColor | null {
|
|
108
|
+
const serialized = readCanvasColor(context, value);
|
|
109
|
+
return serialized === null ? null : parse(serialized);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readCanvasColor(context: CanvasRenderingContext2D, value: string): string | null {
|
|
113
|
+
context.fillStyle = "transparent";
|
|
114
|
+
context.fillStyle = value;
|
|
115
|
+
const first = context.fillStyle;
|
|
116
|
+
context.fillStyle = "white";
|
|
117
|
+
context.fillStyle = value;
|
|
118
|
+
return typeof first === "string" && first === context.fillStyle ? first : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseSrgbSerialization(serialized: string): ParsedColor | null {
|
|
122
|
+
const match = serialized.match(/^color\(srgb ([^ ]+) ([^ ]+) ([^ /)]+)(?: \/ ([^)]+))?\)$/);
|
|
123
|
+
if (!match) return null;
|
|
124
|
+
const channels = match.slice(1, 4).map(Number);
|
|
125
|
+
const alpha = match[4] === undefined ? 1 : Number(match[4]);
|
|
126
|
+
if (!channels.every(Number.isFinite) || !Number.isFinite(alpha)) return null;
|
|
127
|
+
return {
|
|
128
|
+
red: clampChannel(channels[0] * 255),
|
|
129
|
+
green: clampChannel(channels[1] * 255),
|
|
130
|
+
blue: clampChannel(channels[2] * 255),
|
|
131
|
+
alpha: clampAlpha(alpha),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function parseCssColor(value: string): ParsedColor | null {
|
|
136
|
+
return parseSerializedColor(value) ?? parseBrowserColor(value.trim());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function resolvePickerColor(
|
|
140
|
+
value: string | undefined,
|
|
141
|
+
element: Element | null,
|
|
142
|
+
fallback: string,
|
|
143
|
+
): string {
|
|
144
|
+
const view = element?.ownerDocument.defaultView;
|
|
145
|
+
const parsed =
|
|
146
|
+
(value ? parseCssColor(value) : null) ??
|
|
147
|
+
(element && view ? parseCssColor(view.getComputedStyle(element).color) : null);
|
|
148
|
+
return parsed ? toHexColor(parsed) : fallback;
|
|
149
|
+
}
|
|
150
|
+
|
|
75
151
|
export function toColorPickerValue(value: string): string {
|
|
76
152
|
const parsed = parseCssColor(value);
|
|
77
153
|
if (!parsed) return "#000000";
|
|
@@ -93,6 +93,17 @@ describe("crop during a resize", () => {
|
|
|
93
93
|
Object.assign(window, { __timelines: undefined });
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
it("leaves a crop alone when GSAP tweens it by object-of-arrays keyframes", () => {
|
|
97
|
+
const tweened = sizedElement(300, 200, "inset(0px 60px 0px 0px)");
|
|
98
|
+
const kf = { clipPath: ["inset(0px 60px 0px 0px)", "inset(0px 120px 0px 0px)"] };
|
|
99
|
+
const child = { targets: () => [tweened], vars: { keyframes: kf } };
|
|
100
|
+
Object.assign(window, { __timelines: { main: { getChildren: () => [child] } } });
|
|
101
|
+
const stage = prepareCropResize(tweened);
|
|
102
|
+
tweened.style.width = "450px";
|
|
103
|
+
expect(stage()).toBeNull();
|
|
104
|
+
Object.assign(window, { __timelines: undefined });
|
|
105
|
+
});
|
|
106
|
+
|
|
96
107
|
it("decides before the write, which may add a width tween of its own", () => {
|
|
97
108
|
const el = sizedElement(300, 200, "inset(0px 60px 0px 0px)");
|
|
98
109
|
const stage = prepareCropResize(el);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
2
2
|
import type { CommitMutation } from "../../hooks/gsapScriptCommitTypes";
|
|
3
3
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
4
|
-
import {
|
|
5
|
-
import { elementHasNonHoldTween } from "../../hooks/gsapRuntimeKeyframes";
|
|
4
|
+
import { elementHasNonHoldTween, gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes";
|
|
6
5
|
import { buildInsetClipPathSides, type ParsedInsetClipPathSides } from "./clipPathHelpers";
|
|
7
6
|
import { hasCropInsets, readElementCropInsets } from "./domEditOverlayCrop";
|
|
8
7
|
import { forgetStudioBoxSizeDraftBase, readStudioBoxSizeDraftBase } from "./manualEditsDom";
|
|
@@ -21,7 +20,7 @@ type Follows = Record<keyof Box, boolean>;
|
|
|
21
20
|
|
|
22
21
|
/** Per axis, whether a crop follows a resize: not when GSAP drives the clip or tweens that axis's size. */
|
|
23
22
|
function cropFollows(element: HTMLElement): Follows {
|
|
24
|
-
const clip =
|
|
23
|
+
const clip = gsapWritesChannels(element, ["clipPath"]);
|
|
25
24
|
return {
|
|
26
25
|
width: !clip && !elementHasNonHoldTween(element, ["width"]),
|
|
27
26
|
height: !clip && !elementHasNonHoldTween(element, ["height"]),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { roundToCenti } from "../../utils/rounding";
|
|
2
|
+
import { parseCssColor, type ParsedColor } from "./colorValue";
|
|
2
3
|
|
|
3
4
|
export type GradientKind = "linear" | "radial" | "conic";
|
|
4
5
|
|
|
@@ -384,20 +385,42 @@ function interpolateGradientStopColor(model: GradientModel, position: number): s
|
|
|
384
385
|
|
|
385
386
|
const leftColor = left.color;
|
|
386
387
|
const rightColor = right.color;
|
|
387
|
-
const
|
|
388
|
-
const
|
|
388
|
+
const ratio = (clampedPosition - left.position) / Math.max(1, right.position - left.position);
|
|
389
|
+
const mixed = parseCssColor(mixLikeTheGradient(sortedStops, leftColor, rightColor, ratio));
|
|
390
|
+
if (mixed) return formatStopColor(mixed);
|
|
391
|
+
|
|
392
|
+
const leftParsed = leftColor ? parseCssColor(leftColor) : null;
|
|
393
|
+
const rightParsed = rightColor ? parseCssColor(rightColor) : null;
|
|
389
394
|
if (!leftParsed || !rightParsed) return left.color;
|
|
390
395
|
|
|
391
|
-
const ratio = (clampedPosition - left.position) / Math.max(1, right.position - left.position);
|
|
392
396
|
const red = blendChannel(leftParsed.red, rightParsed.red, ratio);
|
|
393
397
|
const green = blendChannel(leftParsed.green, rightParsed.green, ratio);
|
|
394
398
|
const blue = blendChannel(leftParsed.blue, rightParsed.blue, ratio);
|
|
395
|
-
const alpha =
|
|
399
|
+
const alpha = leftParsed.alpha + (rightParsed.alpha - leftParsed.alpha) * ratio;
|
|
400
|
+
return formatStopColor({ red, green, blue, alpha });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const LEGACY_COLOR = /^(?:#|(?:rgba?|hsla?|hwb)\(|[a-z]+$)/i;
|
|
396
404
|
|
|
405
|
+
const isLegacy = (color: string) => LEGACY_COLOR.test(color.trim());
|
|
406
|
+
const isModern = (color: string) => !isLegacy(color) && !/^var\(/i.test(color.trim());
|
|
407
|
+
|
|
408
|
+
function mixLikeTheGradient(
|
|
409
|
+
stops: GradientStop[],
|
|
410
|
+
left: string,
|
|
411
|
+
right: string,
|
|
412
|
+
ratio: number,
|
|
413
|
+
): string {
|
|
414
|
+
const space = stops.some((stop) => isModern(stop.color)) ? "oklab" : "srgb";
|
|
415
|
+
return `color-mix(in ${space}, ${left}, ${right} ${round(ratio * 100)}%)`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function formatStopColor(color: ParsedColor): string {
|
|
419
|
+
const { red, green, blue } = color;
|
|
420
|
+
const alpha = round(color.alpha);
|
|
397
421
|
if (alpha >= 1) {
|
|
398
422
|
return `#${formatHex(red)}${formatHex(green)}${formatHex(blue)}`.toUpperCase();
|
|
399
423
|
}
|
|
400
|
-
|
|
401
424
|
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
|
402
425
|
}
|
|
403
426
|
|
|
@@ -412,34 +435,3 @@ export function insertGradientStop(model: GradientModel, position: number): Grad
|
|
|
412
435
|
stops: nextStops,
|
|
413
436
|
};
|
|
414
437
|
}
|
|
415
|
-
|
|
416
|
-
function parseColorString(
|
|
417
|
-
value: string,
|
|
418
|
-
): { red: number; green: number; blue: number; alpha: number } | null {
|
|
419
|
-
const trimmed = value.trim().toLowerCase();
|
|
420
|
-
if (trimmed === "transparent") {
|
|
421
|
-
return { red: 0, green: 0, blue: 0, alpha: 0 };
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
const hex = trimmed.match(/^#([0-9a-f]{6})$/i);
|
|
425
|
-
if (hex) {
|
|
426
|
-
return {
|
|
427
|
-
red: Number.parseInt(hex[1].slice(0, 2), 16),
|
|
428
|
-
green: Number.parseInt(hex[1].slice(2, 4), 16),
|
|
429
|
-
blue: Number.parseInt(hex[1].slice(4, 6), 16),
|
|
430
|
-
alpha: 1,
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
const rgba = trimmed.match(
|
|
435
|
-
/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i,
|
|
436
|
-
);
|
|
437
|
-
if (!rgba) return null;
|
|
438
|
-
|
|
439
|
-
return {
|
|
440
|
-
red: Number.parseFloat(rgba[1]),
|
|
441
|
-
green: Number.parseFloat(rgba[2]),
|
|
442
|
-
blue: Number.parseFloat(rgba[3]),
|
|
443
|
-
alpha: rgba[4] != null ? Number.parseFloat(rgba[4]) : 1,
|
|
444
|
-
};
|
|
445
|
-
}
|
|
@@ -1,56 +1,2 @@
|
|
|
1
1
|
export const gsapRendersTransform = (el: Element) =>
|
|
2
2
|
!!(el as { _gsap?: { renderTransform?: unknown } })._gsap?.renderTransform;
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Checks whether GSAP actively animates one or more CSS/GSAP properties on
|
|
6
|
-
* the given element by inspecting all registered `__timelines`.
|
|
7
|
-
*/
|
|
8
|
-
// fallow-ignore-next-line complexity
|
|
9
|
-
export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
|
|
10
|
-
const win = el.ownerDocument.defaultView as
|
|
11
|
-
| (Window & {
|
|
12
|
-
__timelines?: Record<
|
|
13
|
-
string,
|
|
14
|
-
{
|
|
15
|
-
getChildren?: (
|
|
16
|
-
deep: boolean,
|
|
17
|
-
) => Array<{ targets?: () => Element[]; vars?: Record<string, unknown> }>;
|
|
18
|
-
}
|
|
19
|
-
>;
|
|
20
|
-
})
|
|
21
|
-
| null;
|
|
22
|
-
if (!win?.__timelines) return false;
|
|
23
|
-
const propSet = new Set(props);
|
|
24
|
-
for (const tl of Object.values(win.__timelines)) {
|
|
25
|
-
if (!tl?.getChildren) continue;
|
|
26
|
-
try {
|
|
27
|
-
for (const child of tl.getChildren(true)) {
|
|
28
|
-
if (!child.targets || !child.vars) continue;
|
|
29
|
-
let targetsEl = false;
|
|
30
|
-
for (const t of child.targets()) {
|
|
31
|
-
if (t === el || (el.id && t.id === el.id)) {
|
|
32
|
-
targetsEl = true;
|
|
33
|
-
break;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
if (!targetsEl) continue;
|
|
37
|
-
const vars = child.vars;
|
|
38
|
-
for (const p of propSet) {
|
|
39
|
-
if (p in vars) return true;
|
|
40
|
-
}
|
|
41
|
-
if (vars.keyframes && typeof vars.keyframes === "object") {
|
|
42
|
-
for (const kfVal of Object.values(vars.keyframes as Record<string, unknown>)) {
|
|
43
|
-
if (kfVal && typeof kfVal === "object") {
|
|
44
|
-
for (const p of propSet) {
|
|
45
|
-
if (p in (kfVal as Record<string, unknown>)) return true;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
} catch {
|
|
52
|
-
/* */
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { applyInlineStyle } from "./inlineTextStyleRange";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
readFirstPaintedElement,
|
|
7
|
+
readInlineStyle,
|
|
8
|
+
readInlineStyleSpread,
|
|
9
|
+
} from "./inlineTextStyleRead";
|
|
6
10
|
|
|
7
11
|
afterEach(() => {
|
|
8
12
|
vi.restoreAllMocks();
|
|
@@ -741,3 +745,29 @@ describe("readInlineStyleSpread", () => {
|
|
|
741
745
|
expect(readInlineStyleSpread(rangeOver(host, 0, 5), "color")).toEqual([]);
|
|
742
746
|
});
|
|
743
747
|
});
|
|
748
|
+
|
|
749
|
+
describe("readFirstPaintedElement", () => {
|
|
750
|
+
it("names the span that paints the text when the selection starts on its parent", () => {
|
|
751
|
+
const host = mount('<span style="color: red">Hello</span>');
|
|
752
|
+
const range = document.createRange();
|
|
753
|
+
range.selectNodeContents(host);
|
|
754
|
+
|
|
755
|
+
expect(readFirstPaintedElement(range, "color")).toBe(host.querySelector("span"));
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("skips a leading space, which paints nothing", () => {
|
|
759
|
+
const host = mount(' <span style="color: red">Hello</span>');
|
|
760
|
+
|
|
761
|
+
expect(readFirstPaintedElement(rangeOver(host, 0, 6), "color")).toBe(
|
|
762
|
+
host.querySelector("span"),
|
|
763
|
+
);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it("names the element of the colour the swatch shows, past text with no colour of its own", () => {
|
|
767
|
+
const host = mount('<span style="color: red"> </span>x<span style="color: lime">y</span>');
|
|
768
|
+
const range = rangeOver(host, 0, 3);
|
|
769
|
+
|
|
770
|
+
expect(readInlineStyleSpread(range, "color")).toEqual(["lime"]);
|
|
771
|
+
expect(readFirstPaintedElement(range, "color")).toBe(host.querySelectorAll("span")[1]);
|
|
772
|
+
});
|
|
773
|
+
});
|
|
@@ -49,6 +49,7 @@ interface StyledRun {
|
|
|
49
49
|
* identity to lose, and merges with its neighbour exactly as before.
|
|
50
50
|
*/
|
|
51
51
|
identity: string;
|
|
52
|
+
painter?: Element | null;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export type InlineStyleDelta = Record<string, string | null>;
|
|
@@ -185,6 +186,7 @@ function holdsBothEnds(host: Element, range: Range): boolean {
|
|
|
185
186
|
export interface InlineStyleChar {
|
|
186
187
|
char: string;
|
|
187
188
|
style: Readonly<Record<string, string>>;
|
|
189
|
+
painter: Element | null;
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
/** Every character the range covers with its style, or null when it covers none. */
|
|
@@ -197,7 +199,7 @@ export function readCoveredInlineStyleChars(range: Range): readonly InlineStyleC
|
|
|
197
199
|
const collapsed = start === end;
|
|
198
200
|
const covered = charRuns(readRuns(host))
|
|
199
201
|
.slice(collapsed ? Math.max(0, start - 1) : start, collapsed ? Math.max(1, start) : end)
|
|
200
|
-
.map((entry) => ({ char: entry.char, style: entry.style }));
|
|
202
|
+
.map((entry) => ({ char: entry.char, style: entry.style, painter: entry.painter ?? null }));
|
|
201
203
|
return covered.length > 0 ? covered : null;
|
|
202
204
|
}
|
|
203
205
|
|
|
@@ -244,7 +246,7 @@ function visitRunFrame(frame: RunWalkFrame, runs: StyledRun[]): RunWalkFrame[] {
|
|
|
244
246
|
const { node, inherited, origin } = frame;
|
|
245
247
|
if (node.nodeType === 3) {
|
|
246
248
|
const text = node.textContent ?? "";
|
|
247
|
-
if (text) runs.push(styledRun(text, inherited, origin));
|
|
249
|
+
if (text) runs.push({ ...styledRun(text, inherited, origin), painter: node.parentElement });
|
|
248
250
|
return [];
|
|
249
251
|
}
|
|
250
252
|
if (!isStyleElement(node)) return [];
|
|
@@ -312,6 +314,7 @@ function charRuns(runs: StyledRun[]): Array<Omit<StyledRun, "text"> & { char: st
|
|
|
312
314
|
style: run.style,
|
|
313
315
|
origin: run.origin,
|
|
314
316
|
identity: run.identity,
|
|
317
|
+
painter: run.painter,
|
|
315
318
|
});
|
|
316
319
|
}
|
|
317
320
|
}
|
|
@@ -45,3 +45,12 @@ export function readInlineStyleSpread(range: Range, property: string): string[]
|
|
|
45
45
|
}
|
|
46
46
|
return spread;
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
export function readFirstPaintedElement(range: Range, property: string): Element | null {
|
|
50
|
+
const covered = readCoveredInlineStyleChars(range);
|
|
51
|
+
if (!covered) return null;
|
|
52
|
+
const painted = covered.filter(({ char }) => char.trim());
|
|
53
|
+
const owner =
|
|
54
|
+
painted.find(({ style }) => style[property] !== undefined) ?? painted[0] ?? covered[0];
|
|
55
|
+
return owner?.painter ?? null;
|
|
56
|
+
}
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
|
|
33
33
|
STUDIO_ROTATION_TRANSFORM_ORIGIN,
|
|
34
34
|
} from "./manualEditsTypes";
|
|
35
|
-
import {
|
|
35
|
+
import { gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes";
|
|
36
36
|
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
|
37
37
|
import { roundTo3, roundToLayoutPx } from "../../utils/rounding";
|
|
38
38
|
import { BOX_SIZE_STYLE_PROPS } from "./manualEditsDomPatches";
|
|
@@ -317,7 +317,7 @@ function applyStudioPathOffsetViaGsap(
|
|
|
317
317
|
element: HTMLElement,
|
|
318
318
|
offset: { x: number; y: number },
|
|
319
319
|
): boolean {
|
|
320
|
-
if (!
|
|
320
|
+
if (!gsapWritesChannels(element, ["x", "y"])) return false;
|
|
321
321
|
element.style.setProperty("translate", "none");
|
|
322
322
|
const win = element.ownerDocument.defaultView as
|
|
323
323
|
| (Window & {
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { applyStudioBoxSize, applyStudioPathOffset, applyStudioRotation } from "./manualEditsDom";
|
|
13
13
|
import { applyStudioMotionFromDom } from "./studioMotion";
|
|
14
14
|
import { STUDIO_MOTION_ATTR, STUDIO_MOTION_TIMELINE_ID } from "./studioMotionTypes";
|
|
15
|
-
import {
|
|
15
|
+
import { gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes";
|
|
16
16
|
|
|
17
17
|
function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
|
|
18
18
|
const ctor = doc.defaultView?.HTMLElement;
|
|
@@ -35,7 +35,7 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
|
|
|
35
35
|
function reapplyPathOffsets(doc: Document): void {
|
|
36
36
|
for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
|
|
37
37
|
// Unlike size below, the offset channels add up: applying both doubles the move.
|
|
38
|
-
if (
|
|
38
|
+
if (gsapWritesChannels(el, ["x", "y"])) continue;
|
|
39
39
|
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
|
|
40
40
|
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
|
|
41
41
|
if (!x && !y) continue;
|
|
@@ -193,7 +193,7 @@ export function ColorField({
|
|
|
193
193
|
const resolveColorGestureValue = useCallback((nextValue: string) => {
|
|
194
194
|
const source = nextValue.startsWith("#") ? "hex" : "picker";
|
|
195
195
|
// Only a COMPLETE hex resolves, so a half-typed one neither previews nor
|
|
196
|
-
// commits.
|
|
196
|
+
// commits. 3 and 6 digits count as complete (the field edits RGB only): gating on
|
|
197
197
|
// 6 alone silently dropped #F00 and friends, which the old onBlur committed.
|
|
198
198
|
if (source === "hex" && !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(nextValue)) return null;
|
|
199
199
|
const nextColor = parseCssColor(nextValue);
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
|
|
4
4
|
import {
|
|
5
|
+
STUDIO_OFFSET_X_PROP,
|
|
6
|
+
STUDIO_OFFSET_Y_PROP,
|
|
5
7
|
STUDIO_PATH_OFFSET_ATTR,
|
|
6
8
|
STUDIO_ROTATION_ATTR,
|
|
7
9
|
STUDIO_ROTATION_PROP,
|
|
@@ -14,6 +16,29 @@ describe("reapplyPositionEditsAfterSeek", () => {
|
|
|
14
16
|
vi.restoreAllMocks();
|
|
15
17
|
});
|
|
16
18
|
|
|
19
|
+
it("keeps an old offset off an element GSAP moves by object-of-arrays keyframes, not off its neighbour", () => {
|
|
20
|
+
const offset = `${STUDIO_OFFSET_X_PROP}: 300px; ${STUDIO_OFFSET_Y_PROP}: 100px; translate: none`;
|
|
21
|
+
document.body.innerHTML = ["moved", "still"]
|
|
22
|
+
.map((id) => `<div id="${id}" ${STUDIO_PATH_OFFSET_ATTR}="true" style="${offset}"></div>`)
|
|
23
|
+
.join("");
|
|
24
|
+
const moved = document.getElementById("moved") as HTMLElement;
|
|
25
|
+
const tween = { targets: () => [moved], vars: { keyframes: { x: [0, 200] } } };
|
|
26
|
+
const win = window as unknown as { __timelines?: unknown; gsap?: unknown };
|
|
27
|
+
const gsap = { set: vi.fn(), getProperty: () => 0 };
|
|
28
|
+
Object.assign(win, { __timelines: { main: { getChildren: () => [tween] } }, gsap });
|
|
29
|
+
try {
|
|
30
|
+
reapplyPositionEditsAfterSeek(document);
|
|
31
|
+
} finally {
|
|
32
|
+
delete win.__timelines;
|
|
33
|
+
delete win.gsap;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
expect(moved.style.getPropertyValue("translate")).toBe("none");
|
|
37
|
+
expect(gsap.set).not.toHaveBeenCalled();
|
|
38
|
+
const still = document.getElementById("still") as HTMLElement;
|
|
39
|
+
expect(still.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
|
|
40
|
+
});
|
|
41
|
+
|
|
17
42
|
it("does no per-edit work on a film Studio never edited", () => {
|
|
18
43
|
document.body.innerHTML = '<div id="a"></div><div id="b"></div>';
|
|
19
44
|
const queryAll = vi.spyOn(document, "querySelectorAll");
|
|
@@ -428,7 +428,7 @@ export const GSAP_TRANSFORM_KEYS = new Set(
|
|
|
428
428
|
);
|
|
429
429
|
|
|
430
430
|
/** Whether a live timeline tween or hold writes any of `channels` on `el`. Sync, no fetch. */
|
|
431
|
-
function gsapWritesChannels(el: Element, channels: string[]): boolean {
|
|
431
|
+
export function gsapWritesChannels(el: Element, channels: string[]): boolean {
|
|
432
432
|
const win = el.ownerDocument.defaultView as { __timelines?: Record<string, RuntimeTimeline> };
|
|
433
433
|
return Object.values(win?.__timelines ?? {}).some((tl) =>
|
|
434
434
|
(tl?.getChildren?.(true) ?? []).some(
|