@hyperframes/studio 0.6.0-alpha.9 → 0.6.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.
- package/dist/assets/{hyperframes-player-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-DUqUmaoH.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +428 -4299
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +163 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +15 -1
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditing.ts +38 -5
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +32 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/nle/NLELayout.tsx +8 -11
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +10 -103
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/hooks/useTimelinePlayer.ts +140 -103
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- package/src/player/components/TimelineClip.test.ts +0 -92
|
@@ -453,16 +453,49 @@ function getElementDepth(el: HTMLElement): number {
|
|
|
453
453
|
return depth;
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
+
const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
|
|
457
|
+
|
|
458
|
+
function isElementComputedVisible(el: HTMLElement): boolean {
|
|
459
|
+
const win = el.ownerDocument.defaultView;
|
|
460
|
+
if (!win) return true;
|
|
461
|
+
let current: HTMLElement | null = el;
|
|
462
|
+
while (current) {
|
|
463
|
+
const computed = win.getComputedStyle(current);
|
|
464
|
+
if (computed.display === "none" || computed.visibility === "hidden") return false;
|
|
465
|
+
const opacity = Number.parseFloat(computed.opacity);
|
|
466
|
+
if (Number.isFinite(opacity) && opacity <= 0.01) return false;
|
|
467
|
+
current = current.parentElement;
|
|
468
|
+
}
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function isEmptyVisualContainer(el: HTMLElement): boolean {
|
|
473
|
+
const tag = el.tagName.toLowerCase();
|
|
474
|
+
if (VISUAL_LEAF_TAGS.has(tag)) return false;
|
|
475
|
+
|
|
476
|
+
const children = el.children;
|
|
477
|
+
if (children.length === 0) {
|
|
478
|
+
const text = (el.textContent ?? "").trim();
|
|
479
|
+
return text.length === 0;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
483
|
+
const child = children[i];
|
|
484
|
+
if (!isHtmlElement(child)) continue;
|
|
485
|
+
if (VISUAL_LEAF_TAGS.has(child.tagName.toLowerCase())) return false;
|
|
486
|
+
if (isElementComputedVisible(child)) return false;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
|
|
456
492
|
function hasRenderedBox(el: HTMLElement): boolean {
|
|
457
493
|
const rect = el.getBoundingClientRect();
|
|
458
494
|
if (rect.width <= 1 || rect.height <= 1) return false;
|
|
459
495
|
|
|
460
|
-
|
|
461
|
-
if (!computed) return true;
|
|
462
|
-
if (computed.display === "none" || computed.visibility === "hidden") return false;
|
|
496
|
+
if (!isElementComputedVisible(el)) return false;
|
|
463
497
|
|
|
464
|
-
|
|
465
|
-
if (Number.isFinite(opacity) && opacity <= 0.01) return false;
|
|
498
|
+
if (isEmptyVisualContainer(el)) return false;
|
|
466
499
|
|
|
467
500
|
return true;
|
|
468
501
|
}
|
|
@@ -16,10 +16,10 @@ describe("manual editing availability", () => {
|
|
|
16
16
|
vi.resetModules();
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
it("enables inspector selection by default while motion
|
|
19
|
+
it("enables inspector selection and manual dragging by default while motion stays opt-in", async () => {
|
|
20
20
|
const availability = await loadAvailabilityWithEnv({});
|
|
21
21
|
|
|
22
|
-
expect(availability.STUDIO_PREVIEW_MANUAL_EDITING_ENABLED).toBe(
|
|
22
|
+
expect(availability.STUDIO_PREVIEW_MANUAL_EDITING_ENABLED).toBe(true);
|
|
23
23
|
expect(availability.STUDIO_PREVIEW_SELECTION_ENABLED).toBe(true);
|
|
24
24
|
expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true);
|
|
25
25
|
expect(availability.STUDIO_MOTION_PANEL_ENABLED).toBe(false);
|
|
@@ -32,7 +32,7 @@ const env = import.meta.env as StudioFeatureFlagEnv;
|
|
|
32
32
|
export const STUDIO_PREVIEW_MANUAL_EDITING_ENABLED = resolveStudioBooleanEnvFlag(
|
|
33
33
|
env,
|
|
34
34
|
[STUDIO_PREVIEW_MANUAL_DRAGGING_ENV, "VITE_STUDIO_PREVIEW_MANUAL_EDITING_ENABLED"],
|
|
35
|
-
|
|
35
|
+
true,
|
|
36
36
|
);
|
|
37
37
|
|
|
38
38
|
export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag(
|
|
@@ -653,10 +653,33 @@ function styleMatchesStudioRotationDraft(element: HTMLElement, value: string): b
|
|
|
653
653
|
);
|
|
654
654
|
}
|
|
655
655
|
|
|
656
|
+
const STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR = "data-hf-studio-original-transform-display";
|
|
657
|
+
|
|
658
|
+
function promoteInlineForTransform(element: HTMLElement): void {
|
|
659
|
+
const computedDisplay = safeComputedStyleProperty(element, "display");
|
|
660
|
+
if (computedDisplay !== "inline") return;
|
|
661
|
+
if (!element.hasAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR)) {
|
|
662
|
+
element.setAttribute(
|
|
663
|
+
STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
|
|
664
|
+
element.style.getPropertyValue("display"),
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
element.style.setProperty("display", "inline-block");
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function restoreInlineDisplay(element: HTMLElement): void {
|
|
671
|
+
const original = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
|
|
672
|
+
if (original == null) return;
|
|
673
|
+
if (original === "") element.style.removeProperty("display");
|
|
674
|
+
else element.style.setProperty("display", original);
|
|
675
|
+
element.removeAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
|
|
676
|
+
}
|
|
677
|
+
|
|
656
678
|
export function applyStudioPathOffset(
|
|
657
679
|
element: HTMLElement,
|
|
658
680
|
offset: { x: number; y: number },
|
|
659
681
|
): void {
|
|
682
|
+
promoteInlineForTransform(element);
|
|
660
683
|
writeStudioPathOffsetVars(element, offset);
|
|
661
684
|
element.style.setProperty(
|
|
662
685
|
"translate",
|
|
@@ -672,6 +695,7 @@ export function applyStudioPathOffsetDraft(
|
|
|
672
695
|
element: HTMLElement,
|
|
673
696
|
offset: { x: number; y: number },
|
|
674
697
|
): void {
|
|
698
|
+
promoteInlineForTransform(element);
|
|
675
699
|
writeStudioPathOffsetVars(element, offset, { updateBase: false });
|
|
676
700
|
element.style.setProperty(
|
|
677
701
|
"translate",
|
|
@@ -683,6 +707,7 @@ export function applyStudioBoxSize(
|
|
|
683
707
|
element: HTMLElement,
|
|
684
708
|
size: { width: number; height: number },
|
|
685
709
|
): void {
|
|
710
|
+
promoteInlineForTransform(element);
|
|
686
711
|
applyStudioBoxSizeDimensions(element, size);
|
|
687
712
|
}
|
|
688
713
|
|
|
@@ -690,10 +715,12 @@ export function applyStudioBoxSizeDraft(
|
|
|
690
715
|
element: HTMLElement,
|
|
691
716
|
size: { width: number; height: number },
|
|
692
717
|
): void {
|
|
718
|
+
promoteInlineForTransform(element);
|
|
693
719
|
applyStudioBoxSizeDimensions(element, size);
|
|
694
720
|
}
|
|
695
721
|
|
|
696
722
|
export function applyStudioRotation(element: HTMLElement, rotation: { angle: number }): void {
|
|
723
|
+
promoteInlineForTransform(element);
|
|
697
724
|
writeStudioRotationVars(element, rotation);
|
|
698
725
|
element.removeAttribute(STUDIO_ROTATION_DRAFT_ATTR);
|
|
699
726
|
element.style.setProperty(
|
|
@@ -703,6 +730,7 @@ export function applyStudioRotation(element: HTMLElement, rotation: { angle: num
|
|
|
703
730
|
}
|
|
704
731
|
|
|
705
732
|
export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle: number }): void {
|
|
733
|
+
promoteInlineForTransform(element);
|
|
706
734
|
writeStudioRotationVars(element, rotation, { updateBase: false });
|
|
707
735
|
element.setAttribute(STUDIO_ROTATION_DRAFT_ATTR, "true");
|
|
708
736
|
element.style.setProperty(
|
|
@@ -718,6 +746,7 @@ function clearStudioPathOffset(element: HTMLElement): void {
|
|
|
718
746
|
) {
|
|
719
747
|
restoreOriginalTranslateProperty(element);
|
|
720
748
|
}
|
|
749
|
+
restoreInlineDisplay(element);
|
|
721
750
|
element.style.removeProperty(STUDIO_OFFSET_X_PROP);
|
|
722
751
|
element.style.removeProperty(STUDIO_OFFSET_Y_PROP);
|
|
723
752
|
element.removeAttribute(STUDIO_PATH_OFFSET_ATTR);
|
|
@@ -732,6 +761,7 @@ function clearStudioRotation(element: HTMLElement): void {
|
|
|
732
761
|
) {
|
|
733
762
|
restoreOriginalRotationProperty(element);
|
|
734
763
|
}
|
|
764
|
+
restoreInlineDisplay(element);
|
|
735
765
|
|
|
736
766
|
element.style.removeProperty(STUDIO_ROTATION_PROP);
|
|
737
767
|
element.removeAttribute(STUDIO_ROTATION_ATTR);
|
|
@@ -816,6 +846,7 @@ function clearStudioBoxSize(element: HTMLElement): void {
|
|
|
816
846
|
restoreOriginalBoxSizeProperty(element, "display", STUDIO_ORIGINAL_DISPLAY_ATTR);
|
|
817
847
|
}
|
|
818
848
|
|
|
849
|
+
restoreInlineDisplay(element);
|
|
819
850
|
element.style.removeProperty(STUDIO_WIDTH_PROP);
|
|
820
851
|
element.style.removeProperty(STUDIO_HEIGHT_PROP);
|
|
821
852
|
element.removeAttribute(STUDIO_BOX_SIZE_ATTR);
|
|
@@ -1346,6 +1377,7 @@ function collectStudioManualEditElements(doc: Document): HTMLElement[] {
|
|
|
1346
1377
|
element.hasAttribute(STUDIO_ROTATION_DRAFT_ATTR) ||
|
|
1347
1378
|
element.hasAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR) ||
|
|
1348
1379
|
element.hasAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR) ||
|
|
1380
|
+
element.hasAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR) ||
|
|
1349
1381
|
element.hasAttribute(STUDIO_ORIGINAL_MIN_WIDTH_ATTR) ||
|
|
1350
1382
|
element.hasAttribute(STUDIO_ORIGINAL_FLEX_BASIS_ATTR) ||
|
|
1351
1383
|
element.hasAttribute(STUDIO_ORIGINAL_SCALE_ATTR) ||
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { X } from "../../icons/SystemIcons";
|
|
4
|
+
import {
|
|
5
|
+
formatCssColor,
|
|
6
|
+
hsvToRgb,
|
|
7
|
+
parseCssColor,
|
|
8
|
+
rgbToHsv,
|
|
9
|
+
toHexColor,
|
|
10
|
+
type ParsedColor,
|
|
11
|
+
} from "./colorValue";
|
|
12
|
+
import { resolveFloatingPanelPosition, type FloatingPosition } from "./floatingPanel";
|
|
13
|
+
import { colorFromCss, FIELD, LABEL } from "./propertyPanelHelpers";
|
|
14
|
+
|
|
15
|
+
const COLOR_PICKER_SIZE = { width: 292, height: 386 };
|
|
16
|
+
|
|
17
|
+
/* ------------------------------------------------------------------ */
|
|
18
|
+
/* ColorSlider */
|
|
19
|
+
/* ------------------------------------------------------------------ */
|
|
20
|
+
|
|
21
|
+
export function ColorSlider({
|
|
22
|
+
label,
|
|
23
|
+
value,
|
|
24
|
+
min,
|
|
25
|
+
max,
|
|
26
|
+
step,
|
|
27
|
+
displayValue,
|
|
28
|
+
background,
|
|
29
|
+
thumbColor,
|
|
30
|
+
disabled,
|
|
31
|
+
onCommit,
|
|
32
|
+
}: {
|
|
33
|
+
label: string;
|
|
34
|
+
value: number;
|
|
35
|
+
min: number;
|
|
36
|
+
max: number;
|
|
37
|
+
step: number;
|
|
38
|
+
displayValue: string;
|
|
39
|
+
background: string;
|
|
40
|
+
thumbColor: string;
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
onCommit: (nextValue: number) => void;
|
|
43
|
+
}) {
|
|
44
|
+
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
45
|
+
const percent = ((value - min) / (max - min)) * 100;
|
|
46
|
+
|
|
47
|
+
const commitFromClientX = (clientX: number) => {
|
|
48
|
+
const rect = trackRef.current?.getBoundingClientRect();
|
|
49
|
+
if (!rect || rect.width <= 0) return;
|
|
50
|
+
const rawValue = min + ((clientX - rect.left) / rect.width) * (max - min);
|
|
51
|
+
const stepped = Math.round(rawValue / step) * step;
|
|
52
|
+
onCommit(Math.max(min, Math.min(max, stepped)));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const commitKeyboardValue = (nextValue: number) => {
|
|
56
|
+
onCommit(Math.max(min, Math.min(max, nextValue)));
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="grid gap-1.5">
|
|
61
|
+
<div className="flex items-center justify-between">
|
|
62
|
+
<span className={LABEL}>{label}</span>
|
|
63
|
+
<span className="text-[10px] font-medium text-neutral-400">{displayValue}</span>
|
|
64
|
+
</div>
|
|
65
|
+
<div
|
|
66
|
+
ref={trackRef}
|
|
67
|
+
role="slider"
|
|
68
|
+
tabIndex={disabled ? -1 : 0}
|
|
69
|
+
aria-label={label}
|
|
70
|
+
aria-valuemin={min}
|
|
71
|
+
aria-valuemax={max}
|
|
72
|
+
aria-valuenow={value}
|
|
73
|
+
aria-disabled={disabled}
|
|
74
|
+
className={`relative h-4 rounded-full border border-neutral-700 shadow-[inset_0_1px_2px_rgba(0,0,0,0.55)] outline-none focus:border-[#f5a400] focus:ring-2 focus:ring-[#f5a400]/40 ${
|
|
75
|
+
disabled ? "cursor-not-allowed opacity-50" : "cursor-ew-resize"
|
|
76
|
+
}`}
|
|
77
|
+
style={{ background }}
|
|
78
|
+
onPointerDown={(event) => {
|
|
79
|
+
if (disabled) return;
|
|
80
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
81
|
+
commitFromClientX(event.clientX);
|
|
82
|
+
}}
|
|
83
|
+
onPointerMove={(event) => {
|
|
84
|
+
if (disabled || event.buttons !== 1) return;
|
|
85
|
+
commitFromClientX(event.clientX);
|
|
86
|
+
}}
|
|
87
|
+
onKeyDown={(event) => {
|
|
88
|
+
if (disabled) return;
|
|
89
|
+
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
commitKeyboardValue(value + step);
|
|
92
|
+
} else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
commitKeyboardValue(value - step);
|
|
95
|
+
} else if (event.key === "Home") {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
commitKeyboardValue(min);
|
|
98
|
+
} else if (event.key === "End") {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
commitKeyboardValue(max);
|
|
101
|
+
}
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<div
|
|
105
|
+
className="pointer-events-none absolute top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.85),0_6px_14px_rgba(0,0,0,0.5)]"
|
|
106
|
+
style={{ left: `${Math.max(0, Math.min(100, percent))}%`, backgroundColor: thumbColor }}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* ------------------------------------------------------------------ */
|
|
114
|
+
/* ColorField */
|
|
115
|
+
/* ------------------------------------------------------------------ */
|
|
116
|
+
|
|
117
|
+
export function ColorField({
|
|
118
|
+
label,
|
|
119
|
+
value,
|
|
120
|
+
disabled,
|
|
121
|
+
onCommit,
|
|
122
|
+
}: {
|
|
123
|
+
label: string;
|
|
124
|
+
value: string;
|
|
125
|
+
disabled?: boolean;
|
|
126
|
+
onCommit: (nextValue: string) => void;
|
|
127
|
+
}) {
|
|
128
|
+
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
|
129
|
+
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
130
|
+
const [open, setOpen] = useState(false);
|
|
131
|
+
const [panelPosition, setPanelPosition] = useState<FloatingPosition | null>(null);
|
|
132
|
+
const [draftColor, setDraftColor] = useState<ParsedColor>(() => colorFromCss(value));
|
|
133
|
+
const [hexDraft, setHexDraft] = useState(() => toHexColor(colorFromCss(value)).toUpperCase());
|
|
134
|
+
const hsv = rgbToHsv(draftColor);
|
|
135
|
+
const hueColor = formatCssColor({
|
|
136
|
+
...hsvToRgb({ hue: hsv.hue, saturation: 1, value: 1 }),
|
|
137
|
+
alpha: 1,
|
|
138
|
+
});
|
|
139
|
+
const opaqueColor = formatCssColor({ ...draftColor, alpha: 1 });
|
|
140
|
+
const currentColor = formatCssColor(draftColor);
|
|
141
|
+
const saturationPercent = Math.round(hsv.saturation * 100);
|
|
142
|
+
const brightnessPercent = Math.round(hsv.value * 100);
|
|
143
|
+
const alphaPercent = Math.round(draftColor.alpha * 100);
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
const nextColor = colorFromCss(value);
|
|
147
|
+
setDraftColor(nextColor);
|
|
148
|
+
setHexDraft(toHexColor(nextColor).toUpperCase());
|
|
149
|
+
}, [value]);
|
|
150
|
+
|
|
151
|
+
const updatePanelPosition = useCallback(() => {
|
|
152
|
+
const anchor = buttonRef.current?.getBoundingClientRect();
|
|
153
|
+
if (!anchor) return;
|
|
154
|
+
const measured = panelRef.current?.getBoundingClientRect();
|
|
155
|
+
setPanelPosition(
|
|
156
|
+
resolveFloatingPanelPosition(
|
|
157
|
+
anchor,
|
|
158
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
159
|
+
{
|
|
160
|
+
width: measured?.width || COLOR_PICKER_SIZE.width,
|
|
161
|
+
height: measured?.height || COLOR_PICKER_SIZE.height,
|
|
162
|
+
},
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
useLayoutEffect(() => {
|
|
168
|
+
if (!open) return;
|
|
169
|
+
updatePanelPosition();
|
|
170
|
+
const handlePositionInvalidated = () => updatePanelPosition();
|
|
171
|
+
window.addEventListener("resize", handlePositionInvalidated);
|
|
172
|
+
window.addEventListener("scroll", handlePositionInvalidated, true);
|
|
173
|
+
return () => {
|
|
174
|
+
window.removeEventListener("resize", handlePositionInvalidated);
|
|
175
|
+
window.removeEventListener("scroll", handlePositionInvalidated, true);
|
|
176
|
+
};
|
|
177
|
+
}, [open, updatePanelPosition]);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (!open) return;
|
|
181
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
182
|
+
const target = event.target as Node | null;
|
|
183
|
+
if (!target) return;
|
|
184
|
+
if (panelRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
|
|
185
|
+
setOpen(false);
|
|
186
|
+
};
|
|
187
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
188
|
+
if (event.key === "Escape") setOpen(false);
|
|
189
|
+
};
|
|
190
|
+
document.addEventListener("pointerdown", handlePointerDown);
|
|
191
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
192
|
+
return () => {
|
|
193
|
+
document.removeEventListener("pointerdown", handlePointerDown);
|
|
194
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
195
|
+
};
|
|
196
|
+
}, [open]);
|
|
197
|
+
|
|
198
|
+
const commitColor = (nextColor: ParsedColor) => {
|
|
199
|
+
setDraftColor(nextColor);
|
|
200
|
+
setHexDraft(toHexColor(nextColor).toUpperCase());
|
|
201
|
+
onCommit(formatCssColor(nextColor));
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const commitHsv = (nextHsv: { hue?: number; saturation?: number; value?: number }) => {
|
|
205
|
+
const rgb = hsvToRgb({
|
|
206
|
+
hue: nextHsv.hue ?? hsv.hue,
|
|
207
|
+
saturation: nextHsv.saturation ?? hsv.saturation,
|
|
208
|
+
value: nextHsv.value ?? hsv.value,
|
|
209
|
+
});
|
|
210
|
+
commitColor({ ...rgb, alpha: draftColor.alpha });
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const updateSaturationValue = (clientX: number, clientY: number, target: HTMLDivElement) => {
|
|
214
|
+
const rect = target.getBoundingClientRect();
|
|
215
|
+
const saturation = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
216
|
+
const nextValue = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height));
|
|
217
|
+
commitHsv({ saturation, value: nextValue });
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const handleHexCommit = (nextHex: string) => {
|
|
221
|
+
setHexDraft(nextHex);
|
|
222
|
+
const normalized = nextHex.trim().startsWith("#") ? nextHex.trim() : `#${nextHex.trim()}`;
|
|
223
|
+
const parsed = parseCssColor(normalized);
|
|
224
|
+
if (!parsed) return;
|
|
225
|
+
commitColor({ ...parsed, alpha: draftColor.alpha });
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const picker = open
|
|
229
|
+
? createPortal(
|
|
230
|
+
<div
|
|
231
|
+
ref={panelRef}
|
|
232
|
+
className="fixed z-[9999] w-[292px] overflow-hidden rounded-2xl border border-neutral-700 bg-neutral-950 shadow-2xl shadow-black/50"
|
|
233
|
+
style={{
|
|
234
|
+
left: panelPosition?.left ?? -9999,
|
|
235
|
+
top: panelPosition?.top ?? -9999,
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
|
|
239
|
+
<div className="min-w-0">
|
|
240
|
+
<div className="truncate text-[11px] font-medium text-neutral-100">{label}</div>
|
|
241
|
+
<div className="text-[9px] uppercase tracking-[0.16em] text-neutral-600">Color</div>
|
|
242
|
+
</div>
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
onClick={() => setOpen(false)}
|
|
246
|
+
className="flex h-7 w-7 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
|
|
247
|
+
aria-label="Close color picker"
|
|
248
|
+
>
|
|
249
|
+
<X size={13} />
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
<div className="space-y-3 p-3">
|
|
253
|
+
<div
|
|
254
|
+
className="relative h-36 cursor-crosshair overflow-hidden rounded-xl border border-neutral-700 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]"
|
|
255
|
+
style={{ backgroundColor: hueColor }}
|
|
256
|
+
onPointerDown={(event) => {
|
|
257
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
258
|
+
updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
|
|
259
|
+
}}
|
|
260
|
+
onPointerMove={(event) => {
|
|
261
|
+
if (event.buttons !== 1) return;
|
|
262
|
+
updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
<div className="absolute inset-0 bg-gradient-to-r from-white to-transparent" />
|
|
266
|
+
<div className="absolute inset-0 bg-gradient-to-t from-black to-transparent" />
|
|
267
|
+
<div
|
|
268
|
+
className="pointer-events-none absolute top-0 h-full w-px -translate-x-1/2 bg-white/70 shadow-[0_0_0_1px_rgba(0,0,0,0.45)] mix-blend-difference"
|
|
269
|
+
style={{ left: `${hsv.saturation * 100}%` }}
|
|
270
|
+
/>
|
|
271
|
+
<div
|
|
272
|
+
className="pointer-events-none absolute left-0 h-px w-full -translate-y-1/2 bg-white/70 shadow-[0_0_0_1px_rgba(0,0,0,0.45)] mix-blend-difference"
|
|
273
|
+
style={{ top: `${(1 - hsv.value) * 100}%` }}
|
|
274
|
+
/>
|
|
275
|
+
<div
|
|
276
|
+
className="pointer-events-none absolute h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.85),0_8px_18px_rgba(0,0,0,0.45)]"
|
|
277
|
+
style={{
|
|
278
|
+
left: `${hsv.saturation * 100}%`,
|
|
279
|
+
top: `${(1 - hsv.value) * 100}%`,
|
|
280
|
+
backgroundColor: opaqueColor,
|
|
281
|
+
}}
|
|
282
|
+
/>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
286
|
+
<div
|
|
287
|
+
className="h-9 w-9 flex-shrink-0 rounded-xl border border-neutral-600 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]"
|
|
288
|
+
style={{ backgroundColor: currentColor }}
|
|
289
|
+
/>
|
|
290
|
+
<div className="min-w-0 flex-1">
|
|
291
|
+
<div className="truncate text-[11px] font-medium text-neutral-100">
|
|
292
|
+
{currentColor}
|
|
293
|
+
</div>
|
|
294
|
+
<div className="mt-0.5 text-[9px] uppercase tracking-[0.12em] text-neutral-600">
|
|
295
|
+
S {saturationPercent}% · B {brightnessPercent}% · A {alphaPercent}%
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
<ColorSlider
|
|
301
|
+
label="Hue"
|
|
302
|
+
value={hsv.hue}
|
|
303
|
+
min={0}
|
|
304
|
+
max={360}
|
|
305
|
+
step={1}
|
|
306
|
+
displayValue={`${Math.round(hsv.hue)}°`}
|
|
307
|
+
background="linear-gradient(90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)"
|
|
308
|
+
thumbColor={hueColor}
|
|
309
|
+
disabled={disabled}
|
|
310
|
+
onCommit={(nextHue) => commitHsv({ hue: nextHue })}
|
|
311
|
+
/>
|
|
312
|
+
|
|
313
|
+
<ColorSlider
|
|
314
|
+
label="Alpha"
|
|
315
|
+
value={draftColor.alpha}
|
|
316
|
+
min={0}
|
|
317
|
+
max={1}
|
|
318
|
+
step={0.01}
|
|
319
|
+
displayValue={`${alphaPercent}%`}
|
|
320
|
+
background={`linear-gradient(90deg, transparent, ${opaqueColor})`}
|
|
321
|
+
thumbColor={currentColor}
|
|
322
|
+
disabled={disabled}
|
|
323
|
+
onCommit={(nextAlpha) => commitColor({ ...draftColor, alpha: nextAlpha })}
|
|
324
|
+
/>
|
|
325
|
+
|
|
326
|
+
<label className="grid gap-1.5">
|
|
327
|
+
<span className={LABEL}>Hex</span>
|
|
328
|
+
<input
|
|
329
|
+
value={hexDraft}
|
|
330
|
+
onChange={(event) => handleHexCommit(event.target.value)}
|
|
331
|
+
className={`${FIELD} h-10 w-full text-[11px] font-medium outline-none`}
|
|
332
|
+
spellCheck={false}
|
|
333
|
+
/>
|
|
334
|
+
</label>
|
|
335
|
+
</div>
|
|
336
|
+
</div>,
|
|
337
|
+
document.body,
|
|
338
|
+
)
|
|
339
|
+
: null;
|
|
340
|
+
|
|
341
|
+
const openPicker = () => {
|
|
342
|
+
if (disabled) return;
|
|
343
|
+
setOpen((current) => !current);
|
|
344
|
+
if (!open) {
|
|
345
|
+
requestAnimationFrame(updatePanelPosition);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<div className="grid min-w-0 gap-1.5">
|
|
351
|
+
<span className={LABEL}>{label}</span>
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
disabled={disabled}
|
|
355
|
+
aria-label={`Pick ${label.toLowerCase()} color`}
|
|
356
|
+
ref={buttonRef}
|
|
357
|
+
onClick={openPicker}
|
|
358
|
+
className={`${FIELD} flex items-center gap-3 text-left hover:border-neutral-700 disabled:cursor-not-allowed ${open ? "border-neutral-600" : ""}`}
|
|
359
|
+
>
|
|
360
|
+
<div
|
|
361
|
+
className="relative h-7 w-7 flex-shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
|
|
362
|
+
style={{ backgroundColor: value || "transparent" }}
|
|
363
|
+
/>
|
|
364
|
+
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-neutral-100">
|
|
365
|
+
{value}
|
|
366
|
+
</span>
|
|
367
|
+
</button>
|
|
368
|
+
{picker}
|
|
369
|
+
</div>
|
|
370
|
+
);
|
|
371
|
+
}
|