@hyperframes/studio 0.8.113 → 0.8.115

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 (194) hide show
  1. package/dist/assets/{hyperframes-player-DzJbtQVO.js → hyperframes-player-C-9N8nk0.js} +1 -1
  2. package/dist/assets/{index-n5FUn1xv.js → index-BoUM6Xqb.js} +1 -1
  3. package/dist/assets/{index-DRhF3v4Z.js → index-CAvZ0AA-.js} +204 -204
  4. package/dist/assets/{index-DgwDQK5G.js → index-CVuorIMv.js} +1 -1
  5. package/dist/assets/index-t1LH6H5H.css +1 -0
  6. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  7. package/dist/chunk-5EKWJZLM.js.map +1 -0
  8. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  9. package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
  10. package/dist/chunk-TXZMMOMA.js.map +1 -0
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
  12. package/dist/index.d.ts +46 -22
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +3899 -3487
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/dist/styles.css +1 -1
  18. package/package.json +10 -8
  19. package/src/App.tsx +84 -86
  20. package/src/components/EditorShell.tsx +3 -3
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/StudioHeader.tsx +4 -4
  23. package/src/components/StudioLeftPanels.tsx +3 -3
  24. package/src/components/StudioOverlays.tsx +4 -4
  25. package/src/components/StudioRightPanels.tsx +14 -9
  26. package/src/components/StudioToast.tsx +1 -1
  27. package/src/components/TimelineToolbar.tsx +3 -3
  28. package/src/components/dock/Dock.tsx +5 -7
  29. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  30. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  31. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  32. package/src/components/editor/DomEditOverlay.tsx +5 -1
  33. package/src/components/editor/FileTreeNodes.tsx +13 -10
  34. package/src/components/editor/InlineTextToolbar.tsx +11 -27
  35. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  37. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  38. package/src/components/editor/SnapToolbar.tsx +43 -5
  39. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  40. package/src/components/editor/audioFxTelemetry.ts +2 -3
  41. package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
  42. package/src/components/editor/colorValue.test.ts +16 -0
  43. package/src/components/editor/colorValue.ts +100 -24
  44. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  45. package/src/components/editor/cropResize.test.ts +11 -0
  46. package/src/components/editor/cropResize.ts +6 -6
  47. package/src/components/editor/domEditingDom.ts +4 -0
  48. package/src/components/editor/domEditingLayers.ts +1 -5
  49. package/src/components/editor/gradientValue.ts +28 -36
  50. package/src/components/editor/gsapAnimatesProperty.ts +0 -54
  51. package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
  52. package/src/components/editor/inlineTextStyleRange.ts +5 -2
  53. package/src/components/editor/inlineTextStyleRead.ts +9 -0
  54. package/src/components/editor/manualEdits.ts +1 -0
  55. package/src/components/editor/manualEditsDom.ts +2 -2
  56. package/src/components/editor/manualEditsParsing.ts +7 -0
  57. package/src/components/editor/manualEditsSeekReapply.ts +2 -2
  58. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  59. package/src/components/editor/marqueeCommit.ts +24 -8
  60. package/src/components/editor/motionPathCommit.test.ts +29 -1
  61. package/src/components/editor/motionPathCommit.ts +21 -9
  62. package/src/components/editor/probeSourceElement.test.ts +45 -0
  63. package/src/components/editor/probeSourceElement.ts +39 -15
  64. package/src/components/editor/propertyPanelColor.tsx +1 -1
  65. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  66. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  67. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  68. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  69. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  70. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  71. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  72. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  73. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  74. package/src/components/editor/propertyPanelTypes.ts +6 -3
  75. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
  76. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  77. package/src/components/editor/useDomEditNudge.ts +2 -0
  78. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  79. package/src/components/editor/useDomEditZOrder.ts +12 -2
  80. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  81. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  82. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  83. package/src/components/ui/menuStyle.test.ts +61 -0
  84. package/src/components/ui/menuStyle.ts +10 -0
  85. package/src/contexts/DomEditContext.tsx +1 -1
  86. package/src/contexts/TimelineEditContext.tsx +1 -0
  87. package/src/domEditExports.test.tsx +9 -1
  88. package/src/hooks/domEditCommitRunner.ts +1 -1
  89. package/src/hooks/elementOffsetStager.ts +5 -4
  90. package/src/hooks/gsapDragCommit.ts +2 -20
  91. package/src/hooks/gsapDragPositionCommit.ts +18 -3
  92. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  93. package/src/hooks/gsapGestureOutcome.ts +64 -0
  94. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  95. package/src/hooks/gsapResizeIntercept.test.ts +33 -0
  96. package/src/hooks/gsapResizeIntercept.ts +1 -0
  97. package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
  98. package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
  99. package/src/hooks/gsapRuntimeReaders.ts +3 -90
  100. package/src/hooks/gsapScriptCommitTypes.ts +5 -0
  101. package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
  102. package/src/hooks/keyframeCacheAstLoad.ts +6 -5
  103. package/src/hooks/plainRotation.ts +3 -2
  104. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  105. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  106. package/src/hooks/refreshTimelineRowText.ts +29 -0
  107. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  108. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  109. package/src/hooks/useAppHotkeys.ts +11 -5
  110. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  112. package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
  113. package/src/hooks/useDomEditTextCommits.ts +5 -1
  114. package/src/hooks/useDomEditWiring.ts +4 -2
  115. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  116. package/src/hooks/useDomGeometryCommit.ts +7 -4
  117. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  118. package/src/hooks/useDomGeometryCommits.ts +3 -2
  119. package/src/hooks/useDomSelection.test.ts +26 -0
  120. package/src/hooks/useDomSelection.ts +10 -3
  121. package/src/hooks/useDomSelectionTypes.ts +6 -1
  122. package/src/hooks/useEnableKeyframes.test.ts +2 -2
  123. package/src/hooks/useEnableKeyframes.ts +5 -3
  124. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
  125. package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
  126. package/src/hooks/useFrameCapture.ts +15 -8
  127. package/src/hooks/useGestureCommit.test.tsx +225 -55
  128. package/src/hooks/useGestureCommit.ts +90 -56
  129. package/src/hooks/useGestureTrailOverlay.tsx +33 -0
  130. package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
  131. package/src/hooks/useGsapAwareEditing.ts +96 -195
  132. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  133. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  134. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  135. package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
  136. package/src/hooks/useGsapScriptCommits.ts +3 -0
  137. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  138. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  139. package/src/hooks/useGsapTweenCache.ts +18 -12
  140. package/src/hooks/usePanelLayout.ts +5 -1
  141. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  142. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  143. package/src/hooks/usePreviewInteraction.ts +2 -0
  144. package/src/hooks/useRenderClipContent.test.ts +18 -0
  145. package/src/hooks/useRenderClipContent.ts +4 -5
  146. package/src/hooks/useShellSlices.ts +53 -0
  147. package/src/hooks/useStableHandlers.test.tsx +77 -0
  148. package/src/hooks/useStableHandlers.ts +44 -0
  149. package/src/hooks/useStudioSdkSessions.ts +2 -1
  150. package/src/hooks/useStudioSidePanels.test.tsx +44 -0
  151. package/src/hooks/useStudioSidePanels.tsx +23 -0
  152. package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
  153. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  154. package/src/hooks/useTimelineDeleteOps.ts +21 -27
  155. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  156. package/src/hooks/useTimelineEditing.ts +33 -27
  157. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  158. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  159. package/src/player/components/ClipContextMenu.tsx +33 -31
  160. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  161. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  162. package/src/player/components/TextClipContent.test.ts +19 -0
  163. package/src/player/components/TextClipContent.tsx +27 -0
  164. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  165. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  166. package/src/player/components/clipMenuToolItems.tsx +13 -9
  167. package/src/player/components/timelineCallbacks.ts +1 -0
  168. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  169. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  170. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  171. package/src/player/components/timelineEditing.ts +3 -1
  172. package/src/player/components/timelineLinkSelection.ts +35 -1
  173. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  174. package/src/player/components/useTimelineClipDrag.ts +6 -7
  175. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  176. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  177. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  178. package/src/player/lib/timelineElementHelpers.ts +6 -15
  179. package/src/player/lib/timelineText.ts +40 -0
  180. package/src/player/store/timelineElement.ts +12 -0
  181. package/src/styles/theme.css +3 -0
  182. package/src/utils/frameCapture.ts +3 -1
  183. package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
  184. package/src/utils/keyframeUsage.ts +79 -0
  185. package/src/utils/previewFeatureUsage.test.ts +32 -0
  186. package/src/utils/previewFeatureUsage.ts +46 -0
  187. package/src/utils/studioTelemetry.test.ts +107 -0
  188. package/src/utils/studioTelemetry.ts +49 -4
  189. package/src/webmcp/StudioAgentTools.tsx +2 -2
  190. package/dist/assets/index-DbfKVTId.css +0 -1
  191. package/dist/chunk-BIUXP3PG.js.map +0 -1
  192. package/dist/chunk-K47E2ZJA.js.map +0 -1
  193. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  194. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
