prev-cli 0.10.0 → 0.11.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/package.json +1 -1
- package/src/theme/Preview.tsx +335 -58
package/package.json
CHANGED
package/src/theme/Preview.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react'
|
|
2
|
-
import { Maximize2, Minimize2,
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
|
+
import { Smartphone, Tablet, Monitor, SunMedium, Moon, Maximize2, Minimize2, GripVertical, SlidersHorizontal, X } from 'lucide-react'
|
|
3
3
|
|
|
4
4
|
interface PreviewProps {
|
|
5
5
|
src: string
|
|
@@ -7,18 +7,45 @@ interface PreviewProps {
|
|
|
7
7
|
title?: string
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
type DeviceMode = 'mobile' | 'tablet' | 'desktop'
|
|
11
|
+
|
|
12
|
+
const DEVICE_WIDTHS: Record<DeviceMode, number | '100%'> = {
|
|
13
|
+
mobile: 375,
|
|
14
|
+
tablet: 768,
|
|
15
|
+
desktop: '100%',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Position {
|
|
19
|
+
x: number
|
|
20
|
+
y: number
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
export function Preview({ src, height = 400, title }: PreviewProps) {
|
|
11
24
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
|
25
|
+
const [deviceMode, setDeviceMode] = useState<DeviceMode>('desktop')
|
|
26
|
+
const [customWidth, setCustomWidth] = useState<number | null>(null)
|
|
27
|
+
const [isDarkMode, setIsDarkMode] = useState(false)
|
|
28
|
+
const [showSlider, setShowSlider] = useState(false)
|
|
29
|
+
const [pillPosition, setPillPosition] = useState<Position>({ x: 0, y: 0 })
|
|
30
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
31
|
+
const [dragOffset, setDragOffset] = useState<Position>({ x: 0, y: 0 })
|
|
32
|
+
|
|
33
|
+
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
34
|
+
const pillRef = useRef<HTMLDivElement>(null)
|
|
35
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
36
|
+
|
|
12
37
|
const previewUrl = `/_preview/${src}`
|
|
13
38
|
const displayTitle = title || src
|
|
14
39
|
|
|
40
|
+
// Calculate current width
|
|
41
|
+
const currentWidth = customWidth ?? (DEVICE_WIDTHS[deviceMode] === '100%' ? null : DEVICE_WIDTHS[deviceMode] as number)
|
|
42
|
+
|
|
43
|
+
// Handle escape key and body scroll lock
|
|
15
44
|
useEffect(() => {
|
|
16
45
|
if (!isFullscreen) return
|
|
17
46
|
|
|
18
|
-
// Lock body scroll
|
|
19
47
|
document.body.style.overflow = 'hidden'
|
|
20
48
|
|
|
21
|
-
// Handle escape key
|
|
22
49
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
23
50
|
if (e.key === 'Escape') {
|
|
24
51
|
setIsFullscreen(false)
|
|
@@ -32,74 +59,324 @@ export function Preview({ src, height = 400, title }: PreviewProps) {
|
|
|
32
59
|
}
|
|
33
60
|
}, [isFullscreen])
|
|
34
61
|
|
|
35
|
-
|
|
62
|
+
// Toggle dark mode on iframe
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const iframe = iframeRef.current
|
|
65
|
+
if (!iframe) return
|
|
66
|
+
|
|
67
|
+
const applyDarkMode = () => {
|
|
68
|
+
try {
|
|
69
|
+
const doc = iframe.contentDocument
|
|
70
|
+
if (doc?.documentElement) {
|
|
71
|
+
if (isDarkMode) {
|
|
72
|
+
doc.documentElement.classList.add('dark')
|
|
73
|
+
} else {
|
|
74
|
+
doc.documentElement.classList.remove('dark')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Cross-origin iframe, can't access
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Apply on load and when mode changes
|
|
83
|
+
iframe.addEventListener('load', applyDarkMode)
|
|
84
|
+
applyDarkMode()
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
iframe.removeEventListener('load', applyDarkMode)
|
|
88
|
+
}
|
|
89
|
+
}, [isDarkMode])
|
|
90
|
+
|
|
91
|
+
// Drag handlers
|
|
92
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
93
|
+
if (!pillRef.current) return
|
|
94
|
+
e.preventDefault()
|
|
95
|
+
|
|
96
|
+
const rect = pillRef.current.getBoundingClientRect()
|
|
97
|
+
setDragOffset({
|
|
98
|
+
x: e.clientX - rect.left,
|
|
99
|
+
y: e.clientY - rect.top,
|
|
100
|
+
})
|
|
101
|
+
setIsDragging(true)
|
|
102
|
+
}, [])
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!isDragging) return
|
|
106
|
+
|
|
107
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
108
|
+
const container = isFullscreen ? document.body : containerRef.current
|
|
109
|
+
if (!container) return
|
|
110
|
+
|
|
111
|
+
const containerRect = isFullscreen
|
|
112
|
+
? { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }
|
|
113
|
+
: container.getBoundingClientRect()
|
|
114
|
+
|
|
115
|
+
const pillWidth = pillRef.current?.offsetWidth || 0
|
|
116
|
+
const pillHeight = pillRef.current?.offsetHeight || 0
|
|
117
|
+
|
|
118
|
+
// Calculate position relative to container
|
|
119
|
+
let newX = e.clientX - containerRect.left - dragOffset.x
|
|
120
|
+
let newY = e.clientY - containerRect.top - dragOffset.y
|
|
121
|
+
|
|
122
|
+
// Constrain to container bounds
|
|
123
|
+
newX = Math.max(0, Math.min(newX, containerRect.width - pillWidth))
|
|
124
|
+
newY = Math.max(0, Math.min(newY, containerRect.height - pillHeight))
|
|
125
|
+
|
|
126
|
+
setPillPosition({ x: newX, y: newY })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const handleMouseUp = () => {
|
|
130
|
+
setIsDragging(false)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
134
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
135
|
+
|
|
136
|
+
return () => {
|
|
137
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
138
|
+
document.removeEventListener('mouseup', handleMouseUp)
|
|
139
|
+
}
|
|
140
|
+
}, [isDragging, dragOffset, isFullscreen])
|
|
141
|
+
|
|
142
|
+
// Reset pill position when entering/exiting fullscreen
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
setPillPosition({ x: 0, y: 0 })
|
|
145
|
+
}, [isFullscreen])
|
|
146
|
+
|
|
147
|
+
const handleDeviceChange = (mode: DeviceMode) => {
|
|
148
|
+
setDeviceMode(mode)
|
|
149
|
+
setCustomWidth(null)
|
|
150
|
+
setShowSlider(false)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const handleSliderChange = (value: number) => {
|
|
154
|
+
setCustomWidth(value)
|
|
155
|
+
setDeviceMode('desktop') // Clear device mode when using custom
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Icon button component
|
|
159
|
+
const IconButton = ({
|
|
160
|
+
onClick,
|
|
161
|
+
active,
|
|
162
|
+
title: btnTitle,
|
|
163
|
+
children
|
|
164
|
+
}: {
|
|
165
|
+
onClick: () => void
|
|
166
|
+
active?: boolean
|
|
167
|
+
title: string
|
|
168
|
+
children: React.ReactNode
|
|
169
|
+
}) => (
|
|
170
|
+
<button
|
|
171
|
+
onClick={onClick}
|
|
172
|
+
className={`p-1.5 rounded transition-colors ${
|
|
173
|
+
active
|
|
174
|
+
? 'bg-blue-500 text-white'
|
|
175
|
+
: 'text-zinc-500 hover:bg-zinc-200 dark:hover:bg-zinc-700 hover:text-zinc-700 dark:hover:text-zinc-300'
|
|
176
|
+
}`}
|
|
177
|
+
title={btnTitle}
|
|
178
|
+
aria-label={btnTitle}
|
|
179
|
+
>
|
|
180
|
+
{children}
|
|
181
|
+
</button>
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
// Floating DevTools Pill
|
|
185
|
+
const DevToolsPill = () => {
|
|
186
|
+
const pillStyle: React.CSSProperties = pillPosition.x === 0 && pillPosition.y === 0
|
|
187
|
+
? { bottom: 12, right: 12 }
|
|
188
|
+
: { left: pillPosition.x, top: pillPosition.y }
|
|
189
|
+
|
|
36
190
|
return (
|
|
37
|
-
<div
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
<button
|
|
52
|
-
onClick={() => setIsFullscreen(false)}
|
|
53
|
-
className="p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded"
|
|
54
|
-
title="Exit fullscreen"
|
|
55
|
-
aria-label="Exit fullscreen"
|
|
56
|
-
>
|
|
57
|
-
<Minimize2 className="w-4 h-4" />
|
|
58
|
-
</button>
|
|
59
|
-
</div>
|
|
191
|
+
<div
|
|
192
|
+
ref={pillRef}
|
|
193
|
+
className={`absolute z-50 flex items-center gap-0.5 px-1.5 py-1 bg-white dark:bg-zinc-800 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-700 ${
|
|
194
|
+
isDragging ? 'cursor-grabbing' : ''
|
|
195
|
+
}`}
|
|
196
|
+
style={pillStyle}
|
|
197
|
+
>
|
|
198
|
+
{/* Drag handle */}
|
|
199
|
+
<div
|
|
200
|
+
onMouseDown={handleMouseDown}
|
|
201
|
+
className="p-1 cursor-grab text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
|
|
202
|
+
title="Drag to move"
|
|
203
|
+
>
|
|
204
|
+
<GripVertical className="w-3 h-3" />
|
|
60
205
|
</div>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
206
|
+
|
|
207
|
+
<div className="w-px h-4 bg-zinc-200 dark:bg-zinc-700 mx-0.5" />
|
|
208
|
+
|
|
209
|
+
{/* Device modes */}
|
|
210
|
+
<IconButton
|
|
211
|
+
onClick={() => handleDeviceChange('mobile')}
|
|
212
|
+
active={deviceMode === 'mobile' && customWidth === null}
|
|
213
|
+
title="Mobile (375px)"
|
|
214
|
+
>
|
|
215
|
+
<Smartphone className="w-3.5 h-3.5" />
|
|
216
|
+
</IconButton>
|
|
217
|
+
|
|
218
|
+
<IconButton
|
|
219
|
+
onClick={() => handleDeviceChange('tablet')}
|
|
220
|
+
active={deviceMode === 'tablet' && customWidth === null}
|
|
221
|
+
title="Tablet (768px)"
|
|
222
|
+
>
|
|
223
|
+
<Tablet className="w-3.5 h-3.5" />
|
|
224
|
+
</IconButton>
|
|
225
|
+
|
|
226
|
+
<IconButton
|
|
227
|
+
onClick={() => handleDeviceChange('desktop')}
|
|
228
|
+
active={deviceMode === 'desktop' && customWidth === null}
|
|
229
|
+
title="Desktop (100%)"
|
|
230
|
+
>
|
|
231
|
+
<Monitor className="w-3.5 h-3.5" />
|
|
232
|
+
</IconButton>
|
|
233
|
+
|
|
234
|
+
<div className="w-px h-4 bg-zinc-200 dark:bg-zinc-700 mx-0.5" />
|
|
235
|
+
|
|
236
|
+
{/* Width slider toggle */}
|
|
237
|
+
<div className="relative">
|
|
238
|
+
<IconButton
|
|
239
|
+
onClick={() => setShowSlider(!showSlider)}
|
|
240
|
+
active={showSlider || customWidth !== null}
|
|
241
|
+
title="Custom width"
|
|
242
|
+
>
|
|
243
|
+
<SlidersHorizontal className="w-3.5 h-3.5" />
|
|
244
|
+
</IconButton>
|
|
245
|
+
|
|
246
|
+
{/* Slider popup */}
|
|
247
|
+
{showSlider && (
|
|
248
|
+
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-3 bg-white dark:bg-zinc-800 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-700 min-w-48">
|
|
249
|
+
<div className="flex items-center justify-between mb-2">
|
|
250
|
+
<span className="text-xs text-zinc-500">Width: {customWidth ?? currentWidth ?? '100%'}px</span>
|
|
251
|
+
<button
|
|
252
|
+
onClick={() => setShowSlider(false)}
|
|
253
|
+
className="p-0.5 text-zinc-400 hover:text-zinc-600"
|
|
254
|
+
>
|
|
255
|
+
<X className="w-3 h-3" />
|
|
256
|
+
</button>
|
|
257
|
+
</div>
|
|
258
|
+
<input
|
|
259
|
+
type="range"
|
|
260
|
+
min={320}
|
|
261
|
+
max={1920}
|
|
262
|
+
value={customWidth ?? (typeof currentWidth === 'number' ? currentWidth : 1920)}
|
|
263
|
+
onChange={(e) => handleSliderChange(parseInt(e.target.value))}
|
|
264
|
+
className="w-full accent-blue-500"
|
|
265
|
+
/>
|
|
266
|
+
<div className="flex justify-between text-xs text-zinc-400 mt-1">
|
|
267
|
+
<span>320px</span>
|
|
268
|
+
<span>1920px</span>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
<div className="w-px h-4 bg-zinc-200 dark:bg-zinc-700 mx-0.5" />
|
|
275
|
+
|
|
276
|
+
{/* Dark mode toggle */}
|
|
277
|
+
<IconButton
|
|
278
|
+
onClick={() => setIsDarkMode(!isDarkMode)}
|
|
279
|
+
active={isDarkMode}
|
|
280
|
+
title={isDarkMode ? 'Light mode' : 'Dark mode'}
|
|
281
|
+
>
|
|
282
|
+
{isDarkMode ? <Moon className="w-3.5 h-3.5" /> : <SunMedium className="w-3.5 h-3.5" />}
|
|
283
|
+
</IconButton>
|
|
284
|
+
|
|
285
|
+
{/* Fullscreen toggle */}
|
|
286
|
+
<IconButton
|
|
287
|
+
onClick={() => setIsFullscreen(!isFullscreen)}
|
|
288
|
+
active={isFullscreen}
|
|
289
|
+
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
290
|
+
>
|
|
291
|
+
{isFullscreen ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
|
|
292
|
+
</IconButton>
|
|
293
|
+
</div>
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Calculate iframe style
|
|
298
|
+
const getIframeContainerStyle = (): React.CSSProperties => {
|
|
299
|
+
if (currentWidth === null) {
|
|
300
|
+
return { width: '100%' }
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
width: currentWidth,
|
|
304
|
+
maxWidth: '100%',
|
|
305
|
+
margin: '0 auto',
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (isFullscreen) {
|
|
310
|
+
return (
|
|
311
|
+
<div className="fixed inset-0 z-40 bg-zinc-100 dark:bg-zinc-900 flex items-start justify-center overflow-auto">
|
|
312
|
+
{/* Checkered background pattern */}
|
|
313
|
+
<div
|
|
314
|
+
className="absolute inset-0 opacity-50"
|
|
315
|
+
style={{
|
|
316
|
+
backgroundImage: 'linear-gradient(45deg, #e5e5e5 25%, transparent 25%), linear-gradient(-45deg, #e5e5e5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e5e5e5 75%), linear-gradient(-45deg, transparent 75%, #e5e5e5 75%)',
|
|
317
|
+
backgroundSize: '20px 20px',
|
|
318
|
+
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',
|
|
319
|
+
}}
|
|
65
320
|
/>
|
|
321
|
+
|
|
322
|
+
{/* Iframe container */}
|
|
323
|
+
<div
|
|
324
|
+
className="relative bg-white dark:bg-zinc-900 shadow-2xl transition-all duration-300 h-full"
|
|
325
|
+
style={getIframeContainerStyle()}
|
|
326
|
+
>
|
|
327
|
+
<iframe
|
|
328
|
+
ref={iframeRef}
|
|
329
|
+
src={previewUrl}
|
|
330
|
+
className="w-full h-full"
|
|
331
|
+
title={displayTitle}
|
|
332
|
+
/>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
<DevToolsPill />
|
|
66
336
|
</div>
|
|
67
337
|
)
|
|
68
338
|
}
|
|
69
339
|
|
|
70
340
|
return (
|
|
71
|
-
<div
|
|
341
|
+
<div
|
|
342
|
+
ref={containerRef}
|
|
343
|
+
className="my-4 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden relative"
|
|
344
|
+
>
|
|
345
|
+
{/* Header */}
|
|
72
346
|
<div className="flex items-center justify-between px-3 py-2 bg-zinc-50 dark:bg-zinc-800 border-b border-zinc-200 dark:border-zinc-700">
|
|
73
347
|
<span className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
|
|
74
348
|
{displayTitle}
|
|
75
349
|
</span>
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
350
|
+
<span className="text-xs text-zinc-400">
|
|
351
|
+
{currentWidth ? `${currentWidth}px` : '100%'}
|
|
352
|
+
</span>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
{/* Preview area with checkered background */}
|
|
356
|
+
<div
|
|
357
|
+
className="relative bg-zinc-100 dark:bg-zinc-900"
|
|
358
|
+
style={{
|
|
359
|
+
backgroundImage: 'linear-gradient(45deg, #e5e5e5 25%, transparent 25%), linear-gradient(-45deg, #e5e5e5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e5e5e5 75%), linear-gradient(-45deg, transparent 75%, #e5e5e5 75%)',
|
|
360
|
+
backgroundSize: '16px 16px',
|
|
361
|
+
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
|
|
362
|
+
}}
|
|
363
|
+
>
|
|
364
|
+
{/* Iframe container */}
|
|
365
|
+
<div
|
|
366
|
+
className="bg-white dark:bg-zinc-900 transition-all duration-300"
|
|
367
|
+
style={getIframeContainerStyle()}
|
|
368
|
+
>
|
|
369
|
+
<iframe
|
|
370
|
+
ref={iframeRef}
|
|
371
|
+
src={previewUrl}
|
|
372
|
+
style={{ height: typeof height === 'number' ? `${height}px` : height }}
|
|
373
|
+
className="w-full"
|
|
374
|
+
title={displayTitle}
|
|
375
|
+
/>
|
|
95
376
|
</div>
|
|
377
|
+
|
|
378
|
+
<DevToolsPill />
|
|
96
379
|
</div>
|
|
97
|
-
<iframe
|
|
98
|
-
src={previewUrl}
|
|
99
|
-
style={{ height: typeof height === 'number' ? `${height}px` : height }}
|
|
100
|
-
className="w-full"
|
|
101
|
-
title={displayTitle}
|
|
102
|
-
/>
|
|
103
380
|
</div>
|
|
104
381
|
)
|
|
105
382
|
}
|