@morphika/andami 0.5.0 → 0.5.2

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 (122) hide show
  1. package/README.md +151 -36
  2. package/app/admin/assets/page.tsx +6 -6
  3. package/app/admin/database/page.tsx +302 -302
  4. package/app/admin/error.tsx +53 -53
  5. package/app/admin/layout.tsx +320 -327
  6. package/app/admin/navigation/page.tsx +255 -255
  7. package/app/admin/pages/[slug]/page.tsx +6 -6
  8. package/app/admin/pages/page.tsx +11 -11
  9. package/app/admin/projects/page.tsx +14 -14
  10. package/app/admin/setup/page.tsx +1 -1
  11. package/app/admin/styles/page.tsx +1 -1
  12. package/components/admin/MetadataEditor.tsx +6 -6
  13. package/components/admin/nav-builder/NavBuilder.tsx +1 -1
  14. package/components/admin/nav-builder/NavBuilderGrid.tsx +3 -3
  15. package/components/admin/nav-builder/NavGridCell.tsx +48 -48
  16. package/components/admin/nav-builder/NavGridItem.tsx +4 -4
  17. package/components/admin/nav-builder/NavItemSettings.tsx +331 -331
  18. package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -102
  19. package/components/admin/nav-builder/NavLivePreview.tsx +1 -1
  20. package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -226
  21. package/components/admin/nav-builder/NavMobileSettings.tsx +242 -242
  22. package/components/admin/nav-builder/NavSettingsFields.tsx +514 -514
  23. package/components/admin/setup-wizard/BrandingStep.tsx +3 -3
  24. package/components/admin/setup-wizard/DatabaseStep.tsx +2 -2
  25. package/components/admin/setup-wizard/DoneStep.tsx +1 -1
  26. package/components/admin/setup-wizard/SetupWizard.tsx +4 -4
  27. package/components/admin/setup-wizard/StorageStep.tsx +2 -2
  28. package/components/admin/setup-wizard/WelcomeStep.tsx +2 -2
  29. package/components/admin/styles/ColorsEditor.tsx +2 -2
  30. package/components/admin/styles/FontsEditor.tsx +6 -6
  31. package/components/admin/styles/GridLayoutEditor.tsx +9 -9
  32. package/components/admin/styles/LinksButtonsEditor.tsx +5 -5
  33. package/components/admin/styles/TypographyEditor.tsx +6 -6
  34. package/components/admin/styles/shared.tsx +68 -68
  35. package/components/blocks/AudioBlockRenderer.tsx +286 -0
  36. package/components/blocks/BeforeAfterBlockRenderer.tsx +274 -0
  37. package/components/blocks/MarqueeBlockRenderer.tsx +316 -0
  38. package/components/blocks/ProjectCarouselBlockRenderer.tsx +1 -1
  39. package/components/builder/BlockCardIcons.tsx +316 -227
  40. package/components/builder/BlockTypePicker.tsx +3 -1
  41. package/components/builder/BubbleIcons.tsx +90 -0
  42. package/components/builder/BuilderCanvas.tsx +2 -0
  43. package/components/builder/CanvasMinimap.tsx +2 -2
  44. package/components/builder/CoverSectionCanvas.tsx +363 -275
  45. package/components/builder/DeviceFrame.tsx +1 -1
  46. package/components/builder/DndWrapper.tsx +3 -3
  47. package/components/builder/InsertionLines.tsx +1 -1
  48. package/components/builder/SectionCardIcons.tsx +421 -320
  49. package/components/builder/SectionEditorBar.tsx +1 -1
  50. package/components/builder/SectionTypePicker.tsx +4 -4
  51. package/components/builder/SectionV2Canvas.tsx +20 -4
  52. package/components/builder/SectionV2Column.tsx +74 -68
  53. package/components/builder/SortableBlock.tsx +93 -73
  54. package/components/builder/SortableRow.tsx +27 -26
  55. package/components/builder/VirtualAssetGrid.tsx +2 -2
  56. package/components/builder/asset-browser/R2BrowserContent.tsx +34 -17
  57. package/components/builder/asset-browser/helpers.ts +4 -0
  58. package/components/builder/asset-browser/types.ts +2 -1
  59. package/components/builder/blockStyles.tsx +192 -173
  60. package/components/builder/color-picker/AlphaSlider.tsx +141 -141
  61. package/components/builder/color-picker/ColorInputs.tsx +105 -105
  62. package/components/builder/color-picker/EyedropperButton.tsx +74 -74
  63. package/components/builder/color-picker/HueSlider.tsx +124 -124
  64. package/components/builder/color-picker/SaturationCanvas.tsx +142 -142
  65. package/components/builder/color-picker/SwatchBar.tsx +93 -93
  66. package/components/builder/editors/AudioBlockEditor.tsx +242 -0
  67. package/components/builder/editors/BeforeAfterBlockEditor.tsx +360 -0
  68. package/components/builder/editors/ButtonBlockEditor.tsx +4 -4
  69. package/components/builder/editors/EnterAnimationPicker.tsx +2 -2
  70. package/components/builder/editors/HoverEffectPicker.tsx +2 -2
  71. package/components/builder/editors/ImageBlockEditor.tsx +2 -2
  72. package/components/builder/editors/ImageGridBlockEditor.tsx +4 -4
  73. package/components/builder/editors/MarqueeBlockEditor.tsx +621 -0
  74. package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -443
  75. package/components/builder/editors/ProjectGridEditor.tsx +9 -9
  76. package/components/builder/editors/SpacerBlockEditor.tsx +5 -5
  77. package/components/builder/editors/StaggerSettings.tsx +109 -109
  78. package/components/builder/editors/TextBlockEditor.tsx +3 -3
  79. package/components/builder/editors/TextStylePicker.tsx +1 -1
  80. package/components/builder/editors/VideoBlockEditor.tsx +2 -2
  81. package/components/builder/editors/index.ts +11 -10
  82. package/components/builder/editors/shared.tsx +7 -7
  83. package/components/builder/live-preview/LiveAudioPreview.tsx +120 -0
  84. package/components/builder/live-preview/LiveBeforeAfterPreview.tsx +176 -0
  85. package/components/builder/live-preview/LiveImageGridPreview.tsx +10 -2
  86. package/components/builder/live-preview/LiveImagePreview.tsx +1 -1
  87. package/components/builder/live-preview/LiveMarqueePreview.tsx +39 -0
  88. package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +1 -1
  89. package/components/builder/live-preview/LiveVideoPreview.tsx +1 -1
  90. package/components/builder/live-preview/ProjectCardWrapper.tsx +291 -291
  91. package/components/builder/settings-panel/AnimationTab.tsx +138 -138
  92. package/components/builder/settings-panel/BlockLayoutTab.tsx +7 -7
  93. package/components/builder/settings-panel/CardEntranceSection.tsx +114 -114
  94. package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
  95. package/components/builder/settings-panel/CoverSectionLayoutTab.tsx +71 -71
  96. package/components/builder/settings-panel/CoverSectionSettings.tsx +335 -335
  97. package/components/builder/settings-panel/PageSettings.tsx +3 -3
  98. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  99. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +4 -4
  100. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +356 -356
  101. package/components/builder/settings-panel/SectionV2Settings.tsx +14 -14
  102. package/components/builder/settings-panel/TRBLInputs.tsx +1 -1
  103. package/lib/animation/enter-types.ts +3 -0
  104. package/lib/animation/hover-effect-presets.ts +210 -210
  105. package/lib/animation/hover-effect-types.ts +3 -0
  106. package/lib/builder/block-registrations.ts +468 -335
  107. package/lib/builder/constants.ts +111 -111
  108. package/lib/builder/store-sections.ts +2 -2
  109. package/lib/builder/types-slices.ts +414 -414
  110. package/lib/builder/types.ts +6 -1
  111. package/lib/config/index.ts +27 -27
  112. package/lib/sanity/types.ts +156 -1
  113. package/lib/version.ts +1 -1
  114. package/package.json +1 -1
  115. package/sanity/schemas/blocks/audioBlock.ts +69 -0
  116. package/sanity/schemas/blocks/beforeAfterBlock.ts +121 -0
  117. package/sanity/schemas/blocks/index.ts +12 -9
  118. package/sanity/schemas/blocks/marqueeBlock.ts +292 -0
  119. package/sanity/schemas/index.ts +120 -111
  120. package/styles/admin.css +85 -85
  121. package/styles/animations.css +237 -237
  122. package/styles/base.css +114 -114
