@open-slide/core 0.0.8 → 0.0.10

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 (34) hide show
  1. package/dist/{build-CXY2DSzy.js → build-DHiRlpjn.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-BYTf0qVz.js → config-LZM903FE.js} +742 -44
  4. package/dist/{dev-BxCKugi3.js → dev-B3JzCYn7.js} +1 -1
  5. package/dist/{preview-C1F-rHfx.js → preview-UikovHEt.js} +1 -1
  6. package/dist/vite/index.js +1 -1
  7. package/package.json +3 -1
  8. package/src/app/App.tsx +2 -0
  9. package/src/app/components/AssetView.tsx +846 -0
  10. package/src/app/components/ClickNavZones.tsx +2 -2
  11. package/src/app/components/PdfProgressToast.tsx +23 -0
  12. package/src/app/components/ThumbnailRail.tsx +2 -2
  13. package/src/app/components/inspector/CommentWidget.tsx +1 -1
  14. package/src/app/components/inspector/InspectOverlay.tsx +81 -41
  15. package/src/app/components/inspector/InspectorPanel.tsx +948 -0
  16. package/src/app/components/inspector/InspectorProvider.tsx +229 -13
  17. package/src/app/components/inspector/SaveBar.tsx +77 -0
  18. package/src/app/components/ui/input.tsx +21 -0
  19. package/src/app/components/ui/label.tsx +24 -0
  20. package/src/app/components/ui/progress.tsx +31 -0
  21. package/src/app/components/ui/select.tsx +190 -0
  22. package/src/app/components/ui/slider.tsx +61 -0
  23. package/src/app/components/ui/sonner.tsx +38 -0
  24. package/src/app/components/ui/textarea.tsx +18 -0
  25. package/src/app/components/ui/toggle-group.tsx +83 -0
  26. package/src/app/components/ui/toggle.tsx +45 -0
  27. package/src/app/components/ui/tooltip.tsx +55 -0
  28. package/src/app/lib/assets.ts +166 -0
  29. package/src/app/lib/export-pdf.ts +194 -0
  30. package/src/app/lib/inspector/fiber.ts +40 -5
  31. package/src/app/lib/inspector/useEditor.ts +62 -0
  32. package/src/app/lib/print-ready.ts +58 -0
  33. package/src/app/routes/Slide.tsx +140 -51
  34. package/src/app/components/inspector/CommentPopover.tsx +0 -94
@@ -1,14 +1,37 @@
1
1
  import { Crosshair } from 'lucide-react';