@@ -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";
@@ -287,7 +287,7 @@ describe("handles follow what Studio would commit", () => {
287
287
  { x: 0, y: 0 },
288
288
  commitList,
289
289
  preview,
290
- vi.fn(),
290
+ vi.fn(async () => undefined),
291
291
  undefined,
292
292
  preflightOnly,
293
293
  );
@@ -401,7 +401,7 @@ describe("handles follow what Studio would commit", () => {
401
401
  { x: 0, y: 0 },
402
402
  [stagger],
403
403
  preview,
404
- vi.fn(),
404
+ vi.fn(async () => undefined),
405
405
  undefined,
406
406
  groupPreflight,
407
407
  );
@@ -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,8 @@
1
+ import type { DomEditPersistOutcome } from "../../hooks/domEditCommitTypes";
1
2
  import type { PatchOperation } from "../../utils/sourcePatcher";
2
3
  import type { CommitMutation } from "../../hooks/gsapScriptCommitTypes";
3
4
  import type { DomEditSelection } from "./domEditingTypes";
4
- import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
5
- import { elementHasNonHoldTween } from "../../hooks/gsapRuntimeKeyframes";
5
+ import { elementHasNonHoldTween, gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes";
6
6
  import { buildInsetClipPathSides, type ParsedInsetClipPathSides } from "./clipPathHelpers";
7
7
  import { hasCropInsets, readElementCropInsets } from "./domEditOverlayCrop";
8
8
  import { forgetStudioBoxSizeDraftBase, readStudioBoxSizeDraftBase } from "./manualEditsDom";
@@ -21,7 +21,7 @@ type Follows = Record<keyof Box, boolean>;
21
21
 
22
22
  /** Per axis, whether a crop follows a resize: not when GSAP drives the clip or tweens that axis's size. */
23
23
  function cropFollows(element: HTMLElement): Follows {
24
- const clip = gsapAnimatesProperty(element, "clipPath");
24
+ const clip = gsapWritesChannels(element, ["clipPath"]);
25
25
  return {
26
26
  width: !clip && !elementHasNonHoldTween(element, ["width"]),
27
27
  height: !clip && !elementHasNonHoldTween(element, ["height"]),
@@ -95,7 +95,7 @@ type PatchCommit = (
95
95
  selection: DomEditSelection,
96
96
  patches: PatchOperation[],
97
97
  options: { label: string; coalesceKey: string; coalesceMs?: number },
98
- ) => Promise<void>;
98
+ ) => Promise<DomEditPersistOutcome | undefined>;
99
99
 
100
100
  /** Stage the crop and save it under the resize's undo key, taking it back off the element if that fails. */
101
101
  export async function saveCropResize(
@@ -103,11 +103,11 @@ export async function saveCropResize(
103
103
  selection: DomEditSelection,
104
104
  commit: PatchCommit,
105
105
  coalesceKey: string,
106
- ): Promise<void> {
106
+ ): Promise<DomEditPersistOutcome | undefined> {
107
107
  const crop = stage();
108
108
  if (!crop) return;
109
109
  try {
110
- await commit(selection, [crop.patch], {
110
+ return await commit(selection, [crop.patch], {
111
111
  label: "Resize layer",
112
112
  coalesceKey,
113
113
  coalesceMs: Number.POSITIVE_INFINITY,
@@ -30,6 +30,10 @@ export function isTextBearingTag(tagName: string): boolean {
30
30
  return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
31
31
  }
32
32
 
33
+ export function isEditableTextLeaf(el: HTMLElement): boolean {
34
+ return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
35
+ }
36
+
33
37
  /** Does this node render AT ALL, ignoring what it inherits? Sole owner of the
34
38
  * rule; the walk below only decides which nodes to ask it about. */
35
39
  function elementRendersItself(win: Window, el: HTMLElement): boolean {
@@ -23,8 +23,8 @@ import {
23
23
  getInlineStyles,
24
24
  getSelectorIndex,
25
25
  getSourceFileForElement,
26
+ isEditableTextLeaf,
26
27
  isHtmlElement,
27
- isTextBearingTag,
28
28
  } from "./domEditingDom";
29
29
  import {
30
30
  findElementForSelection,
@@ -35,10 +35,6 @@ import { isCompositionRootLayer } from "./domEditingRootLayer";
35
35
  import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex";
36
36
  import { type DomEditLayerWalkCache, readDomEditLayerWalkEntry } from "./domEditLayerWalkCache";
37
37
 
38
- export function isEditableTextLeaf(el: HTMLElement): boolean {
39
- return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
40
- }
41
-
42
38
  function sameTagChildIndex(el: HTMLElement): number {
43
39
  let index = 0;
44
40
  let sibling = el.previousElementSibling;
@@ -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
+ }
@@ -10,6 +10,7 @@ export {
10
10
  } from "./manualEditsTypes";
11
11
 
12
12
  export {
13
+ mergeFileChangeAffectedCompositions,
13
14
  readFileChangeAffectedCompositions,
14
15
  readFileChangeAffectsPreview,
15
16
  readFileChangeField,
@@ -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 & {