@hyperframes/studio 0.8.113 → 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.
Files changed (53) hide show
  1. package/dist/assets/{hyperframes-player-DzJbtQVO.js → hyperframes-player-aXNJKiIg.js} +1 -1
  2. package/dist/assets/index-BYnsJnJV.css +1 -0
  3. package/dist/assets/{index-n5FUn1xv.js → index-Bn2RJNTJ.js} +1 -1
  4. package/dist/assets/{index-DgwDQK5G.js → index-C9N-DGjC.js} +1 -1
  5. package/dist/assets/{index-DRhF3v4Z.js → index-Dz7doz9l.js} +199 -199
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1605 -1536
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +9 -8
  12. package/src/App.tsx +82 -84
  13. package/src/components/EditorShell.tsx +3 -3
  14. package/src/components/StudioHeader.tsx +4 -4
  15. package/src/components/StudioLeftPanels.tsx +3 -3
  16. package/src/components/StudioOverlays.tsx +4 -4
  17. package/src/components/StudioRightPanels.tsx +14 -9
  18. package/src/components/StudioToast.tsx +1 -1
  19. package/src/components/TimelineToolbar.tsx +3 -3
  20. package/src/components/editor/InlineTextToolbar.tsx +11 -27
  21. package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
  22. package/src/components/editor/colorValue.test.ts +16 -0
  23. package/src/components/editor/colorValue.ts +100 -24
  24. package/src/components/editor/cropResize.test.ts +11 -0
  25. package/src/components/editor/cropResize.ts +2 -3
  26. package/src/components/editor/gradientValue.ts +28 -36
  27. package/src/components/editor/gsapAnimatesProperty.ts +0 -54
  28. package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
  29. package/src/components/editor/inlineTextStyleRange.ts +5 -2
  30. package/src/components/editor/inlineTextStyleRead.ts +9 -0
  31. package/src/components/editor/manualEditsDom.ts +2 -2
  32. package/src/components/editor/manualEditsSeekReapply.ts +2 -2
  33. package/src/components/editor/propertyPanelColor.tsx +1 -1
  34. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
  35. package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
  36. package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
  37. package/src/hooks/gsapRuntimeReaders.ts +3 -90
  38. package/src/hooks/useAppHotkeys.ts +11 -5
  39. package/src/hooks/useFrameCapture.ts +15 -8
  40. package/src/hooks/useGestureCommit.ts +4 -1
  41. package/src/hooks/useGestureTrailOverlay.tsx +33 -0
  42. package/src/hooks/usePanelLayout.ts +5 -1
  43. package/src/hooks/useShellSlices.ts +53 -0
  44. package/src/hooks/useStableHandlers.test.tsx +77 -0
  45. package/src/hooks/useStableHandlers.ts +44 -0
  46. package/src/hooks/useStudioSdkSessions.ts +2 -1
  47. package/src/hooks/useStudioSidePanels.test.tsx +44 -0
  48. package/src/hooks/useStudioSidePanels.tsx +23 -0
  49. package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
  50. package/src/hooks/useTimelineEditing.ts +33 -27
  51. package/src/utils/frameCapture.ts +3 -1
  52. package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
  53. 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
- export function parseCssColor(value: string): ParsedColor | null {
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 shortHex = trimmed.match(/^#([0-9a-f]{3})$/i);
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(hex[1].slice(0, 2), 16),
54
- green: Number.parseInt(hex[1].slice(2, 4), 16),
55
- blue: Number.parseInt(hex[1].slice(4, 6), 16),
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(Number.parseFloat(rgba[1])),
66
- green: clampChannel(Number.parseFloat(rgba[2])),
67
- blue: clampChannel(Number.parseFloat(rgba[3])),
68
- alpha: clampAlpha(rgba[4] != null ? Number.parseFloat(rgba[4]) : 1),
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 { gsapAnimatesProperty } from "./gsapAnimatesProperty";
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 = gsapAnimatesProperty(element, "clipPath");
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 leftParsed = leftColor ? parseColorString(leftColor) : null;
388
- const rightParsed = rightColor ? parseColorString(rightColor) : null;
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 = round(leftParsed.alpha + (rightParsed.alpha - leftParsed.alpha) * ratio);
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 { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead";
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 { gsapAnimatesProperty } from "./gsapAnimatesProperty";
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 (!gsapAnimatesProperty(element, "x", "y")) return false;
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 { gsapAnimatesProperty } from "./gsapAnimatesProperty";
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 (gsapAnimatesProperty(el, "x", "y")) continue;
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. Both lengths parseCssColor accepts count as complete: gating on
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(