@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.
- package/dist/assets/{hyperframes-player-DzJbtQVO.js → hyperframes-player-C-9N8nk0.js} +1 -1
- package/dist/assets/{index-n5FUn1xv.js → index-BoUM6Xqb.js} +1 -1
- package/dist/assets/{index-DRhF3v4Z.js → index-CAvZ0AA-.js} +204 -204
- package/dist/assets/{index-DgwDQK5G.js → index-CVuorIMv.js} +1 -1
- package/dist/assets/index-t1LH6H5H.css +1 -0
- package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
- package/dist/chunk-5EKWJZLM.js.map +1 -0
- package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
- package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
- package/dist/chunk-TXZMMOMA.js.map +1 -0
- package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
- package/dist/index.d.ts +46 -22
- package/dist/index.html +2 -2
- package/dist/index.js +3899 -3487
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +10 -8
- package/src/App.tsx +84 -86
- package/src/components/EditorShell.tsx +3 -3
- package/src/components/LinkedSelectionToggle.tsx +3 -2
- 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/dock/Dock.tsx +5 -7
- package/src/components/editor/CanvasContextMenu.tsx +3 -2
- package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
- package/src/components/editor/DomEditCropHandles.tsx +7 -4
- package/src/components/editor/DomEditOverlay.tsx +5 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -10
- package/src/components/editor/InlineTextToolbar.tsx +11 -27
- package/src/components/editor/MotionPathOverlay.tsx +7 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
- package/src/components/editor/SnapToolbar.test.tsx +100 -1
- package/src/components/editor/SnapToolbar.tsx +43 -5
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
- package/src/components/editor/audioFxTelemetry.ts +2 -3
- 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/commitPreflightHandles.test.tsx +2 -2
- package/src/components/editor/cropResize.test.ts +11 -0
- package/src/components/editor/cropResize.ts +6 -6
- package/src/components/editor/domEditingDom.ts +4 -0
- package/src/components/editor/domEditingLayers.ts +1 -5
- 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/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +2 -2
- package/src/components/editor/manualEditsParsing.ts +7 -0
- package/src/components/editor/manualEditsSeekReapply.ts +2 -2
- package/src/components/editor/marqueeCommit.test.tsx +106 -37
- package/src/components/editor/marqueeCommit.ts +24 -8
- package/src/components/editor/motionPathCommit.test.ts +29 -1
- package/src/components/editor/motionPathCommit.ts +21 -9
- package/src/components/editor/probeSourceElement.test.ts +45 -0
- package/src/components/editor/probeSourceElement.ts +39 -15
- package/src/components/editor/propertyPanelColor.tsx +1 -1
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
- package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
- package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
- package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
- package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
- package/src/components/editor/propertyPanelFxSection.tsx +30 -28
- package/src/components/editor/propertyPanelTypes.ts +6 -3
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
- package/src/components/editor/useDomEditNudge.test.tsx +37 -0
- package/src/components/editor/useDomEditNudge.ts +2 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
- package/src/components/editor/useDomEditZOrder.ts +12 -2
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
- package/src/components/sidebar/AssetContextMenu.tsx +4 -4
- package/src/components/ui/menuStyle.test.ts +61 -0
- package/src/components/ui/menuStyle.ts +10 -0
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/domEditExports.test.tsx +9 -1
- package/src/hooks/domEditCommitRunner.ts +1 -1
- package/src/hooks/elementOffsetStager.ts +5 -4
- package/src/hooks/gsapDragCommit.ts +2 -20
- package/src/hooks/gsapDragPositionCommit.ts +18 -3
- package/src/hooks/gsapGestureOutcome.test.ts +161 -0
- package/src/hooks/gsapGestureOutcome.ts +64 -0
- package/src/hooks/gsapKeyframeCommit.ts +3 -12
- package/src/hooks/gsapResizeIntercept.test.ts +33 -0
- package/src/hooks/gsapResizeIntercept.ts +1 -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/gsapScriptCommitTypes.ts +5 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
- package/src/hooks/keyframeCacheAstLoad.ts +6 -5
- package/src/hooks/plainRotation.ts +3 -2
- package/src/hooks/previewPromotionSelection.test.tsx +1 -1
- package/src/hooks/refreshTimelineRowText.test.ts +65 -0
- package/src/hooks/refreshTimelineRowText.ts +29 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
- package/src/hooks/useAppHotkeys.ts +11 -5
- package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
- package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
- package/src/hooks/useDomEditTextCommits.ts +5 -1
- package/src/hooks/useDomEditWiring.ts +4 -2
- package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
- package/src/hooks/useDomGeometryCommit.ts +7 -4
- package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
- package/src/hooks/useDomGeometryCommits.ts +3 -2
- package/src/hooks/useDomSelection.test.ts +26 -0
- package/src/hooks/useDomSelection.ts +10 -3
- package/src/hooks/useDomSelectionTypes.ts +6 -1
- package/src/hooks/useEnableKeyframes.test.ts +2 -2
- package/src/hooks/useEnableKeyframes.ts +5 -3
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
- package/src/hooks/useFrameCapture.ts +15 -8
- package/src/hooks/useGestureCommit.test.tsx +225 -55
- package/src/hooks/useGestureCommit.ts +90 -56
- package/src/hooks/useGestureTrailOverlay.tsx +33 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
- package/src/hooks/useGsapAwareEditing.ts +96 -195
- package/src/hooks/useGsapAwareGroupMove.ts +179 -0
- package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
- package/src/hooks/useGsapKeyframeOps.ts +36 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
- package/src/hooks/useGsapSelectionHandlers.ts +24 -18
- package/src/hooks/useGsapTweenCache.ts +18 -12
- package/src/hooks/usePanelLayout.ts +5 -1
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
- package/src/hooks/usePreviewInteraction.test.ts +21 -0
- package/src/hooks/usePreviewInteraction.ts +2 -0
- package/src/hooks/useRenderClipContent.test.ts +18 -0
- package/src/hooks/useRenderClipContent.ts +4 -5
- 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/useTimelineDeleteOps.test.tsx +30 -22
- package/src/hooks/useTimelineDeleteOps.ts +21 -27
- package/src/hooks/useTimelineEditing.test.tsx +1 -1
- package/src/hooks/useTimelineEditing.ts +33 -27
- package/src/player/components/AutomationSelectionMenu.tsx +3 -2
- package/src/player/components/ClipContextMenu.test.tsx +73 -0
- package/src/player/components/ClipContextMenu.tsx +33 -31
- package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
- package/src/player/components/OutOfSyncBadge.tsx +5 -5
- package/src/player/components/TextClipContent.test.ts +19 -0
- package/src/player/components/TextClipContent.tsx +27 -0
- package/src/player/components/TrackGapContextMenu.tsx +3 -6
- package/src/player/components/clipMenuToolItems.test.tsx +23 -0
- package/src/player/components/clipMenuToolItems.tsx +13 -9
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
- package/src/player/components/timelineClipGestureHandlers.ts +20 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineLinkSelection.ts +35 -1
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
- package/src/player/components/useTimelineClipDrag.ts +6 -7
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +6 -1
- package/src/player/lib/timelineElementHelpers.test.ts +87 -1
- package/src/player/lib/timelineElementHelpers.ts +6 -15
- package/src/player/lib/timelineText.ts +40 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/theme.css +3 -0
- package/src/utils/frameCapture.ts +3 -1
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
- package/src/utils/keyframeUsage.ts +79 -0
- package/src/utils/previewFeatureUsage.test.ts +32 -0
- package/src/utils/previewFeatureUsage.ts +46 -0
- package/src/utils/studioTelemetry.test.ts +107 -0
- package/src/utils/studioTelemetry.ts +49 -4
- package/src/webmcp/StudioAgentTools.tsx +2 -2
- package/dist/assets/index-DbfKVTId.css +0 -1
- package/dist/chunk-BIUXP3PG.js.map +0 -1
- package/dist/chunk-K47E2ZJA.js.map +0 -1
- /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
- /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
|
-
|
|
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";
|
|
@@ -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 {
|
|
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 =
|
|
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<
|
|
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<
|
|
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
|
|
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 & {
|