@hyperframes/studio 0.5.5 → 0.6.0-alpha.1
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-Cd8vYWxP.js +198 -0
- package/dist/assets/index-D04_ZoMm.js +107 -0
- package/dist/assets/index-UWFaHilT.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2621 -170
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.test.ts +241 -0
- package/src/components/editor/DomEditOverlay.tsx +1300 -0
- package/src/components/editor/MotionPanel.tsx +651 -0
- package/src/components/editor/PropertyPanel.test.ts +67 -0
- package/src/components/editor/PropertyPanel.tsx +2891 -207
- package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
- package/src/components/editor/TimelineLayerPanel.tsx +113 -0
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +872 -0
- package/src/components/editor/domEditing.ts +993 -0
- package/src/components/editor/floatingPanel.test.ts +34 -0
- package/src/components/editor/floatingPanel.ts +54 -0
- package/src/components/editor/fontAssets.ts +32 -0
- package/src/components/editor/fontCatalog.ts +126 -0
- package/src/components/editor/gradientValue.test.ts +89 -0
- package/src/components/editor/gradientValue.ts +445 -0
- package/src/components/editor/manualEditingAvailability.test.ts +120 -0
- package/src/components/editor/manualEditingAvailability.ts +60 -0
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1397 -0
- package/src/components/editor/manualOffsetDrag.test.ts +140 -0
- package/src/components/editor/manualOffsetDrag.ts +307 -0
- package/src/components/editor/studioMotion.test.ts +355 -0
- package/src/components/editor/studioMotion.ts +632 -0
- package/src/components/nle/NLELayout.tsx +27 -4
- package/src/components/nle/NLEPreview.tsx +50 -5
- package/src/components/renders/RenderQueue.tsx +13 -62
- package/src/components/renders/useRenderQueue.ts +6 -30
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/CompositionsTab.test.ts +16 -1
- package/src/components/sidebar/CompositionsTab.tsx +117 -45
- package/src/components/sidebar/LeftSidebar.tsx +140 -125
- package/src/hooks/usePersistentEditHistory.test.ts +256 -0
- package/src/hooks/usePersistentEditHistory.ts +337 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +50 -13
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.tsx +18 -2
- package/src/player/components/Timeline.test.ts +20 -0
- package/src/player/components/Timeline.tsx +103 -21
- package/src/player/components/TimelineClip.test.ts +92 -0
- package/src/player/components/TimelineClip.tsx +241 -7
- package/src/player/components/timelineEditing.test.ts +16 -3
- package/src/player/components/timelineEditing.ts +10 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
- package/src/player/hooks/useTimelinePlayer.ts +287 -16
- package/src/player/store/playerStore.ts +2 -0
- package/src/utils/clipboard.test.ts +89 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/sourcePatcher.test.ts +128 -1
- package/src/utils/sourcePatcher.ts +130 -18
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/src/utils/timelineInspector.test.ts +79 -0
- package/src/utils/timelineInspector.ts +116 -0
- package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
- package/dist/assets/index-04Mp2wOn.css +0 -1
- package/dist/assets/index-960mgQMI.js +0 -93
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Window } from "happy-dom";
|
|
3
|
+
import type { DomEditLayerItem } from "./domEditing";
|
|
4
|
+
import { getTimelineLayerPanelSummary } from "./TimelineLayerPanel";
|
|
5
|
+
|
|
6
|
+
function createLayer(overrides: Partial<DomEditLayerItem> = {}): DomEditLayerItem {
|
|
7
|
+
const window = new Window();
|
|
8
|
+
return {
|
|
9
|
+
childCount: 0,
|
|
10
|
+
depth: 0,
|
|
11
|
+
element: window.document.createElement(overrides.tagName ?? "div"),
|
|
12
|
+
key: "layer",
|
|
13
|
+
label: "Layer",
|
|
14
|
+
sourceFile: "index.html",
|
|
15
|
+
tagName: "div",
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("TimelineLayerPanel", () => {
|
|
21
|
+
it("describes a leaf media clip as a single selectable layer", () => {
|
|
22
|
+
expect(
|
|
23
|
+
getTimelineLayerPanelSummary([
|
|
24
|
+
createLayer({ key: "alpha-video", label: "Alpha Video", tagName: "video" }),
|
|
25
|
+
]),
|
|
26
|
+
).toBe("Single selectable media layer");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("describes real nested layers with the nested count", () => {
|
|
30
|
+
expect(
|
|
31
|
+
getTimelineLayerPanelSummary([
|
|
32
|
+
createLayer({ key: "root", childCount: 2 }),
|
|
33
|
+
createLayer({ key: "title", depth: 1 }),
|
|
34
|
+
createLayer({ key: "subtitle", depth: 1 }),
|
|
35
|
+
]),
|
|
36
|
+
).toBe("2 nested selectable layers");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps empty layer lists explicit", () => {
|
|
40
|
+
expect(getTimelineLayerPanelSummary([])).toBe("No selectable layers");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import type { DomEditLayerItem } from "./domEditing";
|
|
3
|
+
|
|
4
|
+
interface TimelineLayerPanelProps {
|
|
5
|
+
clipLabel: string;
|
|
6
|
+
layers: DomEditLayerItem[];
|
|
7
|
+
selectedLayerKey: string | null;
|
|
8
|
+
onSelectLayer: (layer: DomEditLayerItem) => void;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MEDIA_LAYER_TAGS = new Set(["audio", "canvas", "img", "picture", "svg", "video"]);
|
|
13
|
+
|
|
14
|
+
export function getTimelineLayerPanelSummary(layers: readonly DomEditLayerItem[]): string {
|
|
15
|
+
const childCount = Math.max(0, layers.length - 1);
|
|
16
|
+
if (childCount > 0) {
|
|
17
|
+
return `${childCount} nested selectable layer${childCount === 1 ? "" : "s"}`;
|
|
18
|
+
}
|
|
19
|
+
const layer = layers[0];
|
|
20
|
+
if (!layer) return "No selectable layers";
|
|
21
|
+
return MEDIA_LAYER_TAGS.has(layer.tagName.trim().toLowerCase())
|
|
22
|
+
? "Single selectable media layer"
|
|
23
|
+
: "Single selectable layer";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const TimelineLayerPanel = memo(function TimelineLayerPanel({
|
|
27
|
+
clipLabel,
|
|
28
|
+
layers,
|
|
29
|
+
selectedLayerKey,
|
|
30
|
+
onSelectLayer,
|
|
31
|
+
onClose,
|
|
32
|
+
}: TimelineLayerPanelProps) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-950">
|
|
35
|
+
<div className="flex items-start justify-between gap-3 border-b border-white/10 px-3 py-3">
|
|
36
|
+
<div className="min-w-0">
|
|
37
|
+
<div className="text-[9px] font-semibold uppercase tracking-[0.18em] text-neutral-500">
|
|
38
|
+
Clip layers
|
|
39
|
+
</div>
|
|
40
|
+
<div className="mt-1 truncate text-sm font-semibold text-neutral-100">{clipLabel}</div>
|
|
41
|
+
</div>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
onPointerDown={(event) => {
|
|
45
|
+
event.stopPropagation();
|
|
46
|
+
}}
|
|
47
|
+
onClick={onClose}
|
|
48
|
+
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md border border-white/10 bg-black/20 text-neutral-500 transition-colors hover:border-white/20 hover:text-neutral-200"
|
|
49
|
+
aria-label="Close clip layers"
|
|
50
|
+
>
|
|
51
|
+
<svg
|
|
52
|
+
width="14"
|
|
53
|
+
height="14"
|
|
54
|
+
viewBox="0 0 24 24"
|
|
55
|
+
fill="none"
|
|
56
|
+
stroke="currentColor"
|
|
57
|
+
strokeWidth="1.8"
|
|
58
|
+
strokeLinecap="round"
|
|
59
|
+
aria-hidden="true"
|
|
60
|
+
>
|
|
61
|
+
<path d="M18 6 6 18" />
|
|
62
|
+
<path d="m6 6 12 12" />
|
|
63
|
+
</svg>
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
<div className="border-b border-white/10 px-3 py-2 text-[11px] text-neutral-500">
|
|
67
|
+
{getTimelineLayerPanelSummary(layers)}
|
|
68
|
+
</div>
|
|
69
|
+
<div className="min-h-0 flex-1 overflow-y-auto py-1">
|
|
70
|
+
{layers.map((layer) => {
|
|
71
|
+
const selected = layer.key === selectedLayerKey;
|
|
72
|
+
return (
|
|
73
|
+
<button
|
|
74
|
+
key={layer.key}
|
|
75
|
+
type="button"
|
|
76
|
+
data-timeline-layer-row={layer.key}
|
|
77
|
+
onPointerDown={(event) => {
|
|
78
|
+
event.stopPropagation();
|
|
79
|
+
onSelectLayer(layer);
|
|
80
|
+
}}
|
|
81
|
+
onClick={(event) => {
|
|
82
|
+
event.stopPropagation();
|
|
83
|
+
onSelectLayer(layer);
|
|
84
|
+
}}
|
|
85
|
+
className={`group flex w-full items-center gap-2 px-2.5 py-1.5 text-left transition-colors ${
|
|
86
|
+
selected
|
|
87
|
+
? "bg-studio-accent/14 text-studio-accent"
|
|
88
|
+
: "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100"
|
|
89
|
+
}`}
|
|
90
|
+
style={{ paddingLeft: 10 + layer.depth * 14 }}
|
|
91
|
+
>
|
|
92
|
+
<span
|
|
93
|
+
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md border text-[9px] font-bold uppercase ${
|
|
94
|
+
selected
|
|
95
|
+
? "border-studio-accent/50 bg-studio-accent/18"
|
|
96
|
+
: "border-white/10 bg-black/20 text-neutral-500 group-hover:text-neutral-300"
|
|
97
|
+
}`}
|
|
98
|
+
>
|
|
99
|
+
{layer.tagName.slice(0, 2)}
|
|
100
|
+
</span>
|
|
101
|
+
<span className="min-w-0 flex-1 truncate text-xs font-medium">{layer.label}</span>
|
|
102
|
+
{layer.childCount > 0 && (
|
|
103
|
+
<span className="rounded-full border border-white/10 bg-black/25 px-1.5 py-0.5 text-[9px] font-semibold tabular-nums text-neutral-500">
|
|
104
|
+
{layer.childCount}
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
107
|
+
</button>
|
|
108
|
+
);
|
|
109
|
+
})}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
formatCssColor,
|
|
4
|
+
hsvToRgb,
|
|
5
|
+
mergeColorWithExistingAlpha,
|
|
6
|
+
parseCssColor,
|
|
7
|
+
rgbToHsv,
|
|
8
|
+
toColorPickerValue,
|
|
9
|
+
toHexColor,
|
|
10
|
+
} from "./colorValue";
|
|
11
|
+
|
|
12
|
+
describe("parseCssColor", () => {
|
|
13
|
+
it("parses rgb values", () => {
|
|
14
|
+
expect(parseCssColor("rgb(12, 34, 56)")).toEqual({
|
|
15
|
+
red: 12,
|
|
16
|
+
green: 34,
|
|
17
|
+
blue: 56,
|
|
18
|
+
alpha: 1,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("parses rgba values", () => {
|
|
23
|
+
expect(parseCssColor("rgba(15, 23, 42, 0.64)")).toEqual({
|
|
24
|
+
red: 15,
|
|
25
|
+
green: 23,
|
|
26
|
+
blue: 42,
|
|
27
|
+
alpha: 0.64,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("parses transparent", () => {
|
|
32
|
+
expect(parseCssColor("transparent")).toEqual({
|
|
33
|
+
red: 0,
|
|
34
|
+
green: 0,
|
|
35
|
+
blue: 0,
|
|
36
|
+
alpha: 0,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("toColorPickerValue", () => {
|
|
42
|
+
it("converts css color to hex", () => {
|
|
43
|
+
expect(toColorPickerValue("rgba(15, 23, 42, 0.64)")).toBe("#0f172a");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("toHexColor", () => {
|
|
48
|
+
it("formats rgb channels as hex", () => {
|
|
49
|
+
expect(toHexColor({ red: 15, green: 23, blue: 42 })).toBe("#0f172a");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("formatCssColor", () => {
|
|
54
|
+
it("formats opaque colors as rgb", () => {
|
|
55
|
+
expect(formatCssColor({ red: 18, green: 52, blue: 86, alpha: 1 })).toBe("rgb(18, 52, 86)");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("formats translucent colors as rgba", () => {
|
|
59
|
+
expect(formatCssColor({ red: 18, green: 52, blue: 86, alpha: 0.64 })).toBe(
|
|
60
|
+
"rgba(18, 52, 86, 0.64)",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("rgb hsv conversion", () => {
|
|
66
|
+
it("round-trips primary color values", () => {
|
|
67
|
+
const hsv = rgbToHsv({ red: 47, green: 198, blue: 127 });
|
|
68
|
+
expect(hsvToRgb(hsv)).toEqual({ red: 47, green: 198, blue: 127 });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("mergeColorWithExistingAlpha", () => {
|
|
73
|
+
it("preserves alpha when the previous color was translucent", () => {
|
|
74
|
+
expect(mergeColorWithExistingAlpha("#123456", "rgba(15, 23, 42, 0.64)")).toBe(
|
|
75
|
+
"rgba(18, 52, 86, 0.64)",
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns rgb when the previous color was opaque", () => {
|
|
80
|
+
expect(mergeColorWithExistingAlpha("#123456", "rgb(15, 23, 42)")).toBe("rgb(18, 52, 86)");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
export interface ParsedColor {
|
|
2
|
+
red: number;
|
|
3
|
+
green: number;
|
|
4
|
+
blue: number;
|
|
5
|
+
alpha: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface HsvColor {
|
|
9
|
+
hue: number;
|
|
10
|
+
saturation: number;
|
|
11
|
+
value: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function clampChannel(value: number): number {
|
|
15
|
+
return Math.max(0, Math.min(255, Math.round(value)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function clampAlpha(value: number): number {
|
|
19
|
+
return Math.max(0, Math.min(1, value));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toHex(value: number): string {
|
|
23
|
+
return clampChannel(value).toString(16).padStart(2, "0");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatAlpha(value: number): string {
|
|
27
|
+
return `${Math.round(clampAlpha(value) * 100) / 100}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseCssColor(value: string): ParsedColor | null {
|
|
31
|
+
const trimmed = value.trim().toLowerCase();
|
|
32
|
+
if (!trimmed) return null;
|
|
33
|
+
if (trimmed === "transparent") {
|
|
34
|
+
return { red: 0, green: 0, blue: 0, alpha: 0 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const shortHex = trimmed.match(/^#([0-9a-f]{3})$/i);
|
|
38
|
+
if (shortHex) {
|
|
39
|
+
const [r, g, b] = shortHex[1].split("");
|
|
40
|
+
return {
|
|
41
|
+
red: Number.parseInt(r + r, 16),
|
|
42
|
+
green: Number.parseInt(g + g, 16),
|
|
43
|
+
blue: Number.parseInt(b + b, 16),
|
|
44
|
+
alpha: 1,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hex = trimmed.match(/^#([0-9a-f]{6})$/i);
|
|
49
|
+
if (hex) {
|
|
50
|
+
return {
|
|
51
|
+
red: Number.parseInt(hex[1].slice(0, 2), 16),
|
|
52
|
+
green: Number.parseInt(hex[1].slice(2, 4), 16),
|
|
53
|
+
blue: Number.parseInt(hex[1].slice(4, 6), 16),
|
|
54
|
+
alpha: 1,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const rgba = trimmed.match(
|
|
59
|
+
/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i,
|
|
60
|
+
);
|
|
61
|
+
if (rgba) {
|
|
62
|
+
return {
|
|
63
|
+
red: clampChannel(Number.parseFloat(rgba[1])),
|
|
64
|
+
green: clampChannel(Number.parseFloat(rgba[2])),
|
|
65
|
+
blue: clampChannel(Number.parseFloat(rgba[3])),
|
|
66
|
+
alpha: clampAlpha(rgba[4] != null ? Number.parseFloat(rgba[4]) : 1),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function toColorPickerValue(value: string): string {
|
|
74
|
+
const parsed = parseCssColor(value);
|
|
75
|
+
if (!parsed) return "#000000";
|
|
76
|
+
return toHexColor(parsed);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function toHexColor(color: Pick<ParsedColor, "red" | "green" | "blue">): string {
|
|
80
|
+
return `#${toHex(color.red)}${toHex(color.green)}${toHex(color.blue)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatCssColor(color: ParsedColor): string {
|
|
84
|
+
const red = clampChannel(color.red);
|
|
85
|
+
const green = clampChannel(color.green);
|
|
86
|
+
const blue = clampChannel(color.blue);
|
|
87
|
+
const alpha = clampAlpha(color.alpha);
|
|
88
|
+
|
|
89
|
+
if (alpha >= 1) {
|
|
90
|
+
return `rgb(${red}, ${green}, ${blue})`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return `rgba(${red}, ${green}, ${blue}, ${formatAlpha(alpha)})`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function rgbToHsv(color: Pick<ParsedColor, "red" | "green" | "blue">): HsvColor {
|
|
97
|
+
const red = clampChannel(color.red) / 255;
|
|
98
|
+
const green = clampChannel(color.green) / 255;
|
|
99
|
+
const blue = clampChannel(color.blue) / 255;
|
|
100
|
+
const max = Math.max(red, green, blue);
|
|
101
|
+
const min = Math.min(red, green, blue);
|
|
102
|
+
const delta = max - min;
|
|
103
|
+
|
|
104
|
+
let hue = 0;
|
|
105
|
+
if (delta !== 0) {
|
|
106
|
+
if (max === red) {
|
|
107
|
+
hue = 60 * (((green - blue) / delta) % 6);
|
|
108
|
+
} else if (max === green) {
|
|
109
|
+
hue = 60 * ((blue - red) / delta + 2);
|
|
110
|
+
} else {
|
|
111
|
+
hue = 60 * ((red - green) / delta + 4);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (hue < 0) hue += 360;
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
hue,
|
|
119
|
+
saturation: max === 0 ? 0 : delta / max,
|
|
120
|
+
value: max,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function hsvToRgb(color: HsvColor): Pick<ParsedColor, "red" | "green" | "blue"> {
|
|
125
|
+
const hue = (((color.hue % 360) + 360) % 360) / 60;
|
|
126
|
+
const saturation = Math.max(0, Math.min(1, color.saturation));
|
|
127
|
+
const value = Math.max(0, Math.min(1, color.value));
|
|
128
|
+
const chroma = value * saturation;
|
|
129
|
+
const x = chroma * (1 - Math.abs((hue % 2) - 1));
|
|
130
|
+
const m = value - chroma;
|
|
131
|
+
|
|
132
|
+
let red = 0;
|
|
133
|
+
let green = 0;
|
|
134
|
+
let blue = 0;
|
|
135
|
+
|
|
136
|
+
if (hue >= 0 && hue < 1) {
|
|
137
|
+
red = chroma;
|
|
138
|
+
green = x;
|
|
139
|
+
} else if (hue >= 1 && hue < 2) {
|
|
140
|
+
red = x;
|
|
141
|
+
green = chroma;
|
|
142
|
+
} else if (hue >= 2 && hue < 3) {
|
|
143
|
+
green = chroma;
|
|
144
|
+
blue = x;
|
|
145
|
+
} else if (hue >= 3 && hue < 4) {
|
|
146
|
+
green = x;
|
|
147
|
+
blue = chroma;
|
|
148
|
+
} else if (hue >= 4 && hue < 5) {
|
|
149
|
+
red = x;
|
|
150
|
+
blue = chroma;
|
|
151
|
+
} else {
|
|
152
|
+
red = chroma;
|
|
153
|
+
blue = x;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
red: clampChannel((red + m) * 255),
|
|
158
|
+
green: clampChannel((green + m) * 255),
|
|
159
|
+
blue: clampChannel((blue + m) * 255),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function mergeColorWithExistingAlpha(nextHex: string, previousValue: string): string {
|
|
164
|
+
const hex = nextHex.trim();
|
|
165
|
+
const match = hex.match(/^#([0-9a-f]{6})$/i);
|
|
166
|
+
if (!match) return previousValue;
|
|
167
|
+
|
|
168
|
+
const previous = parseCssColor(previousValue);
|
|
169
|
+
const red = Number.parseInt(match[1].slice(0, 2), 16);
|
|
170
|
+
const green = Number.parseInt(match[1].slice(2, 4), 16);
|
|
171
|
+
const blue = Number.parseInt(match[1].slice(4, 6), 16);
|
|
172
|
+
const alpha = previous?.alpha ?? 1;
|
|
173
|
+
|
|
174
|
+
return formatCssColor({ red, green, blue, alpha });
|
|
175
|
+
}
|