2
- import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react';
2
+ import {
3
+ createContext,
4
+ type ReactNode,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react';
3
12
  import { Button } from '@/components/ui/button';
4
13
  import { type SlideComment, useComments } from '@/lib/inspector/useComments';
14
+ import { type Edit, type EditOp, useEditor } from '@/lib/inspector/useEditor';
5
15
 
6
- export type PendingTarget = {
16
+ export type SelectedTarget = {
7
17
  line: number;
8
18
  column: number;
9
- anchorRect: DOMRect;
10
- clickX: number;
11
- clickY: number;
19
+ anchor: HTMLElement;
20
+ };
21
+
22
+ type AssetAttrOp = { assetPath: string; previewUrl: string };
23
+
24
+ type Bucket = {
25
+ line: number;
26
+ column: number;
27
+ styleOps: Map<string, string | null>;
28
+ textOp: { value: string } | null;
29
+ attrOps: Map<string, AssetAttrOp>;
30
+ // Pre-edit snapshot of the DOM, captured the first time we touch
31
+ // each style key / text / attribute. Used by `cancelEdits` to revert.
32
+ origStyle: Map<string, string>;
33
+ origText: { value: string } | null;
34
+ origAttrs: Map<string, string | null>;
12
35
  };
13
36
 
14
37
  type InspectorCtx = {
@@ -21,8 +44,18 @@ type InspectorCtx = {
21
44
  refetch: () => Promise<void>;
22
45
  add: (line: number, column: number, text: string) => Promise<void>;
23
46
  remove: (id: string) => Promise<void>;
24
- pending: PendingTarget | null;
25
- setPending: (p: PendingTarget | null) => void;
47
+ selected: SelectedTarget | null;
48
+ setSelected: (s: SelectedTarget | null) => void;
49
+ applyEdit: (line: number, column: number, ops: EditOp[]) => Promise<void>;
50
+ applyEdits: (edits: Edit[]) => Promise<void>;
51
+ // Mutate the DOM optimistically, snapshot the pre-edit values, and
52
+ // remember the ops. `commitEdits` (manual Save or auto-flush on
53
+ // close) is what actually writes to disk; `cancelEdits` reverts.
54
+ bufferOps: (line: number, column: number, anchor: HTMLElement, ops: EditOp[]) => void;
55
+ pendingCount: number;
56
+ commitEdits: () => Promise<void>;
57
+ cancelEdits: () => void;
58
+ committing: boolean;
26
59
  };
27
60
 
28
61
  const Ctx = createContext<InspectorCtx | null>(null);
@@ -35,19 +68,177 @@ export function useInspector(): InspectorCtx {
35
68
 
36
69
  export function InspectorProvider({ slideId, children }: { slideId: string; children: ReactNode }) {
37
70
  const [active, setActive] = useState(false);
38
- const [pending, setPending] = useState<PendingTarget | null>(null);
71
+ const [selected, setSelected] = useState<SelectedTarget | null>(null);
39
72
  const { comments, error, refetch, add, remove } = useComments(slideId);
73
+ const { applyEdit, applyEdits } = useEditor(slideId);
74
+
75
+ const pendingRef = useRef<Map<string, Bucket>>(new Map());
76
+ const [pendingCount, setPendingCount] = useState(0);
77
+ const [committing, setCommitting] = useState(false);
78
+
79
+ const refreshCount = useCallback(() => {
80
+ let n = 0;
81
+ for (const b of pendingRef.current.values()) {
82
+ if (b.styleOps.size > 0 || b.textOp !== null || b.attrOps.size > 0) n++;
83
+ }
84
+ setPendingCount(n);
85
+ }, []);
86
+
87
+ const bufferOps = useCallback(
88
+ (line: number, column: number, anchor: HTMLElement, ops: EditOp[]) => {
89
+ const key = `${line}:${column}`;
90
+ let bucket = pendingRef.current.get(key);
91
+ if (!bucket) {
92
+ bucket = {
93
+ line,
94
+ column,
95
+ styleOps: new Map(),
96
+ textOp: null,
97
+ attrOps: new Map(),
98
+ origStyle: new Map(),
99
+ origText: null,
100
+ origAttrs: new Map(),
101
+ };
102
+ pendingRef.current.set(key, bucket);
103
+ }
104
+ const style = anchor.style as unknown as Record<string, string>;
105
+ for (const op of ops) {
106
+ if (op.kind === 'set-style') {
107
+ if (!bucket.origStyle.has(op.key)) {
108
+ bucket.origStyle.set(op.key, style[op.key] ?? '');
109
+ }
110
+ bucket.styleOps.set(op.key, op.value);
111
+ if (anchor.isConnected) style[op.key] = op.value ?? '';
112
+ } else if (op.kind === 'set-text') {
113
+ if (bucket.origText === null) {
114
+ bucket.origText = { value: anchor.textContent ?? '' };
115
+ }
116
+ bucket.textOp = { value: op.value };
117
+ if (anchor.isConnected) anchor.textContent = op.value;
118
+ } else if (op.kind === 'set-attr-asset') {
119
+ if (!bucket.origAttrs.has(op.attr)) {
120
+ bucket.origAttrs.set(
121
+ op.attr,
122
+ anchor.hasAttribute(op.attr) ? anchor.getAttribute(op.attr) : null,
123
+ );
124
+ }
125
+ bucket.attrOps.set(op.attr, { assetPath: op.assetPath, previewUrl: op.previewUrl });
126
+ if (anchor.isConnected) anchor.setAttribute(op.attr, op.previewUrl);
127
+ }
128
+ }
129
+ refreshCount();
130
+ },
131
+ [refreshCount],
132
+ );
133
+
134
+ const commitEdits = useCallback(async () => {
135
+ const buckets = pendingRef.current;
136
+ if (buckets.size === 0) return;
137
+ const edits: Edit[] = [];
138
+ for (const { line, column, styleOps, textOp, attrOps } of buckets.values()) {
139
+ const list: EditOp[] = [];
140
+ for (const [k, v] of styleOps) list.push({ kind: 'set-style', key: k, value: v });
141
+ if (textOp !== null) list.push({ kind: 'set-text', value: textOp.value });
142
+ for (const [attr, op] of attrOps) {
143
+ list.push({
144
+ kind: 'set-attr-asset',
145
+ attr,
146
+ assetPath: op.assetPath,
147
+ previewUrl: op.previewUrl,
148
+ });
149
+ }
150
+ if (list.length > 0) edits.push({ line, column, ops: list });
151
+ }
152
+ pendingRef.current = new Map();
153
+ setPendingCount(0);
154
+ if (edits.length === 0) return;
155
+ setCommitting(true);
156
+ try {
157
+ await applyEdits(edits);
158
+ } finally {
159
+ setCommitting(false);
160
+ }
161
+ }, [applyEdits]);
162
+
163
+ const cancelEdits = useCallback(() => {
164
+ if (pendingRef.current.size === 0) return;
165
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
166
+ for (const b of pendingRef.current.values()) {
167
+ const el = root?.querySelector<HTMLElement>(`[data-slide-loc="${b.line}:${b.column}"]`);
168
+ if (!el) continue;
169
+ const style = el.style as unknown as Record<string, string>;
170
+ for (const [k, v] of b.origStyle) style[k] = v;
171
+ if (b.origText !== null) el.textContent = b.origText.value;
172
+ for (const [attr, value] of b.origAttrs) {
173
+ if (value === null) el.removeAttribute(attr);
174
+ else el.setAttribute(attr, value);
175
+ }
176
+ }
177
+ pendingRef.current = new Map();
178
+ setPendingCount(0);
179
+ }, []);
180
+
181
+ // Auto-flush on inspector close and on route unmount so toggling
182
+ // off or navigating away doesn't drop buffered edits.
183
+ const commitRef = useRef(commitEdits);
184
+ commitRef.current = commitEdits;
185
+ useEffect(() => {
186
+ if (!active) commitRef.current().catch(() => {});
187
+ }, [active]);
188
+ useEffect(() => {
189
+ return () => {
190
+ commitRef.current().catch(() => {});
191
+ };
192
+ }, []);
193
+
194
+ // Re-apply buffered ops onto any `[data-slide-loc]` element that gets
195
+ // (re)mounted in the slide canvas. Without this, navigating to a
196
+ // different page and back drops the optimistic styles, since the
197
+ // page's DOM nodes are torn down on unmount even though the buffer
198
+ // (keyed by source line:col) survives.
199
+ useEffect(() => {
200
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
201
+ if (!root) return;
202
+
203
+ const applyBuffered = (el: HTMLElement) => {
204
+ const loc = el.dataset.slideLoc;
205
+ if (!loc) return;
206
+ const bucket = pendingRef.current.get(loc);
207
+ if (!bucket) return;
208
+ const style = el.style as unknown as Record<string, string>;
209
+ for (const [key, value] of bucket.styleOps) {
210
+ const v = value ?? '';
211
+ if (style[key] !== v) style[key] = v;
212
+ }
213
+ if (bucket.textOp !== null && el.textContent !== bucket.textOp.value) {
214
+ el.textContent = bucket.textOp.value;
215
+ }
216
+ for (const [attr, op] of bucket.attrOps) {
217
+ if (el.getAttribute(attr) !== op.previewUrl) el.setAttribute(attr, op.previewUrl);
218
+ }
219
+ };
220
+
221
+ const replayAll = () => {
222
+ if (pendingRef.current.size === 0) return;
223
+ root.querySelectorAll<HTMLElement>('[data-slide-loc]').forEach(applyBuffered);
224
+ };
225
+
226
+ replayAll();
227
+ const observer = new MutationObserver(replayAll);
228
+ observer.observe(root, { childList: true, subtree: true });
229
+ return () => observer.disconnect();
230
+ }, []);
40
231
 
41
232
  const toggle = useCallback(() => {
42
233
  setActive((a) => {
43
- if (a) setPending(null);
234
+ if (a) setSelected(null);
44
235
  return !a;
45
236
  });
46
237
  }, []);
47
238
 
48
239
  const cancel = useCallback(() => {
49
240
  setActive(false);
50
- setPending(null);
241
+ setSelected(null);
51
242
  }, []);
52
243
 
53
244
  const value = useMemo<InspectorCtx>(
@@ -61,10 +252,35 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
61
252
  refetch,
62
253
  add,
63
254
  remove,
64
- pending,
65
- setPending,
255
+ selected,
256
+ setSelected,
257
+ applyEdit,
258
+ applyEdits,
259
+ bufferOps,
260
+ pendingCount,
261
+ commitEdits,
262
+ cancelEdits,
263
+ committing,
66
264
  }),
67
- [slideId, active, toggle, cancel, comments, error, refetch, add, remove, pending],
265
+ [
266
+ slideId,
267
+ active,
268
+ toggle,
269
+ cancel,
270
+ comments,
271
+ error,
272
+ refetch,
273
+ add,
274
+ remove,
275
+ selected,
276
+ applyEdit,
277
+ applyEdits,
278
+ bufferOps,
279
+ pendingCount,
280
+ commitEdits,
281
+ cancelEdits,
282
+ committing,
283
+ ],
68
284
  );
69
285
 
70
286
  return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
@@ -0,0 +1,77 @@
1
+ import { Check, Loader2, Save, Undo2 } from 'lucide-react';
2
+ import { useEffect, useState } from 'react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { useInspector } from './InspectorProvider';
5
+
6
+ // Optimistic DOM updates make the canvas *look* saved, so without
7
+ // this affordance a user could close the tab thinking their tweaks
8
+ // hit disk when they're still buffered in memory.
9
+ export function SaveBar() {
10
+ const { pendingCount, commitEdits, cancelEdits, committing } = useInspector();
11
+ const [justSaved, setJustSaved] = useState(false);
12
+
13
+ // Brief "Saved" hold so the bar's disappearance feels intentional.
14
+ useEffect(() => {
15
+ if (!justSaved) return;
16
+ const t = setTimeout(() => setJustSaved(false), 1200);
17
+ return () => clearTimeout(t);
18
+ }, [justSaved]);
19
+
20
+ const visible = pendingCount > 0 || committing || justSaved;
21
+ if (!visible) return null;
22
+
23
+ const onSave = async () => {
24
+ await commitEdits();
25
+ setJustSaved(true);
26
+ };
27
+
28
+ return (
29
+ <div
30
+ data-inspector-ui
31
+ className="pointer-events-none absolute bottom-6 left-1/2 z-30 -translate-x-1/2 animate-in fade-in slide-in-from-bottom-2 duration-200"
32
+ >
33
+ <div className="pointer-events-auto flex items-center gap-2 rounded-full border bg-card/95 py-1 pr-1 pl-3 shadow-lg backdrop-blur">
34
+ {justSaved ? (
35
+ <span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
36
+ <Check className="size-3.5 text-emerald-600" />
37
+ Saved
38
+ </span>
39
+ ) : (
40
+ <span className="text-xs font-medium text-foreground">
41
+ {pendingCount} unsaved {pendingCount === 1 ? 'change' : 'changes'}
42
+ </span>
43
+ )}
44
+ {!justSaved && (
45
+ <Button
46
+ size="sm"
47
+ variant="ghost"
48
+ className="h-7 rounded-full px-2.5 text-[11px] text-muted-foreground hover:text-foreground"
49
+ onClick={cancelEdits}
50
+ disabled={committing || pendingCount === 0}
51
+ >
52
+ <Undo2 className="size-3.5" />
53
+ Discard
54
+ </Button>
55
+ )}
56
+ <Button
57
+ size="sm"
58
+ className="h-7 rounded-full px-3 text-[11px]"
59
+ onClick={onSave}
60
+ disabled={committing || pendingCount === 0}
61
+ >
62
+ {committing ? (
63
+ <>
64
+ <Loader2 className="size-3.5 animate-spin" />
65
+ Saving
66
+ </>
67
+ ) : (
68
+ <>
69
+ <Save className="size-3.5" />
70
+ Save
71
+ </>
72
+ )}
73
+ </Button>
74
+ </div>
75
+ </div>
76
+ );
77
+ }
@@ -0,0 +1,21 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6
+ return (
7
+ <input
8
+ type={type}
9
+ data-slot="input"
10
+ className={cn(
11
+ "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
12
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
13
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
14
+ className
15
+ )}
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ export { Input }
@@ -0,0 +1,24 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Label as LabelPrimitive } from "radix-ui"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function Label({
9
+ className,
10
+ ...props
11
+ }: React.ComponentProps<typeof LabelPrimitive.Root>) {
12
+ return (
13
+ <LabelPrimitive.Root
14
+ data-slot="label"
15
+ className={cn(
16
+ "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
17
+ className
18
+ )}
19
+ {...props}
20
+ />
21
+ )
22
+ }
23
+
24
+ export { Label }
@@ -0,0 +1,31 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Progress as ProgressPrimitive } from "radix-ui"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function Progress({
9
+ className,
10
+ value,
11
+ ...props
12
+ }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
13
+ return (
14
+ <ProgressPrimitive.Root
15
+ data-slot="progress"
16
+ className={cn(
17
+ "relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
18
+ className
19
+ )}
20
+ {...props}
21
+ >
22
+ <ProgressPrimitive.Indicator
23
+ data-slot="progress-indicator"
24
+ className="h-full w-full flex-1 bg-primary transition-all"
25
+ style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
26
+ />
27
+ </ProgressPrimitive.Root>
28
+ )
29
+ }
30
+
31
+ export { Progress }
@@ -0,0 +1,190 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
5
+ import { Select as SelectPrimitive } from "radix-ui"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function Select({
10
+ ...props
11
+ }: React.ComponentProps<typeof SelectPrimitive.Root>) {
12
+ return <SelectPrimitive.Root data-slot="select" {...props} />
13
+ }
14
+
15
+ function SelectGroup({
16
+ ...props
17
+ }: React.ComponentProps<typeof SelectPrimitive.Group>) {
18
+ return <SelectPrimitive.Group data-slot="select-group" {...props} />
19
+ }
20
+
21
+ function SelectValue({
22
+ ...props
23
+ }: React.ComponentProps<typeof SelectPrimitive.Value>) {
24
+ return <SelectPrimitive.Value data-slot="select-value" {...props} />
25
+ }
26
+
27
+ function SelectTrigger({
28
+ className,
29
+ size = "default",
30
+ children,
31
+ ...props
32
+ }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
33
+ size?: "sm" | "default"
34
+ }) {
35
+ return (
36
+ <SelectPrimitive.Trigger
37
+ data-slot="select-trigger"
38
+ data-size={size}
39
+ className={cn(
40
+ "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
41
+ className
42
+ )}
43
+ {...props}
44
+ >
45
+ {children}
46
+ <SelectPrimitive.Icon asChild>
47
+ <ChevronDownIcon className="size-4 opacity-50" />
48
+ </SelectPrimitive.Icon>
49
+ </SelectPrimitive.Trigger>
50
+ )
51
+ }
52
+
53
+ function SelectContent({
54
+ className,
55
+ children,
56
+ position = "item-aligned",
57
+ align = "center",
58
+ ...props
59
+ }: React.ComponentProps<typeof SelectPrimitive.Content>) {
60
+ return (
61
+ <SelectPrimitive.Portal>
62
+ <SelectPrimitive.Content
63
+ data-slot="select-content"
64
+ className={cn(
65
+ "relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
66
+ position === "popper" &&
67
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
68
+ className
69
+ )}
70
+ position={position}
71
+ align={align}
72
+ {...props}
73
+ >
74
+ <SelectScrollUpButton />
75
+ <SelectPrimitive.Viewport
76
+ className={cn(
77
+ "p-1",
78
+ position === "popper" &&
79
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
80
+ )}
81
+ >
82
+ {children}
83
+ </SelectPrimitive.Viewport>
84
+ <SelectScrollDownButton />
85
+ </SelectPrimitive.Content>
86
+ </SelectPrimitive.Portal>
87
+ )
88
+ }
89
+
90
+ function SelectLabel({
91
+ className,
92
+ ...props
93
+ }: React.ComponentProps<typeof SelectPrimitive.Label>) {
94
+ return (
95
+ <SelectPrimitive.Label
96
+ data-slot="select-label"
97
+ className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
98
+ {...props}
99
+ />
100
+ )
101
+ }
102
+
103
+ function SelectItem({
104
+ className,
105
+ children,
106
+ ...props
107
+ }: React.ComponentProps<typeof SelectPrimitive.Item>) {
108
+ return (
109
+ <SelectPrimitive.Item
110
+ data-slot="select-item"
111
+ className={cn(
112
+ "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
113
+ className
114
+ )}
115
+ {...props}
116
+ >
117
+ <span
118
+ data-slot="select-item-indicator"
119
+ className="absolute right-2 flex size-3.5 items-center justify-center"
120
+ >
121
+ <SelectPrimitive.ItemIndicator>
122
+ <CheckIcon className="size-4" />
123
+ </SelectPrimitive.ItemIndicator>
124
+ </span>
125
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
126
+ </SelectPrimitive.Item>
127
+ )
128
+ }
129
+
130
+ function SelectSeparator({
131
+ className,
132
+ ...props
133
+ }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
134
+ return (
135
+ <SelectPrimitive.Separator
136
+ data-slot="select-separator"
137
+ className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
138
+ {...props}
139
+ />
140
+ )
141
+ }
142
+
143
+ function SelectScrollUpButton({
144
+ className,
145
+ ...props
146
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
147
+ return (
148
+ <SelectPrimitive.ScrollUpButton
149
+ data-slot="select-scroll-up-button"
150
+ className={cn(
151
+ "flex cursor-default items-center justify-center py-1",
152
+ className
153
+ )}
154
+ {...props}
155
+ >
156
+ <ChevronUpIcon className="size-4" />
157
+ </SelectPrimitive.ScrollUpButton>
158
+ )
159
+ }
160
+
161
+ function SelectScrollDownButton({
162
+ className,
163
+ ...props
164
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
165
+ return (
166
+ <SelectPrimitive.ScrollDownButton
167
+ data-slot="select-scroll-down-button"
168
+ className={cn(
169
+ "flex cursor-default items-center justify-center py-1",
170
+ className
171
+ )}
172
+ {...props}
173
+ >
174
+ <ChevronDownIcon className="size-4" />
175
+ </SelectPrimitive.ScrollDownButton>
176
+ )
177
+ }
178
+
179
+ export {
180
+ Select,
181
+ SelectContent,
182
+ SelectGroup,
183
+ SelectItem,
184
+ SelectLabel,
185
+ SelectScrollDownButton,
186
+ SelectScrollUpButton,
187
+ SelectSeparator,
188
+ SelectTrigger,
189
+ SelectValue,
190
+ }
@@ -0,0 +1,61 @@
1
+ import * as React from "react"
2
+ import { Slider as SliderPrimitive } from "radix-ui"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ function Slider({
7
+ className,
8
+ defaultValue,
9
+ value,
10
+ min = 0,
11
+ max = 100,
12
+ ...props
13
+ }: React.ComponentProps<typeof SliderPrimitive.Root>) {
14
+ const _values = React.useMemo(
15
+ () =>
16
+ Array.isArray(value)
17
+ ? value
18
+ : Array.isArray(defaultValue)
19
+ ? defaultValue
20
+ : [min, max],
21
+ [value, defaultValue, min, max]
22
+ )
23
+
24
+ return (
25
+ <SliderPrimitive.Root
26
+ data-slot="slider"
27
+ defaultValue={defaultValue}
28
+ value={value}
29
+ min={min}
30
+ max={max}
31
+ className={cn(
32
+ "relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
33
+ className
34
+ )}
35
+ {...props}
36
+ >
37
+ <SliderPrimitive.Track
38
+ data-slot="slider-track"
39
+ className={cn(
40
+ "relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
41
+ )}
42
+ >
43
+ <SliderPrimitive.Range
44
+ data-slot="slider-range"
45
+ className={cn(
46
+ "absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
47
+ )}
48
+ />
49
+ </SliderPrimitive.Track>
50
+ {Array.from({ length: _values.length }, (_, index) => (
51
+ <SliderPrimitive.Thumb
52
+ data-slot="slider-thumb"
53
+ key={index}
54
+ className="block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
55
+ />
56
+ ))}
57
+ </SliderPrimitive.Root>
58
+ )
59
+ }
60
+
61
+ export { Slider }