@jogak/ui 0.1.0-alpha.1 → 0.1.0-alpha.3
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 +21 -0
- package/package.json +8 -3
- package/src/app/App.tsx +169 -0
- package/src/app/main.tsx +14 -0
- package/src/components/Actions/index.tsx +122 -0
- package/src/components/Controls/index.tsx +211 -0
- package/src/components/Preview/index.tsx +739 -0
- package/src/components/Sidebar/index.tsx +312 -0
- package/src/hooks/useRegistry.ts +22 -0
- package/src/index.ts +12 -0
- package/src/vite-env.d.ts +6 -0
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { ReactElement, CSSProperties } from 'react'
|
|
3
|
+
import { Highlight, themes } from 'prism-react-renderer'
|
|
4
|
+
import type { PrismTheme } from 'prism-react-renderer'
|
|
5
|
+
import { reactAdapter, useEntry } from '@jogak/react'
|
|
6
|
+
import type { UseEntryState } from '@jogak/react'
|
|
7
|
+
import type { RegistryEntry, RegistryEntryMeta, ArgType } from '@jogak/core'
|
|
8
|
+
import { Controls } from '../Controls/index.js'
|
|
9
|
+
import { Actions } from '../Actions/index.js'
|
|
10
|
+
|
|
11
|
+
export interface PreviewProps {
|
|
12
|
+
readonly entryId: string
|
|
13
|
+
readonly jogakName: string | null
|
|
14
|
+
readonly overrideArgs: Readonly<Record<string, unknown>>
|
|
15
|
+
readonly onArgChange: (key: string, value: unknown) => void
|
|
16
|
+
readonly onReset: () => void
|
|
17
|
+
readonly codeTheme: string
|
|
18
|
+
/**
|
|
19
|
+
* URL deep link `?entry=<id>` (jogak 미지정) 케이스에서 entry hydrate 후
|
|
20
|
+
* 첫 jogak로 자동 보정하기 위한 콜백. 부모가 selectedJogakName / URL을 갱신.
|
|
21
|
+
*/
|
|
22
|
+
readonly onResolveJogak?: (entryId: string, jogakName: string) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type ViewportKey = 'mobile' | 'tablet' | 'desktop'
|
|
26
|
+
type BgMode = 'white' | 'dark' | 'transparent'
|
|
27
|
+
|
|
28
|
+
const VIEWPORT_WIDTHS: Record<ViewportKey, number | 'none'> = {
|
|
29
|
+
mobile: 375,
|
|
30
|
+
tablet: 768,
|
|
31
|
+
desktop: 'none',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const VIEWPORT_LABELS: Record<ViewportKey, string> = {
|
|
35
|
+
mobile: 'Mobile',
|
|
36
|
+
tablet: 'Tablet',
|
|
37
|
+
desktop: 'Desktop',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const BG_STYLES: Record<BgMode, CSSProperties> = {
|
|
41
|
+
white: { background: '#ffffff' },
|
|
42
|
+
dark: { background: '#1f2937' },
|
|
43
|
+
transparent: {
|
|
44
|
+
backgroundImage: [
|
|
45
|
+
'linear-gradient(45deg, #e2e8f0 25%, transparent 25%)',
|
|
46
|
+
'linear-gradient(-45deg, #e2e8f0 25%, transparent 25%)',
|
|
47
|
+
'linear-gradient(45deg, transparent 75%, #e2e8f0 75%)',
|
|
48
|
+
'linear-gradient(-45deg, transparent 75%, #e2e8f0 75%)',
|
|
49
|
+
].join(', '),
|
|
50
|
+
backgroundSize: '16px 16px',
|
|
51
|
+
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
|
|
52
|
+
backgroundColor: '#ffffff',
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 캔버스 영역 minHeight — loading/ready 사이 layout shift 방지 (계약 §10). */
|
|
57
|
+
const CANVAS_MIN_HEIGHT = 320
|
|
58
|
+
|
|
59
|
+
function resolvePrismTheme(name: string): PrismTheme {
|
|
60
|
+
const map = themes as Record<string, PrismTheme | undefined>
|
|
61
|
+
return map[name] ?? themes.vsDark
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Preview — `useEntry(entryId)`의 status에 따라 분기 (계약 §5.4).
|
|
66
|
+
*
|
|
67
|
+
* - `loading` → 메타로 헤더(title, jogak 이름)만 표시, 캔버스에 skeleton
|
|
68
|
+
* - `ready` → 현행 렌더 (entry.jogaks/component 사용)
|
|
69
|
+
* - `error` → 에러 패널
|
|
70
|
+
* - `unknown` → "Entry not found" placeholder
|
|
71
|
+
*
|
|
72
|
+
* Layout shift 방지를 위해 캔버스 영역 minHeight 유지.
|
|
73
|
+
*/
|
|
74
|
+
export function Preview({
|
|
75
|
+
entryId,
|
|
76
|
+
jogakName,
|
|
77
|
+
overrideArgs,
|
|
78
|
+
onArgChange,
|
|
79
|
+
onReset,
|
|
80
|
+
codeTheme,
|
|
81
|
+
onResolveJogak,
|
|
82
|
+
}: PreviewProps): ReactElement {
|
|
83
|
+
const state = useEntry(entryId)
|
|
84
|
+
const [viewport, setViewport] = useState<ViewportKey>('desktop')
|
|
85
|
+
const [bgMode, setBgMode] = useState<BgMode>('white')
|
|
86
|
+
const [bottomTab, setBottomTab] = useState<'controls' | 'actions'>('controls')
|
|
87
|
+
|
|
88
|
+
const prismTheme = resolvePrismTheme(codeTheme)
|
|
89
|
+
|
|
90
|
+
// ── unknown ───────────────────────────────────────────────
|
|
91
|
+
if (state.status === 'unknown') {
|
|
92
|
+
return (
|
|
93
|
+
<div data-testid="preview-not-found" style={{ padding: 24, color: '#ef4444' }}>
|
|
94
|
+
Entry not found: {entryId}
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── error ─────────────────────────────────────────────────
|
|
100
|
+
if (state.status === 'error') {
|
|
101
|
+
return (
|
|
102
|
+
<div
|
|
103
|
+
data-testid="preview-error"
|
|
104
|
+
style={{
|
|
105
|
+
padding: 24,
|
|
106
|
+
color: '#b91c1c',
|
|
107
|
+
background: '#fef2f2',
|
|
108
|
+
height: '100%',
|
|
109
|
+
display: 'flex',
|
|
110
|
+
flexDirection: 'column',
|
|
111
|
+
gap: 12,
|
|
112
|
+
alignItems: 'flex-start',
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<div style={{ fontWeight: 600 }}>Failed to load entry: {entryId}</div>
|
|
116
|
+
<pre
|
|
117
|
+
style={{
|
|
118
|
+
margin: 0,
|
|
119
|
+
padding: 12,
|
|
120
|
+
background: '#fff',
|
|
121
|
+
border: '1px solid #fecaca',
|
|
122
|
+
borderRadius: 6,
|
|
123
|
+
fontSize: 12,
|
|
124
|
+
whiteSpace: 'pre-wrap',
|
|
125
|
+
maxWidth: '100%',
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
{state.error.message}
|
|
129
|
+
</pre>
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── loading ───────────────────────────────────────────────
|
|
135
|
+
if (state.status === 'loading') {
|
|
136
|
+
return (
|
|
137
|
+
<LoadingFrame
|
|
138
|
+
meta={state.meta}
|
|
139
|
+
jogakName={jogakName}
|
|
140
|
+
viewport={viewport}
|
|
141
|
+
bgMode={bgMode}
|
|
142
|
+
onViewportChange={setViewport}
|
|
143
|
+
onBgModeChange={setBgMode}
|
|
144
|
+
/>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── ready ─────────────────────────────────────────────────
|
|
149
|
+
return (
|
|
150
|
+
<ReadyFrame
|
|
151
|
+
entry={state.entry}
|
|
152
|
+
jogakName={jogakName}
|
|
153
|
+
overrideArgs={overrideArgs}
|
|
154
|
+
onArgChange={onArgChange}
|
|
155
|
+
onReset={onReset}
|
|
156
|
+
onResolveJogak={onResolveJogak}
|
|
157
|
+
viewport={viewport}
|
|
158
|
+
bgMode={bgMode}
|
|
159
|
+
bottomTab={bottomTab}
|
|
160
|
+
onViewportChange={setViewport}
|
|
161
|
+
onBgModeChange={setBgMode}
|
|
162
|
+
onBottomTabChange={setBottomTab}
|
|
163
|
+
prismTheme={prismTheme}
|
|
164
|
+
/>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── LoadingFrame ──────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
interface LoadingFrameProps {
|
|
171
|
+
readonly meta: RegistryEntryMeta
|
|
172
|
+
readonly jogakName: string | null
|
|
173
|
+
readonly viewport: ViewportKey
|
|
174
|
+
readonly bgMode: BgMode
|
|
175
|
+
readonly onViewportChange: (vp: ViewportKey) => void
|
|
176
|
+
readonly onBgModeChange: (bg: BgMode) => void
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function LoadingFrame({
|
|
180
|
+
meta,
|
|
181
|
+
jogakName,
|
|
182
|
+
viewport,
|
|
183
|
+
bgMode,
|
|
184
|
+
onViewportChange,
|
|
185
|
+
onBgModeChange,
|
|
186
|
+
}: LoadingFrameProps): ReactElement {
|
|
187
|
+
const displayJogak = jogakName ?? meta.jogakNames[0] ?? '...'
|
|
188
|
+
const maxWidth = VIEWPORT_WIDTHS[viewport]
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div
|
|
192
|
+
data-testid="preview-loading"
|
|
193
|
+
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
|
|
194
|
+
>
|
|
195
|
+
<Toolbar
|
|
196
|
+
title={meta.title}
|
|
197
|
+
jogakName={displayJogak}
|
|
198
|
+
viewport={viewport}
|
|
199
|
+
bgMode={bgMode}
|
|
200
|
+
onViewportChange={onViewportChange}
|
|
201
|
+
onBgModeChange={onBgModeChange}
|
|
202
|
+
showReset={false}
|
|
203
|
+
onReset={() => {}}
|
|
204
|
+
/>
|
|
205
|
+
<div
|
|
206
|
+
style={{
|
|
207
|
+
flex: 1,
|
|
208
|
+
minHeight: CANVAS_MIN_HEIGHT,
|
|
209
|
+
overflow: 'auto',
|
|
210
|
+
...BG_STYLES[bgMode],
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
<div
|
|
214
|
+
style={{
|
|
215
|
+
maxWidth: maxWidth === 'none' ? '100%' : maxWidth,
|
|
216
|
+
margin: '0 auto',
|
|
217
|
+
padding: 24,
|
|
218
|
+
}}
|
|
219
|
+
>
|
|
220
|
+
<div
|
|
221
|
+
style={{
|
|
222
|
+
border: '1px dashed #e5e7eb',
|
|
223
|
+
borderRadius: 8,
|
|
224
|
+
padding: 16,
|
|
225
|
+
minHeight: CANVAS_MIN_HEIGHT - 64,
|
|
226
|
+
display: 'flex',
|
|
227
|
+
alignItems: 'center',
|
|
228
|
+
justifyContent: 'center',
|
|
229
|
+
color: '#9ca3af',
|
|
230
|
+
fontSize: 13,
|
|
231
|
+
background:
|
|
232
|
+
'linear-gradient(90deg, rgba(229,231,235,0) 0%, rgba(229,231,235,0.45) 50%, rgba(229,231,235,0) 100%)',
|
|
233
|
+
backgroundSize: '200% 100%',
|
|
234
|
+
animation: 'jogakSkeleton 1.4s ease-in-out infinite',
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
Loading {meta.title}…
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
<style>
|
|
242
|
+
{`@keyframes jogakSkeleton { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }`}
|
|
243
|
+
</style>
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── ReadyFrame ────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
interface ReadyFrameProps {
|
|
251
|
+
readonly entry: RegistryEntry
|
|
252
|
+
readonly jogakName: string | null
|
|
253
|
+
readonly overrideArgs: Readonly<Record<string, unknown>>
|
|
254
|
+
readonly onArgChange: (key: string, value: unknown) => void
|
|
255
|
+
readonly onReset: () => void
|
|
256
|
+
readonly onResolveJogak: ((entryId: string, jogakName: string) => void) | undefined
|
|
257
|
+
readonly viewport: ViewportKey
|
|
258
|
+
readonly bgMode: BgMode
|
|
259
|
+
readonly bottomTab: 'controls' | 'actions'
|
|
260
|
+
readonly onViewportChange: (vp: ViewportKey) => void
|
|
261
|
+
readonly onBgModeChange: (bg: BgMode) => void
|
|
262
|
+
readonly onBottomTabChange: (tab: 'controls' | 'actions') => void
|
|
263
|
+
readonly prismTheme: PrismTheme
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function ReadyFrame({
|
|
267
|
+
entry,
|
|
268
|
+
jogakName,
|
|
269
|
+
overrideArgs,
|
|
270
|
+
onArgChange,
|
|
271
|
+
onReset,
|
|
272
|
+
onResolveJogak,
|
|
273
|
+
viewport,
|
|
274
|
+
bgMode,
|
|
275
|
+
bottomTab,
|
|
276
|
+
onViewportChange,
|
|
277
|
+
onBgModeChange,
|
|
278
|
+
onBottomTabChange,
|
|
279
|
+
prismTheme,
|
|
280
|
+
}: ReadyFrameProps): ReactElement {
|
|
281
|
+
// jogakName이 비어있으면 (deep link `?entry=...&jogak` 누락) 첫 jogak로 보정.
|
|
282
|
+
const resolvedJogakName = jogakName ?? entry.jogaks[0]?.name ?? null
|
|
283
|
+
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
if (jogakName === null && resolvedJogakName !== null && onResolveJogak !== undefined) {
|
|
286
|
+
onResolveJogak(entry.id, resolvedJogakName)
|
|
287
|
+
}
|
|
288
|
+
}, [jogakName, resolvedJogakName, entry.id, onResolveJogak])
|
|
289
|
+
|
|
290
|
+
if (resolvedJogakName === null) {
|
|
291
|
+
return (
|
|
292
|
+
<div style={{ padding: 24, color: '#ef4444' }}>
|
|
293
|
+
Entry has no jogaks: {entry.id}
|
|
294
|
+
</div>
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const jogak = entry.jogaks.find((j) => j.name === resolvedJogakName)
|
|
299
|
+
if (jogak === undefined) {
|
|
300
|
+
return (
|
|
301
|
+
<div style={{ padding: 24, color: '#ef4444' }}>
|
|
302
|
+
Jogak not found: {resolvedJogakName}
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const baseArgs = jogak.args ?? {}
|
|
308
|
+
const mergedArgs = { ...baseArgs, ...overrideArgs }
|
|
309
|
+
const mergedArgTypes: Readonly<Record<string, ArgType>> = {
|
|
310
|
+
...(entry.meta.argTypes ?? {}),
|
|
311
|
+
...(jogak.argTypes ?? {}),
|
|
312
|
+
}
|
|
313
|
+
const hasOverrides = Object.keys(overrideArgs).length > 0
|
|
314
|
+
const maxWidth = VIEWPORT_WIDTHS[viewport]
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
318
|
+
<Toolbar
|
|
319
|
+
title={entry.title}
|
|
320
|
+
jogakName={jogak.name}
|
|
321
|
+
viewport={viewport}
|
|
322
|
+
bgMode={bgMode}
|
|
323
|
+
onViewportChange={onViewportChange}
|
|
324
|
+
onBgModeChange={onBgModeChange}
|
|
325
|
+
showReset={hasOverrides}
|
|
326
|
+
onReset={onReset}
|
|
327
|
+
/>
|
|
328
|
+
|
|
329
|
+
{/* ── 캔버스 ───────────────────────────────────────── */}
|
|
330
|
+
<div
|
|
331
|
+
style={{
|
|
332
|
+
flex: 1,
|
|
333
|
+
minHeight: CANVAS_MIN_HEIGHT,
|
|
334
|
+
overflow: 'auto',
|
|
335
|
+
...BG_STYLES[bgMode],
|
|
336
|
+
}}
|
|
337
|
+
>
|
|
338
|
+
<div
|
|
339
|
+
style={{
|
|
340
|
+
maxWidth: maxWidth === 'none' ? '100%' : maxWidth,
|
|
341
|
+
margin: '0 auto',
|
|
342
|
+
padding: 24,
|
|
343
|
+
}}
|
|
344
|
+
>
|
|
345
|
+
<JogakRenderer
|
|
346
|
+
key={`${entry.id}/${jogak.name}`}
|
|
347
|
+
entry={entry}
|
|
348
|
+
args={mergedArgs}
|
|
349
|
+
source={entry.source}
|
|
350
|
+
theme={prismTheme}
|
|
351
|
+
/>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
{/* ── 컨트롤/액션 패널 ──────────────────────────────── */}
|
|
356
|
+
<div
|
|
357
|
+
style={{
|
|
358
|
+
height: 260,
|
|
359
|
+
flexShrink: 0,
|
|
360
|
+
display: 'flex',
|
|
361
|
+
flexDirection: 'column',
|
|
362
|
+
borderTop: '2px solid #e5e7eb',
|
|
363
|
+
}}
|
|
364
|
+
>
|
|
365
|
+
<div
|
|
366
|
+
role="tablist"
|
|
367
|
+
style={{
|
|
368
|
+
display: 'flex',
|
|
369
|
+
gap: 4,
|
|
370
|
+
padding: '4px 12px 0',
|
|
371
|
+
background: '#fff',
|
|
372
|
+
borderBottom: '1px solid #e5e7eb',
|
|
373
|
+
flexShrink: 0,
|
|
374
|
+
}}
|
|
375
|
+
>
|
|
376
|
+
{(['controls', 'actions'] as const).map((tab) => {
|
|
377
|
+
const active = bottomTab === tab
|
|
378
|
+
return (
|
|
379
|
+
<button
|
|
380
|
+
key={tab}
|
|
381
|
+
type="button"
|
|
382
|
+
role="tab"
|
|
383
|
+
aria-selected={active}
|
|
384
|
+
onClick={() => { onBottomTabChange(tab) }}
|
|
385
|
+
style={{
|
|
386
|
+
padding: '6px 14px',
|
|
387
|
+
fontSize: 12,
|
|
388
|
+
fontWeight: active ? 600 : 500,
|
|
389
|
+
color: active ? '#111827' : '#6b7280',
|
|
390
|
+
background: 'transparent',
|
|
391
|
+
border: 'none',
|
|
392
|
+
borderBottom: active ? '2px solid #2563eb' : '2px solid transparent',
|
|
393
|
+
marginBottom: -1,
|
|
394
|
+
cursor: 'pointer',
|
|
395
|
+
textTransform: 'capitalize',
|
|
396
|
+
}}
|
|
397
|
+
>
|
|
398
|
+
{tab}
|
|
399
|
+
</button>
|
|
400
|
+
)
|
|
401
|
+
})}
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
|
405
|
+
{bottomTab === 'controls' ? (
|
|
406
|
+
<Controls
|
|
407
|
+
args={mergedArgs}
|
|
408
|
+
argTypes={mergedArgTypes}
|
|
409
|
+
onArgChange={onArgChange}
|
|
410
|
+
/>
|
|
411
|
+
) : (
|
|
412
|
+
<Actions />
|
|
413
|
+
)}
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── Toolbar (loading / ready 공용) ─────────────────────────
|
|
421
|
+
|
|
422
|
+
interface ToolbarProps {
|
|
423
|
+
readonly title: string
|
|
424
|
+
readonly jogakName: string
|
|
425
|
+
readonly viewport: ViewportKey
|
|
426
|
+
readonly bgMode: BgMode
|
|
427
|
+
readonly onViewportChange: (vp: ViewportKey) => void
|
|
428
|
+
readonly onBgModeChange: (bg: BgMode) => void
|
|
429
|
+
readonly showReset: boolean
|
|
430
|
+
readonly onReset: () => void
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function Toolbar({
|
|
434
|
+
title,
|
|
435
|
+
jogakName,
|
|
436
|
+
viewport,
|
|
437
|
+
bgMode,
|
|
438
|
+
onViewportChange,
|
|
439
|
+
onBgModeChange,
|
|
440
|
+
showReset,
|
|
441
|
+
onReset,
|
|
442
|
+
}: ToolbarProps): ReactElement {
|
|
443
|
+
return (
|
|
444
|
+
<div
|
|
445
|
+
style={{
|
|
446
|
+
display: 'flex',
|
|
447
|
+
alignItems: 'center',
|
|
448
|
+
gap: 10,
|
|
449
|
+
padding: '7px 14px',
|
|
450
|
+
borderBottom: '1px solid #e5e7eb',
|
|
451
|
+
background: '#fff',
|
|
452
|
+
flexShrink: 0,
|
|
453
|
+
}}
|
|
454
|
+
>
|
|
455
|
+
<div style={{ flex: 1, fontSize: 13 }}>
|
|
456
|
+
<span style={{ color: '#9ca3af' }}>{title}</span>
|
|
457
|
+
<span style={{ color: '#d1d5db', margin: '0 6px' }}>/</span>
|
|
458
|
+
<span style={{ color: '#111827', fontWeight: 600 }}>{jogakName}</span>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
{/* 뷰포트 토글 */}
|
|
462
|
+
<div
|
|
463
|
+
style={{
|
|
464
|
+
display: 'flex',
|
|
465
|
+
gap: 2,
|
|
466
|
+
background: '#f3f4f6',
|
|
467
|
+
borderRadius: 6,
|
|
468
|
+
padding: 2,
|
|
469
|
+
}}
|
|
470
|
+
>
|
|
471
|
+
{(['mobile', 'tablet', 'desktop'] as const).map((vp) => (
|
|
472
|
+
<button
|
|
473
|
+
key={vp}
|
|
474
|
+
type="button"
|
|
475
|
+
onClick={() => { onViewportChange(vp) }}
|
|
476
|
+
aria-pressed={viewport === vp}
|
|
477
|
+
style={{
|
|
478
|
+
padding: '3px 9px',
|
|
479
|
+
fontSize: 12,
|
|
480
|
+
border: 'none',
|
|
481
|
+
borderRadius: 4,
|
|
482
|
+
cursor: 'pointer',
|
|
483
|
+
background: viewport === vp ? '#fff' : 'transparent',
|
|
484
|
+
color: viewport === vp ? '#111827' : '#6b7280',
|
|
485
|
+
fontWeight: viewport === vp ? 600 : 400,
|
|
486
|
+
boxShadow: viewport === vp ? '0 1px 2px rgba(0,0,0,0.08)' : 'none',
|
|
487
|
+
transition: 'all 0.1s',
|
|
488
|
+
}}
|
|
489
|
+
>
|
|
490
|
+
{VIEWPORT_LABELS[vp]}
|
|
491
|
+
</button>
|
|
492
|
+
))}
|
|
493
|
+
</div>
|
|
494
|
+
|
|
495
|
+
{/* 배경 토글 */}
|
|
496
|
+
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
497
|
+
{(['white', 'dark', 'transparent'] as const).map((bg) => (
|
|
498
|
+
<button
|
|
499
|
+
key={bg}
|
|
500
|
+
type="button"
|
|
501
|
+
onClick={() => { onBgModeChange(bg) }}
|
|
502
|
+
aria-pressed={bgMode === bg}
|
|
503
|
+
aria-label={`${bg} background`}
|
|
504
|
+
style={{
|
|
505
|
+
width: 20,
|
|
506
|
+
height: 20,
|
|
507
|
+
borderRadius: 4,
|
|
508
|
+
border: bgMode === bg ? '2px solid #2563eb' : '2px solid #d1d5db',
|
|
509
|
+
cursor: 'pointer',
|
|
510
|
+
padding: 0,
|
|
511
|
+
flexShrink: 0,
|
|
512
|
+
...BG_STYLES[bg],
|
|
513
|
+
}}
|
|
514
|
+
/>
|
|
515
|
+
))}
|
|
516
|
+
</div>
|
|
517
|
+
|
|
518
|
+
{/* 리셋 */}
|
|
519
|
+
{showReset && (
|
|
520
|
+
<button
|
|
521
|
+
type="button"
|
|
522
|
+
onClick={onReset}
|
|
523
|
+
style={{
|
|
524
|
+
padding: '3px 10px',
|
|
525
|
+
fontSize: 12,
|
|
526
|
+
border: '1px solid #d1d5db',
|
|
527
|
+
borderRadius: 4,
|
|
528
|
+
background: '#fff',
|
|
529
|
+
cursor: 'pointer',
|
|
530
|
+
color: '#374151',
|
|
531
|
+
}}
|
|
532
|
+
>
|
|
533
|
+
Reset
|
|
534
|
+
</button>
|
|
535
|
+
)}
|
|
536
|
+
</div>
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── JogakRenderer ─────────────────────────────────────────
|
|
541
|
+
|
|
542
|
+
interface JogakRendererProps {
|
|
543
|
+
readonly entry: RegistryEntry
|
|
544
|
+
readonly args: Readonly<Record<string, unknown>>
|
|
545
|
+
readonly source: string | undefined
|
|
546
|
+
readonly theme: PrismTheme
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function JogakRenderer({ entry, args, source, theme }: JogakRendererProps): ReactElement {
|
|
550
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
551
|
+
const [showCode, setShowCode] = useState(false)
|
|
552
|
+
|
|
553
|
+
useEffect(() => {
|
|
554
|
+
const container = containerRef.current
|
|
555
|
+
if (container === null) return
|
|
556
|
+
reactAdapter.render(entry, args, container)
|
|
557
|
+
return () => { reactAdapter.unmount(container) }
|
|
558
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
559
|
+
}, [entry])
|
|
560
|
+
|
|
561
|
+
useEffect(() => {
|
|
562
|
+
const container = containerRef.current
|
|
563
|
+
if (container === null) return
|
|
564
|
+
reactAdapter.render(entry, args, container)
|
|
565
|
+
}, [entry, args])
|
|
566
|
+
|
|
567
|
+
return (
|
|
568
|
+
<div>
|
|
569
|
+
{/* preview-content 영역 + 토글 버튼 */}
|
|
570
|
+
<div style={{ position: 'relative' }}>
|
|
571
|
+
<div
|
|
572
|
+
ref={containerRef}
|
|
573
|
+
data-testid="preview-content"
|
|
574
|
+
style={{
|
|
575
|
+
border: '1px dashed #e5e7eb',
|
|
576
|
+
borderRadius: 8,
|
|
577
|
+
padding: 16,
|
|
578
|
+
paddingBottom: 36,
|
|
579
|
+
}}
|
|
580
|
+
/>
|
|
581
|
+
<button
|
|
582
|
+
type="button"
|
|
583
|
+
onClick={() => { setShowCode((v) => !v) }}
|
|
584
|
+
aria-pressed={showCode}
|
|
585
|
+
aria-label={showCode ? 'Hide source code' : 'Show source code'}
|
|
586
|
+
style={{
|
|
587
|
+
position: 'absolute',
|
|
588
|
+
bottom: 8,
|
|
589
|
+
right: 8,
|
|
590
|
+
padding: '4px 9px',
|
|
591
|
+
fontSize: 11,
|
|
592
|
+
fontFamily:
|
|
593
|
+
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
594
|
+
fontWeight: 600,
|
|
595
|
+
letterSpacing: '0.02em',
|
|
596
|
+
background: showCode ? '#2563eb' : '#1e293b',
|
|
597
|
+
color: '#fff',
|
|
598
|
+
border: 'none',
|
|
599
|
+
borderRadius: 5,
|
|
600
|
+
cursor: 'pointer',
|
|
601
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
|
|
602
|
+
transition: 'background 0.15s',
|
|
603
|
+
}}
|
|
604
|
+
>
|
|
605
|
+
{'</>'}
|
|
606
|
+
</button>
|
|
607
|
+
</div>
|
|
608
|
+
|
|
609
|
+
{/* 코드 패널 — preview-content 하단으로 펼쳐짐 */}
|
|
610
|
+
{showCode && (
|
|
611
|
+
<div
|
|
612
|
+
style={{
|
|
613
|
+
marginTop: 8,
|
|
614
|
+
borderRadius: 8,
|
|
615
|
+
overflow: 'hidden',
|
|
616
|
+
height: 320,
|
|
617
|
+
boxShadow: '0 0 0 1px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.12)',
|
|
618
|
+
}}
|
|
619
|
+
>
|
|
620
|
+
<SourceViewer source={source} theme={theme} />
|
|
621
|
+
</div>
|
|
622
|
+
)}
|
|
623
|
+
</div>
|
|
624
|
+
)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ── SourceViewer ──────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
interface SourceViewerProps {
|
|
630
|
+
readonly source: string | undefined
|
|
631
|
+
readonly theme: PrismTheme
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function SourceViewer({ source, theme }: SourceViewerProps): ReactElement {
|
|
635
|
+
const [copied, setCopied] = useState(false)
|
|
636
|
+
const bgColor = (theme.plain.backgroundColor as string | undefined) ?? '#1e293b'
|
|
637
|
+
|
|
638
|
+
if (source === undefined) {
|
|
639
|
+
return (
|
|
640
|
+
<div
|
|
641
|
+
style={{
|
|
642
|
+
height: '100%',
|
|
643
|
+
display: 'flex',
|
|
644
|
+
alignItems: 'center',
|
|
645
|
+
justifyContent: 'center',
|
|
646
|
+
background: bgColor,
|
|
647
|
+
color: '#94a3b8',
|
|
648
|
+
fontSize: 13,
|
|
649
|
+
}}
|
|
650
|
+
>
|
|
651
|
+
Source not available
|
|
652
|
+
</div>
|
|
653
|
+
)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const handleCopy = (): void => {
|
|
657
|
+
void navigator.clipboard.writeText(source).then(() => {
|
|
658
|
+
setCopied(true)
|
|
659
|
+
setTimeout(() => { setCopied(false) }, 2000)
|
|
660
|
+
})
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return (
|
|
664
|
+
<div style={{ position: 'relative', height: '100%' }}>
|
|
665
|
+
<button
|
|
666
|
+
type="button"
|
|
667
|
+
onClick={handleCopy}
|
|
668
|
+
style={{
|
|
669
|
+
position: 'absolute',
|
|
670
|
+
top: 10,
|
|
671
|
+
right: 12,
|
|
672
|
+
zIndex: 1,
|
|
673
|
+
padding: '3px 9px',
|
|
674
|
+
fontSize: 11,
|
|
675
|
+
background: 'rgba(255,255,255,0.1)',
|
|
676
|
+
color: '#e2e8f0',
|
|
677
|
+
border: '1px solid rgba(255,255,255,0.18)',
|
|
678
|
+
borderRadius: 4,
|
|
679
|
+
cursor: 'pointer',
|
|
680
|
+
}}
|
|
681
|
+
>
|
|
682
|
+
{copied ? '✓ Copied' : 'Copy'}
|
|
683
|
+
</button>
|
|
684
|
+
|
|
685
|
+
<Highlight code={source.trim()} language="tsx" theme={theme}>
|
|
686
|
+
{({ style, tokens, getLineProps, getTokenProps }) => (
|
|
687
|
+
<pre
|
|
688
|
+
style={{
|
|
689
|
+
...style,
|
|
690
|
+
margin: 0,
|
|
691
|
+
padding: '12px 0',
|
|
692
|
+
fontSize: 12.5,
|
|
693
|
+
lineHeight: 1.7,
|
|
694
|
+
fontFamily:
|
|
695
|
+
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
696
|
+
height: '100%',
|
|
697
|
+
boxSizing: 'border-box',
|
|
698
|
+
overflow: 'auto',
|
|
699
|
+
}}
|
|
700
|
+
>
|
|
701
|
+
{tokens.map((line, i) => (
|
|
702
|
+
<div
|
|
703
|
+
key={i}
|
|
704
|
+
{...getLineProps({ line })}
|
|
705
|
+
style={{
|
|
706
|
+
...getLineProps({ line }).style,
|
|
707
|
+
display: 'flex',
|
|
708
|
+
paddingRight: 24,
|
|
709
|
+
}}
|
|
710
|
+
>
|
|
711
|
+
<span
|
|
712
|
+
style={{
|
|
713
|
+
userSelect: 'none',
|
|
714
|
+
minWidth: 40,
|
|
715
|
+
paddingLeft: 14,
|
|
716
|
+
paddingRight: 14,
|
|
717
|
+
textAlign: 'right',
|
|
718
|
+
color: 'rgba(148,163,184,0.45)',
|
|
719
|
+
flexShrink: 0,
|
|
720
|
+
}}
|
|
721
|
+
>
|
|
722
|
+
{i + 1}
|
|
723
|
+
</span>
|
|
724
|
+
<span>
|
|
725
|
+
{line.map((token, key) => (
|
|
726
|
+
<span key={key} {...getTokenProps({ token })} />
|
|
727
|
+
))}
|
|
728
|
+
</span>
|
|
729
|
+
</div>
|
|
730
|
+
))}
|
|
731
|
+
</pre>
|
|
732
|
+
)}
|
|
733
|
+
</Highlight>
|
|
734
|
+
</div>
|
|
735
|
+
)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Re-export type for ui consumers that may want to type their own wrappers.
|
|
739
|
+
export type { UseEntryState }
|