@@ -1,124 +1,124 @@
1
- "use client";
2
-
3
- /**
4
- * HueSlider — Horizontal rainbow hue selector (0–360).
5
- *
6
- * Mouse + touch + keyboard support.
7
- */
8
-
9
- import { useRef, useCallback, useEffect } from "react";
10
- import type { HueSliderProps } from "./types";
11
- import { clamp } from "./utils";
12
-
13
- export default function HueSlider({ hue, onChange }: HueSliderProps) {
14
- const trackRef = useRef<HTMLDivElement>(null);
15
- const dragging = useRef(false);
16
-
17
- const computeFromX = useCallback(
18
- (clientX: number) => {
19
- const rect = trackRef.current?.getBoundingClientRect();
20
- if (!rect) return;
21
- const x = clamp((clientX - rect.left) / rect.width, 0, 1);
22
- onChange(Math.round(x * 360));
23
- },
24
- [onChange]
25
- );
26
-
27
- const handleMouseDown = useCallback(
28
- (e: React.MouseEvent) => {
29
- e.preventDefault();
30
- dragging.current = true;
31
- computeFromX(e.clientX);
32
- },
33
- [computeFromX]
34
- );
35
-
36
- const handleTouchStart = useCallback(
37
- (e: React.TouchEvent) => {
38
- e.preventDefault();
39
- dragging.current = true;
40
- computeFromX(e.touches[0].clientX);
41
- },
42
- [computeFromX]
43
- );
44
-
45
- const handleTouchMove = useCallback(
46
- (e: React.TouchEvent) => {
47
- if (!dragging.current) return;
48
- e.preventDefault();
49
- computeFromX(e.touches[0].clientX);
50
- },
51
- [computeFromX]
52
- );
53
-
54
- useEffect(() => {
55
- const handleMove = (e: MouseEvent) => {
56
- if (!dragging.current) return;
57
- computeFromX(e.clientX);
58
- };
59
- const handleUp = () => {
60
- dragging.current = false;
61
- };
62
- window.addEventListener("mousemove", handleMove);
63
- window.addEventListener("mouseup", handleUp);
64
- window.addEventListener("touchend", handleUp);
65
- return () => {
66
- window.removeEventListener("mousemove", handleMove);
67
- window.removeEventListener("mouseup", handleUp);
68
- window.removeEventListener("touchend", handleUp);
69
- };
70
- }, [computeFromX]);
71
-
72
- const handleKeyDown = useCallback(
73
- (e: React.KeyboardEvent) => {
74
- const step = e.shiftKey ? 20 : 5;
75
- let newHue = hue;
76
- switch (e.key) {
77
- case "ArrowRight":
78
- newHue = clamp(hue + step, 0, 360);
79
- break;
80
- case "ArrowLeft":
81
- newHue = clamp(hue - step, 0, 360);
82
- break;
83
- default:
84
- return;
85
- }
86
- e.preventDefault();
87
- onChange(newHue);
88
- },
89
- [hue, onChange]
90
- );
91
-
92
- return (
93
- <div
94
- ref={trackRef}
95
- role="slider"
96
- tabIndex={0}
97
- aria-label="Color hue"
98
- aria-valuemin={0}
99
- aria-valuemax={360}
100
- aria-valuenow={hue}
101
- aria-valuetext={`Hue ${hue} degrees`}
102
- className="w-full h-3.5 rounded-full cursor-pointer relative select-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#076bff]"
103
- style={{
104
- background:
105
- "linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)",
106
- }}
107
- onMouseDown={handleMouseDown}
108
- onTouchStart={handleTouchStart}
109
- onTouchMove={handleTouchMove}
110
- onKeyDown={handleKeyDown}
111
- >
112
- <div
113
- className="absolute w-[18px] h-[18px] rounded-full border-[2.5px] border-white pointer-events-none"
114
- style={{
115
- left: `${(hue / 360) * 100}%`,
116
- top: "50%",
117
- transform: "translate(-50%, -50%)",
118
- background: `hsl(${hue}, 100%, 50%)`,
119
- boxShadow: "0 1px 4px rgba(0,0,0,0.4)",
120
- }}
121
- />
122
- </div>
123
- );
124
- }
1
+ "use client";
2
+
3
+ /**
4
+ * HueSlider — Horizontal rainbow hue selector (0–360).
5
+ *
6
+ * Mouse + touch + keyboard support.
7
+ */
8
+
9
+ import { useRef, useCallback, useEffect } from "react";
10
+ import type { HueSliderProps } from "./types";
11
+ import { clamp } from "./utils";
12
+
13
+ export default function HueSlider({ hue, onChange }: HueSliderProps) {
14
+ const trackRef = useRef<HTMLDivElement>(null);
15
+ const dragging = useRef(false);
16
+
17
+ const computeFromX = useCallback(
18
+ (clientX: number) => {
19
+ const rect = trackRef.current?.getBoundingClientRect();
20
+ if (!rect) return;
21
+ const x = clamp((clientX - rect.left) / rect.width, 0, 1);
22
+ onChange(Math.round(x * 360));
23
+ },
24
+ [onChange]
25
+ );
26
+
27
+ const handleMouseDown = useCallback(
28
+ (e: React.MouseEvent) => {
29
+ e.preventDefault();
30
+ dragging.current = true;
31
+ computeFromX(e.clientX);
32
+ },
33
+ [computeFromX]
34
+ );
35
+
36
+ const handleTouchStart = useCallback(
37
+ (e: React.TouchEvent) => {
38
+ e.preventDefault();
39
+ dragging.current = true;
40
+ computeFromX(e.touches[0].clientX);
41
+ },
42
+ [computeFromX]
43
+ );
44
+
45
+ const handleTouchMove = useCallback(
46
+ (e: React.TouchEvent) => {
47
+ if (!dragging.current) return;
48
+ e.preventDefault();
49
+ computeFromX(e.touches[0].clientX);
50
+ },
51
+ [computeFromX]
52
+ );
53
+
54
+ useEffect(() => {
55
+ const handleMove = (e: MouseEvent) => {
56
+ if (!dragging.current) return;
57
+ computeFromX(e.clientX);
58
+ };
59
+ const handleUp = () => {
60
+ dragging.current = false;
61
+ };
62
+ window.addEventListener("mousemove", handleMove);
63
+ window.addEventListener("mouseup", handleUp);
64
+ window.addEventListener("touchend", handleUp);
65
+ return () => {
66
+ window.removeEventListener("mousemove", handleMove);
67
+ window.removeEventListener("mouseup", handleUp);
68
+ window.removeEventListener("touchend", handleUp);
69
+ };
70
+ }, [computeFromX]);
71
+
72
+ const handleKeyDown = useCallback(
73
+ (e: React.KeyboardEvent) => {
74
+ const step = e.shiftKey ? 20 : 5;
75
+ let newHue = hue;
76
+ switch (e.key) {
77
+ case "ArrowRight":
78
+ newHue = clamp(hue + step, 0, 360);
79
+ break;
80
+ case "ArrowLeft":
81
+ newHue = clamp(hue - step, 0, 360);
82
+ break;
83
+ default:
84
+ return;
85
+ }
86
+ e.preventDefault();
87
+ onChange(newHue);
88
+ },
89
+ [hue, onChange]
90
+ );
91
+
92
+ return (
93
+ <div
94
+ ref={trackRef}
95
+ role="slider"
96
+ tabIndex={0}
97
+ aria-label="Color hue"
98
+ aria-valuemin={0}
99
+ aria-valuemax={360}
100
+ aria-valuenow={hue}
101
+ aria-valuetext={`Hue ${hue} degrees`}
102
+ className="w-full h-3.5 rounded-full cursor-pointer relative select-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#3580f9]"
103
+ style={{
104
+ background:
105
+ "linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)",
106
+ }}
107
+ onMouseDown={handleMouseDown}
108
+ onTouchStart={handleTouchStart}
109
+ onTouchMove={handleTouchMove}
110
+ onKeyDown={handleKeyDown}
111
+ >
112
+ <div
113
+ className="absolute w-[18px] h-[18px] rounded-full border-[2.5px] border-white pointer-events-none"
114
+ style={{
115
+ left: `${(hue / 360) * 100}%`,
116
+ top: "50%",
117
+ transform: "translate(-50%, -50%)",
118
+ background: `hsl(${hue}, 100%, 50%)`,
119
+ boxShadow: "0 1px 4px rgba(0,0,0,0.4)",
120
+ }}
121
+ />
122
+ </div>
123
+ );
124
+ }
@@ -1,142 +1,142 @@
1
- "use client";
2
-
3
- /**
4
- * SaturationCanvas — HSV saturation/brightness 2D canvas.
5
- *
6
- * Mouse + touch + keyboard support.
7
- * Renders a hue-tinted gradient with white→color horizontally
8
- * and transparent→black vertically.
9
- */
10
-
11
- import { useRef, useCallback, useEffect } from "react";
12
- import type { SaturationCanvasProps } from "./types";
13
- import { clamp } from "./utils";
14
-
15
- export default function SaturationCanvas({
16
- hue,
17
- saturation,
18
- value,
19
- onChange,
20
- }: SaturationCanvasProps) {
21
- const canvasRef = useRef<HTMLDivElement>(null);
22
- const dragging = useRef(false);
23
-
24
- const computeFromEvent = useCallback(
25
- (clientX: number, clientY: number) => {
26
- const rect = canvasRef.current?.getBoundingClientRect();
27
- if (!rect) return;
28
- const x = clamp((clientX - rect.left) / rect.width, 0, 1);
29
- const y = clamp((clientY - rect.top) / rect.height, 0, 1);
30
- onChange(Math.round(x * 100), Math.round((1 - y) * 100));
31
- },
32
- [onChange]
33
- );
34
-
35
- // Mouse events
36
- const handleMouseDown = useCallback(
37
- (e: React.MouseEvent) => {
38
- e.preventDefault();
39
- dragging.current = true;
40
- computeFromEvent(e.clientX, e.clientY);
41
- },
42
- [computeFromEvent]
43
- );
44
-
45
- // Touch events
46
- const handleTouchStart = useCallback(
47
- (e: React.TouchEvent) => {
48
- e.preventDefault();
49
- dragging.current = true;
50
- const t = e.touches[0];
51
- computeFromEvent(t.clientX, t.clientY);
52
- },
53
- [computeFromEvent]
54
- );
55
-
56
- const handleTouchMove = useCallback(
57
- (e: React.TouchEvent) => {
58
- if (!dragging.current) return;
59
- e.preventDefault();
60
- const t = e.touches[0];
61
- computeFromEvent(t.clientX, t.clientY);
62
- },
63
- [computeFromEvent]
64
- );
65
-
66
- // Global mouse move/up
67
- useEffect(() => {
68
- const handleMove = (e: MouseEvent) => {
69
- if (!dragging.current) return;
70
- computeFromEvent(e.clientX, e.clientY);
71
- };
72
- const handleUp = () => {
73
- dragging.current = false;
74
- };
75
- window.addEventListener("mousemove", handleMove);
76
- window.addEventListener("mouseup", handleUp);
77
- window.addEventListener("touchend", handleUp);
78
- return () => {
79
- window.removeEventListener("mousemove", handleMove);
80
- window.removeEventListener("mouseup", handleUp);
81
- window.removeEventListener("touchend", handleUp);
82
- };
83
- }, [computeFromEvent]);
84
-
85
- // Keyboard
86
- const handleKeyDown = useCallback(
87
- (e: React.KeyboardEvent) => {
88
- const step = e.shiftKey ? 10 : 2;
89
- let newSat = saturation;
90
- let newVal = value;
91
- switch (e.key) {
92
- case "ArrowRight":
93
- newSat = clamp(saturation + step, 0, 100);
94
- break;
95
- case "ArrowLeft":
96
- newSat = clamp(saturation - step, 0, 100);
97
- break;
98
- case "ArrowUp":
99
- newVal = clamp(value + step, 0, 100);
100
- break;
101
- case "ArrowDown":
102
- newVal = clamp(value - step, 0, 100);
103
- break;
104
- default:
105
- return;
106
- }
107
- e.preventDefault();
108
- onChange(newSat, newVal);
109
- },
110
- [saturation, value, onChange]
111
- );
112
-
113
- return (
114
- <div
115
- ref={canvasRef}
116
- role="slider"
117
- tabIndex={0}
118
- aria-label="Color saturation and brightness"
119
- aria-valuetext={`Saturation ${saturation}%, Brightness ${value}%`}
120
- className="w-full h-[220px] rounded-xl cursor-crosshair relative select-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#076bff]"
121
- style={{
122
- background: `linear-gradient(to bottom, transparent, #000), linear-gradient(to right, #fff, hsl(${hue}, 100%, 50%))`,
123
- }}
124
- onMouseDown={handleMouseDown}
125
- onTouchStart={handleTouchStart}
126
- onTouchMove={handleTouchMove}
127
- onKeyDown={handleKeyDown}
128
- >
129
- {/* Cursor indicator */}
130
- <div
131
- className="absolute w-5 h-5 rounded-full border-[2.5px] border-white pointer-events-none"
132
- style={{
133
- left: `${saturation}%`,
134
- top: `${100 - value}%`,
135
- transform: "translate(-50%, -50%)",
136
- boxShadow:
137
- "0 0 0 1px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4)",
138
- }}
139
- />
140
- </div>
141
- );
142
- }
1
+ "use client";
2
+
3
+ /**
4
+ * SaturationCanvas — HSV saturation/brightness 2D canvas.
5
+ *
6
+ * Mouse + touch + keyboard support.
7
+ * Renders a hue-tinted gradient with white→color horizontally
8
+ * and transparent→black vertically.
9
+ */
10
+
11
+ import { useRef, useCallback, useEffect } from "react";
12
+ import type { SaturationCanvasProps } from "./types";
13
+ import { clamp } from "./utils";
14
+
15
+ export default function SaturationCanvas({
16
+ hue,
17
+ saturation,
18
+ value,
19
+ onChange,
20
+ }: SaturationCanvasProps) {
21
+ const canvasRef = useRef<HTMLDivElement>(null);
22
+ const dragging = useRef(false);
23
+
24
+ const computeFromEvent = useCallback(
25
+ (clientX: number, clientY: number) => {
26
+ const rect = canvasRef.current?.getBoundingClientRect();
27
+ if (!rect) return;
28
+ const x = clamp((clientX - rect.left) / rect.width, 0, 1);
29
+ const y = clamp((clientY - rect.top) / rect.height, 0, 1);
30
+ onChange(Math.round(x * 100), Math.round((1 - y) * 100));
31
+ },
32
+ [onChange]
33
+ );
34
+
35
+ // Mouse events
36
+ const handleMouseDown = useCallback(
37
+ (e: React.MouseEvent) => {
38
+ e.preventDefault();
39
+ dragging.current = true;
40
+ computeFromEvent(e.clientX, e.clientY);
41
+ },
42
+ [computeFromEvent]
43
+ );
44
+
45
+ // Touch events
46
+ const handleTouchStart = useCallback(
47
+ (e: React.TouchEvent) => {
48
+ e.preventDefault();
49
+ dragging.current = true;
50
+ const t = e.touches[0];
51
+ computeFromEvent(t.clientX, t.clientY);
52
+ },
53
+ [computeFromEvent]
54
+ );
55
+
56
+ const handleTouchMove = useCallback(
57
+ (e: React.TouchEvent) => {
58
+ if (!dragging.current) return;
59
+ e.preventDefault();
60
+ const t = e.touches[0];
61
+ computeFromEvent(t.clientX, t.clientY);
62
+ },
63
+ [computeFromEvent]
64
+ );
65
+
66
+ // Global mouse move/up
67
+ useEffect(() => {
68
+ const handleMove = (e: MouseEvent) => {
69
+ if (!dragging.current) return;
70
+ computeFromEvent(e.clientX, e.clientY);
71
+ };
72
+ const handleUp = () => {
73
+ dragging.current = false;
74
+ };
75
+ window.addEventListener("mousemove", handleMove);
76
+ window.addEventListener("mouseup", handleUp);
77
+ window.addEventListener("touchend", handleUp);
78
+ return () => {
79
+ window.removeEventListener("mousemove", handleMove);
80
+ window.removeEventListener("mouseup", handleUp);
81
+ window.removeEventListener("touchend", handleUp);
82
+ };
83
+ }, [computeFromEvent]);
84
+
85
+ // Keyboard
86
+ const handleKeyDown = useCallback(
87
+ (e: React.KeyboardEvent) => {
88
+ const step = e.shiftKey ? 10 : 2;
89
+ let newSat = saturation;
90
+ let newVal = value;
91
+ switch (e.key) {
92
+ case "ArrowRight":
93
+ newSat = clamp(saturation + step, 0, 100);
94
+ break;
95
+ case "ArrowLeft":
96
+ newSat = clamp(saturation - step, 0, 100);
97
+ break;
98
+ case "ArrowUp":
99
+ newVal = clamp(value + step, 0, 100);
100
+ break;
101
+ case "ArrowDown":
102
+ newVal = clamp(value - step, 0, 100);
103
+ break;
104
+ default:
105
+ return;
106
+ }
107
+ e.preventDefault();
108
+ onChange(newSat, newVal);
109
+ },
110
+ [saturation, value, onChange]
111
+ );
112
+
113
+ return (
114
+ <div
115
+ ref={canvasRef}
116
+ role="slider"
117
+ tabIndex={0}
118
+ aria-label="Color saturation and brightness"
119
+ aria-valuetext={`Saturation ${saturation}%, Brightness ${value}%`}
120
+ className="w-full h-[220px] rounded-xl cursor-crosshair relative select-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#3580f9]"
121
+ style={{
122
+ background: `linear-gradient(to bottom, transparent, #000), linear-gradient(to right, #fff, hsl(${hue}, 100%, 50%))`,
123
+ }}
124
+ onMouseDown={handleMouseDown}
125
+ onTouchStart={handleTouchStart}
126
+ onTouchMove={handleTouchMove}
127
+ onKeyDown={handleKeyDown}
128
+ >
129
+ {/* Cursor indicator */}
130
+ <div
131
+ className="absolute w-5 h-5 rounded-full border-[2.5px] border-white pointer-events-none"
132
+ style={{
133
+ left: `${saturation}%`,
134
+ top: `${100 - value}%`,
135
+ transform: "translate(-50%, -50%)",
136
+ boxShadow:
137
+ "0 0 0 1px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4)",
138
+ }}
139
+ />
140
+ </div>
141
+ );
142
+ }