@hyperframes/studio 0.5.0-alpha.9 → 0.5.0

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 (65) hide show
  1. package/dist/assets/hyperframes-player-CoI5h1xv.js +353 -0
  2. package/dist/assets/index-BKjcNNNd.css +1 -0
  3. package/dist/assets/index-CqiisJmo.js +93 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +208 -1438
  7. package/src/captions/generator.test.ts +19 -0
  8. package/src/captions/generator.ts +9 -2
  9. package/src/captions/hooks/useCaptionSync.ts +6 -1
  10. package/src/captions/parser.test.ts +14 -0
  11. package/src/captions/parser.ts +1 -0
  12. package/src/components/LintModal.tsx +4 -3
  13. package/src/components/editor/PropertyPanel.tsx +206 -2466
  14. package/src/components/nle/NLELayout.tsx +47 -17
  15. package/src/components/nle/NLEPreview.tsx +5 -50
  16. package/src/components/sidebar/AssetsTab.tsx +4 -3
  17. package/src/components/sidebar/CompositionsTab.test.ts +1 -16
  18. package/src/components/sidebar/CompositionsTab.tsx +45 -117
  19. package/src/components/sidebar/LeftSidebar.tsx +55 -34
  20. package/src/components/ui/HyperframesLoader.tsx +104 -0
  21. package/src/components/ui/index.ts +2 -0
  22. package/src/icons/SystemIcons.tsx +2 -0
  23. package/src/player/components/CompositionThumbnail.tsx +10 -42
  24. package/src/player/components/EditModal.tsx +20 -5
  25. package/src/player/components/Player.tsx +129 -28
  26. package/src/player/components/PlayerControls.tsx +3 -44
  27. package/src/player/components/Timeline.test.ts +0 -12
  28. package/src/player/components/Timeline.tsx +25 -52
  29. package/src/player/components/TimelineClip.tsx +9 -21
  30. package/src/player/components/timelineEditing.test.ts +4 -2
  31. package/src/player/components/timelineEditing.ts +3 -1
  32. package/src/player/components/timelineTheme.test.ts +19 -0
  33. package/src/player/components/timelineTheme.ts +8 -4
  34. package/src/player/hooks/useTimelinePlayer.test.ts +160 -21
  35. package/src/player/hooks/useTimelinePlayer.ts +206 -93
  36. package/src/player/lib/time.test.ts +11 -1
  37. package/src/player/lib/time.ts +6 -0
  38. package/src/player/store/playerStore.ts +1 -0
  39. package/src/styles/studio.css +112 -0
  40. package/src/utils/frameCapture.test.ts +26 -0
  41. package/src/utils/frameCapture.ts +40 -0
  42. package/src/utils/mediaTypes.ts +1 -1
  43. package/src/utils/projectRouting.test.ts +87 -0
  44. package/src/utils/projectRouting.ts +27 -0
  45. package/src/utils/sourcePatcher.test.ts +1 -128
  46. package/src/utils/sourcePatcher.ts +18 -130
  47. package/src/utils/timelineAssetDrop.test.ts +11 -31
  48. package/src/utils/timelineAssetDrop.ts +2 -22
  49. package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
  50. package/dist/assets/index-DKaNgV2Z.css +0 -1
  51. package/dist/assets/index-peNJzL-4.js +0 -105
  52. package/src/components/editor/DomEditOverlay.tsx +0 -445
  53. package/src/components/editor/colorValue.test.ts +0 -82
  54. package/src/components/editor/colorValue.ts +0 -175
  55. package/src/components/editor/domEditing.test.ts +0 -537
  56. package/src/components/editor/domEditing.ts +0 -762
  57. package/src/components/editor/floatingPanel.test.ts +0 -34
  58. package/src/components/editor/floatingPanel.ts +0 -54
  59. package/src/components/editor/fontAssets.ts +0 -32
  60. package/src/components/editor/fontCatalog.ts +0 -126
  61. package/src/components/editor/gradientValue.test.ts +0 -89
  62. package/src/components/editor/gradientValue.ts +0 -445
  63. package/src/player/components/CompositionThumbnail.test.ts +0 -19
  64. package/src/utils/clipboard.test.ts +0 -88
  65. package/src/utils/clipboard.ts +0 -57
