@agent-native/toolkit 0.6.0 → 0.7.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/README.md +17 -1
- package/dist/chat-history/ChatHistoryList.d.ts +103 -0
- package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.js +109 -0
- package/dist/chat-history/ChatHistoryList.js.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.js +283 -0
- package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
- package/dist/chat-history/index.d.ts +2 -0
- package/dist/chat-history/index.d.ts.map +1 -0
- package/dist/chat-history/index.js +2 -0
- package/dist/chat-history/index.js.map +1 -0
- package/dist/chat-history.css +314 -0
- package/package.json +17 -1
- package/src/app-shell/header-actions.tsx +84 -0
- package/src/app-shell/index.ts +1 -0
- package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
- package/src/chat-history/ChatHistoryList.tsx +469 -0
- package/src/chat-history/index.ts +7 -0
- package/src/chat-history.css +314 -0
- package/src/collab-ui/AgentPresenceChip.tsx +69 -0
- package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
- package/src/collab-ui/PresenceBar.tsx +346 -0
- package/src/collab-ui/RecentEditHighlights.tsx +225 -0
- package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
- package/src/collab-ui/agent-identity.ts +16 -0
- package/src/collab-ui/index.ts +39 -0
- package/src/collab-ui/lead-client.ts +32 -0
- package/src/collab-ui/types.spec.ts +18 -0
- package/src/collab-ui/types.ts +96 -0
- package/src/composer/AgentComposerFrame.tsx +59 -0
- package/src/composer/ComposerPlusMenu.tsx +983 -0
- package/src/composer/MentionPopover.tsx +412 -0
- package/src/composer/PastedTextChip.tsx +150 -0
- package/src/composer/PromptComposer.spec.ts +73 -0
- package/src/composer/PromptComposer.tsx +718 -0
- package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
- package/src/composer/RealtimeVoiceMode.tsx +856 -0
- package/src/composer/TiptapComposer.spec.ts +390 -0
- package/src/composer/TiptapComposer.tsx +2936 -0
- package/src/composer/VoiceButton.spec.ts +33 -0
- package/src/composer/VoiceButton.tsx +255 -0
- package/src/composer/asset-picker-url.spec.ts +56 -0
- package/src/composer/asset-picker-url.ts +80 -0
- package/src/composer/attachment-accept.spec.ts +22 -0
- package/src/composer/attachment-accept.ts +71 -0
- package/src/composer/draft-key.spec.ts +19 -0
- package/src/composer/draft-key.ts +7 -0
- package/src/composer/extensions/FileReference.tsx +55 -0
- package/src/composer/extensions/MentionReference.tsx +93 -0
- package/src/composer/extensions/SkillReference.tsx +56 -0
- package/src/composer/index.ts +79 -0
- package/src/composer/pasted-text.spec.ts +162 -0
- package/src/composer/pasted-text.ts +160 -0
- package/src/composer/prompt-attachments.spec.ts +80 -0
- package/src/composer/prompt-attachments.ts +93 -0
- package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
- package/src/composer/realtime-voice-audio-level.ts +64 -0
- package/src/composer/realtime-voice-transcript.spec.ts +95 -0
- package/src/composer/realtime-voice-transcript.ts +134 -0
- package/src/composer/runtime-adapters.tsx +343 -0
- package/src/composer/types.ts +66 -0
- package/src/composer/use-file-search.ts +64 -0
- package/src/composer/use-mention-search.ts +90 -0
- package/src/composer/use-skills.ts +45 -0
- package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
- package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
- package/src/composer/useVoiceDictation.spec.ts +45 -0
- package/src/composer/useVoiceDictation.ts +1191 -0
- package/src/context-ui/ContextMeter.tsx +101 -0
- package/src/context-ui/ContextSegmentRow.tsx +184 -0
- package/src/context-ui/ContextTreemap.tsx +125 -0
- package/src/context-ui/ContextXRayPanel.tsx +377 -0
- package/src/context-ui/format.ts +42 -0
- package/src/context-ui/index.ts +15 -0
- package/src/context-ui/types.ts +43 -0
- package/src/design-tweaks/index.ts +1 -0
- package/src/design-tweaks/visual-style-controls.tsx +1084 -0
- package/src/editor/BubbleToolbar.tsx +253 -0
- package/src/editor/CodeBlockNode.tsx +254 -0
- package/src/editor/DragHandle.spec.ts +588 -0
- package/src/editor/DragHandle.ts +1437 -0
- package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
- package/src/editor/ImageExtension.ts +306 -0
- package/src/editor/RegistryBlockContext.tsx +107 -0
- package/src/editor/RegistryBlockNode.spec.ts +279 -0
- package/src/editor/RegistryBlockNode.tsx +652 -0
- package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
- package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
- package/src/editor/RichMarkdownEditor.tsx +81 -0
- package/src/editor/RunId.ts +61 -0
- package/src/editor/SharedRichEditor.tsx +321 -0
- package/src/editor/SlashCommandMenu.tsx +310 -0
- package/src/editor/disableHistory.spec.ts +85 -0
- package/src/editor/extensions.ts +402 -0
- package/src/editor/gfmDoc.spec.ts +230 -0
- package/src/editor/gfmDoc.ts +92 -0
- package/src/editor/index.ts +93 -0
- package/src/editor/registrySlashCommands.ts +131 -0
- package/src/editor/structuralReorderUndo.spec.ts +144 -0
- package/src/editor/surgical-apply.spec.ts +279 -0
- package/src/editor/surgical-apply.ts +192 -0
- package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
- package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
- package/src/editor/useCollabReconcile.ts +589 -0
- package/src/editor.css +510 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/use-mobile.tsx +21 -0
- package/src/hooks/use-toast.ts +188 -0
- package/src/index.ts +8 -0
- package/src/onboarding/index.ts +1 -0
- package/src/provider.tsx +47 -0
- package/src/sharing/VisibilityBadge.spec.ts +96 -0
- package/src/sharing/VisibilityBadge.tsx +37 -0
- package/src/sharing/index.ts +4 -0
- package/src/styles.css +21 -0
- package/src/ui/accordion.tsx +56 -0
- package/src/ui/alert-dialog.tsx +141 -0
- package/src/ui/alert.tsx +59 -0
- package/src/ui/aspect-ratio.tsx +5 -0
- package/src/ui/avatar.tsx +48 -0
- package/src/ui/badge.tsx +37 -0
- package/src/ui/breadcrumb.tsx +115 -0
- package/src/ui/button.tsx +78 -0
- package/src/ui/calendar.tsx +214 -0
- package/src/ui/card.tsx +86 -0
- package/src/ui/carousel.tsx +260 -0
- package/src/ui/chart.tsx +375 -0
- package/src/ui/checkbox.tsx +28 -0
- package/src/ui/collapsible.tsx +9 -0
- package/src/ui/command.spec.tsx +51 -0
- package/src/ui/command.tsx +174 -0
- package/src/ui/context-menu.tsx +202 -0
- package/src/ui/date-picker.tsx +64 -0
- package/src/ui/dialog.spec.tsx +43 -0
- package/src/ui/dialog.tsx +146 -0
- package/src/ui/drawer.tsx +126 -0
- package/src/ui/dropdown-menu.tsx +218 -0
- package/src/ui/form.tsx +177 -0
- package/src/ui/hover-card.tsx +27 -0
- package/src/ui/index.ts +52 -0
- package/src/ui/input-otp.tsx +69 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.tsx +24 -0
- package/src/ui/logo.tsx +36 -0
- package/src/ui/menubar.tsx +235 -0
- package/src/ui/navigation-menu.tsx +128 -0
- package/src/ui/pagination.tsx +121 -0
- package/src/ui/popover.tsx +44 -0
- package/src/ui/progress.tsx +26 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/resizable.tsx +43 -0
- package/src/ui/scroll-area.tsx +46 -0
- package/src/ui/select.tsx +158 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/sheet.tsx +160 -0
- package/src/ui/sidebar.tsx +777 -0
- package/src/ui/skeleton.tsx +18 -0
- package/src/ui/slider.tsx +26 -0
- package/src/ui/sonner.tsx +58 -0
- package/src/ui/spinner.tsx +17 -0
- package/src/ui/switch.tsx +27 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/tabs.tsx +53 -0
- package/src/ui/textarea.tsx +23 -0
- package/src/ui/toast.tsx +127 -0
- package/src/ui/toaster.tsx +33 -0
- package/src/ui/toggle-group.tsx +59 -0
- package/src/ui/toggle.tsx +43 -0
- package/src/ui/tooltip.tsx +53 -0
- package/src/ui/use-toast.ts +3 -0
- package/src/utils.ts +8 -0
|
@@ -0,0 +1,1084 @@
|
|
|
1
|
+
import { IconColorPicker } from "@tabler/icons-react";
|
|
2
|
+
import {
|
|
3
|
+
useEffect,
|
|
4
|
+
useId,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type KeyboardEvent,
|
|
8
|
+
type PointerEvent,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
|
|
12
|
+
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover.js";
|
|
13
|
+
import { cn } from "../utils.js";
|
|
14
|
+
|
|
15
|
+
export type VisualControlValue = string | number | boolean;
|
|
16
|
+
|
|
17
|
+
export interface VisualControlOption {
|
|
18
|
+
label: string;
|
|
19
|
+
value: string;
|
|
20
|
+
color?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface VisualTweakDefinition {
|
|
24
|
+
id: string;
|
|
25
|
+
label: string;
|
|
26
|
+
type: "color-swatch" | "color-swatches" | "segment" | "slider" | "toggle";
|
|
27
|
+
options?: VisualControlOption[];
|
|
28
|
+
min?: number;
|
|
29
|
+
max?: number;
|
|
30
|
+
step?: number;
|
|
31
|
+
defaultValue: VisualControlValue;
|
|
32
|
+
cssVar?: string;
|
|
33
|
+
unit?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clampNumber(value: number, min?: number, max?: number) {
|
|
37
|
+
let next = value;
|
|
38
|
+
if (typeof min === "number") next = Math.max(min, next);
|
|
39
|
+
if (typeof max === "number") next = Math.min(max, next);
|
|
40
|
+
return next;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatNumber(value: number, unit?: string) {
|
|
44
|
+
const rounded = Number.isInteger(value) ? value : Number(value.toFixed(2));
|
|
45
|
+
return unit ? `${rounded}${unit}` : String(rounded);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseDraftNumber(value: string, fallback: number) {
|
|
49
|
+
const match = value.trim().match(/-?\d+(?:\.\d+)?/);
|
|
50
|
+
if (!match) return fallback;
|
|
51
|
+
const next = Number(match[0]);
|
|
52
|
+
return Number.isFinite(next) ? next : fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface RgbaColor {
|
|
56
|
+
r: number;
|
|
57
|
+
g: number;
|
|
58
|
+
b: number;
|
|
59
|
+
a: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface HsvaColor {
|
|
63
|
+
h: number;
|
|
64
|
+
s: number;
|
|
65
|
+
v: number;
|
|
66
|
+
a: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const CHECKERBOARD_IMAGE =
|
|
70
|
+
"linear-gradient(45deg, #d4d4d4 25%, transparent 25%), linear-gradient(-45deg, #d4d4d4 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d4d4d4 75%), linear-gradient(-45deg, transparent 75%, #d4d4d4 75%)";
|
|
71
|
+
const FALLBACK_RGBA: RgbaColor = { r: 0, g: 0, b: 0, a: 1 };
|
|
72
|
+
|
|
73
|
+
function normalizeRgba(color: RgbaColor): RgbaColor {
|
|
74
|
+
return {
|
|
75
|
+
r: clamp(color.r, 0, 255),
|
|
76
|
+
g: clamp(color.g, 0, 255),
|
|
77
|
+
b: clamp(color.b, 0, 255),
|
|
78
|
+
a: clampFloat(color.a, 0, 1),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeHexColor(value: string): string | null {
|
|
83
|
+
const trimmed = value.trim();
|
|
84
|
+
if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed.toUpperCase();
|
|
85
|
+
if (/^[0-9a-f]{6}$/i.test(trimmed)) return `#${trimmed}`.toUpperCase();
|
|
86
|
+
if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
|
|
87
|
+
return `#${trimmed
|
|
88
|
+
.slice(1)
|
|
89
|
+
.split("")
|
|
90
|
+
.map((char) => char + char)
|
|
91
|
+
.join("")}`.toUpperCase();
|
|
92
|
+
}
|
|
93
|
+
if (/^[0-9a-f]{3}$/i.test(trimmed)) {
|
|
94
|
+
return `#${trimmed
|
|
95
|
+
.split("")
|
|
96
|
+
.map((char) => char + char)
|
|
97
|
+
.join("")}`.toUpperCase();
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseCssColor(value: string): RgbaColor | null {
|
|
103
|
+
const trimmed = value.trim();
|
|
104
|
+
if (!trimmed) return null;
|
|
105
|
+
const normalizedHex = normalizeHexColor(trimmed);
|
|
106
|
+
if (normalizedHex) {
|
|
107
|
+
return normalizeRgba({
|
|
108
|
+
r: Number.parseInt(normalizedHex.slice(1, 3), 16),
|
|
109
|
+
g: Number.parseInt(normalizedHex.slice(3, 5), 16),
|
|
110
|
+
b: Number.parseInt(normalizedHex.slice(5, 7), 16),
|
|
111
|
+
a: 1,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const rgb = trimmed.match(
|
|
115
|
+
/^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*([0-9.]+%?))?/i,
|
|
116
|
+
);
|
|
117
|
+
if (rgb) {
|
|
118
|
+
return normalizeRgba({
|
|
119
|
+
r: Number(rgb[1]),
|
|
120
|
+
g: Number(rgb[2]),
|
|
121
|
+
b: Number(rgb[3]),
|
|
122
|
+
a: parseAlpha(rgb[4]),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const lower = trimmed.toLowerCase();
|
|
126
|
+
if (lower === "transparent" || lower === "rgba(0, 0, 0, 0)") {
|
|
127
|
+
return { r: 0, g: 0, b: 0, a: 0 };
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function channelToHex(value: number): string {
|
|
133
|
+
return clamp(value, 0, 255).toString(16).padStart(2, "0");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function rgbaToHex(color: RgbaColor): string {
|
|
137
|
+
const normalized = normalizeRgba(color);
|
|
138
|
+
return `#${channelToHex(normalized.r)}${channelToHex(normalized.g)}${channelToHex(normalized.b)}`.toUpperCase();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function rgbaToCss(color: RgbaColor): string {
|
|
142
|
+
const normalized = normalizeRgba(color);
|
|
143
|
+
if (normalized.a >= 1) return rgbaToHex(normalized);
|
|
144
|
+
return `rgba(${normalized.r}, ${normalized.g}, ${normalized.b}, ${trimNumber(normalized.a)})`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseAlpha(value: string | undefined): number {
|
|
148
|
+
if (!value) return 1;
|
|
149
|
+
if (value.endsWith("%")) return Number(value.slice(0, -1)) / 100;
|
|
150
|
+
return Number(value);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function trimNumber(value: number) {
|
|
154
|
+
return Number(value.toFixed(2)).toString();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function rgbaToHsv(color: RgbaColor): HsvaColor {
|
|
158
|
+
const normalized = normalizeRgba(color);
|
|
159
|
+
const r = normalized.r / 255;
|
|
160
|
+
const g = normalized.g / 255;
|
|
161
|
+
const b = normalized.b / 255;
|
|
162
|
+
const max = Math.max(r, g, b);
|
|
163
|
+
const min = Math.min(r, g, b);
|
|
164
|
+
const delta = max - min;
|
|
165
|
+
|
|
166
|
+
let h = 0;
|
|
167
|
+
if (delta !== 0) {
|
|
168
|
+
if (max === r) h = ((g - b) / delta) % 6;
|
|
169
|
+
else if (max === g) h = (b - r) / delta + 2;
|
|
170
|
+
else h = (r - g) / delta + 4;
|
|
171
|
+
h *= 60;
|
|
172
|
+
if (h < 0) h += 360;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
h: Math.round(h),
|
|
177
|
+
s: max === 0 ? 0 : Math.round((delta / max) * 100),
|
|
178
|
+
v: Math.round(max * 100),
|
|
179
|
+
a: normalized.a,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function hsvToRgba(color: HsvaColor): RgbaColor {
|
|
184
|
+
const h = ((color.h % 360) + 360) % 360;
|
|
185
|
+
const s = clampFloat(color.s, 0, 100) / 100;
|
|
186
|
+
const v = clampFloat(color.v, 0, 100) / 100;
|
|
187
|
+
const chroma = v * s;
|
|
188
|
+
const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
189
|
+
const m = v - chroma;
|
|
190
|
+
|
|
191
|
+
let r = 0;
|
|
192
|
+
let g = 0;
|
|
193
|
+
let b = 0;
|
|
194
|
+
if (h < 60) [r, g, b] = [chroma, x, 0];
|
|
195
|
+
else if (h < 120) [r, g, b] = [x, chroma, 0];
|
|
196
|
+
else if (h < 180) [r, g, b] = [0, chroma, x];
|
|
197
|
+
else if (h < 240) [r, g, b] = [0, x, chroma];
|
|
198
|
+
else if (h < 300) [r, g, b] = [x, 0, chroma];
|
|
199
|
+
else [r, g, b] = [chroma, 0, x];
|
|
200
|
+
|
|
201
|
+
return normalizeRgba({
|
|
202
|
+
r: Math.round((r + m) * 255),
|
|
203
|
+
g: Math.round((g + m) * 255),
|
|
204
|
+
b: Math.round((b + m) * 255),
|
|
205
|
+
a: clampFloat(color.a, 0, 1),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function clamp(value: number, min: number, max: number): number {
|
|
210
|
+
if (!Number.isFinite(value)) return min;
|
|
211
|
+
return Math.max(min, Math.min(max, Math.round(value)));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function clampFloat(value: number, min: number, max: number): number {
|
|
215
|
+
if (!Number.isFinite(value)) return min;
|
|
216
|
+
return Math.max(min, Math.min(max, value));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function swatchBackground(value: string) {
|
|
220
|
+
if (value === "transparent" || value === "rgba(0, 0, 0, 0)") {
|
|
221
|
+
return {
|
|
222
|
+
background: CHECKERBOARD_IMAGE,
|
|
223
|
+
backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0",
|
|
224
|
+
backgroundSize: "8px 8px",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return { background: value };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function VisualInspectorPanel({
|
|
231
|
+
title,
|
|
232
|
+
subtitle,
|
|
233
|
+
children,
|
|
234
|
+
className,
|
|
235
|
+
headerAction,
|
|
236
|
+
}: {
|
|
237
|
+
title: ReactNode;
|
|
238
|
+
subtitle?: ReactNode;
|
|
239
|
+
children: ReactNode;
|
|
240
|
+
className?: string;
|
|
241
|
+
headerAction?: ReactNode;
|
|
242
|
+
}) {
|
|
243
|
+
return (
|
|
244
|
+
<aside
|
|
245
|
+
className={cn(
|
|
246
|
+
"w-64 overflow-hidden rounded-xl border border-border bg-card/95 text-card-foreground shadow-2xl shadow-black/35 backdrop-blur",
|
|
247
|
+
className,
|
|
248
|
+
)}
|
|
249
|
+
>
|
|
250
|
+
<div className="flex min-h-10 items-start justify-between gap-2 border-b border-border/70 px-3 py-2.5">
|
|
251
|
+
<div className="min-w-0">
|
|
252
|
+
<div className="truncate text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
253
|
+
{title}
|
|
254
|
+
</div>
|
|
255
|
+
{subtitle && (
|
|
256
|
+
<div className="mt-0.5 truncate text-[12px] text-foreground">
|
|
257
|
+
{subtitle}
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
{headerAction}
|
|
262
|
+
</div>
|
|
263
|
+
<div className="max-h-[min(680px,calc(100vh-7rem))] overflow-y-auto p-2">
|
|
264
|
+
{children}
|
|
265
|
+
</div>
|
|
266
|
+
</aside>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function VisualInspectorSection({
|
|
271
|
+
title,
|
|
272
|
+
children,
|
|
273
|
+
className,
|
|
274
|
+
}: {
|
|
275
|
+
title: ReactNode;
|
|
276
|
+
children: ReactNode;
|
|
277
|
+
className?: string;
|
|
278
|
+
}) {
|
|
279
|
+
return (
|
|
280
|
+
<section className={cn("rounded-lg px-1.5 py-2", className)}>
|
|
281
|
+
<div className="mb-2 px-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/75">
|
|
282
|
+
{title}
|
|
283
|
+
</div>
|
|
284
|
+
<div className="space-y-2">{children}</div>
|
|
285
|
+
</section>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function VisualControlRow({
|
|
290
|
+
label,
|
|
291
|
+
children,
|
|
292
|
+
className,
|
|
293
|
+
}: {
|
|
294
|
+
label: ReactNode;
|
|
295
|
+
children: ReactNode;
|
|
296
|
+
className?: string;
|
|
297
|
+
}) {
|
|
298
|
+
return (
|
|
299
|
+
<div className={cn("grid gap-1", className)}>
|
|
300
|
+
<span className="text-[11px] text-muted-foreground">{label}</span>
|
|
301
|
+
{children}
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function VisualSwatchControl({
|
|
307
|
+
options,
|
|
308
|
+
value,
|
|
309
|
+
onChange,
|
|
310
|
+
columns = 8,
|
|
311
|
+
className,
|
|
312
|
+
}: {
|
|
313
|
+
options: VisualControlOption[];
|
|
314
|
+
value: string;
|
|
315
|
+
onChange: (value: string) => void;
|
|
316
|
+
columns?: number;
|
|
317
|
+
className?: string;
|
|
318
|
+
}) {
|
|
319
|
+
return (
|
|
320
|
+
<div
|
|
321
|
+
className={cn("grid gap-1.5", className)}
|
|
322
|
+
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
|
|
323
|
+
>
|
|
324
|
+
{options.map((option) => {
|
|
325
|
+
const swatch = option.color ?? option.value;
|
|
326
|
+
const isTransparent = swatch === "transparent";
|
|
327
|
+
return (
|
|
328
|
+
<button
|
|
329
|
+
key={`${option.value}-${option.label}`}
|
|
330
|
+
type="button"
|
|
331
|
+
title={option.label}
|
|
332
|
+
aria-label={option.label}
|
|
333
|
+
onClick={() => onChange(option.value)}
|
|
334
|
+
className={cn(
|
|
335
|
+
"size-5 cursor-pointer rounded-md border border-border/70 transition-[transform,border-color] hover:scale-105 hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
336
|
+
value === option.value &&
|
|
337
|
+
"border-foreground/70 ring-2 ring-foreground/40 ring-offset-1 ring-offset-card",
|
|
338
|
+
)}
|
|
339
|
+
style={{
|
|
340
|
+
background: isTransparent
|
|
341
|
+
? "linear-gradient(45deg, hsl(var(--muted)) 25%, transparent 25%), linear-gradient(-45deg, hsl(var(--muted)) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, hsl(var(--muted)) 75%), linear-gradient(-45deg, transparent 75%, hsl(var(--muted)) 75%)"
|
|
342
|
+
: swatch,
|
|
343
|
+
backgroundPosition: isTransparent
|
|
344
|
+
? "0 0, 0 4px, 4px -4px, -4px 0"
|
|
345
|
+
: undefined,
|
|
346
|
+
backgroundSize: isTransparent ? "8px 8px" : undefined,
|
|
347
|
+
}}
|
|
348
|
+
/>
|
|
349
|
+
);
|
|
350
|
+
})}
|
|
351
|
+
</div>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function VisualColorPicker({
|
|
356
|
+
label,
|
|
357
|
+
value,
|
|
358
|
+
onChange,
|
|
359
|
+
documentColors = [],
|
|
360
|
+
allowTransparent = false,
|
|
361
|
+
hexLabel = "Hex",
|
|
362
|
+
documentColorsLabel = "Document colors",
|
|
363
|
+
transparentLabel = "Transparent",
|
|
364
|
+
className,
|
|
365
|
+
}: {
|
|
366
|
+
label: string;
|
|
367
|
+
value: string;
|
|
368
|
+
onChange: (value: string) => void;
|
|
369
|
+
documentColors?: string[];
|
|
370
|
+
allowTransparent?: boolean;
|
|
371
|
+
hexLabel?: string;
|
|
372
|
+
documentColorsLabel?: string;
|
|
373
|
+
transparentLabel?: string;
|
|
374
|
+
className?: string;
|
|
375
|
+
}) {
|
|
376
|
+
const [open, setOpen] = useState(false);
|
|
377
|
+
const color = parseCssColor(value) ?? FALLBACK_RGBA;
|
|
378
|
+
const colorHex = rgbaToHex(color);
|
|
379
|
+
const [draft, setDraft] = useState(colorHex.replace(/^#/, ""));
|
|
380
|
+
const [picking, setPicking] = useState(false);
|
|
381
|
+
const hasEyeDropper = typeof window !== "undefined" && "EyeDropper" in window;
|
|
382
|
+
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
if (!open) setDraft(colorHex.replace(/^#/, ""));
|
|
385
|
+
}, [colorHex, open]);
|
|
386
|
+
|
|
387
|
+
const commitDraft = (nextDraft = draft) => {
|
|
388
|
+
const normalized = normalizeHexColor(nextDraft);
|
|
389
|
+
if (!normalized) {
|
|
390
|
+
setDraft(colorHex.replace(/^#/, ""));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
setDraft(normalized.replace(/^#/, ""));
|
|
394
|
+
onChange(normalized);
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const emitHsv = (nextHsv: HsvaColor) => {
|
|
398
|
+
const nextColor = hsvToRgba({ ...nextHsv, a: color.a });
|
|
399
|
+
const nextValue = rgbaToCss(nextColor);
|
|
400
|
+
setDraft(rgbaToHex(nextColor).replace(/^#/, ""));
|
|
401
|
+
onChange(nextValue);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const pickScreenColor = async () => {
|
|
405
|
+
if (!hasEyeDropper || picking) return;
|
|
406
|
+
try {
|
|
407
|
+
setPicking(true);
|
|
408
|
+
const EyeDropperCtor = (
|
|
409
|
+
window as unknown as {
|
|
410
|
+
EyeDropper?: new () => { open: () => Promise<{ sRGBHex: string }> };
|
|
411
|
+
}
|
|
412
|
+
).EyeDropper;
|
|
413
|
+
if (!EyeDropperCtor) return;
|
|
414
|
+
const result = await new EyeDropperCtor().open();
|
|
415
|
+
if (result?.sRGBHex) {
|
|
416
|
+
const next = normalizeHexColor(result.sRGBHex);
|
|
417
|
+
if (next) {
|
|
418
|
+
setDraft(next.replace(/^#/, ""));
|
|
419
|
+
onChange(next);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
} finally {
|
|
423
|
+
setPicking(false);
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const uniqueDocumentColors = Array.from(
|
|
428
|
+
new Set(
|
|
429
|
+
documentColors
|
|
430
|
+
.map((color) =>
|
|
431
|
+
color === "transparent" ? color : normalizeHexColor(color),
|
|
432
|
+
)
|
|
433
|
+
.filter(Boolean) as string[],
|
|
434
|
+
),
|
|
435
|
+
).slice(0, 16);
|
|
436
|
+
const displayValue =
|
|
437
|
+
value === "transparent" || color.a === 0
|
|
438
|
+
? transparentLabel
|
|
439
|
+
: colorHex.replace(/^#/, "");
|
|
440
|
+
const hsv = rgbaToHsv(color);
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
444
|
+
<PopoverTrigger asChild>
|
|
445
|
+
<button
|
|
446
|
+
type="button"
|
|
447
|
+
aria-label={label}
|
|
448
|
+
className={cn(
|
|
449
|
+
"flex h-7 w-full cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background/70 px-2 text-[11px] shadow-none transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
450
|
+
className,
|
|
451
|
+
)}
|
|
452
|
+
>
|
|
453
|
+
<span
|
|
454
|
+
className="size-4 shrink-0 rounded-[3px] border border-border/70"
|
|
455
|
+
style={swatchBackground(value)}
|
|
456
|
+
/>
|
|
457
|
+
<span className="min-w-0 flex-1 truncate text-left font-medium tabular-nums text-foreground">
|
|
458
|
+
{displayValue}
|
|
459
|
+
</span>
|
|
460
|
+
<span
|
|
461
|
+
aria-hidden="true"
|
|
462
|
+
className="size-0 border-x-[4px] border-t-[5px] border-x-transparent border-t-muted-foreground/70"
|
|
463
|
+
/>
|
|
464
|
+
</button>
|
|
465
|
+
</PopoverTrigger>
|
|
466
|
+
<PopoverContent
|
|
467
|
+
side="left"
|
|
468
|
+
align="start"
|
|
469
|
+
sideOffset={8}
|
|
470
|
+
className="z-[10000] w-[252px] p-0 text-[11px] shadow-xl"
|
|
471
|
+
onFocusOutside={(event) => event.preventDefault()}
|
|
472
|
+
>
|
|
473
|
+
<div className="rounded-md bg-popover text-popover-foreground">
|
|
474
|
+
<VisualSaturationBrightnessField
|
|
475
|
+
hsv={hsv}
|
|
476
|
+
label={label}
|
|
477
|
+
onChange={emitHsv}
|
|
478
|
+
/>
|
|
479
|
+
|
|
480
|
+
<div className="mt-2.5 px-3">
|
|
481
|
+
<div className="grid grid-cols-[1.5rem_1fr] items-center gap-x-2">
|
|
482
|
+
<div className="flex items-center justify-center">
|
|
483
|
+
<button
|
|
484
|
+
type="button"
|
|
485
|
+
aria-label={label}
|
|
486
|
+
disabled={!hasEyeDropper || picking}
|
|
487
|
+
onClick={() => void pickScreenColor()}
|
|
488
|
+
className={cn(
|
|
489
|
+
"flex size-6 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
490
|
+
(!hasEyeDropper || picking) &&
|
|
491
|
+
"pointer-events-none opacity-40",
|
|
492
|
+
picking &&
|
|
493
|
+
"bg-primary/10 text-primary ring-1 ring-primary/50",
|
|
494
|
+
)}
|
|
495
|
+
>
|
|
496
|
+
<IconColorPicker className="size-4" />
|
|
497
|
+
</button>
|
|
498
|
+
</div>
|
|
499
|
+
<VisualColorTrack
|
|
500
|
+
label={label}
|
|
501
|
+
value={hsv.h}
|
|
502
|
+
min={0}
|
|
503
|
+
max={360}
|
|
504
|
+
backgroundImage="linear-gradient(90deg, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)"
|
|
505
|
+
onChange={(nextHue) =>
|
|
506
|
+
emitHsv({ ...hsv, h: nextHue === 360 ? 0 : nextHue })
|
|
507
|
+
}
|
|
508
|
+
/>
|
|
509
|
+
<span
|
|
510
|
+
className="mt-2 size-[18px] shrink-0 rounded-[3px] border border-border/60"
|
|
511
|
+
style={swatchBackground(rgbaToCss(color))}
|
|
512
|
+
/>
|
|
513
|
+
<VisualColorTrack
|
|
514
|
+
label={transparentLabel}
|
|
515
|
+
value={Math.round(color.a * 100)}
|
|
516
|
+
min={0}
|
|
517
|
+
max={100}
|
|
518
|
+
backgroundImage={`${CHECKERBOARD_IMAGE}, linear-gradient(90deg, rgba(${color.r}, ${color.g}, ${color.b}, 0), rgba(${color.r}, ${color.g}, ${color.b}, 1))`}
|
|
519
|
+
backgroundSize="8px 8px, 8px 8px, 8px 8px, 8px 8px, 100% 100%"
|
|
520
|
+
backgroundPosition="0 0, 0 4px, 4px -4px, -4px 0, 0 0"
|
|
521
|
+
onChange={(nextOpacity) => {
|
|
522
|
+
const nextColor = normalizeRgba({
|
|
523
|
+
...color,
|
|
524
|
+
a: nextOpacity / 100,
|
|
525
|
+
});
|
|
526
|
+
const nextValue = rgbaToCss(nextColor);
|
|
527
|
+
setDraft(rgbaToHex(nextColor).replace(/^#/, ""));
|
|
528
|
+
onChange(nextValue);
|
|
529
|
+
}}
|
|
530
|
+
/>
|
|
531
|
+
</div>
|
|
532
|
+
</div>
|
|
533
|
+
|
|
534
|
+
<div className="mt-2.5 grid grid-cols-[4.5rem_1fr] items-center gap-1 border-b border-border/70 px-3 pb-3">
|
|
535
|
+
<span className="text-[11px] font-medium text-muted-foreground">
|
|
536
|
+
{hexLabel}
|
|
537
|
+
</span>
|
|
538
|
+
<input
|
|
539
|
+
value={draft}
|
|
540
|
+
spellCheck={false}
|
|
541
|
+
onChange={(event) => setDraft(event.currentTarget.value)}
|
|
542
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
543
|
+
onBlur={() => commitDraft()}
|
|
544
|
+
onKeyDown={(event) => {
|
|
545
|
+
if (event.key === "Enter") {
|
|
546
|
+
event.preventDefault();
|
|
547
|
+
commitDraft();
|
|
548
|
+
event.currentTarget.blur();
|
|
549
|
+
}
|
|
550
|
+
if (event.key === "Escape") {
|
|
551
|
+
event.preventDefault();
|
|
552
|
+
setDraft(colorHex.replace(/^#/, ""));
|
|
553
|
+
event.currentTarget.blur();
|
|
554
|
+
}
|
|
555
|
+
}}
|
|
556
|
+
className="h-6 min-w-0 rounded-md border border-input bg-background/70 px-2 text-right text-[11px] uppercase tabular-nums outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring"
|
|
557
|
+
/>
|
|
558
|
+
</div>
|
|
559
|
+
{(allowTransparent || uniqueDocumentColors.length > 0) && (
|
|
560
|
+
<div className="px-3 py-2.5">
|
|
561
|
+
<div className="mb-2 flex h-5 items-center justify-between text-[11px] text-muted-foreground">
|
|
562
|
+
<span>{documentColorsLabel}</span>
|
|
563
|
+
</div>
|
|
564
|
+
<div className="grid grid-cols-8 gap-1">
|
|
565
|
+
{allowTransparent && (
|
|
566
|
+
<button
|
|
567
|
+
type="button"
|
|
568
|
+
aria-label={transparentLabel}
|
|
569
|
+
aria-pressed={value === "transparent"}
|
|
570
|
+
className={cn(
|
|
571
|
+
"size-5 cursor-pointer rounded-sm border transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
572
|
+
value === "transparent" || color.a === 0
|
|
573
|
+
? "border-primary ring-1 ring-primary"
|
|
574
|
+
: "border-border/60",
|
|
575
|
+
)}
|
|
576
|
+
style={swatchBackground("transparent")}
|
|
577
|
+
onClick={() => {
|
|
578
|
+
setDraft(colorHex.replace(/^#/, ""));
|
|
579
|
+
onChange("transparent");
|
|
580
|
+
}}
|
|
581
|
+
/>
|
|
582
|
+
)}
|
|
583
|
+
{uniqueDocumentColors.map((color) => (
|
|
584
|
+
<button
|
|
585
|
+
key={color}
|
|
586
|
+
type="button"
|
|
587
|
+
title={color}
|
|
588
|
+
aria-label={color}
|
|
589
|
+
aria-pressed={colorHex === color}
|
|
590
|
+
className={cn(
|
|
591
|
+
"size-5 cursor-pointer rounded-sm border transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
592
|
+
colorHex === color
|
|
593
|
+
? "border-primary ring-1 ring-primary"
|
|
594
|
+
: "border-border/60",
|
|
595
|
+
)}
|
|
596
|
+
style={swatchBackground(color)}
|
|
597
|
+
onClick={() => {
|
|
598
|
+
setDraft(color);
|
|
599
|
+
onChange(color);
|
|
600
|
+
}}
|
|
601
|
+
/>
|
|
602
|
+
))}
|
|
603
|
+
</div>
|
|
604
|
+
</div>
|
|
605
|
+
)}
|
|
606
|
+
</div>
|
|
607
|
+
</PopoverContent>
|
|
608
|
+
</Popover>
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function VisualSaturationBrightnessField({
|
|
613
|
+
hsv,
|
|
614
|
+
label,
|
|
615
|
+
onChange,
|
|
616
|
+
}: {
|
|
617
|
+
hsv: HsvaColor;
|
|
618
|
+
label: string;
|
|
619
|
+
onChange: (value: HsvaColor) => void;
|
|
620
|
+
}) {
|
|
621
|
+
const fieldRef = useRef<HTMLDivElement>(null);
|
|
622
|
+
const draggingRef = useRef(false);
|
|
623
|
+
const hueColor = rgbaToCss(hsvToRgba({ h: hsv.h, s: 100, v: 100, a: 1 }));
|
|
624
|
+
|
|
625
|
+
const updateFromPointer = (event: PointerEvent<HTMLDivElement>) => {
|
|
626
|
+
const rect = fieldRef.current?.getBoundingClientRect();
|
|
627
|
+
if (!rect) return;
|
|
628
|
+
onChange({
|
|
629
|
+
...hsv,
|
|
630
|
+
s: clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100),
|
|
631
|
+
v: clamp(100 - ((event.clientY - rect.top) / rect.height) * 100, 0, 100),
|
|
632
|
+
});
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const stepWithKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
636
|
+
const step = event.shiftKey ? 10 : 1;
|
|
637
|
+
if (event.key === "ArrowRight") {
|
|
638
|
+
event.preventDefault();
|
|
639
|
+
onChange({ ...hsv, s: clamp(hsv.s + step, 0, 100) });
|
|
640
|
+
}
|
|
641
|
+
if (event.key === "ArrowLeft") {
|
|
642
|
+
event.preventDefault();
|
|
643
|
+
onChange({ ...hsv, s: clamp(hsv.s - step, 0, 100) });
|
|
644
|
+
}
|
|
645
|
+
if (event.key === "ArrowUp") {
|
|
646
|
+
event.preventDefault();
|
|
647
|
+
onChange({ ...hsv, v: clamp(hsv.v + step, 0, 100) });
|
|
648
|
+
}
|
|
649
|
+
if (event.key === "ArrowDown") {
|
|
650
|
+
event.preventDefault();
|
|
651
|
+
onChange({ ...hsv, v: clamp(hsv.v - step, 0, 100) });
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
return (
|
|
656
|
+
<div
|
|
657
|
+
ref={fieldRef}
|
|
658
|
+
tabIndex={0}
|
|
659
|
+
aria-label={label}
|
|
660
|
+
onPointerDown={(event) => {
|
|
661
|
+
draggingRef.current = true;
|
|
662
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
663
|
+
updateFromPointer(event);
|
|
664
|
+
}}
|
|
665
|
+
onPointerMove={(event) => {
|
|
666
|
+
if (!draggingRef.current) return;
|
|
667
|
+
updateFromPointer(event);
|
|
668
|
+
}}
|
|
669
|
+
onPointerUp={(event) => {
|
|
670
|
+
draggingRef.current = false;
|
|
671
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
672
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
673
|
+
}
|
|
674
|
+
}}
|
|
675
|
+
onPointerCancel={() => {
|
|
676
|
+
draggingRef.current = false;
|
|
677
|
+
}}
|
|
678
|
+
onKeyDown={stepWithKeyboard}
|
|
679
|
+
className="relative h-40 w-full cursor-crosshair overflow-hidden rounded-t-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset active:cursor-grabbing"
|
|
680
|
+
style={{
|
|
681
|
+
backgroundImage: `linear-gradient(to top, #000 0%, transparent 100%), linear-gradient(to right, #fff 0%, ${hueColor} 100%)`,
|
|
682
|
+
}}
|
|
683
|
+
>
|
|
684
|
+
<span
|
|
685
|
+
className="pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_hsl(var(--foreground)/0.6)]"
|
|
686
|
+
style={{
|
|
687
|
+
left: `${hsv.s}%`,
|
|
688
|
+
top: `${100 - hsv.v}%`,
|
|
689
|
+
}}
|
|
690
|
+
/>
|
|
691
|
+
</div>
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function VisualColorTrack({
|
|
696
|
+
label,
|
|
697
|
+
value,
|
|
698
|
+
min,
|
|
699
|
+
max,
|
|
700
|
+
backgroundImage,
|
|
701
|
+
backgroundSize,
|
|
702
|
+
backgroundPosition,
|
|
703
|
+
onChange,
|
|
704
|
+
}: {
|
|
705
|
+
label: string;
|
|
706
|
+
value: number;
|
|
707
|
+
min: number;
|
|
708
|
+
max: number;
|
|
709
|
+
backgroundImage: string;
|
|
710
|
+
backgroundSize?: string;
|
|
711
|
+
backgroundPosition?: string;
|
|
712
|
+
onChange: (value: number) => void;
|
|
713
|
+
}) {
|
|
714
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
715
|
+
const draggingRef = useRef(false);
|
|
716
|
+
const percent = ((value - min) / (max - min)) * 100;
|
|
717
|
+
|
|
718
|
+
const updateFromPointer = (event: PointerEvent<HTMLDivElement>) => {
|
|
719
|
+
const rect = trackRef.current?.getBoundingClientRect();
|
|
720
|
+
if (!rect) return;
|
|
721
|
+
const next = min + ((event.clientX - rect.left) / rect.width) * (max - min);
|
|
722
|
+
onChange(clamp(next, min, max));
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const stepWithKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
726
|
+
const step = event.shiftKey ? 10 : 1;
|
|
727
|
+
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
|
|
728
|
+
event.preventDefault();
|
|
729
|
+
onChange(clamp(value + step, min, max));
|
|
730
|
+
}
|
|
731
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
|
|
732
|
+
event.preventDefault();
|
|
733
|
+
onChange(clamp(value - step, min, max));
|
|
734
|
+
}
|
|
735
|
+
if (event.key === "Home") {
|
|
736
|
+
event.preventDefault();
|
|
737
|
+
onChange(min);
|
|
738
|
+
}
|
|
739
|
+
if (event.key === "End") {
|
|
740
|
+
event.preventDefault();
|
|
741
|
+
onChange(max);
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
return (
|
|
746
|
+
<div
|
|
747
|
+
ref={trackRef}
|
|
748
|
+
role="slider"
|
|
749
|
+
tabIndex={0}
|
|
750
|
+
aria-label={label}
|
|
751
|
+
aria-valuemin={min}
|
|
752
|
+
aria-valuemax={max}
|
|
753
|
+
aria-valuenow={Math.round(value)}
|
|
754
|
+
onKeyDown={stepWithKeyboard}
|
|
755
|
+
onPointerDown={(event) => {
|
|
756
|
+
draggingRef.current = true;
|
|
757
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
758
|
+
updateFromPointer(event);
|
|
759
|
+
}}
|
|
760
|
+
onPointerMove={(event) => {
|
|
761
|
+
if (!draggingRef.current) return;
|
|
762
|
+
updateFromPointer(event);
|
|
763
|
+
}}
|
|
764
|
+
onPointerUp={(event) => {
|
|
765
|
+
draggingRef.current = false;
|
|
766
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
767
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
768
|
+
}
|
|
769
|
+
}}
|
|
770
|
+
onPointerCancel={() => {
|
|
771
|
+
draggingRef.current = false;
|
|
772
|
+
}}
|
|
773
|
+
className="relative h-3.5 cursor-pointer rounded-full border border-border/60 outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:cursor-grabbing"
|
|
774
|
+
style={{ backgroundImage, backgroundSize, backgroundPosition }}
|
|
775
|
+
>
|
|
776
|
+
<span
|
|
777
|
+
className="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_hsl(var(--foreground)/0.6)]"
|
|
778
|
+
style={{ left: `${clamp(percent, 0, 100)}%` }}
|
|
779
|
+
/>
|
|
780
|
+
</div>
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export function VisualSegmentedControl({
|
|
785
|
+
options,
|
|
786
|
+
value,
|
|
787
|
+
onChange,
|
|
788
|
+
className,
|
|
789
|
+
}: {
|
|
790
|
+
options: VisualControlOption[];
|
|
791
|
+
value: string;
|
|
792
|
+
onChange: (value: string) => void;
|
|
793
|
+
className?: string;
|
|
794
|
+
}) {
|
|
795
|
+
return (
|
|
796
|
+
<div
|
|
797
|
+
className={cn(
|
|
798
|
+
"flex min-h-7 overflow-hidden rounded-md border border-border bg-background/60",
|
|
799
|
+
className,
|
|
800
|
+
)}
|
|
801
|
+
>
|
|
802
|
+
{options.map((option) => (
|
|
803
|
+
<button
|
|
804
|
+
key={option.value}
|
|
805
|
+
type="button"
|
|
806
|
+
onClick={() => onChange(option.value)}
|
|
807
|
+
className={cn(
|
|
808
|
+
"min-w-0 flex-1 cursor-pointer px-2 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/70 hover:text-foreground",
|
|
809
|
+
value === option.value && "bg-accent text-foreground",
|
|
810
|
+
)}
|
|
811
|
+
>
|
|
812
|
+
<span className="truncate">{option.label}</span>
|
|
813
|
+
</button>
|
|
814
|
+
))}
|
|
815
|
+
</div>
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export function VisualToggleControl({
|
|
820
|
+
checked,
|
|
821
|
+
onChange,
|
|
822
|
+
label,
|
|
823
|
+
}: {
|
|
824
|
+
checked: boolean;
|
|
825
|
+
onChange: (checked: boolean) => void;
|
|
826
|
+
label?: string;
|
|
827
|
+
}) {
|
|
828
|
+
return (
|
|
829
|
+
<button
|
|
830
|
+
type="button"
|
|
831
|
+
aria-pressed={checked}
|
|
832
|
+
aria-label={label}
|
|
833
|
+
onClick={() => onChange(!checked)}
|
|
834
|
+
className={cn(
|
|
835
|
+
"relative h-5 w-9 cursor-pointer rounded-full border border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
836
|
+
checked ? "bg-primary/35" : "bg-muted",
|
|
837
|
+
)}
|
|
838
|
+
>
|
|
839
|
+
<span
|
|
840
|
+
className={cn(
|
|
841
|
+
"absolute top-1/2 size-3.5 -translate-y-1/2 rounded-full bg-foreground shadow transition-transform",
|
|
842
|
+
checked ? "translate-x-[18px]" : "translate-x-[3px]",
|
|
843
|
+
)}
|
|
844
|
+
/>
|
|
845
|
+
</button>
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
export function VisualSliderControl({
|
|
850
|
+
value,
|
|
851
|
+
onChange,
|
|
852
|
+
min = 0,
|
|
853
|
+
max = 100,
|
|
854
|
+
step = 1,
|
|
855
|
+
unit,
|
|
856
|
+
}: {
|
|
857
|
+
value: number;
|
|
858
|
+
onChange: (value: number) => void;
|
|
859
|
+
min?: number;
|
|
860
|
+
max?: number;
|
|
861
|
+
step?: number;
|
|
862
|
+
unit?: string;
|
|
863
|
+
}) {
|
|
864
|
+
const safeValue = clampNumber(Number.isFinite(value) ? value : min, min, max);
|
|
865
|
+
return (
|
|
866
|
+
<div className="flex h-7 items-center gap-2">
|
|
867
|
+
<input
|
|
868
|
+
type="range"
|
|
869
|
+
min={min}
|
|
870
|
+
max={max}
|
|
871
|
+
step={step}
|
|
872
|
+
value={safeValue}
|
|
873
|
+
onChange={(event) => onChange(Number(event.currentTarget.value))}
|
|
874
|
+
className="min-w-0 flex-1 cursor-pointer accent-foreground"
|
|
875
|
+
/>
|
|
876
|
+
<span className="w-9 text-right text-[11px] tabular-nums text-muted-foreground">
|
|
877
|
+
{formatNumber(safeValue, unit)}
|
|
878
|
+
</span>
|
|
879
|
+
</div>
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export function VisualScrubInput({
|
|
884
|
+
label,
|
|
885
|
+
value,
|
|
886
|
+
onChange,
|
|
887
|
+
min,
|
|
888
|
+
max,
|
|
889
|
+
step = 1,
|
|
890
|
+
unit,
|
|
891
|
+
disabled = false,
|
|
892
|
+
}: {
|
|
893
|
+
label: string;
|
|
894
|
+
value: number;
|
|
895
|
+
onChange: (value: number) => void;
|
|
896
|
+
min?: number;
|
|
897
|
+
max?: number;
|
|
898
|
+
step?: number;
|
|
899
|
+
unit?: string;
|
|
900
|
+
disabled?: boolean;
|
|
901
|
+
}) {
|
|
902
|
+
const id = useId();
|
|
903
|
+
const [draft, setDraft] = useState(() => formatNumber(value, unit));
|
|
904
|
+
const [focused, setFocused] = useState(false);
|
|
905
|
+
const dragRef = useRef<{
|
|
906
|
+
pointerId: number;
|
|
907
|
+
prevX: number;
|
|
908
|
+
dragged: boolean;
|
|
909
|
+
} | null>(null);
|
|
910
|
+
|
|
911
|
+
useEffect(() => {
|
|
912
|
+
if (!focused) setDraft(formatNumber(value, unit));
|
|
913
|
+
}, [focused, unit, value]);
|
|
914
|
+
|
|
915
|
+
const commit = (nextDraft = draft) => {
|
|
916
|
+
const parsed = parseDraftNumber(nextDraft, value);
|
|
917
|
+
const next = clampNumber(parsed, min, max);
|
|
918
|
+
onChange(next);
|
|
919
|
+
setDraft(formatNumber(next, unit));
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
const setNext = (next: number) => {
|
|
923
|
+
const clamped = clampNumber(next, min, max);
|
|
924
|
+
onChange(clamped);
|
|
925
|
+
setDraft(formatNumber(clamped, unit));
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
929
|
+
if (event.key === "Enter") {
|
|
930
|
+
event.preventDefault();
|
|
931
|
+
commit();
|
|
932
|
+
event.currentTarget.blur();
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (event.key === "Escape") {
|
|
936
|
+
event.preventDefault();
|
|
937
|
+
setDraft(formatNumber(value, unit));
|
|
938
|
+
event.currentTarget.blur();
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
942
|
+
event.preventDefault();
|
|
943
|
+
const mult =
|
|
944
|
+
event.shiftKey || event.metaKey ? 10 : event.altKey ? 0.1 : 1;
|
|
945
|
+
const direction = event.key === "ArrowUp" ? 1 : -1;
|
|
946
|
+
const base = parseDraftNumber(draft, value);
|
|
947
|
+
setNext(base + direction * step * mult);
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
const onPointerDown = (event: PointerEvent<HTMLLabelElement>) => {
|
|
952
|
+
if (disabled || event.button !== 0) return;
|
|
953
|
+
event.preventDefault();
|
|
954
|
+
dragRef.current = {
|
|
955
|
+
pointerId: event.pointerId,
|
|
956
|
+
prevX: event.clientX,
|
|
957
|
+
dragged: false,
|
|
958
|
+
};
|
|
959
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
const onPointerMove = (event: PointerEvent<HTMLLabelElement>) => {
|
|
963
|
+
const drag = dragRef.current;
|
|
964
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
965
|
+
const delta = event.clientX - drag.prevX;
|
|
966
|
+
if (delta === 0) return;
|
|
967
|
+
drag.prevX = event.clientX;
|
|
968
|
+
drag.dragged = true;
|
|
969
|
+
const mult = event.shiftKey || event.metaKey ? 10 : event.altKey ? 0.1 : 1;
|
|
970
|
+
setNext(value + delta * step * mult);
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
const onPointerUp = (event: PointerEvent<HTMLLabelElement>) => {
|
|
974
|
+
const drag = dragRef.current;
|
|
975
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
976
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
977
|
+
dragRef.current = null;
|
|
978
|
+
if (!drag.dragged) {
|
|
979
|
+
document.getElementById(id)?.focus();
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
return (
|
|
984
|
+
<div className="flex items-center gap-1.5">
|
|
985
|
+
<label
|
|
986
|
+
htmlFor={id}
|
|
987
|
+
onPointerDown={onPointerDown}
|
|
988
|
+
onPointerMove={onPointerMove}
|
|
989
|
+
onPointerUp={onPointerUp}
|
|
990
|
+
className={cn(
|
|
991
|
+
"flex w-8 shrink-0 cursor-ew-resize select-none items-center justify-center rounded border border-transparent px-1 text-[10px] font-semibold text-muted-foreground hover:border-border hover:bg-accent/60",
|
|
992
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
993
|
+
)}
|
|
994
|
+
>
|
|
995
|
+
{label}
|
|
996
|
+
</label>
|
|
997
|
+
<input
|
|
998
|
+
id={id}
|
|
999
|
+
value={draft}
|
|
1000
|
+
disabled={disabled}
|
|
1001
|
+
onFocus={() => setFocused(true)}
|
|
1002
|
+
onBlur={() => {
|
|
1003
|
+
setFocused(false);
|
|
1004
|
+
commit();
|
|
1005
|
+
}}
|
|
1006
|
+
onChange={(event) => setDraft(event.currentTarget.value)}
|
|
1007
|
+
onKeyDown={onKeyDown}
|
|
1008
|
+
className="h-7 min-w-0 flex-1 rounded-md border border-input bg-background/70 px-2 text-right text-[11px] tabular-nums text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-50"
|
|
1009
|
+
/>
|
|
1010
|
+
</div>
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
export function VisualTweakControl({
|
|
1015
|
+
tweak,
|
|
1016
|
+
value,
|
|
1017
|
+
onChange,
|
|
1018
|
+
className,
|
|
1019
|
+
}: {
|
|
1020
|
+
tweak: VisualTweakDefinition;
|
|
1021
|
+
value: VisualControlValue;
|
|
1022
|
+
onChange: (value: VisualControlValue) => void;
|
|
1023
|
+
className?: string;
|
|
1024
|
+
}) {
|
|
1025
|
+
if (tweak.type === "toggle") {
|
|
1026
|
+
return (
|
|
1027
|
+
<div
|
|
1028
|
+
className={cn("flex h-7 items-center justify-between gap-2", className)}
|
|
1029
|
+
>
|
|
1030
|
+
<span className="truncate text-[11px] text-muted-foreground">
|
|
1031
|
+
{tweak.label}
|
|
1032
|
+
</span>
|
|
1033
|
+
<VisualToggleControl
|
|
1034
|
+
checked={Boolean(value)}
|
|
1035
|
+
onChange={onChange}
|
|
1036
|
+
label={tweak.label}
|
|
1037
|
+
/>
|
|
1038
|
+
</div>
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const numericValue =
|
|
1043
|
+
typeof value === "number"
|
|
1044
|
+
? value
|
|
1045
|
+
: typeof value === "string"
|
|
1046
|
+
? Number(value)
|
|
1047
|
+
: Number(tweak.defaultValue);
|
|
1048
|
+
|
|
1049
|
+
return (
|
|
1050
|
+
<VisualControlRow label={tweak.label} className={className}>
|
|
1051
|
+
{((tweak.type as string) === "color-swatch" ||
|
|
1052
|
+
(tweak.type as string) === "color-swatches") && (
|
|
1053
|
+
<VisualColorPicker
|
|
1054
|
+
label={tweak.label}
|
|
1055
|
+
value={String(value)}
|
|
1056
|
+
documentColors={(tweak.options ?? []).map(
|
|
1057
|
+
(option) => option.color ?? option.value,
|
|
1058
|
+
)}
|
|
1059
|
+
onChange={onChange}
|
|
1060
|
+
/>
|
|
1061
|
+
)}
|
|
1062
|
+
{tweak.type === "segment" && (
|
|
1063
|
+
<VisualSegmentedControl
|
|
1064
|
+
options={tweak.options ?? []}
|
|
1065
|
+
value={String(value)}
|
|
1066
|
+
onChange={onChange}
|
|
1067
|
+
/>
|
|
1068
|
+
)}
|
|
1069
|
+
{tweak.type === "slider" && (
|
|
1070
|
+
<VisualSliderControl
|
|
1071
|
+
min={tweak.min ?? 0}
|
|
1072
|
+
max={tweak.max ?? 100}
|
|
1073
|
+
step={tweak.step ?? 1}
|
|
1074
|
+
unit={
|
|
1075
|
+
tweak.unit ??
|
|
1076
|
+
(tweak.cssVar?.toLowerCase().includes("radius") ? "px" : undefined)
|
|
1077
|
+
}
|
|
1078
|
+
value={Number.isFinite(numericValue) ? numericValue : 0}
|
|
1079
|
+
onChange={onChange}
|
|
1080
|
+
/>
|
|
1081
|
+
)}
|
|
1082
|
+
</VisualControlRow>
|
|
1083
|
+
);
|
|
1084
|
+
}
|