@gentleduck/registry-ui 0.2.1
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/CHANGELOG.md +62 -0
- package/index.css +3 -0
- package/package.json +59 -0
- package/src/_old/_table/index.ts +5 -0
- package/src/_old/_table/table-advanced.constants.tsx +24 -0
- package/src/_old/_table/table-advanced.tsx +311 -0
- package/src/_old/_table/table-advanced.types.ts +272 -0
- package/src/_old/_table/table.constants.ts +2 -0
- package/src/_old/_table/table.hook.tsx +115 -0
- package/src/_old/_table/table.lib.ts +85 -0
- package/src/_old/_table/table.tsx +916 -0
- package/src/_old/_table/table.types.ts +118 -0
- package/src/_old/_table/todo.md +11 -0
- package/src/_old/_upload/index.ts +9 -0
- package/src/_old/_upload/todo.md +38 -0
- package/src/_old/_upload/upload-advanced-chunks.tsx +1624 -0
- package/src/_old/_upload/upload-advanced.tsx +507 -0
- package/src/_old/_upload/upload-sonner.tsx +58 -0
- package/src/_old/_upload/upload.assets.tsx +239 -0
- package/src/_old/_upload/upload.constants.tsx +75 -0
- package/src/_old/_upload/upload.dto.ts +19 -0
- package/src/_old/_upload/upload.lib.tsx +630 -0
- package/src/_old/_upload/upload.tsx +491 -0
- package/src/_old/_upload/upload.types.ts +436 -0
- package/src/accordion/accordion.tsx +247 -0
- package/src/accordion/index.ts +1 -0
- package/src/alert/alert.constants.ts +17 -0
- package/src/alert/alert.tsx +52 -0
- package/src/alert/index.ts +2 -0
- package/src/alert-dialog/alert-dialog.tsx +107 -0
- package/src/alert-dialog/index.ts +1 -0
- package/src/aspect-ratio/aspect-ratio.tsx +33 -0
- package/src/aspect-ratio/index.ts +1 -0
- package/src/audio/audio-record.tsx +776 -0
- package/src/audio/audio-visualizer.tsx +377 -0
- package/src/audio/audio.libs.ts +5 -0
- package/src/audio/audio.types.ts +50 -0
- package/src/audio/index.ts +2 -0
- package/src/avatar/avatar.tsx +78 -0
- package/src/avatar/index.ts +1 -0
- package/src/badge/badge.constants.ts +38 -0
- package/src/badge/badge.tsx +19 -0
- package/src/badge/index.ts +2 -0
- package/src/breadcrumb/breadcrumb.tsx +119 -0
- package/src/breadcrumb/index.ts +1 -0
- package/src/button/button.constants.ts +44 -0
- package/src/button/button.tsx +79 -0
- package/src/button/button.types.ts +38 -0
- package/src/button/index.ts +3 -0
- package/src/button-group/button-group.constants.ts +26 -0
- package/src/button-group/button-group.tsx +65 -0
- package/src/button-group/index.ts +2 -0
- package/src/calendar/calendar.tsx +191 -0
- package/src/calendar/index.ts +1 -0
- package/src/card/card.tsx +81 -0
- package/src/card/index.ts +1 -0
- package/src/carousel/carousel.tsx +211 -0
- package/src/carousel/carousel.types.ts +23 -0
- package/src/carousel/index.ts +2 -0
- package/src/chart/chart.libs.ts +27 -0
- package/src/chart/chart.tsx +260 -0
- package/src/chart/chart.types.ts +38 -0
- package/src/chart/index.ts +3 -0
- package/src/checkbox/checkbox.tsx +144 -0
- package/src/checkbox/checkbox.types.ts +24 -0
- package/src/checkbox/index.ts +2 -0
- package/src/collapsible/collapsible.tsx +151 -0
- package/src/collapsible/index.ts +1 -0
- package/src/combobox/combobox.tsx +132 -0
- package/src/combobox/index.ts +1 -0
- package/src/command/command.tsx +192 -0
- package/src/command/command.types.ts +11 -0
- package/src/command/index.ts +2 -0
- package/src/context-menu/context-menu.tsx +178 -0
- package/src/context-menu/index.ts +1 -0
- package/src/dialog/dialog-responsive.tsx +137 -0
- package/src/dialog/dialog.tsx +97 -0
- package/src/dialog/index.ts +2 -0
- package/src/direction/direction.tsx +13 -0
- package/src/direction/index.ts +1 -0
- package/src/drawer/drawer.tsx +185 -0
- package/src/drawer/index.ts +1 -0
- package/src/dropdown-menu/dropdown-menu.tsx +181 -0
- package/src/dropdown-menu/index.ts +1 -0
- package/src/empty/empty.constants.ts +15 -0
- package/src/empty/empty.tsx +73 -0
- package/src/empty/index.ts +2 -0
- package/src/field/field.constants.ts +22 -0
- package/src/field/field.tsx +203 -0
- package/src/field/index.ts +2 -0
- package/src/hover-card/hover-card.tsx +79 -0
- package/src/hover-card/index.ts +1 -0
- package/src/input/index.ts +1 -0
- package/src/input/input.tsx +45 -0
- package/src/input-group/index.ts +1 -0
- package/src/input-group/input-group.tsx +170 -0
- package/src/input-otp/index.ts +1 -0
- package/src/input-otp/input-otp.tsx +66 -0
- package/src/item/index.ts +2 -0
- package/src/item/item.constants.ts +22 -0
- package/src/item/item.tsx +185 -0
- package/src/json-editor/index.ts +4 -0
- package/src/json-editor/json-editor.hooks.ts +21 -0
- package/src/json-editor/json-editor.libs.ts +34 -0
- package/src/json-editor/json-editor.tsx +425 -0
- package/src/json-editor/json-editor.types.ts +80 -0
- package/src/json-editor/json-editor.view.tsx +110 -0
- package/src/json-editor/json-text-area.tsx +7 -0
- package/src/kbd/index.ts +1 -0
- package/src/kbd/kbd.tsx +39 -0
- package/src/label/index.ts +1 -0
- package/src/label/label.tsx +28 -0
- package/src/menubar/index.ts +1 -0
- package/src/menubar/menubar.tsx +213 -0
- package/src/navigation-menu/index.ts +1 -0
- package/src/navigation-menu/navigation-menu.tsx +152 -0
- package/src/pagination/index.ts +2 -0
- package/src/pagination/pagination.tsx +191 -0
- package/src/pagination/pagination.types.ts +17 -0
- package/src/popover/index.ts +1 -0
- package/src/popover/popover.tsx +35 -0
- package/src/preview-panel/index.ts +3 -0
- package/src/preview-panel/preview-panel-dialog.tsx +99 -0
- package/src/preview-panel/preview-panel.tsx +389 -0
- package/src/preview-panel/preview-panel.types.ts +49 -0
- package/src/progress/index.ts +1 -0
- package/src/progress/progress.tsx +32 -0
- package/src/radio-group/index.ts +1 -0
- package/src/radio-group/radio-group.tsx +92 -0
- package/src/resizable/index.ts +1 -0
- package/src/resizable/resizable.tsx +52 -0
- package/src/scroll-area/index.ts +1 -0
- package/src/scroll-area/scroll-area.tsx +30 -0
- package/src/select/index.ts +1 -0
- package/src/select/select.tsx +138 -0
- package/src/separator/index.ts +1 -0
- package/src/separator/separator.tsx +28 -0
- package/src/sheet/index.ts +2 -0
- package/src/sheet/sheet.constants.tsx +20 -0
- package/src/sheet/sheet.tsx +92 -0
- package/src/sidebar/index.ts +4 -0
- package/src/sidebar/sidebar.constants.ts +30 -0
- package/src/sidebar/sidebar.hooks.ts +13 -0
- package/src/sidebar/sidebar.tsx +676 -0
- package/src/sidebar/sidebar.types.ts +28 -0
- package/src/skeleton/index.ts +1 -0
- package/src/skeleton/skeleton.tsx +22 -0
- package/src/slider/index.ts +1 -0
- package/src/slider/slider.tsx +57 -0
- package/src/sonner/index.ts +4 -0
- package/src/sonner/sonner.chunks.tsx +80 -0
- package/src/sonner/sonner.libs.ts +13 -0
- package/src/sonner/sonner.tsx +31 -0
- package/src/sonner/sonner.types.ts +9 -0
- package/src/switch/index.ts +1 -0
- package/src/switch/switch.tsx +63 -0
- package/src/table/index.ts +1 -0
- package/src/table/table.tsx +95 -0
- package/src/tabs/index.ts +1 -0
- package/src/tabs/tabs.tsx +151 -0
- package/src/textarea/index.ts +1 -0
- package/src/textarea/textarea.tsx +24 -0
- package/src/toggle/index.ts +2 -0
- package/src/toggle/toggle.constants.ts +22 -0
- package/src/toggle/toggle.tsx +24 -0
- package/src/toggle-group/index.ts +1 -0
- package/src/toggle-group/toggle-group.tsx +69 -0
- package/src/tooltip/index.ts +1 -0
- package/src/tooltip/tooltip.tsx +32 -0
- package/src/upload/index.ts +1 -0
- package/src/upload/upload.constants.tsx +19 -0
- package/src/upload/upload.libs.ts +97 -0
- package/src/upload/upload.tsx +340 -0
- package/src/upload/upload.types.ts +44 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '@gentleduck/libs/cn'
|
|
4
|
+
import { Maximize2 } from 'lucide-react'
|
|
5
|
+
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
6
|
+
import { Button } from '../button'
|
|
7
|
+
import { Dialog, DialogContent, DialogTrigger } from '../dialog'
|
|
8
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../tooltip'
|
|
9
|
+
import { PreviewPanel } from './preview-panel'
|
|
10
|
+
import type { PreviewPanelDialogProps, PreviewPanelState } from './preview-panel.types'
|
|
11
|
+
|
|
12
|
+
function PreviewPanelDialog({
|
|
13
|
+
children,
|
|
14
|
+
html,
|
|
15
|
+
className,
|
|
16
|
+
panelClassName,
|
|
17
|
+
maxHeight,
|
|
18
|
+
minZoom = 0.25,
|
|
19
|
+
maxZoom = 4,
|
|
20
|
+
initialZoom = 1,
|
|
21
|
+
showControls = true,
|
|
22
|
+
syncPanels = true,
|
|
23
|
+
fullscreenText = 'Open fullscreen',
|
|
24
|
+
}: PreviewPanelDialogProps & { fullscreenText?: string }) {
|
|
25
|
+
const [sharedState, setSharedState] = useState<PreviewPanelState | undefined>(undefined)
|
|
26
|
+
|
|
27
|
+
// Ref tracks whether a state update is already scheduled this frame.
|
|
28
|
+
// Prevents multiple setState calls per animation frame when both
|
|
29
|
+
// panels emit state changes simultaneously.
|
|
30
|
+
const pendingRef = useRef(false)
|
|
31
|
+
|
|
32
|
+
const handleStateChange = useCallback(
|
|
33
|
+
(state: PreviewPanelState) => {
|
|
34
|
+
if (!syncPanels) return
|
|
35
|
+
if (pendingRef.current) return
|
|
36
|
+
pendingRef.current = true
|
|
37
|
+
requestAnimationFrame(() => {
|
|
38
|
+
pendingRef.current = false
|
|
39
|
+
setSharedState(state)
|
|
40
|
+
})
|
|
41
|
+
},
|
|
42
|
+
[syncPanels],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const contentProps = useMemo(() => (html ? { html } : { children }), [html, children])
|
|
46
|
+
|
|
47
|
+
const dialogPanelClassName = useMemo(() => cn('min-h-[70vh]', panelClassName), [panelClassName])
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className={cn('group relative', className)}>
|
|
51
|
+
<div className="relative overflow-hidden rounded-lg border bg-card">
|
|
52
|
+
<PreviewPanel
|
|
53
|
+
{...contentProps}
|
|
54
|
+
maxHeight={maxHeight}
|
|
55
|
+
minZoom={minZoom}
|
|
56
|
+
maxZoom={maxZoom}
|
|
57
|
+
initialZoom={initialZoom}
|
|
58
|
+
showControls={showControls}
|
|
59
|
+
className={panelClassName}
|
|
60
|
+
onStateChange={handleStateChange}
|
|
61
|
+
syncState={syncPanels ? sharedState : undefined}
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
<TooltipProvider>
|
|
65
|
+
<Dialog>
|
|
66
|
+
<Tooltip>
|
|
67
|
+
<TooltipTrigger asChild>
|
|
68
|
+
<DialogTrigger asChild>
|
|
69
|
+
<Button
|
|
70
|
+
variant="ghost"
|
|
71
|
+
size="icon-sm"
|
|
72
|
+
icon={<Maximize2 aria-hidden="true" />}
|
|
73
|
+
aria-label={fullscreenText}
|
|
74
|
+
className="absolute end-3 bottom-3 z-10 border bg-background/80 backdrop-blur-sm"
|
|
75
|
+
/>
|
|
76
|
+
</DialogTrigger>
|
|
77
|
+
</TooltipTrigger>
|
|
78
|
+
<TooltipContent>{fullscreenText}</TooltipContent>
|
|
79
|
+
</Tooltip>
|
|
80
|
+
<DialogContent className="max-h-[85vh] max-w-[90vw] overflow-auto p-0">
|
|
81
|
+
<PreviewPanel
|
|
82
|
+
{...contentProps}
|
|
83
|
+
minZoom={minZoom}
|
|
84
|
+
maxZoom={maxZoom}
|
|
85
|
+
initialZoom={initialZoom}
|
|
86
|
+
showControls={showControls}
|
|
87
|
+
className={dialogPanelClassName}
|
|
88
|
+
onStateChange={handleStateChange}
|
|
89
|
+
syncState={syncPanels ? sharedState : undefined}
|
|
90
|
+
/>
|
|
91
|
+
</DialogContent>
|
|
92
|
+
</Dialog>
|
|
93
|
+
</TooltipProvider>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export { PreviewPanelDialog }
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '@gentleduck/libs/cn'
|
|
4
|
+
import { type Direction, useDirection } from '@gentleduck/primitives/direction'
|
|
5
|
+
import { Minus, Plus, RotateCcw } from 'lucide-react'
|
|
6
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
7
|
+
import { Badge } from '../badge'
|
|
8
|
+
import { Button } from '../button'
|
|
9
|
+
import { ButtonGroup } from '../button-group'
|
|
10
|
+
import { Separator } from '../separator'
|
|
11
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../tooltip'
|
|
12
|
+
import type { PreviewPanelProps } from './preview-panel.types'
|
|
13
|
+
|
|
14
|
+
const ZOOM_STEP_BUTTON = 0.25
|
|
15
|
+
const ZOOM_STEP_WHEEL = 0.1
|
|
16
|
+
|
|
17
|
+
function clamp(value: number, min: number, max: number) {
|
|
18
|
+
return Math.max(min, Math.min(max, value))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ZoomControls = memo(function ZoomControls({
|
|
22
|
+
onZoomIn,
|
|
23
|
+
onZoomOut,
|
|
24
|
+
onReset,
|
|
25
|
+
zoom,
|
|
26
|
+
zoomInText = 'Zoom in',
|
|
27
|
+
zoomOutText = 'Zoom out',
|
|
28
|
+
resetText = 'Reset view',
|
|
29
|
+
}: {
|
|
30
|
+
onZoomIn: () => void
|
|
31
|
+
onZoomOut: () => void
|
|
32
|
+
onReset: () => void
|
|
33
|
+
zoom: number
|
|
34
|
+
zoomInText?: string
|
|
35
|
+
zoomOutText?: string
|
|
36
|
+
resetText?: string
|
|
37
|
+
}) {
|
|
38
|
+
return (
|
|
39
|
+
<TooltipProvider>
|
|
40
|
+
<ButtonGroup data-slot="preview-panel-controls" className="rounded-md border bg-background/80 backdrop-blur-sm">
|
|
41
|
+
<Tooltip>
|
|
42
|
+
<TooltipTrigger asChild>
|
|
43
|
+
<Button
|
|
44
|
+
variant="ghost"
|
|
45
|
+
size="icon-sm"
|
|
46
|
+
onClick={onZoomIn}
|
|
47
|
+
icon={<Plus aria-hidden="true" />}
|
|
48
|
+
aria-label={zoomInText}
|
|
49
|
+
/>
|
|
50
|
+
</TooltipTrigger>
|
|
51
|
+
<TooltipContent>{zoomInText}</TooltipContent>
|
|
52
|
+
</Tooltip>
|
|
53
|
+
<Separator orientation="vertical" />
|
|
54
|
+
<Badge variant="secondary" size="sm" className="rounded-none tabular-nums">
|
|
55
|
+
{Math.round(zoom * 100)}%
|
|
56
|
+
</Badge>
|
|
57
|
+
<Separator orientation="vertical" />
|
|
58
|
+
<Tooltip>
|
|
59
|
+
<TooltipTrigger asChild>
|
|
60
|
+
<Button
|
|
61
|
+
variant="ghost"
|
|
62
|
+
size="icon-sm"
|
|
63
|
+
onClick={onZoomOut}
|
|
64
|
+
icon={<Minus aria-hidden="true" />}
|
|
65
|
+
aria-label={zoomOutText}
|
|
66
|
+
/>
|
|
67
|
+
</TooltipTrigger>
|
|
68
|
+
<TooltipContent>{zoomOutText}</TooltipContent>
|
|
69
|
+
</Tooltip>
|
|
70
|
+
<Separator orientation="vertical" />
|
|
71
|
+
<Tooltip>
|
|
72
|
+
<TooltipTrigger asChild>
|
|
73
|
+
<Button
|
|
74
|
+
variant="ghost"
|
|
75
|
+
size="icon-sm"
|
|
76
|
+
onClick={onReset}
|
|
77
|
+
icon={<RotateCcw aria-hidden="true" />}
|
|
78
|
+
aria-label={resetText}
|
|
79
|
+
/>
|
|
80
|
+
</TooltipTrigger>
|
|
81
|
+
<TooltipContent>{resetText}</TooltipContent>
|
|
82
|
+
</Tooltip>
|
|
83
|
+
</ButtonGroup>
|
|
84
|
+
</TooltipProvider>
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// A generic pan-zoom container. Accepts children or an html string.
|
|
89
|
+
// All transforms bypass React via direct DOM writes for zero re-renders
|
|
90
|
+
// during continuous interactions (drag, wheel, pinch).
|
|
91
|
+
|
|
92
|
+
function PreviewPanel({
|
|
93
|
+
maxHeight,
|
|
94
|
+
minZoom = 0.25,
|
|
95
|
+
maxZoom = 4,
|
|
96
|
+
initialZoom = 1,
|
|
97
|
+
showControls = true,
|
|
98
|
+
html,
|
|
99
|
+
children,
|
|
100
|
+
className,
|
|
101
|
+
style,
|
|
102
|
+
onStateChange,
|
|
103
|
+
syncState,
|
|
104
|
+
dir,
|
|
105
|
+
...rest
|
|
106
|
+
}: PreviewPanelProps) {
|
|
107
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
108
|
+
const contentRef = useRef<HTMLDivElement>(null)
|
|
109
|
+
|
|
110
|
+
// All mutable interaction state lives in a single ref object.
|
|
111
|
+
// Nothing here triggers React re-renders.
|
|
112
|
+
const s = useRef({
|
|
113
|
+
zoom: initialZoom,
|
|
114
|
+
x: 0,
|
|
115
|
+
y: 0,
|
|
116
|
+
dragging: false,
|
|
117
|
+
dragStartX: 0,
|
|
118
|
+
dragStartY: 0,
|
|
119
|
+
posStartX: 0,
|
|
120
|
+
posStartY: 0,
|
|
121
|
+
rafId: 0,
|
|
122
|
+
emitPending: false,
|
|
123
|
+
pinchDist: 0,
|
|
124
|
+
pinchZoom: initialZoom,
|
|
125
|
+
willChangeTimer: 0,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Only React state: the zoom label percentage
|
|
129
|
+
const [displayZoom, setDisplayZoom] = useState(initialZoom)
|
|
130
|
+
|
|
131
|
+
// Stable ref for the callback so effects never re-subscribe
|
|
132
|
+
const onStateChangeRef = useRef(onStateChange)
|
|
133
|
+
onStateChangeRef.current = onStateChange
|
|
134
|
+
|
|
135
|
+
// Flush a pending state emission. Called from RAF or directly by button handlers.
|
|
136
|
+
const flushEmit = useCallback(() => {
|
|
137
|
+
if (!s.current.emitPending) return
|
|
138
|
+
s.current.emitPending = false
|
|
139
|
+
onStateChangeRef.current?.({
|
|
140
|
+
zoom: s.current.zoom,
|
|
141
|
+
x: s.current.x,
|
|
142
|
+
y: s.current.y,
|
|
143
|
+
})
|
|
144
|
+
}, [])
|
|
145
|
+
|
|
146
|
+
// Mark state as dirty so the next RAF tick emits it.
|
|
147
|
+
// For continuous interactions (drag, wheel, pinch) this batches
|
|
148
|
+
// multiple events into one React state update per frame.
|
|
149
|
+
const markDirty = useCallback(() => {
|
|
150
|
+
s.current.emitPending = true
|
|
151
|
+
}, [])
|
|
152
|
+
|
|
153
|
+
// Write transform directly to the DOM element.
|
|
154
|
+
const applyTransform = useCallback((animate: boolean) => {
|
|
155
|
+
const el = contentRef.current
|
|
156
|
+
if (!el) return
|
|
157
|
+
const { x, y, zoom } = s.current
|
|
158
|
+
el.style.transform = `translate3d(${x}px,${y}px,0) scale(${zoom})`
|
|
159
|
+
el.style.transition = animate ? 'transform 0.15s ease-out' : 'none'
|
|
160
|
+
// GPU-composite during interaction, then clear so browser
|
|
161
|
+
// re-rasterizes at the new zoom for crisp SVG text
|
|
162
|
+
el.style.willChange = 'transform'
|
|
163
|
+
clearTimeout(s.current.willChangeTimer)
|
|
164
|
+
s.current.willChangeTimer = window.setTimeout(() => {
|
|
165
|
+
if (contentRef.current && !s.current.dragging) {
|
|
166
|
+
contentRef.current.style.willChange = 'auto'
|
|
167
|
+
}
|
|
168
|
+
}, 200)
|
|
169
|
+
}, [])
|
|
170
|
+
|
|
171
|
+
// Batch DOM writes behind a single requestAnimationFrame.
|
|
172
|
+
// Also flushes any pending state emission in the same frame.
|
|
173
|
+
const scheduleApply = useCallback(() => {
|
|
174
|
+
if (s.current.rafId) return
|
|
175
|
+
s.current.rafId = requestAnimationFrame(() => {
|
|
176
|
+
s.current.rafId = 0
|
|
177
|
+
applyTransform(false)
|
|
178
|
+
flushEmit()
|
|
179
|
+
})
|
|
180
|
+
}, [applyTransform, flushEmit])
|
|
181
|
+
|
|
182
|
+
const syncDisplay = useCallback(() => setDisplayZoom(s.current.zoom), [])
|
|
183
|
+
|
|
184
|
+
// -- Sync from external state (receives changes from a paired panel) --
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!syncState) return
|
|
188
|
+
const { zoom, x, y } = syncState
|
|
189
|
+
// Epsilon check prevents applying our own emitted state back
|
|
190
|
+
if (Math.abs(s.current.zoom - zoom) < 0.001 && Math.abs(s.current.x - x) < 0.5 && Math.abs(s.current.y - y) < 0.5)
|
|
191
|
+
return
|
|
192
|
+
s.current.zoom = zoom
|
|
193
|
+
s.current.x = x
|
|
194
|
+
s.current.y = y
|
|
195
|
+
// Apply silently without emitting back to avoid ping-pong
|
|
196
|
+
applyTransform(true)
|
|
197
|
+
syncDisplay()
|
|
198
|
+
}, [syncState, applyTransform, syncDisplay])
|
|
199
|
+
|
|
200
|
+
// -- Button handlers (discrete, emit immediately) --
|
|
201
|
+
|
|
202
|
+
const handleZoomIn = useCallback(() => {
|
|
203
|
+
s.current.zoom = clamp(s.current.zoom + ZOOM_STEP_BUTTON, minZoom, maxZoom)
|
|
204
|
+
applyTransform(true)
|
|
205
|
+
syncDisplay()
|
|
206
|
+
markDirty()
|
|
207
|
+
flushEmit()
|
|
208
|
+
}, [applyTransform, syncDisplay, markDirty, flushEmit, minZoom, maxZoom])
|
|
209
|
+
|
|
210
|
+
const handleZoomOut = useCallback(() => {
|
|
211
|
+
s.current.zoom = clamp(s.current.zoom - ZOOM_STEP_BUTTON, minZoom, maxZoom)
|
|
212
|
+
applyTransform(true)
|
|
213
|
+
syncDisplay()
|
|
214
|
+
markDirty()
|
|
215
|
+
flushEmit()
|
|
216
|
+
}, [applyTransform, syncDisplay, markDirty, flushEmit, minZoom, maxZoom])
|
|
217
|
+
|
|
218
|
+
const handleReset = useCallback(() => {
|
|
219
|
+
s.current.zoom = initialZoom
|
|
220
|
+
s.current.x = 0
|
|
221
|
+
s.current.y = 0
|
|
222
|
+
applyTransform(true)
|
|
223
|
+
syncDisplay()
|
|
224
|
+
markDirty()
|
|
225
|
+
flushEmit()
|
|
226
|
+
}, [applyTransform, syncDisplay, markDirty, flushEmit, initialZoom])
|
|
227
|
+
|
|
228
|
+
// -- Pointer drag --
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
const el = containerRef.current
|
|
232
|
+
if (!el) return
|
|
233
|
+
|
|
234
|
+
const onDown = (e: PointerEvent) => {
|
|
235
|
+
if (e.button !== 0) return
|
|
236
|
+
if ((e.target as HTMLElement).closest('[data-slot="preview-panel-controls"]')) return
|
|
237
|
+
e.preventDefault()
|
|
238
|
+
el.setPointerCapture(e.pointerId)
|
|
239
|
+
s.current.dragging = true
|
|
240
|
+
s.current.dragStartX = e.clientX
|
|
241
|
+
s.current.dragStartY = e.clientY
|
|
242
|
+
s.current.posStartX = s.current.x
|
|
243
|
+
s.current.posStartY = s.current.y
|
|
244
|
+
el.style.cursor = 'grabbing'
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const onMove = (e: PointerEvent) => {
|
|
248
|
+
if (!s.current.dragging) return
|
|
249
|
+
s.current.x = s.current.posStartX + (e.clientX - s.current.dragStartX)
|
|
250
|
+
s.current.y = s.current.posStartY + (e.clientY - s.current.dragStartY)
|
|
251
|
+
markDirty()
|
|
252
|
+
scheduleApply()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const onUp = (e: PointerEvent) => {
|
|
256
|
+
if (!s.current.dragging) return
|
|
257
|
+
s.current.dragging = false
|
|
258
|
+
el.releasePointerCapture(e.pointerId)
|
|
259
|
+
el.style.cursor = 'grab'
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const onLeave = () => {
|
|
263
|
+
if (!s.current.dragging) return
|
|
264
|
+
s.current.dragging = false
|
|
265
|
+
el.style.cursor = 'grab'
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
el.addEventListener('pointerdown', onDown)
|
|
269
|
+
el.addEventListener('pointermove', onMove)
|
|
270
|
+
el.addEventListener('pointerup', onUp)
|
|
271
|
+
el.addEventListener('pointerleave', onLeave)
|
|
272
|
+
return () => {
|
|
273
|
+
el.removeEventListener('pointerdown', onDown)
|
|
274
|
+
el.removeEventListener('pointermove', onMove)
|
|
275
|
+
el.removeEventListener('pointerup', onUp)
|
|
276
|
+
el.removeEventListener('pointerleave', onLeave)
|
|
277
|
+
}
|
|
278
|
+
}, [markDirty, scheduleApply])
|
|
279
|
+
|
|
280
|
+
// -- Wheel zoom (passive: false to preventDefault page scroll) --
|
|
281
|
+
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
const el = containerRef.current
|
|
284
|
+
if (!el) return
|
|
285
|
+
|
|
286
|
+
const onWheel = (e: WheelEvent) => {
|
|
287
|
+
e.preventDefault()
|
|
288
|
+
e.stopPropagation()
|
|
289
|
+
const delta = e.deltaY > 0 ? -ZOOM_STEP_WHEEL : ZOOM_STEP_WHEEL
|
|
290
|
+
s.current.zoom = clamp(s.current.zoom + delta, minZoom, maxZoom)
|
|
291
|
+
markDirty()
|
|
292
|
+
scheduleApply()
|
|
293
|
+
syncDisplay()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
el.addEventListener('wheel', onWheel, { passive: false })
|
|
297
|
+
return () => el.removeEventListener('wheel', onWheel)
|
|
298
|
+
}, [markDirty, scheduleApply, syncDisplay, minZoom, maxZoom])
|
|
299
|
+
|
|
300
|
+
// -- Pinch to zoom (two-finger touch) --
|
|
301
|
+
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
const el = containerRef.current
|
|
304
|
+
if (!el) return
|
|
305
|
+
|
|
306
|
+
const dist = (e: TouchEvent) => {
|
|
307
|
+
const a = e.touches[0]!
|
|
308
|
+
const b = e.touches[1]!
|
|
309
|
+
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const onTouchStart = (e: TouchEvent) => {
|
|
313
|
+
if (e.touches.length === 2) {
|
|
314
|
+
e.preventDefault()
|
|
315
|
+
s.current.pinchDist = dist(e)
|
|
316
|
+
s.current.pinchZoom = s.current.zoom
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const onTouchMove = (e: TouchEvent) => {
|
|
321
|
+
if (e.touches.length === 2) {
|
|
322
|
+
e.preventDefault()
|
|
323
|
+
const scale = dist(e) / s.current.pinchDist
|
|
324
|
+
s.current.zoom = clamp(s.current.pinchZoom * scale, minZoom, maxZoom)
|
|
325
|
+
markDirty()
|
|
326
|
+
scheduleApply()
|
|
327
|
+
syncDisplay()
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
el.addEventListener('touchstart', onTouchStart, { passive: false })
|
|
332
|
+
el.addEventListener('touchmove', onTouchMove, { passive: false })
|
|
333
|
+
return () => {
|
|
334
|
+
el.removeEventListener('touchstart', onTouchStart)
|
|
335
|
+
el.removeEventListener('touchmove', onTouchMove)
|
|
336
|
+
}
|
|
337
|
+
}, [markDirty, scheduleApply, syncDisplay, minZoom, maxZoom])
|
|
338
|
+
|
|
339
|
+
// Cleanup on unmount
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
return () => {
|
|
342
|
+
if (s.current.rafId) cancelAnimationFrame(s.current.rafId)
|
|
343
|
+
clearTimeout(s.current.willChangeTimer)
|
|
344
|
+
}
|
|
345
|
+
}, [])
|
|
346
|
+
|
|
347
|
+
// Stable content props - only changes when html/children identity changes
|
|
348
|
+
const contentProps = useMemo(
|
|
349
|
+
() => (html ? { dangerouslySetInnerHTML: { __html: html } } : { children }),
|
|
350
|
+
[html, children],
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
// Stable inline style for the container
|
|
354
|
+
const containerStyle = useMemo(
|
|
355
|
+
() => ({ maxHeight, cursor: 'grab' as const, touchAction: 'none' as const }),
|
|
356
|
+
[maxHeight],
|
|
357
|
+
)
|
|
358
|
+
const direction = useDirection(dir as Direction)
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<div
|
|
362
|
+
data-slot="preview-panel"
|
|
363
|
+
className={cn('relative flex flex-col', className)}
|
|
364
|
+
dir={direction}
|
|
365
|
+
style={style}
|
|
366
|
+
{...rest}>
|
|
367
|
+
{showControls && (
|
|
368
|
+
<div className="absolute end-3 top-3 z-10">
|
|
369
|
+
<ZoomControls onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} onReset={handleReset} zoom={displayZoom} />
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
372
|
+
<div
|
|
373
|
+
ref={containerRef}
|
|
374
|
+
className="flex flex-1 items-center justify-center overflow-hidden"
|
|
375
|
+
style={containerStyle}>
|
|
376
|
+
<div
|
|
377
|
+
ref={contentRef}
|
|
378
|
+
className="flex w-full items-center justify-center p-6"
|
|
379
|
+
style={CONTENT_STYLE}
|
|
380
|
+
{...contentProps}
|
|
381
|
+
/>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const CONTENT_STYLE = { transformOrigin: 'center center' } as const
|
|
388
|
+
|
|
389
|
+
export { PreviewPanel, ZoomControls }
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type React from 'react'
|
|
2
|
+
|
|
3
|
+
export type PreviewPanelState = {
|
|
4
|
+
zoom: number
|
|
5
|
+
x: number
|
|
6
|
+
y: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PreviewPanelProps extends React.HTMLProps<HTMLDivElement> {
|
|
10
|
+
/** Maximum height of the panel container. */
|
|
11
|
+
maxHeight?: string
|
|
12
|
+
/** Minimum zoom level. Default 0.25. */
|
|
13
|
+
minZoom?: number
|
|
14
|
+
/** Maximum zoom level. Default 4. */
|
|
15
|
+
maxZoom?: number
|
|
16
|
+
/** Initial zoom level. Default 1. */
|
|
17
|
+
initialZoom?: number
|
|
18
|
+
/** Whether to show zoom controls. Default true. */
|
|
19
|
+
showControls?: boolean
|
|
20
|
+
/** Raw HTML string to render inside the panel. Takes priority over children. */
|
|
21
|
+
html?: string
|
|
22
|
+
/** Called whenever zoom or position changes. Use to sync with another panel. */
|
|
23
|
+
onStateChange?: (state: PreviewPanelState) => void
|
|
24
|
+
/** External state to apply. When set, the panel syncs to this state. */
|
|
25
|
+
syncState?: PreviewPanelState
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PreviewPanelDialogProps {
|
|
29
|
+
/** Content to render in both the inline panel and the dialog panel. */
|
|
30
|
+
children?: React.ReactNode
|
|
31
|
+
/** Raw HTML string to render. Takes priority over children. */
|
|
32
|
+
html?: string
|
|
33
|
+
/** Class name for the inline panel wrapper. */
|
|
34
|
+
className?: string
|
|
35
|
+
/** Class name applied to both PreviewPanel instances. */
|
|
36
|
+
panelClassName?: string
|
|
37
|
+
/** Maximum height of the inline panel. */
|
|
38
|
+
maxHeight?: string
|
|
39
|
+
/** Minimum zoom level. Default 0.25. */
|
|
40
|
+
minZoom?: number
|
|
41
|
+
/** Maximum zoom level. Default 4. */
|
|
42
|
+
maxZoom?: number
|
|
43
|
+
/** Initial zoom level. Default 1. */
|
|
44
|
+
initialZoom?: number
|
|
45
|
+
/** Whether to show zoom controls. Default true. */
|
|
46
|
+
showControls?: boolean
|
|
47
|
+
/** Whether to sync zoom and position between inline and dialog panels. Default true. */
|
|
48
|
+
syncPanels?: boolean
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './progress'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '@gentleduck/libs/cn'
|
|
4
|
+
import { type Direction, useDirection } from '@gentleduck/primitives/direction'
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
|
|
7
|
+
const Progress = React.forwardRef<
|
|
8
|
+
HTMLDivElement,
|
|
9
|
+
Omit<React.HTMLProps<HTMLDivElement>, 'value' | 'ref'> & { value: number }
|
|
10
|
+
>(({ className, value, dir, ...props }, ref) => {
|
|
11
|
+
const direction = useDirection(dir as Direction)
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-secondary', className)}
|
|
15
|
+
ref={ref}
|
|
16
|
+
{...props}
|
|
17
|
+
aria-valuemax={100}
|
|
18
|
+
aria-valuemin={0}
|
|
19
|
+
aria-valuenow={value}
|
|
20
|
+
dir={direction}
|
|
21
|
+
data-slot="progress"
|
|
22
|
+
role="progressbar">
|
|
23
|
+
<div
|
|
24
|
+
className="h-full w-full flex-1 bg-primary transition-all"
|
|
25
|
+
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
Progress.displayName = 'Progress'
|
|
31
|
+
|
|
32
|
+
export { Progress }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './radio-group'
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '@gentleduck/libs/cn'
|
|
4
|
+
import { useSvgIndicator } from '@gentleduck/primitives/checkers'
|
|
5
|
+
import * as RadioGroupPrimitive from '@gentleduck/primitives/radio-group'
|
|
6
|
+
import * as React from 'react'
|
|
7
|
+
|
|
8
|
+
const RadioGroup = React.forwardRef<
|
|
9
|
+
React.ComponentRef<typeof RadioGroupPrimitive.Root>,
|
|
10
|
+
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
|
11
|
+
>(({ className, ...props }, ref) => {
|
|
12
|
+
return (
|
|
13
|
+
<RadioGroupPrimitive.Root
|
|
14
|
+
className={cn('flex flex-col gap-2', className)}
|
|
15
|
+
data-slot="radio-group"
|
|
16
|
+
ref={ref}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
)
|
|
20
|
+
})
|
|
21
|
+
RadioGroup.displayName = 'RadioGroup'
|
|
22
|
+
|
|
23
|
+
const RadioGroupItem = React.forwardRef<
|
|
24
|
+
React.ComponentRef<typeof RadioGroupPrimitive.Item>,
|
|
25
|
+
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> & {
|
|
26
|
+
indicator?: React.ReactElement
|
|
27
|
+
checkedIndicator?: React.ReactElement
|
|
28
|
+
textValue?: string
|
|
29
|
+
}
|
|
30
|
+
>(({ className, indicator, checkedIndicator, children, textValue, ...props }, ref) => {
|
|
31
|
+
const { indicatorReady, checkedIndicatorReady, inputStyle, SvgIndicator } = useSvgIndicator({
|
|
32
|
+
checkedIndicator,
|
|
33
|
+
indicator,
|
|
34
|
+
})
|
|
35
|
+
const itemRef = React.useRef<HTMLButtonElement>(null)
|
|
36
|
+
const itemId = React.useId()
|
|
37
|
+
const resolvedTextValue =
|
|
38
|
+
textValue ?? (typeof children === 'string' || typeof children === 'number' ? String(children) : undefined)
|
|
39
|
+
|
|
40
|
+
const indicatorStateClass =
|
|
41
|
+
indicatorReady && checkedIndicatorReady
|
|
42
|
+
? 'after:mask-[var(--svg-off)] data-[state=checked]:after:mask-[var(--svg-on)]'
|
|
43
|
+
: indicatorReady
|
|
44
|
+
? 'after:mask-[var(--svg-off)]'
|
|
45
|
+
: checkedIndicatorReady
|
|
46
|
+
? 'data-[state=checked]:after:mask-[var(--svg-on)]'
|
|
47
|
+
: ''
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex items-center gap-2">
|
|
51
|
+
<RadioGroupPrimitive.Item
|
|
52
|
+
id={itemId}
|
|
53
|
+
className={cn(
|
|
54
|
+
// Base radio styles (uses data-[state=checked]: instead of checked: for button elements)
|
|
55
|
+
'relative m-0 flex size-[1em] appearance-none items-center rounded-full p-2',
|
|
56
|
+
'border border-border bg-border text-primary-foreground data-[state=checked]:border-primary data-[state=checked]:bg-primary',
|
|
57
|
+
'ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
58
|
+
'data-disabled:cursor-not-allowed data-disabled:opacity-50',
|
|
59
|
+
'after:mask-type-alpha after:mask-contain after:absolute after:block after:size-[1em] after:rounded-[inherit] after:bg-current after:drop-shadow',
|
|
60
|
+
'after:opacity-0 data-[state=checked]:after:opacity-100',
|
|
61
|
+
// Radio-specific indicator
|
|
62
|
+
'justify-center after:text-[10px]',
|
|
63
|
+
'after:scale-0 data-[state=checked]:after:scale-100',
|
|
64
|
+
indicatorStateClass,
|
|
65
|
+
// Animation
|
|
66
|
+
'transition-all transition-discrete duration-[200ms,150ms] ease-(--duck-motion-ease)',
|
|
67
|
+
'[&:before,&:after]:transition-gpu [&:before,&:after]:duration-[inherit] [&:before,&:after]:ease-[inherit] [&:before,&:after]:will-change-[inherit]',
|
|
68
|
+
'rounded-full',
|
|
69
|
+
className,
|
|
70
|
+
)}
|
|
71
|
+
data-slot="radio-group-item"
|
|
72
|
+
ref={(node) => {
|
|
73
|
+
itemRef.current = node
|
|
74
|
+
if (typeof ref === 'function') ref(node)
|
|
75
|
+
else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node
|
|
76
|
+
}}
|
|
77
|
+
style={inputStyle}
|
|
78
|
+
data-text-value={resolvedTextValue}
|
|
79
|
+
{...props}
|
|
80
|
+
/>
|
|
81
|
+
<SvgIndicator className="sr-only" />
|
|
82
|
+
{children && (
|
|
83
|
+
<label className="cursor-pointer font-normal text-base" data-slot="radio-label" htmlFor={itemId}>
|
|
84
|
+
{children}
|
|
85
|
+
</label>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
RadioGroupItem.displayName = 'RadioGroupItem'
|
|
91
|
+
|
|
92
|
+
export { RadioGroup, RadioGroupItem }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './resizable'
|