@@ -1,445 +0,0 @@
1
- import { memo, useMemo, useRef, useState, type RefObject } from "react";
2
- import { useMountEffect } from "../../hooks/useMountEffect";
3
- import { type DomEditSelection, findElementForSelection } from "./domEditing";
4
-
5
- interface OverlayRect {
6
- left: number;
7
- top: number;
8
- width: number;
9
- height: number;
10
- scaleX: number;
11
- scaleY: number;
12
- }
13
-
14
- interface DomEditOverlayProps {
15
- iframeRef: RefObject<HTMLIFrameElement | null>;
16
- selection: DomEditSelection | null;
17
- allowCanvasMovement?: boolean;
18
- onCanvasMouseDown: (
19
- event: React.MouseEvent<HTMLDivElement>,
20
- options?: { preferClipAncestor?: boolean },
21
- ) => void;
22
- onCanvasDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => void;
23
- onSelectedDoubleClick: () => void;
24
- onBlockedMove: (selection: DomEditSelection) => void;
25
- onMoveCommit: (
26
- selection: DomEditSelection,
27
- next: { left: number; top: number },
28
- ) => Promise<void> | void;
29
- onResizeCommit: (
30
- selection: DomEditSelection,
31
- next: { width: number; height: number },
32
- ) => Promise<void> | void;
33
- }
34
-
35
- function toOverlayRect(
36
- overlayEl: HTMLDivElement,
37
- iframe: HTMLIFrameElement,
38
- element: HTMLElement,
39
- ): OverlayRect | null {
40
- const iframeRect = iframe.getBoundingClientRect();
41
- const overlayRect = overlayEl.getBoundingClientRect();
42
- const doc = iframe.contentDocument;
43
- const root =
44
- doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
45
- const rootRect = root?.getBoundingClientRect();
46
- const rootWidth = rootRect?.width;
47
- const rootHeight = rootRect?.height;
48
- if (!rootWidth || !rootHeight) return null;
49
-
50
- const elementRect = element.getBoundingClientRect();
51
- const scaleX = iframeRect.width / rootWidth;
52
- const scaleY = iframeRect.height / rootHeight;
53
-
54
- return {
55
- left: iframeRect.left - overlayRect.left + elementRect.left * scaleX,
56
- top: iframeRect.top - overlayRect.top + elementRect.top * scaleY,
57
- width: elementRect.width * scaleX,
58
- height: elementRect.height * scaleY,
59
- scaleX,
60
- scaleY,
61
- };
62
- }
63
-
64
- type GestureKind = "drag" | "resize";
65
- const BLOCKED_MOVE_THRESHOLD_PX = 4;
66
- const OVERLAY_RECT_EPSILON_PX = 0.5;
67
-
68
- function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolean {
69
- if (a === b) return true;
70
- if (!a || !b) return false;
71
- return (
72
- Math.abs(a.left - b.left) < OVERLAY_RECT_EPSILON_PX &&
73
- Math.abs(a.top - b.top) < OVERLAY_RECT_EPSILON_PX &&
74
- Math.abs(a.width - b.width) < OVERLAY_RECT_EPSILON_PX &&
75
- Math.abs(a.height - b.height) < OVERLAY_RECT_EPSILON_PX &&
76
- Math.abs(a.scaleX - b.scaleX) < 0.001 &&
77
- Math.abs(a.scaleY - b.scaleY) < 0.001
78
- );
79
- }
80
-
81
- function selectionCacheKey(
82
- selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
83
- ): string {
84
- return [
85
- selection.sourceFile ?? "",
86
- selection.id ?? "",
87
- selection.selector ?? "",
88
- selection.selectorIndex ?? "",
89
- ].join("|");
90
- }
91
-
92
- function restoreInlineStyle(
93
- element: HTMLElement,
94
- property: "left" | "top" | "width" | "height",
95
- value: string,
96
- ) {
97
- if (value) element.style.setProperty(property, value);
98
- else element.style.removeProperty(property);
99
- }
100
-
101
- interface GestureState {
102
- kind: GestureKind;
103
- startX: number;
104
- startY: number;
105
- initialStyleLeft: string;
106
- initialStyleTop: string;
107
- originLeft: number;
108
- originTop: number;
109
- originWidth: number;
110
- originHeight: number;
111
- actualLeft: number;
112
- actualTop: number;
113
- actualWidth: number;
114
- actualHeight: number;
115
- scaleX: number;
116
- scaleY: number;
117
- }
118
-
119
- interface BlockedMoveState {
120
- pointerId: number;
121
- startX: number;
122
- startY: number;
123
- notified: boolean;
124
- }
125
-
126
- export const DomEditOverlay = memo(function DomEditOverlay({
127
- iframeRef,
128
- selection,
129
- allowCanvasMovement = true,
130
- onCanvasMouseDown,
131
- onCanvasDoubleClick,
132
- onSelectedDoubleClick,
133
- onBlockedMove,
134
- onMoveCommit,
135
- onResizeCommit,
136
- }: DomEditOverlayProps) {
137
- const overlayRef = useRef<HTMLDivElement | null>(null);
138
- const boxRef = useRef<HTMLDivElement | null>(null);
139
- const [overlayRect, setOverlayRect] = useState<OverlayRect | null>(null);
140
- const gestureRef = useRef<GestureState | null>(null);
141
- const blockedMoveRef = useRef<BlockedMoveState | null>(null);
142
- const suppressNextBoxClickRef = useRef(false);
143
- const rafPausedRef = useRef(false);
144
- const resolvedElementRef = useRef<{ key: string; element: HTMLElement } | null>(null);
145
-
146
- const selectionRef = useRef(selection);
147
- selectionRef.current = selection;
148
- const overlayRectRef = useRef(overlayRect);
149
- overlayRectRef.current = overlayRect;
150
- const onMoveCommitRef = useRef(onMoveCommit);
151
- onMoveCommitRef.current = onMoveCommit;
152
- const onResizeCommitRef = useRef(onResizeCommit);
153
- onResizeCommitRef.current = onResizeCommit;
154
- const onBlockedMoveRef = useRef(onBlockedMove);
155
- onBlockedMoveRef.current = onBlockedMove;
156
-
157
- useMountEffect(() => {
158
- let frame = 0;
159
- const clearOverlayRect = () => {
160
- if (!overlayRectRef.current) return;
161
- overlayRectRef.current = null;
162
- setOverlayRect(null);
163
- };
164
- const setNextOverlayRect = (next: OverlayRect | null) => {
165
- if (rectsEqual(overlayRectRef.current, next)) return;
166
- overlayRectRef.current = next;
167
- setOverlayRect(next);
168
- };
169
- const resolveElement = (doc: Document, sel: DomEditSelection) => {
170
- const key = selectionCacheKey(sel);
171
- const cached = resolvedElementRef.current;
172
- if (
173
- cached?.key === key &&
174
- cached.element.isConnected &&
175
- cached.element.ownerDocument === doc
176
- ) {
177
- return cached.element;
178
- }
179
-
180
- const next = findElementForSelection(doc, sel, sel.sourceFile);
181
- resolvedElementRef.current = next ? { key, element: next } : null;
182
- return next;
183
- };
184
-
185
- const update = () => {
186
- frame = requestAnimationFrame(update);
187
- if (rafPausedRef.current) return;
188
-
189
- const sel = selectionRef.current;
190
- const iframe = iframeRef.current;
191
- const overlayEl = overlayRef.current;
192
- if (!sel || !iframe || !overlayEl) {
193
- resolvedElementRef.current = null;
194
- clearOverlayRect();
195
- return;
196
- }
197
-
198
- const doc = iframe.contentDocument;
199
- if (!doc) return;
200
-
201
- const el = resolveElement(doc, sel);
202
- if (!el) {
203
- clearOverlayRect();
204
- return;
205
- }
206
-
207
- const next = toOverlayRect(overlayEl, iframe, el);
208
- setNextOverlayRect(next);
209
- };
210
-
211
- frame = requestAnimationFrame(update);
212
- return () => cancelAnimationFrame(frame);
213
- });
214
-
215
- const selectionKey = useMemo(() => {
216
- if (!selection) return "none";
217
- return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${
218
- selection.selectorIndex ?? 0
219
- }`;
220
- }, [selection]);
221
-
222
- const startGesture = (kind: GestureKind, e: React.PointerEvent) => {
223
- const sel = selectionRef.current;
224
- const rect = overlayRectRef.current;
225
- const box = boxRef.current;
226
- if (!sel || !rect || !box) return;
227
-
228
- const left = Number.parseFloat(sel.computedStyles.left ?? "");
229
- const top = Number.parseFloat(sel.computedStyles.top ?? "");
230
- const width = Number.parseFloat(sel.computedStyles.width ?? "");
231
- const height = Number.parseFloat(sel.computedStyles.height ?? "");
232
- if (!Number.isFinite(left) || !Number.isFinite(top)) return;
233
- if (kind === "resize" && !Number.isFinite(width) && !Number.isFinite(height)) return;
234
-
235
- e.preventDefault();
236
- e.stopPropagation();
237
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
238
-
239
- rafPausedRef.current = true;
240
-
241
- gestureRef.current = {
242
- kind,
243
- startX: e.clientX,
244
- startY: e.clientY,
245
- initialStyleLeft: sel.element.style.left,
246
- initialStyleTop: sel.element.style.top,
247
- originLeft: rect.left,
248
- originTop: rect.top,
249
- originWidth: rect.width,
250
- originHeight: rect.height,
251
- actualLeft: left,
252
- actualTop: top,
253
- actualWidth: Number.isFinite(width) ? width : 0,
254
- actualHeight: Number.isFinite(height) ? height : 0,
255
- scaleX: rect.scaleX,
256
- scaleY: rect.scaleY,
257
- };
258
- };
259
-
260
- const onPointerMove = (e: React.PointerEvent) => {
261
- const g = gestureRef.current;
262
- const sel = selectionRef.current;
263
- const box = boxRef.current;
264
- const blockedMove = blockedMoveRef.current;
265
- if (blockedMove && sel) {
266
- const dx = e.clientX - blockedMove.startX;
267
- const dy = e.clientY - blockedMove.startY;
268
- if (!blockedMove.notified && Math.hypot(dx, dy) >= BLOCKED_MOVE_THRESHOLD_PX) {
269
- blockedMove.notified = true;
270
- suppressNextBoxClickRef.current = true;
271
- onBlockedMoveRef.current(sel);
272
- }
273
- return;
274
- }
275
-
276
- if (!g || !sel || !box) return;
277
-
278
- const dx = e.clientX - g.startX;
279
- const dy = e.clientY - g.startY;
280
-
281
- if (g.kind === "drag") {
282
- const nextBoxLeft = g.originLeft + dx;
283
- const nextBoxTop = g.originTop + dy;
284
- box.style.left = `${nextBoxLeft}px`;
285
- box.style.top = `${nextBoxTop}px`;
286
- sel.element.style.left = `${Math.round(g.actualLeft + dx / g.scaleX)}px`;
287
- sel.element.style.top = `${Math.round(g.actualTop + dy / g.scaleY)}px`;
288
- } else {
289
- const newW = Math.max(20, g.originWidth + dx);
290
- const newH = Math.max(20, g.originHeight + dy);
291
- box.style.width = `${newW}px`;
292
- box.style.height = `${newH}px`;
293
- sel.element.style.width = `${Math.round(g.actualWidth + dx / g.scaleX)}px`;
294
- sel.element.style.height = `${Math.round(g.actualHeight + dy / g.scaleY)}px`;
295
- }
296
- };
297
-
298
- const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
299
- const g = gestureRef.current;
300
- const sel = selectionRef.current;
301
- const box = boxRef.current;
302
- blockedMoveRef.current = null;
303
- if (!g || !sel) {
304
- gestureRef.current = null;
305
- rafPausedRef.current = false;
306
- return;
307
- }
308
-
309
- gestureRef.current = null;
310
- rafPausedRef.current = false;
311
-
312
- const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY);
313
- if (g.kind === "drag" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
314
- restoreInlineStyle(sel.element, "left", g.initialStyleLeft);
315
- restoreInlineStyle(sel.element, "top", g.initialStyleTop);
316
- if (box) {
317
- box.style.left = `${g.originLeft}px`;
318
- box.style.top = `${g.originTop}px`;
319
- }
320
- suppressNextBoxClickRef.current = true;
321
- onCanvasMouseDown(e as unknown as React.MouseEvent<HTMLDivElement>, {
322
- preferClipAncestor: false,
323
- });
324
- return;
325
- }
326
-
327
- if (g.kind === "drag") {
328
- const finalLeft = Number.parseFloat(sel.element.style.left) || g.actualLeft;
329
- const finalTop = Number.parseFloat(sel.element.style.top) || g.actualTop;
330
- void Promise.resolve(onMoveCommitRef.current(sel, { left: finalLeft, top: finalTop })).catch(
331
- () => {
332
- sel.element.style.left = `${Math.round(g.actualLeft)}px`;
333
- sel.element.style.top = `${Math.round(g.actualTop)}px`;
334
- },
335
- );
336
- } else {
337
- const finalW = Number.parseFloat(sel.element.style.width) || g.actualWidth;
338
- const finalH = Number.parseFloat(sel.element.style.height) || g.actualHeight;
339
- void Promise.resolve(onResizeCommitRef.current(sel, { width: finalW, height: finalH })).catch(
340
- () => {
341
- if (g.actualWidth > 0) sel.element.style.width = `${Math.round(g.actualWidth)}px`;
342
- else sel.element.style.removeProperty("width");
343
- if (g.actualHeight > 0) sel.element.style.height = `${Math.round(g.actualHeight)}px`;
344
- else sel.element.style.removeProperty("height");
345
- },
346
- );
347
- }
348
- };
349
-
350
- // Click on overlay background → select whatever is under the pointer in the iframe.
351
- // This handles clicking children inside an already-selected parent: the selection
352
- // box stops propagation for drag gestures, but clicks on the transparent overlay
353
- // area outside the box pass through to the iframe pick logic.
354
- const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
355
- const target = event.target as HTMLElement | null;
356
- if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
357
- onCanvasMouseDown(event, { preferClipAncestor: false });
358
- };
359
-
360
- const handleOverlayDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
361
- const target = event.target as HTMLElement | null;
362
- if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
363
- onCanvasDoubleClick(event);
364
- };
365
-
366
- // Click on the selection box itself → re-pick the element under the pointer.
367
- // This lets you click a child element even when a parent is selected, because
368
- // the click coordinates are forwarded to the iframe's element picker.
369
- const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
370
- if (gestureRef.current) return;
371
- if (suppressNextBoxClickRef.current) {
372
- suppressNextBoxClickRef.current = false;
373
- event.stopPropagation();
374
- return;
375
- }
376
- onCanvasMouseDown(event, { preferClipAncestor: false });
377
- };
378
-
379
- const clearPointerState = () => {
380
- blockedMoveRef.current = null;
381
- gestureRef.current = null;
382
- rafPausedRef.current = false;
383
- };
384
-
385
- return (
386
- <div
387
- key={selectionKey}
388
- ref={overlayRef}
389
- className="absolute inset-0 z-10 pointer-events-auto"
390
- onMouseDown={handleOverlayMouseDown}
391
- onDoubleClick={handleOverlayDoubleClick}
392
- onPointerMove={onPointerMove}
393
- onPointerUp={onPointerUp}
394
- onPointerCancel={clearPointerState}
395
- >
396
- {selection && overlayRect && (
397
- <>
398
- <div
399
- key={selectionKey}
400
- ref={boxRef}
401
- data-dom-edit-selection-box="true"
402
- className="pointer-events-auto absolute rounded-xl border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
403
- style={{
404
- left: overlayRect.left,
405
- top: overlayRect.top,
406
- width: overlayRect.width,
407
- height: overlayRect.height,
408
- cursor: allowCanvasMovement && selection.capabilities.canMove ? "move" : "default",
409
- }}
410
- onPointerDown={(e) => {
411
- if (!allowCanvasMovement) return;
412
- if (selection.capabilities.canMove) {
413
- startGesture("drag", e);
414
- return;
415
- }
416
- e.preventDefault();
417
- e.stopPropagation();
418
- e.currentTarget.setPointerCapture(e.pointerId);
419
- blockedMoveRef.current = {
420
- pointerId: e.pointerId,
421
- startX: e.clientX,
422
- startY: e.clientY,
423
- notified: false,
424
- };
425
- }}
426
- onClick={handleBoxClick}
427
- onDoubleClick={onSelectedDoubleClick}
428
- >
429
- {/* Resize handle — bottom-right corner */}
430
- {allowCanvasMovement && selection.capabilities.canResize && (
431
- <div
432
- className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
433
- style={{ cursor: "se-resize", touchAction: "none" }}
434
- onPointerDown={(e) => {
435
- e.stopPropagation();
436
- startGesture("resize", e);
437
- }}
438
- />
439
- )}
440
- </div>
441
- </>
442
- )}
443
- </div>
444
- );
445
- });
@@ -1,82 +0,0 @@
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
- });
@@ -1,175 +0,0 @@
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
- }