@sntlr/registry-shell 1.0.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/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/adapter/custom.d.ts +47 -0
- package/dist/adapter/custom.js +53 -0
- package/dist/adapter/custom.js.map +1 -0
- package/dist/adapter/default.d.ts +40 -0
- package/dist/adapter/default.js +202 -0
- package/dist/adapter/default.js.map +1 -0
- package/dist/cli/build.d.ts +1 -0
- package/dist/cli/build.js +31 -0
- package/dist/cli/build.js.map +1 -0
- package/dist/cli/dev.d.ts +1 -0
- package/dist/cli/dev.js +26 -0
- package/dist/cli/dev.js.map +1 -0
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/index.js +49 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init.d.ts +1 -0
- package/dist/cli/init.js +70 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/shared.d.ts +33 -0
- package/dist/cli/shared.js +278 -0
- package/dist/cli/shared.js.map +1 -0
- package/dist/cli/start.d.ts +1 -0
- package/dist/cli/start.js +24 -0
- package/dist/cli/start.js.map +1 -0
- package/dist/config-loader.d.ts +49 -0
- package/dist/config-loader.js +140 -0
- package/dist/define-config.d.ts +188 -0
- package/dist/define-config.js +21 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +9 -0
- package/package.json +124 -0
- package/src/adapter/custom.ts +90 -0
- package/src/adapter/default.ts +241 -0
- package/src/cli/build.ts +38 -0
- package/src/cli/dev.ts +38 -0
- package/src/cli/index.ts +52 -0
- package/src/cli/init.ts +76 -0
- package/src/cli/shared.ts +306 -0
- package/src/cli/start.ts +28 -0
- package/src/config-loader.ts +190 -0
- package/src/define-config.ts +206 -0
- package/src/index.ts +17 -0
- package/src/next-app/app/[...asset]/route.ts +81 -0
- package/src/next-app/app/_user-global.css +6 -0
- package/src/next-app/app/_user-sources.css +9 -0
- package/src/next-app/app/a11y/[name]/route.ts +19 -0
- package/src/next-app/app/api/search-index/route.ts +19 -0
- package/src/next-app/app/components/[name]/page.tsx +61 -0
- package/src/next-app/app/components/layout.tsx +18 -0
- package/src/next-app/app/docs/[slug]/page.tsx +53 -0
- package/src/next-app/app/docs/layout.tsx +18 -0
- package/src/next-app/app/globals.css +329 -0
- package/src/next-app/app/layout.tsx +102 -0
- package/src/next-app/app/page.tsx +9 -0
- package/src/next-app/app/preview-snapshot/[name]/page.tsx +20 -0
- package/src/next-app/app/preview-snapshot/layout.tsx +17 -0
- package/src/next-app/app/props/[name]/route.ts +19 -0
- package/src/next-app/app/r/[name]/route.ts +14 -0
- package/src/next-app/app/tests/[name]/route.ts +19 -0
- package/src/next-app/components/a11y-info.tsx +287 -0
- package/src/next-app/components/a11y-provider.tsx +39 -0
- package/src/next-app/components/component-breadcrumb.tsx +55 -0
- package/src/next-app/components/component-icon.tsx +140 -0
- package/src/next-app/components/component-preview.tsx +13 -0
- package/src/next-app/components/component-tabs.tsx +209 -0
- package/src/next-app/components/docs-toc.tsx +86 -0
- package/src/next-app/components/global-mobile-sidebar.tsx +35 -0
- package/src/next-app/components/header.tsx +188 -0
- package/src/next-app/components/heading-anchor.tsx +52 -0
- package/src/next-app/components/homepage-demo.tsx +180 -0
- package/src/next-app/components/locale-toggle.tsx +35 -0
- package/src/next-app/components/localized-mdx-client.tsx +14 -0
- package/src/next-app/components/localized-mdx.tsx +27 -0
- package/src/next-app/components/mobile-sidebar.tsx +22 -0
- package/src/next-app/components/nav-data-provider.tsx +37 -0
- package/src/next-app/components/navigation-progress.tsx +62 -0
- package/src/next-app/components/preview-canvas.tsx +368 -0
- package/src/next-app/components/preview-controls.tsx +94 -0
- package/src/next-app/components/preview-layout.tsx +218 -0
- package/src/next-app/components/props-table.tsx +134 -0
- package/src/next-app/components/resizable-preview.tsx +101 -0
- package/src/next-app/components/search.tsx +177 -0
- package/src/next-app/components/settings-modal.tsx +98 -0
- package/src/next-app/components/shell-ui/accordion.tsx +70 -0
- package/src/next-app/components/shell-ui/backdrop.tsx +29 -0
- package/src/next-app/components/shell-ui/badge.tsx +55 -0
- package/src/next-app/components/shell-ui/breadcrumb.tsx +120 -0
- package/src/next-app/components/shell-ui/button.tsx +64 -0
- package/src/next-app/components/shell-ui/card.tsx +127 -0
- package/src/next-app/components/shell-ui/checkbox.tsx +33 -0
- package/src/next-app/components/shell-ui/dialog.tsx +171 -0
- package/src/next-app/components/shell-ui/empty-state.tsx +66 -0
- package/src/next-app/components/shell-ui/input.tsx +27 -0
- package/src/next-app/components/shell-ui/kbd.tsx +30 -0
- package/src/next-app/components/shell-ui/label.tsx +25 -0
- package/src/next-app/components/shell-ui/select.tsx +204 -0
- package/src/next-app/components/shell-ui/separator.tsx +32 -0
- package/src/next-app/components/shell-ui/skeleton.tsx +18 -0
- package/src/next-app/components/shell-ui/table.tsx +124 -0
- package/src/next-app/components/shell-ui/tabs.tsx +102 -0
- package/src/next-app/components/shell-ui/toggle.tsx +56 -0
- package/src/next-app/components/sidebar-layout.tsx +37 -0
- package/src/next-app/components/sidebar-provider.tsx +75 -0
- package/src/next-app/components/sidebar.tsx +222 -0
- package/src/next-app/components/snapshot-preview.tsx +28 -0
- package/src/next-app/components/test-info.tsx +155 -0
- package/src/next-app/components/theme-provider.tsx +16 -0
- package/src/next-app/components/theme-toggle.tsx +21 -0
- package/src/next-app/components/translated-text.tsx +8 -0
- package/src/next-app/fallback/homepage.tsx +112 -0
- package/src/next-app/fallback/previews.ts +17 -0
- package/src/next-app/hooks/use-active-section.ts +23 -0
- package/src/next-app/hooks/use-controls.ts +72 -0
- package/src/next-app/hooks/use-mobile.ts +19 -0
- package/src/next-app/lib/branding.ts +52 -0
- package/src/next-app/lib/components-nav.ts +8 -0
- package/src/next-app/lib/docs.ts +16 -0
- package/src/next-app/lib/github.ts +38 -0
- package/src/next-app/lib/i18n.tsx +630 -0
- package/src/next-app/lib/locales.ts +17 -0
- package/src/next-app/lib/preview-loader.ts +7 -0
- package/src/next-app/lib/registry-adapter.ts +199 -0
- package/src/next-app/lib/utils.ts +6 -0
- package/src/next-app/next-env.d.ts +6 -0
- package/src/next-app/next.config.ts +101 -0
- package/src/next-app/postcss.config.mjs +7 -0
- package/src/next-app/public/favicon.ico +0 -0
- package/src/next-app/public/favicon_dark.svg +3 -0
- package/src/next-app/public/favicon_light.svg +3 -0
- package/src/next-app/registry.config.ts +50 -0
- package/src/next-app/tsconfig.json +29 -0
- package/src/next-app/user-aliases.d.ts +17 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react"
|
|
10
|
+
import { Crosshair, SlidersHorizontal, Maximize, X } from "lucide-react"
|
|
11
|
+
import { Button } from "@shell/components/shell-ui/button"
|
|
12
|
+
|
|
13
|
+
const DOT_SPACING = 24
|
|
14
|
+
const DOT_RADIUS = 1
|
|
15
|
+
const PROXIMITY_RADIUS = 120
|
|
16
|
+
const MIN_ZOOM = 0.25
|
|
17
|
+
const MAX_ZOOM = 3
|
|
18
|
+
const ZOOM_STEP = 0.1
|
|
19
|
+
|
|
20
|
+
export interface CameraState {
|
|
21
|
+
x: number
|
|
22
|
+
y: number
|
|
23
|
+
zoom: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CanvasPosition {
|
|
27
|
+
x: number
|
|
28
|
+
y: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function PreviewCanvas({
|
|
32
|
+
children,
|
|
33
|
+
showControls,
|
|
34
|
+
onToggleControls,
|
|
35
|
+
fullscreen,
|
|
36
|
+
onToggleFullscreen,
|
|
37
|
+
camera: cameraProp,
|
|
38
|
+
onCameraChange,
|
|
39
|
+
position: posProp,
|
|
40
|
+
onPositionChange,
|
|
41
|
+
}: {
|
|
42
|
+
children: ReactNode
|
|
43
|
+
showControls?: boolean
|
|
44
|
+
onToggleControls?: () => void
|
|
45
|
+
fullscreen?: boolean
|
|
46
|
+
onToggleFullscreen?: () => void
|
|
47
|
+
camera?: CameraState
|
|
48
|
+
onCameraChange?: (camera: CameraState) => void
|
|
49
|
+
position?: CanvasPosition
|
|
50
|
+
onPositionChange?: (pos: CanvasPosition) => void
|
|
51
|
+
}) {
|
|
52
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
53
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
54
|
+
const animRef = useRef<number>(0)
|
|
55
|
+
|
|
56
|
+
// Use controlled state if provided, otherwise internal
|
|
57
|
+
const [internalCamera, setInternalCamera] = useState({ x: 0, y: 0, zoom: 1 })
|
|
58
|
+
const [internalPos, setInternalPos] = useState({ x: 0, y: 0 })
|
|
59
|
+
const camera = cameraProp ?? internalCamera
|
|
60
|
+
const pos = posProp ?? internalPos
|
|
61
|
+
const cameraRef = useRef(camera)
|
|
62
|
+
const posRef = useRef(pos)
|
|
63
|
+
useEffect(() => { cameraRef.current = camera }, [camera])
|
|
64
|
+
useEffect(() => { posRef.current = pos }, [pos])
|
|
65
|
+
|
|
66
|
+
const setCamera: React.Dispatch<React.SetStateAction<CameraState>> = useCallback((v) => {
|
|
67
|
+
const next = typeof v === "function" ? v(cameraRef.current) : v
|
|
68
|
+
if (onCameraChange) onCameraChange(next)
|
|
69
|
+
else setInternalCamera(next)
|
|
70
|
+
}, [onCameraChange])
|
|
71
|
+
|
|
72
|
+
const setPos: React.Dispatch<React.SetStateAction<CanvasPosition>> = useCallback((v) => {
|
|
73
|
+
const next = typeof v === "function" ? v(posRef.current) : v
|
|
74
|
+
if (onPositionChange) onPositionChange(next)
|
|
75
|
+
else setInternalPos(next)
|
|
76
|
+
}, [onPositionChange])
|
|
77
|
+
|
|
78
|
+
const mouseRef = useRef({ x: -1000, y: -1000 })
|
|
79
|
+
const dragging = useRef<"pan" | "move" | null>(null)
|
|
80
|
+
const dragStart = useRef({ mx: 0, my: 0, cx: 0, cy: 0 })
|
|
81
|
+
|
|
82
|
+
// Draw dot grid — use a ref so the rAF loop always calls the latest version
|
|
83
|
+
const drawDotsRef = useRef<() => void>(() => {})
|
|
84
|
+
|
|
85
|
+
const drawDots = useCallback(() => {
|
|
86
|
+
const canvas = canvasRef.current
|
|
87
|
+
const container = containerRef.current
|
|
88
|
+
if (!canvas || !container) return
|
|
89
|
+
|
|
90
|
+
const rect = container.getBoundingClientRect()
|
|
91
|
+
const dpr = window.devicePixelRatio || 1
|
|
92
|
+
canvas.width = rect.width * dpr
|
|
93
|
+
canvas.height = rect.height * dpr
|
|
94
|
+
canvas.style.width = `${rect.width}px`
|
|
95
|
+
canvas.style.height = `${rect.height}px`
|
|
96
|
+
|
|
97
|
+
const ctx = canvas.getContext("2d")
|
|
98
|
+
if (!ctx) return
|
|
99
|
+
ctx.scale(dpr, dpr)
|
|
100
|
+
ctx.clearRect(0, 0, rect.width, rect.height)
|
|
101
|
+
|
|
102
|
+
const { x: camX, y: camY, zoom } = camera
|
|
103
|
+
// Keep spacing constant in screen space — never denser than base
|
|
104
|
+
const spacing = DOT_SPACING
|
|
105
|
+
const mx = mouseRef.current.x
|
|
106
|
+
const my = mouseRef.current.y
|
|
107
|
+
|
|
108
|
+
// Offset for panning (dots scroll with camera)
|
|
109
|
+
const offsetX = ((camX * zoom) % spacing + spacing) % spacing
|
|
110
|
+
const offsetY = ((camY * zoom) % spacing + spacing) % spacing
|
|
111
|
+
|
|
112
|
+
const isDark = document.documentElement.classList.contains("dark")
|
|
113
|
+
const dotColor = isDark ? "255,255,255" : "0,0,0"
|
|
114
|
+
const dotRadius = Math.max(DOT_RADIUS, DOT_RADIUS * zoom)
|
|
115
|
+
|
|
116
|
+
for (let x = offsetX - spacing; x < rect.width + spacing; x += spacing) {
|
|
117
|
+
for (
|
|
118
|
+
let y = offsetY - spacing;
|
|
119
|
+
y < rect.height + spacing;
|
|
120
|
+
y += spacing
|
|
121
|
+
) {
|
|
122
|
+
const dist = Math.sqrt((x - mx) ** 2 + (y - my) ** 2)
|
|
123
|
+
const proximity = Math.max(0, 1 - dist / PROXIMITY_RADIUS)
|
|
124
|
+
const alpha = 0.08 + proximity * 0.35
|
|
125
|
+
ctx.fillStyle = `rgba(${dotColor}, ${alpha})`
|
|
126
|
+
ctx.beginPath()
|
|
127
|
+
ctx.arc(x, y, dotRadius, 0, Math.PI * 2)
|
|
128
|
+
ctx.fill()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
animRef.current = requestAnimationFrame(() => drawDotsRef.current())
|
|
133
|
+
}, [camera])
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
drawDotsRef.current = drawDots
|
|
137
|
+
}, [drawDots])
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
animRef.current = requestAnimationFrame(() => drawDotsRef.current())
|
|
141
|
+
return () => cancelAnimationFrame(animRef.current)
|
|
142
|
+
}, [drawDots])
|
|
143
|
+
|
|
144
|
+
// Track mouse for dot proximity
|
|
145
|
+
const onMouseMove = useCallback(
|
|
146
|
+
(e: React.MouseEvent) => {
|
|
147
|
+
const rect = containerRef.current?.getBoundingClientRect()
|
|
148
|
+
if (rect) {
|
|
149
|
+
mouseRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (dragging.current === "pan") {
|
|
153
|
+
const dx = (e.clientX - dragStart.current.mx) / camera.zoom
|
|
154
|
+
const dy = (e.clientY - dragStart.current.my) / camera.zoom
|
|
155
|
+
setCamera((c) => ({
|
|
156
|
+
...c,
|
|
157
|
+
x: dragStart.current.cx + dx,
|
|
158
|
+
y: dragStart.current.cy + dy,
|
|
159
|
+
}))
|
|
160
|
+
} else if (dragging.current === "move") {
|
|
161
|
+
const dx = (e.clientX - dragStart.current.mx) / camera.zoom
|
|
162
|
+
const dy = (e.clientY - dragStart.current.my) / camera.zoom
|
|
163
|
+
setPos({
|
|
164
|
+
x: dragStart.current.cx + dx,
|
|
165
|
+
y: dragStart.current.cy + dy,
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
[camera.zoom, setCamera, setPos]
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const onMouseDown = useCallback(
|
|
173
|
+
(e: React.MouseEvent) => {
|
|
174
|
+
// Only pan on background (canvas) clicks
|
|
175
|
+
if (e.target === canvasRef.current) {
|
|
176
|
+
dragging.current = "pan"
|
|
177
|
+
dragStart.current = {
|
|
178
|
+
mx: e.clientX,
|
|
179
|
+
my: e.clientY,
|
|
180
|
+
cx: camera.x,
|
|
181
|
+
cy: camera.y,
|
|
182
|
+
}
|
|
183
|
+
e.preventDefault()
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
[camera.x, camera.y]
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
const componentWrapperRef = useRef<HTMLDivElement>(null)
|
|
190
|
+
|
|
191
|
+
const onComponentMouseDown = useCallback(
|
|
192
|
+
(e: React.MouseEvent) => {
|
|
193
|
+
// Only start move-drag when clicking the wrapper itself (the padding
|
|
194
|
+
// area), not on interactive children inside the component.
|
|
195
|
+
if (e.target !== componentWrapperRef.current) return
|
|
196
|
+
|
|
197
|
+
dragging.current = "move"
|
|
198
|
+
dragStart.current = {
|
|
199
|
+
mx: e.clientX,
|
|
200
|
+
my: e.clientY,
|
|
201
|
+
cx: pos.x,
|
|
202
|
+
cy: pos.y,
|
|
203
|
+
}
|
|
204
|
+
e.stopPropagation()
|
|
205
|
+
e.preventDefault()
|
|
206
|
+
},
|
|
207
|
+
[pos.x, pos.y]
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
const onMouseUp = useCallback(() => {
|
|
211
|
+
dragging.current = null
|
|
212
|
+
}, [])
|
|
213
|
+
|
|
214
|
+
const onMouseLeave = useCallback(() => {
|
|
215
|
+
mouseRef.current = { x: -1000, y: -1000 }
|
|
216
|
+
dragging.current = null
|
|
217
|
+
}, [])
|
|
218
|
+
|
|
219
|
+
// Pinch zoom on touch devices
|
|
220
|
+
const lastTouchDist = useRef<number | null>(null)
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
const el = containerRef.current
|
|
223
|
+
if (!el) return
|
|
224
|
+
function handleTouchMove(e: TouchEvent) {
|
|
225
|
+
if (e.touches.length === 2) {
|
|
226
|
+
e.preventDefault()
|
|
227
|
+
const dx = e.touches[0].clientX - e.touches[1].clientX
|
|
228
|
+
const dy = e.touches[0].clientY - e.touches[1].clientY
|
|
229
|
+
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
230
|
+
if (lastTouchDist.current !== null) {
|
|
231
|
+
const delta = (dist - lastTouchDist.current) * 0.005
|
|
232
|
+
setCamera((c) => ({
|
|
233
|
+
...c,
|
|
234
|
+
zoom: Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, c.zoom + delta)),
|
|
235
|
+
}))
|
|
236
|
+
}
|
|
237
|
+
lastTouchDist.current = dist
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function handleTouchEnd() { lastTouchDist.current = null }
|
|
241
|
+
el.addEventListener("touchmove", handleTouchMove, { passive: false })
|
|
242
|
+
el.addEventListener("touchend", handleTouchEnd)
|
|
243
|
+
return () => {
|
|
244
|
+
el.removeEventListener("touchmove", handleTouchMove)
|
|
245
|
+
el.removeEventListener("touchend", handleTouchEnd)
|
|
246
|
+
}
|
|
247
|
+
}, [setCamera])
|
|
248
|
+
|
|
249
|
+
// Native wheel listener with { passive: false } to actually prevent page scroll
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
const el = containerRef.current
|
|
252
|
+
if (!el) return
|
|
253
|
+
function handleWheel(e: WheelEvent) {
|
|
254
|
+
e.preventDefault()
|
|
255
|
+
e.stopPropagation()
|
|
256
|
+
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP
|
|
257
|
+
setCamera((c) => ({
|
|
258
|
+
...c,
|
|
259
|
+
zoom: Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, c.zoom + delta)),
|
|
260
|
+
}))
|
|
261
|
+
}
|
|
262
|
+
el.addEventListener("wheel", handleWheel, { passive: false })
|
|
263
|
+
return () => el.removeEventListener("wheel", handleWheel)
|
|
264
|
+
}, [setCamera])
|
|
265
|
+
|
|
266
|
+
const recenter = useCallback(() => {
|
|
267
|
+
setCamera({ x: 0, y: 0, zoom: 1 })
|
|
268
|
+
setPos({ x: 0, y: 0 })
|
|
269
|
+
}, [setCamera, setPos])
|
|
270
|
+
|
|
271
|
+
const zoomIn = useCallback(() => {
|
|
272
|
+
setCamera((c) => ({
|
|
273
|
+
...c,
|
|
274
|
+
zoom: Math.min(MAX_ZOOM, c.zoom + ZOOM_STEP),
|
|
275
|
+
}))
|
|
276
|
+
}, [setCamera])
|
|
277
|
+
|
|
278
|
+
const zoomOut = useCallback(() => {
|
|
279
|
+
setCamera((c) => ({
|
|
280
|
+
...c,
|
|
281
|
+
zoom: Math.max(MIN_ZOOM, c.zoom - ZOOM_STEP),
|
|
282
|
+
}))
|
|
283
|
+
}, [setCamera])
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<div className={
|
|
287
|
+
fullscreen
|
|
288
|
+
? "relative w-full h-full select-none"
|
|
289
|
+
: "relative w-full h-full overflow-hidden select-none"
|
|
290
|
+
}>
|
|
291
|
+
<div
|
|
292
|
+
ref={containerRef}
|
|
293
|
+
tabIndex={0}
|
|
294
|
+
className="absolute inset-0 cursor-grab active:cursor-grabbing outline-none"
|
|
295
|
+
onMouseMove={onMouseMove}
|
|
296
|
+
onMouseDown={onMouseDown}
|
|
297
|
+
onMouseUp={onMouseUp}
|
|
298
|
+
onMouseLeave={onMouseLeave}
|
|
299
|
+
onKeyDown={(e) => {
|
|
300
|
+
// Don't intercept arrows when an input/textarea/contenteditable is focused
|
|
301
|
+
const tag = (e.target as HTMLElement).tagName
|
|
302
|
+
const editable = tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable
|
|
303
|
+
if (editable) return
|
|
304
|
+
|
|
305
|
+
const PAN_STEP = 20
|
|
306
|
+
if (e.key === "ArrowUp") { e.preventDefault(); setCamera((c) => ({ ...c, y: c.y + PAN_STEP })) }
|
|
307
|
+
else if (e.key === "ArrowDown") { e.preventDefault(); setCamera((c) => ({ ...c, y: c.y - PAN_STEP })) }
|
|
308
|
+
else if (e.key === "ArrowLeft") { e.preventDefault(); setCamera((c) => ({ ...c, x: c.x + PAN_STEP })) }
|
|
309
|
+
else if (e.key === "ArrowRight") { e.preventDefault(); setCamera((c) => ({ ...c, x: c.x - PAN_STEP })) }
|
|
310
|
+
else if (e.key === "=" || e.key === "+") { e.preventDefault(); zoomIn() }
|
|
311
|
+
else if (e.key === "-") { e.preventDefault(); zoomOut() }
|
|
312
|
+
else if (e.key === "0") { e.preventDefault(); recenter() }
|
|
313
|
+
}}
|
|
314
|
+
>
|
|
315
|
+
<canvas ref={canvasRef} className="absolute inset-0" />
|
|
316
|
+
|
|
317
|
+
{/* Component layer */}
|
|
318
|
+
<div
|
|
319
|
+
className="absolute inset-0 pointer-events-none"
|
|
320
|
+
style={{
|
|
321
|
+
transform: `translate(${camera.x * camera.zoom}px, ${camera.y * camera.zoom}px) scale(${camera.zoom})`,
|
|
322
|
+
transformOrigin: "center",
|
|
323
|
+
}}
|
|
324
|
+
>
|
|
325
|
+
{/* Move handle — fills the entire transform layer, behind the component */}
|
|
326
|
+
<div
|
|
327
|
+
ref={componentWrapperRef}
|
|
328
|
+
className="absolute inset-0 pointer-events-auto cursor-move"
|
|
329
|
+
onMouseDown={onComponentMouseDown}
|
|
330
|
+
/>
|
|
331
|
+
{/* Component — sits above the move handle, uses its own cursors */}
|
|
332
|
+
<div
|
|
333
|
+
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
|
334
|
+
>
|
|
335
|
+
<div
|
|
336
|
+
className="pointer-events-auto cursor-default"
|
|
337
|
+
style={{
|
|
338
|
+
transform: `translate(${pos.x}px, ${pos.y}px)`,
|
|
339
|
+
}}
|
|
340
|
+
>
|
|
341
|
+
{children}
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
|
|
347
|
+
{/* Toolbar — top-right: recenter + props toggle + fullscreen */}
|
|
348
|
+
<div className="absolute top-3 right-3 flex items-center gap-0.5 bg-background/80 backdrop-blur-sm border rounded-md p-0.5 pointer-events-auto z-10">
|
|
349
|
+
<Button variant="ghost" size="icon-xs" className="max-md:min-h-11 max-md:min-w-11" onClick={recenter} aria-label="Recenter">
|
|
350
|
+
<Crosshair />
|
|
351
|
+
</Button>
|
|
352
|
+
<Button
|
|
353
|
+
variant={showControls && onToggleControls ? "default" : "ghost"}
|
|
354
|
+
size="icon-xs"
|
|
355
|
+
className="max-md:min-h-11 max-md:min-w-11"
|
|
356
|
+
onClick={onToggleControls}
|
|
357
|
+
disabled={!onToggleControls}
|
|
358
|
+
aria-label="Toggle controls"
|
|
359
|
+
>
|
|
360
|
+
<SlidersHorizontal />
|
|
361
|
+
</Button>
|
|
362
|
+
<Button variant="ghost" size="icon-xs" className="max-md:min-h-11 max-md:min-w-11" onClick={onToggleFullscreen} aria-label="Toggle fullscreen">
|
|
363
|
+
{fullscreen ? <X /> : <Maximize />}
|
|
364
|
+
</Button>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
)
|
|
368
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ControlEntry } from "@shell/hooks/use-controls"
|
|
4
|
+
import { Label } from "@shell/components/shell-ui/label"
|
|
5
|
+
import {
|
|
6
|
+
Select,
|
|
7
|
+
SelectContent,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
} from "@shell/components/shell-ui/select"
|
|
12
|
+
import { Input } from "@shell/components/shell-ui/input"
|
|
13
|
+
|
|
14
|
+
export function PreviewControls({ entries }: { entries: ControlEntry[] }) {
|
|
15
|
+
if (entries.length === 0) return null
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="space-y-3">
|
|
19
|
+
{entries.map((entry) => (
|
|
20
|
+
<div key={entry.name} className="space-y-1">
|
|
21
|
+
<Label className="text-xs text-muted-foreground capitalize">
|
|
22
|
+
{entry.name}
|
|
23
|
+
</Label>
|
|
24
|
+
<ControlInput entry={entry} />
|
|
25
|
+
</div>
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ControlInput({ entry }: { entry: ControlEntry }) {
|
|
32
|
+
const { def, value, onChange } = entry
|
|
33
|
+
|
|
34
|
+
switch (def.type) {
|
|
35
|
+
case "select":
|
|
36
|
+
return (
|
|
37
|
+
<Select value={value as string} onValueChange={(v) => onChange(v)}>
|
|
38
|
+
<SelectTrigger className="w-full h-8 text-xs">
|
|
39
|
+
<SelectValue />
|
|
40
|
+
</SelectTrigger>
|
|
41
|
+
<SelectContent>
|
|
42
|
+
{def.options.map((opt) => (
|
|
43
|
+
<SelectItem key={opt} value={opt} className="text-xs">
|
|
44
|
+
{opt}
|
|
45
|
+
</SelectItem>
|
|
46
|
+
))}
|
|
47
|
+
</SelectContent>
|
|
48
|
+
</Select>
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
case "boolean":
|
|
52
|
+
return (
|
|
53
|
+
<button
|
|
54
|
+
onClick={() => onChange(!value)}
|
|
55
|
+
className={`flex h-8 w-full items-center justify-between rounded-md border px-2 text-xs cursor-pointer transition-colors ${
|
|
56
|
+
value
|
|
57
|
+
? "border-primary bg-primary/5 text-foreground"
|
|
58
|
+
: "border-input text-muted-foreground"
|
|
59
|
+
}`}
|
|
60
|
+
>
|
|
61
|
+
<span>{value ? "true" : "false"}</span>
|
|
62
|
+
<div
|
|
63
|
+
className={`h-4 w-7 rounded-full transition-colors ${value ? "bg-primary" : "bg-muted"}`}
|
|
64
|
+
>
|
|
65
|
+
<div
|
|
66
|
+
className={`h-4 w-4 rounded-full bg-background border shadow-sm transition-transform ${value ? "translate-x-3" : "translate-x-0"}`}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
</button>
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
case "text":
|
|
73
|
+
return (
|
|
74
|
+
<Input
|
|
75
|
+
type="text"
|
|
76
|
+
value={value as string}
|
|
77
|
+
onChange={(e) => onChange(e.target.value)}
|
|
78
|
+
className="h-8 text-xs"
|
|
79
|
+
/>
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
case "number":
|
|
83
|
+
return (
|
|
84
|
+
<Input
|
|
85
|
+
type="number"
|
|
86
|
+
value={value as number}
|
|
87
|
+
min={def.min}
|
|
88
|
+
max={def.max}
|
|
89
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
90
|
+
className="h-8 text-xs"
|
|
91
|
+
/>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"
|
|
4
|
+
import type { ControlEntry } from "@shell/hooks/use-controls"
|
|
5
|
+
import type { CameraState, CanvasPosition } from "@shell/components/preview-canvas"
|
|
6
|
+
import { PreviewControls } from "@shell/components/preview-controls"
|
|
7
|
+
import { PreviewCanvas } from "@shell/components/preview-canvas"
|
|
8
|
+
import { useTranslations } from "@shell/lib/i18n"
|
|
9
|
+
import { useMobileSidebar } from "@shell/components/sidebar-provider"
|
|
10
|
+
|
|
11
|
+
const SnapshotModeContext = createContext(false)
|
|
12
|
+
|
|
13
|
+
/** Wrap children in this to render PreviewLayout without the canvas chrome. */
|
|
14
|
+
export function SnapshotModeProvider({ children }: { children: React.ReactNode }) {
|
|
15
|
+
return <SnapshotModeContext.Provider value={true}>{children}</SnapshotModeContext.Provider>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function useIsMobile() {
|
|
19
|
+
const [mobile, setMobile] = useState<boolean | null>(null)
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const mq = window.matchMedia("(max-width: 767px)")
|
|
22
|
+
const handler = (e: MediaQueryListEvent) => setMobile(e.matches)
|
|
23
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync with external matchMedia API
|
|
24
|
+
setMobile(mq.matches)
|
|
25
|
+
mq.addEventListener("change", handler)
|
|
26
|
+
return () => mq.removeEventListener("change", handler)
|
|
27
|
+
}, [])
|
|
28
|
+
return mobile
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function PreviewLayout({
|
|
32
|
+
children,
|
|
33
|
+
controls,
|
|
34
|
+
}: {
|
|
35
|
+
children: React.ReactNode
|
|
36
|
+
controls?: ControlEntry[]
|
|
37
|
+
}) {
|
|
38
|
+
const isSnapshot = useContext(SnapshotModeContext)
|
|
39
|
+
const hasControls = controls && controls.length > 0
|
|
40
|
+
const isMobile = useIsMobile()
|
|
41
|
+
|
|
42
|
+
const [showControls, setShowControls] = useState(() => {
|
|
43
|
+
if (typeof window === "undefined") return false
|
|
44
|
+
return sessionStorage.getItem("preview-controls") === "true"
|
|
45
|
+
})
|
|
46
|
+
const [restoredFullscreen] = useState(() => {
|
|
47
|
+
if (typeof window === "undefined") return false
|
|
48
|
+
return sessionStorage.getItem("preview-fullscreen") === "true"
|
|
49
|
+
})
|
|
50
|
+
const [fullscreen, setFullscreen] = useState(restoredFullscreen)
|
|
51
|
+
const [fsVisible, setFsVisible] = useState(restoredFullscreen)
|
|
52
|
+
const t = useTranslations()
|
|
53
|
+
const { setCollapsed } = useMobileSidebar()
|
|
54
|
+
|
|
55
|
+
// Shared camera state between inline and fullscreen canvases
|
|
56
|
+
const [camera, setCamera] = useState<CameraState>({ x: 0, y: 0, zoom: 1 })
|
|
57
|
+
const [position, setPosition] = useState<CanvasPosition>({ x: 0, y: 0 })
|
|
58
|
+
|
|
59
|
+
// Ref to the inline preview container — used to capture its position
|
|
60
|
+
const inlineRef = useRef<HTMLDivElement>(null)
|
|
61
|
+
// The captured rect of the inline preview when entering/exiting fullscreen
|
|
62
|
+
const [originRect, setOriginRect] = useState({ top: 0, bottom: 0, left: 0, right: 0 })
|
|
63
|
+
|
|
64
|
+
// Sync state on mount / mobile change
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (isMobile === null) return
|
|
67
|
+
// Restore fullscreen sidebar collapse
|
|
68
|
+
if (fullscreen && !isMobile) setCollapsed(true)
|
|
69
|
+
}, [isMobile]) // eslint-disable-line react-hooks/exhaustive-deps -- intentionally run only on mount/mobile change
|
|
70
|
+
|
|
71
|
+
// Animate fullscreen in
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (fullscreen) {
|
|
74
|
+
requestAnimationFrame(() => setFsVisible(true))
|
|
75
|
+
}
|
|
76
|
+
}, [fullscreen])
|
|
77
|
+
|
|
78
|
+
const enterFullscreen = useCallback(() => {
|
|
79
|
+
if (inlineRef.current) {
|
|
80
|
+
const rect = inlineRef.current.getBoundingClientRect()
|
|
81
|
+
const vw = window.innerWidth
|
|
82
|
+
const vh = window.innerHeight
|
|
83
|
+
setOriginRect({
|
|
84
|
+
top: rect.top,
|
|
85
|
+
bottom: vh - rect.bottom,
|
|
86
|
+
left: rect.left,
|
|
87
|
+
right: vw - rect.right,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
if (!isMobile) setCollapsed(true)
|
|
91
|
+
setCamera({ x: 0, y: 0, zoom: 1 })
|
|
92
|
+
setPosition({ x: 0, y: 0 })
|
|
93
|
+
setFullscreen(true)
|
|
94
|
+
sessionStorage.setItem("preview-fullscreen", "true")
|
|
95
|
+
}, [isMobile, setCollapsed])
|
|
96
|
+
|
|
97
|
+
// In snapshot mode, render children directly — no canvas, no controls
|
|
98
|
+
if (isSnapshot) {
|
|
99
|
+
return <>{children}</>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toggleControls() {
|
|
103
|
+
if (isMobile) {
|
|
104
|
+
if (fullscreen) {
|
|
105
|
+
setShowControls((s) => {
|
|
106
|
+
sessionStorage.setItem("preview-controls", String(!s))
|
|
107
|
+
return !s
|
|
108
|
+
})
|
|
109
|
+
} else {
|
|
110
|
+
setFullscreen(true)
|
|
111
|
+
setShowControls(true)
|
|
112
|
+
sessionStorage.setItem("preview-controls", "true")
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
setShowControls((s) => {
|
|
116
|
+
sessionStorage.setItem("preview-controls", String(!s))
|
|
117
|
+
return !s
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function exitFullscreen() {
|
|
123
|
+
// Animate sidebar open + preview shrink simultaneously
|
|
124
|
+
if (!isMobile) {
|
|
125
|
+
setCollapsed(false)
|
|
126
|
+
// Set origin to where the inline preview will land (sidebar width = 16rem = 256px on md+)
|
|
127
|
+
setOriginRect((prev) => ({ ...prev, left: 256 }))
|
|
128
|
+
}
|
|
129
|
+
setFsVisible(false)
|
|
130
|
+
setCamera({ x: 0, y: 0, zoom: 1 })
|
|
131
|
+
setPosition({ x: 0, y: 0 })
|
|
132
|
+
sessionStorage.setItem("preview-fullscreen", "false")
|
|
133
|
+
// After animation, switch to relative
|
|
134
|
+
setTimeout(() => {
|
|
135
|
+
setFullscreen(false)
|
|
136
|
+
if (isMobile) {
|
|
137
|
+
setShowControls(false)
|
|
138
|
+
sessionStorage.setItem("preview-controls", "false")
|
|
139
|
+
}
|
|
140
|
+
}, 300)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<>
|
|
145
|
+
{/* Inline placeholder — reserves space in the document flow when fullscreen */}
|
|
146
|
+
{fullscreen && (
|
|
147
|
+
<div ref={inlineRef} className="border-y border-border h-full" />
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{/* Single preview container — animates between inline and fullscreen */}
|
|
151
|
+
<div
|
|
152
|
+
ref={!fullscreen ? inlineRef : undefined}
|
|
153
|
+
className={`
|
|
154
|
+
bg-background border-y border-border overflow-hidden flex flex-col
|
|
155
|
+
${fullscreen ? "fixed z-40 transition-[top,bottom,left,right] duration-300 ease-in-out motion-reduce:transition-none" : "relative h-full"}
|
|
156
|
+
`}
|
|
157
|
+
style={fullscreen ? {
|
|
158
|
+
top: fsVisible ? "3.5rem" : originRect.top,
|
|
159
|
+
bottom: fsVisible ? 0 : originRect.bottom,
|
|
160
|
+
left: fsVisible ? 0 : originRect.left,
|
|
161
|
+
right: fsVisible ? 0 : originRect.right,
|
|
162
|
+
} : undefined}
|
|
163
|
+
>
|
|
164
|
+
<div className="flex-1 min-h-0 relative">
|
|
165
|
+
<PreviewCanvas
|
|
166
|
+
showControls={showControls}
|
|
167
|
+
onToggleControls={hasControls ? toggleControls : undefined}
|
|
168
|
+
fullscreen={fullscreen}
|
|
169
|
+
onToggleFullscreen={fullscreen ? exitFullscreen : enterFullscreen}
|
|
170
|
+
camera={camera}
|
|
171
|
+
onCameraChange={setCamera}
|
|
172
|
+
position={position}
|
|
173
|
+
onPositionChange={setPosition}
|
|
174
|
+
>
|
|
175
|
+
{children}
|
|
176
|
+
</PreviewCanvas>
|
|
177
|
+
|
|
178
|
+
{/* Desktop props panel — floating card top-right */}
|
|
179
|
+
{hasControls && (
|
|
180
|
+
<aside
|
|
181
|
+
className={`
|
|
182
|
+
hidden md:flex flex-col absolute top-14 right-3 z-50 w-56 rounded-lg border border-border bg-background shadow-xl overflow-hidden transition-all duration-300 ease-in-out motion-reduce:transition-none
|
|
183
|
+
${showControls ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2 pointer-events-none"}
|
|
184
|
+
`}
|
|
185
|
+
style={{ maxHeight: fullscreen ? "calc(100vh - 10rem)" : "calc(100% - 4.5rem)" }}
|
|
186
|
+
>
|
|
187
|
+
<div className="shrink-0 px-4 pt-4 pb-3">
|
|
188
|
+
<p className="text-xs font-semibold text-foreground">
|
|
189
|
+
{t("controls.title")}
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
|
|
193
|
+
<PreviewControls entries={controls} />
|
|
194
|
+
</div>
|
|
195
|
+
</aside>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{/* Mobile props panel — inline, pushes preview up */}
|
|
200
|
+
{hasControls && (
|
|
201
|
+
<div
|
|
202
|
+
className="md:hidden shrink-0 border-t border-border bg-background transition-[max-height] duration-300 ease-in-out motion-reduce:transition-none overflow-hidden flex flex-col"
|
|
203
|
+
style={{ maxHeight: showControls ? "50vh" : 0 }}
|
|
204
|
+
>
|
|
205
|
+
<div className="shrink-0 px-4 pt-4 pb-3">
|
|
206
|
+
<p className="text-xs font-semibold text-foreground">
|
|
207
|
+
{t("controls.title")}
|
|
208
|
+
</p>
|
|
209
|
+
</div>
|
|
210
|
+
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-8">
|
|
211
|
+
<PreviewControls entries={controls} />
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
</>
|
|
217
|
+
)
|
|
218
|
+
}
|