@jogak/ui 0.1.0-alpha.1 → 0.1.0-alpha.10.2
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 +112 -0
- package/README.md +201 -15
- package/dist/app/App.d.ts +24 -1
- package/dist/components/Preview/IframeMount.d.ts +35 -0
- package/dist/components/Preview/ShadowMount.d.ts +25 -0
- package/dist/components/Preview/index.d.ts +19 -2
- package/dist/host/index.cjs +1 -0
- package/dist/host/index.d.ts +38 -1
- package/dist/host/index.mjs +40 -34
- package/dist/index.cjs +1 -0
- package/dist/index.mjs +726 -901
- package/package.json +15 -6
- package/preview-frame.html +17 -0
- package/src/app/App.tsx +189 -0
- package/src/app/main.tsx +31 -0
- package/src/app/preview-frame.tsx +61 -0
- package/src/components/Actions/index.tsx +92 -0
- package/src/components/Controls/index.tsx +190 -0
- package/src/components/Preview/IframeMount.tsx +101 -0
- package/src/components/Preview/ShadowMount.tsx +57 -0
- package/src/components/Preview/index.tsx +766 -0
- package/src/components/Sidebar/index.tsx +285 -0
- package/src/hooks/useRegistry.ts +22 -0
- package/src/index.ts +12 -0
- package/src/styles/jogak.css +128 -0
- package/src/vite-env.d.ts +30 -0
- package/dist/host/index.js +0 -1
- package/dist/index.js +0 -1
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { ReactElement, CSSProperties } from 'react'
|
|
3
|
+
import clsx from 'clsx'
|
|
4
|
+
import { Highlight, themes } from 'prism-react-renderer'
|
|
5
|
+
import type { PrismTheme } from 'prism-react-renderer'
|
|
6
|
+
import { reactAdapter, useEntry } from '@jogak/core/renderers/react'
|
|
7
|
+
import type { UseEntryState } from '@jogak/core/renderers/react'
|
|
8
|
+
import type { RegistryEntry, RegistryEntryMeta, ArgType } from '@jogak/core'
|
|
9
|
+
import { Controls } from '../Controls/index.js'
|
|
10
|
+
import { Actions } from '../Actions/index.js'
|
|
11
|
+
import { ShadowMount } from './ShadowMount.js'
|
|
12
|
+
import { IframeMount } from './IframeMount.js'
|
|
13
|
+
|
|
14
|
+
export interface PreviewProps {
|
|
15
|
+
readonly entryId: string
|
|
16
|
+
readonly jogakName: string | null
|
|
17
|
+
readonly overrideArgs: Readonly<Record<string, unknown>>
|
|
18
|
+
readonly onArgChange: (key: string, value: unknown) => void
|
|
19
|
+
readonly onReset: () => void
|
|
20
|
+
readonly codeTheme: string
|
|
21
|
+
/**
|
|
22
|
+
* URL deep link `?entry=<id>` (jogak 미지정) 케이스에서 entry hydrate 후
|
|
23
|
+
* 첫 jogak로 자동 보정하기 위한 콜백. 부모가 selectedJogakName / URL을 갱신.
|
|
24
|
+
*/
|
|
25
|
+
readonly onResolveJogak?: (entryId: string, jogakName: string) => void
|
|
26
|
+
/**
|
|
27
|
+
* 알파.8: Preview 영역 격리 모드. default `'iframe'`.
|
|
28
|
+
*
|
|
29
|
+
* - `'iframe'` (default) — 사용자 vite scope에 마운트. 사용자 utility 정상 컴파일.
|
|
30
|
+
* - `'shadow'` (deprecated) — ShadowRoot에 마운트. 사용자 utility 미적용.
|
|
31
|
+
* - `'none'` (deprecated) — chrome과 같은 document에 렌더.
|
|
32
|
+
*/
|
|
33
|
+
readonly previewIsolation?: 'none' | 'shadow' | 'iframe'
|
|
34
|
+
/**
|
|
35
|
+
* 알파.9: 어댑터 dev URL. iframe `src` base.
|
|
36
|
+
* 빈 문자열 시 fallback (jogak SPA Vite scope의 `/preview-frame.html`).
|
|
37
|
+
*/
|
|
38
|
+
readonly userPreviewUrl?: string
|
|
39
|
+
/**
|
|
40
|
+
* 알파.9: iframe entry path.
|
|
41
|
+
*/
|
|
42
|
+
readonly previewEntryPath?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type ViewportKey = 'mobile' | 'tablet' | 'desktop'
|
|
46
|
+
type BgMode = 'white' | 'dark' | 'transparent'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* dynamic style + CSS variable 주입을 위한 React `CSSProperties` 확장 타입
|
|
50
|
+
* (api-contracts 알파.5 PR 2 §6.1).
|
|
51
|
+
*/
|
|
52
|
+
type CSSVarStyle = CSSProperties & Record<`--${string}`, string | number>
|
|
53
|
+
|
|
54
|
+
const VIEWPORT_WIDTHS: Record<ViewportKey, number | 'none'> = {
|
|
55
|
+
mobile: 375,
|
|
56
|
+
tablet: 768,
|
|
57
|
+
desktop: 'none',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const VIEWPORT_LABELS: Record<ViewportKey, string> = {
|
|
61
|
+
mobile: 'Mobile',
|
|
62
|
+
tablet: 'Tablet',
|
|
63
|
+
desktop: 'Desktop',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* bgMode별 캔버스 background 표현 — 4개 longhand CSS variable로 분해.
|
|
68
|
+
*
|
|
69
|
+
* v4 background shorthand arbitrary value(`bg-[...]`)는 ambiguous 하므로
|
|
70
|
+
* `bg-[image:...]`, `bg-[length:...]`, `bg-[position:...]` longhand hint를 사용해야 한다.
|
|
71
|
+
* 따라서 `BG_STYLES` (CSSProperties spread)를 폐기하고 mode별 변수 묶음만 정의한다
|
|
72
|
+
* (api-contracts 알파.5 PR 2 §3.2 결정 B).
|
|
73
|
+
*/
|
|
74
|
+
const BG_VARS: Record<BgMode, CSSVarStyle> = {
|
|
75
|
+
white: {
|
|
76
|
+
'--jogak-canvas-bg': '#ffffff',
|
|
77
|
+
'--jogak-canvas-bg-image': 'none',
|
|
78
|
+
'--jogak-canvas-bg-size': 'auto',
|
|
79
|
+
'--jogak-canvas-bg-position': '0 0',
|
|
80
|
+
},
|
|
81
|
+
dark: {
|
|
82
|
+
'--jogak-canvas-bg': '#1f2937',
|
|
83
|
+
'--jogak-canvas-bg-image': 'none',
|
|
84
|
+
'--jogak-canvas-bg-size': 'auto',
|
|
85
|
+
'--jogak-canvas-bg-position': '0 0',
|
|
86
|
+
},
|
|
87
|
+
transparent: {
|
|
88
|
+
'--jogak-canvas-bg': '#ffffff',
|
|
89
|
+
'--jogak-canvas-bg-image':
|
|
90
|
+
'linear-gradient(45deg, #e2e8f0 25%, transparent 25%), ' +
|
|
91
|
+
'linear-gradient(-45deg, #e2e8f0 25%, transparent 25%), ' +
|
|
92
|
+
'linear-gradient(45deg, transparent 75%, #e2e8f0 75%), ' +
|
|
93
|
+
'linear-gradient(-45deg, transparent 75%, #e2e8f0 75%)',
|
|
94
|
+
'--jogak-canvas-bg-size': '16px 16px',
|
|
95
|
+
'--jogak-canvas-bg-position': '0 0, 0 8px, 8px -8px, -8px 0px',
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 캔버스/미니버튼 공통 — 모드 무관. BG_VARS 가 변수 값을 mode별로 swap. */
|
|
100
|
+
const CANVAS_BG_CLASS =
|
|
101
|
+
'jogak:bg-[var(--jogak-canvas-bg)] ' +
|
|
102
|
+
'jogak:bg-[image:var(--jogak-canvas-bg-image)] ' +
|
|
103
|
+
'jogak:bg-[length:var(--jogak-canvas-bg-size)] ' +
|
|
104
|
+
'jogak:bg-[position:var(--jogak-canvas-bg-position)]'
|
|
105
|
+
|
|
106
|
+
/** 캔버스 영역 minHeight — loading/ready 사이 layout shift 방지 (계약 §10). */
|
|
107
|
+
const CANVAS_MIN_HEIGHT = 320
|
|
108
|
+
|
|
109
|
+
function resolvePrismTheme(name: string): PrismTheme {
|
|
110
|
+
const map = themes as Record<string, PrismTheme | undefined>
|
|
111
|
+
return map[name] ?? themes.vsDark
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Preview — `useEntry(entryId)`의 status에 따라 분기 (계약 §5.4).
|
|
116
|
+
*
|
|
117
|
+
* - `loading` → 메타로 헤더(title, jogak 이름)만 표시, 캔버스에 skeleton
|
|
118
|
+
* - `ready` → 현행 렌더 (entry.jogaks/component 사용)
|
|
119
|
+
* - `error` → 에러 패널
|
|
120
|
+
* - `unknown` → "Entry not found" placeholder
|
|
121
|
+
*
|
|
122
|
+
* Layout shift 방지를 위해 캔버스 영역 minHeight 유지.
|
|
123
|
+
*/
|
|
124
|
+
export function Preview({
|
|
125
|
+
entryId,
|
|
126
|
+
jogakName,
|
|
127
|
+
overrideArgs,
|
|
128
|
+
onArgChange,
|
|
129
|
+
onReset,
|
|
130
|
+
codeTheme,
|
|
131
|
+
onResolveJogak,
|
|
132
|
+
previewIsolation = 'iframe',
|
|
133
|
+
userPreviewUrl = '',
|
|
134
|
+
previewEntryPath = '/__jogak_preview__/index.html',
|
|
135
|
+
}: PreviewProps): ReactElement {
|
|
136
|
+
const state = useEntry(entryId)
|
|
137
|
+
const [viewport, setViewport] = useState<ViewportKey>('desktop')
|
|
138
|
+
const [bgMode, setBgMode] = useState<BgMode>('white')
|
|
139
|
+
const [bottomTab, setBottomTab] = useState<'controls' | 'actions'>('controls')
|
|
140
|
+
|
|
141
|
+
const prismTheme = resolvePrismTheme(codeTheme)
|
|
142
|
+
|
|
143
|
+
// ── unknown ───────────────────────────────────────────────
|
|
144
|
+
if (state.status === 'unknown') {
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
data-testid="preview-not-found"
|
|
148
|
+
className="jogak:p-6 jogak:text-[var(--jogak-color-error)]"
|
|
149
|
+
>
|
|
150
|
+
Entry not found: {entryId}
|
|
151
|
+
</div>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── error ─────────────────────────────────────────────────
|
|
156
|
+
if (state.status === 'error') {
|
|
157
|
+
return (
|
|
158
|
+
<div
|
|
159
|
+
data-testid="preview-error"
|
|
160
|
+
className="jogak:p-6 jogak:text-[var(--jogak-color-error-fg)] jogak:bg-[var(--jogak-color-bg-error)] jogak:h-full jogak:flex jogak:flex-col jogak:gap-3 jogak:items-start"
|
|
161
|
+
>
|
|
162
|
+
<div className="jogak:font-semibold">Failed to load entry: {entryId}</div>
|
|
163
|
+
<pre className="jogak:m-0 jogak:p-3 jogak:bg-[var(--jogak-color-bg)] jogak:border jogak:border-[var(--jogak-color-error-border)] jogak:rounded-[var(--jogak-radius-lg)] jogak:text-[12px] jogak:whitespace-pre-wrap jogak:max-w-full">
|
|
164
|
+
{state.error.message}
|
|
165
|
+
</pre>
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── loading ───────────────────────────────────────────────
|
|
171
|
+
if (state.status === 'loading') {
|
|
172
|
+
return (
|
|
173
|
+
<LoadingFrame
|
|
174
|
+
meta={state.meta}
|
|
175
|
+
jogakName={jogakName}
|
|
176
|
+
viewport={viewport}
|
|
177
|
+
bgMode={bgMode}
|
|
178
|
+
onViewportChange={setViewport}
|
|
179
|
+
onBgModeChange={setBgMode}
|
|
180
|
+
/>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── ready ─────────────────────────────────────────────────
|
|
185
|
+
return (
|
|
186
|
+
<ReadyFrame
|
|
187
|
+
entry={state.entry}
|
|
188
|
+
jogakName={jogakName}
|
|
189
|
+
overrideArgs={overrideArgs}
|
|
190
|
+
onArgChange={onArgChange}
|
|
191
|
+
onReset={onReset}
|
|
192
|
+
onResolveJogak={onResolveJogak}
|
|
193
|
+
viewport={viewport}
|
|
194
|
+
bgMode={bgMode}
|
|
195
|
+
bottomTab={bottomTab}
|
|
196
|
+
onViewportChange={setViewport}
|
|
197
|
+
onBgModeChange={setBgMode}
|
|
198
|
+
onBottomTabChange={setBottomTab}
|
|
199
|
+
prismTheme={prismTheme}
|
|
200
|
+
previewIsolation={previewIsolation}
|
|
201
|
+
userPreviewUrl={userPreviewUrl}
|
|
202
|
+
previewEntryPath={previewEntryPath}
|
|
203
|
+
/>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── LoadingFrame ──────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
interface LoadingFrameProps {
|
|
210
|
+
readonly meta: RegistryEntryMeta
|
|
211
|
+
readonly jogakName: string | null
|
|
212
|
+
readonly viewport: ViewportKey
|
|
213
|
+
readonly bgMode: BgMode
|
|
214
|
+
readonly onViewportChange: (vp: ViewportKey) => void
|
|
215
|
+
readonly onBgModeChange: (bg: BgMode) => void
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function LoadingFrame({
|
|
219
|
+
meta,
|
|
220
|
+
jogakName,
|
|
221
|
+
viewport,
|
|
222
|
+
bgMode,
|
|
223
|
+
onViewportChange,
|
|
224
|
+
onBgModeChange,
|
|
225
|
+
}: LoadingFrameProps): ReactElement {
|
|
226
|
+
const displayJogak = jogakName ?? meta.jogakNames[0] ?? '...'
|
|
227
|
+
const maxWidth = VIEWPORT_WIDTHS[viewport]
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div
|
|
231
|
+
data-testid="preview-loading"
|
|
232
|
+
className="jogak:flex jogak:flex-col jogak:h-full"
|
|
233
|
+
>
|
|
234
|
+
<Toolbar
|
|
235
|
+
title={meta.title}
|
|
236
|
+
jogakName={displayJogak}
|
|
237
|
+
viewport={viewport}
|
|
238
|
+
bgMode={bgMode}
|
|
239
|
+
onViewportChange={onViewportChange}
|
|
240
|
+
onBgModeChange={onBgModeChange}
|
|
241
|
+
showReset={false}
|
|
242
|
+
onReset={() => {}}
|
|
243
|
+
/>
|
|
244
|
+
<div
|
|
245
|
+
className={`jogak:flex-1 jogak:overflow-auto jogak:min-h-[320px] ${CANVAS_BG_CLASS}`}
|
|
246
|
+
// eslint-disable-next-line no-restricted-syntax -- jogak: BG_VARS object inject
|
|
247
|
+
style={BG_VARS[bgMode]}
|
|
248
|
+
>
|
|
249
|
+
<div
|
|
250
|
+
className="jogak:mx-auto jogak:p-6 jogak:max-w-[var(--jogak-canvas-mw)]"
|
|
251
|
+
// eslint-disable-next-line no-restricted-syntax -- jogak: canvas-mw CSS var
|
|
252
|
+
style={
|
|
253
|
+
{
|
|
254
|
+
'--jogak-canvas-mw': maxWidth === 'none' ? '100%' : `${maxWidth}px`,
|
|
255
|
+
} as CSSVarStyle
|
|
256
|
+
}
|
|
257
|
+
>
|
|
258
|
+
{/*
|
|
259
|
+
* skeleton box — 알파.5 PR 4 마이그레이션: gradient + keyframe animation 을
|
|
260
|
+
* jogak.css `@layer components` 의 `.jogak-skeleton-shimmer` class 로 이동
|
|
261
|
+
* (api-contracts §6). inline `style={{...}}` 객체 + inline `<style>` 태그
|
|
262
|
+
* 동시 제거. 정적 부분(border / radius / padding / flex / color / fontSize /
|
|
263
|
+
* minHeight)은 jogak: utility 그대로 유지.
|
|
264
|
+
*/}
|
|
265
|
+
<div className="jogak-skeleton-shimmer jogak:border jogak:border-dashed jogak:border-[var(--jogak-color-border)] jogak:rounded-[var(--jogak-radius-xl)] jogak:p-4 jogak:flex jogak:items-center jogak:justify-center jogak:text-[var(--jogak-color-fg-subtle)] jogak:text-[13px] jogak:min-h-[256px]">
|
|
266
|
+
Loading {meta.title}…
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── ReadyFrame ────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
interface ReadyFrameProps {
|
|
277
|
+
readonly entry: RegistryEntry
|
|
278
|
+
readonly jogakName: string | null
|
|
279
|
+
readonly overrideArgs: Readonly<Record<string, unknown>>
|
|
280
|
+
readonly onArgChange: (key: string, value: unknown) => void
|
|
281
|
+
readonly onReset: () => void
|
|
282
|
+
readonly onResolveJogak: ((entryId: string, jogakName: string) => void) | undefined
|
|
283
|
+
readonly viewport: ViewportKey
|
|
284
|
+
readonly bgMode: BgMode
|
|
285
|
+
readonly bottomTab: 'controls' | 'actions'
|
|
286
|
+
readonly onViewportChange: (vp: ViewportKey) => void
|
|
287
|
+
readonly onBgModeChange: (bg: BgMode) => void
|
|
288
|
+
readonly onBottomTabChange: (tab: 'controls' | 'actions') => void
|
|
289
|
+
readonly prismTheme: PrismTheme
|
|
290
|
+
readonly previewIsolation: 'none' | 'shadow' | 'iframe'
|
|
291
|
+
readonly userPreviewUrl: string
|
|
292
|
+
readonly previewEntryPath: string
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function ReadyFrame({
|
|
296
|
+
entry,
|
|
297
|
+
jogakName,
|
|
298
|
+
overrideArgs,
|
|
299
|
+
onArgChange,
|
|
300
|
+
onReset,
|
|
301
|
+
onResolveJogak,
|
|
302
|
+
viewport,
|
|
303
|
+
bgMode,
|
|
304
|
+
bottomTab,
|
|
305
|
+
onViewportChange,
|
|
306
|
+
onBgModeChange,
|
|
307
|
+
onBottomTabChange,
|
|
308
|
+
prismTheme,
|
|
309
|
+
previewIsolation,
|
|
310
|
+
userPreviewUrl,
|
|
311
|
+
previewEntryPath,
|
|
312
|
+
}: ReadyFrameProps): ReactElement {
|
|
313
|
+
// jogakName이 비어있으면 (deep link `?entry=...&jogak` 누락) 첫 jogak로 보정.
|
|
314
|
+
const resolvedJogakName = jogakName ?? entry.jogaks[0]?.name ?? null
|
|
315
|
+
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
if (jogakName === null && resolvedJogakName !== null && onResolveJogak !== undefined) {
|
|
318
|
+
onResolveJogak(entry.id, resolvedJogakName)
|
|
319
|
+
}
|
|
320
|
+
}, [jogakName, resolvedJogakName, entry.id, onResolveJogak])
|
|
321
|
+
|
|
322
|
+
if (resolvedJogakName === null) {
|
|
323
|
+
return (
|
|
324
|
+
<div className="jogak:p-6 jogak:text-[var(--jogak-color-error)]">
|
|
325
|
+
Entry has no jogaks: {entry.id}
|
|
326
|
+
</div>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const jogak = entry.jogaks.find((j) => j.name === resolvedJogakName)
|
|
331
|
+
if (jogak === undefined) {
|
|
332
|
+
return (
|
|
333
|
+
<div className="jogak:p-6 jogak:text-[var(--jogak-color-error)]">
|
|
334
|
+
Jogak not found: {resolvedJogakName}
|
|
335
|
+
</div>
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const baseArgs = jogak.args ?? {}
|
|
340
|
+
const mergedArgs = { ...baseArgs, ...overrideArgs }
|
|
341
|
+
const mergedArgTypes: Readonly<Record<string, ArgType>> = {
|
|
342
|
+
...(entry.meta.argTypes ?? {}),
|
|
343
|
+
...(jogak.argTypes ?? {}),
|
|
344
|
+
}
|
|
345
|
+
const hasOverrides = Object.keys(overrideArgs).length > 0
|
|
346
|
+
const maxWidth = VIEWPORT_WIDTHS[viewport]
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<div className="jogak:flex jogak:flex-col jogak:h-full">
|
|
350
|
+
<Toolbar
|
|
351
|
+
title={entry.title}
|
|
352
|
+
jogakName={jogak.name}
|
|
353
|
+
viewport={viewport}
|
|
354
|
+
bgMode={bgMode}
|
|
355
|
+
onViewportChange={onViewportChange}
|
|
356
|
+
onBgModeChange={onBgModeChange}
|
|
357
|
+
showReset={hasOverrides}
|
|
358
|
+
onReset={onReset}
|
|
359
|
+
/>
|
|
360
|
+
|
|
361
|
+
{/* ── 캔버스 ───────────────────────────────────────── */}
|
|
362
|
+
<div
|
|
363
|
+
className={`jogak:flex-1 jogak:overflow-auto jogak:min-h-[320px] ${CANVAS_BG_CLASS}`}
|
|
364
|
+
// eslint-disable-next-line no-restricted-syntax -- jogak: BG_VARS object inject
|
|
365
|
+
style={BG_VARS[bgMode]}
|
|
366
|
+
>
|
|
367
|
+
<div
|
|
368
|
+
data-jogak-content
|
|
369
|
+
className="jogak:mx-auto jogak:p-6 jogak:max-w-[var(--jogak-canvas-mw)]"
|
|
370
|
+
// eslint-disable-next-line no-restricted-syntax -- jogak: canvas-mw CSS var
|
|
371
|
+
style={
|
|
372
|
+
{
|
|
373
|
+
'--jogak-canvas-mw': maxWidth === 'none' ? '100%' : `${maxWidth}px`,
|
|
374
|
+
} as CSSVarStyle
|
|
375
|
+
}
|
|
376
|
+
>
|
|
377
|
+
<JogakRenderer
|
|
378
|
+
key={`${entry.id}/${jogak.name}`}
|
|
379
|
+
entry={entry}
|
|
380
|
+
args={mergedArgs}
|
|
381
|
+
source={entry.source}
|
|
382
|
+
theme={prismTheme}
|
|
383
|
+
previewIsolation={previewIsolation}
|
|
384
|
+
userPreviewUrl={userPreviewUrl}
|
|
385
|
+
previewEntryPath={previewEntryPath}
|
|
386
|
+
/>
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
{/* ── 컨트롤/액션 패널 ──────────────────────────────── */}
|
|
391
|
+
<div
|
|
392
|
+
data-testid="bottom-panel"
|
|
393
|
+
className="jogak:h-[260px] jogak:shrink-0 jogak:flex jogak:flex-col jogak:border-t-2 jogak:border-[var(--jogak-color-border)]"
|
|
394
|
+
>
|
|
395
|
+
<div
|
|
396
|
+
role="tablist"
|
|
397
|
+
className="jogak:flex jogak:gap-1 jogak:pt-1 jogak:px-3 jogak:pb-0 jogak:bg-[var(--jogak-color-bg)] jogak:border-b jogak:border-[var(--jogak-color-border)] jogak:shrink-0"
|
|
398
|
+
>
|
|
399
|
+
{(['controls', 'actions'] as const).map((tab) => {
|
|
400
|
+
const active = bottomTab === tab
|
|
401
|
+
return (
|
|
402
|
+
<button
|
|
403
|
+
key={tab}
|
|
404
|
+
type="button"
|
|
405
|
+
role="tab"
|
|
406
|
+
aria-selected={active}
|
|
407
|
+
onClick={() => { onBottomTabChange(tab) }}
|
|
408
|
+
className={clsx(
|
|
409
|
+
'jogak:px-[14px] jogak:py-[6px] jogak:text-[12px] jogak:bg-transparent jogak:border-x-0 jogak:border-t-0 jogak:border-b-2 jogak:border-solid jogak:-mb-px jogak:cursor-pointer jogak:capitalize',
|
|
410
|
+
active
|
|
411
|
+
? 'jogak:font-semibold jogak:text-[var(--jogak-color-fg-strong)] jogak:border-[var(--jogak-color-accent)]'
|
|
412
|
+
: 'jogak:font-medium jogak:text-[var(--jogak-color-fg-muted)] jogak:border-transparent',
|
|
413
|
+
)}
|
|
414
|
+
>
|
|
415
|
+
{tab}
|
|
416
|
+
</button>
|
|
417
|
+
)
|
|
418
|
+
})}
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<div className="jogak:flex-1 jogak:min-h-0 jogak:overflow-auto">
|
|
422
|
+
{bottomTab === 'controls' ? (
|
|
423
|
+
<Controls
|
|
424
|
+
args={mergedArgs}
|
|
425
|
+
argTypes={mergedArgTypes}
|
|
426
|
+
onArgChange={onArgChange}
|
|
427
|
+
/>
|
|
428
|
+
) : (
|
|
429
|
+
<Actions />
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── Toolbar (loading / ready 공용) ─────────────────────────
|
|
438
|
+
|
|
439
|
+
interface ToolbarProps {
|
|
440
|
+
readonly title: string
|
|
441
|
+
readonly jogakName: string
|
|
442
|
+
readonly viewport: ViewportKey
|
|
443
|
+
readonly bgMode: BgMode
|
|
444
|
+
readonly onViewportChange: (vp: ViewportKey) => void
|
|
445
|
+
readonly onBgModeChange: (bg: BgMode) => void
|
|
446
|
+
readonly showReset: boolean
|
|
447
|
+
readonly onReset: () => void
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function Toolbar({
|
|
451
|
+
title,
|
|
452
|
+
jogakName,
|
|
453
|
+
viewport,
|
|
454
|
+
bgMode,
|
|
455
|
+
onViewportChange,
|
|
456
|
+
onBgModeChange,
|
|
457
|
+
showReset,
|
|
458
|
+
onReset,
|
|
459
|
+
}: ToolbarProps): ReactElement {
|
|
460
|
+
return (
|
|
461
|
+
<div className="jogak:flex jogak:items-center jogak:gap-[10px] jogak:px-[14px] jogak:py-[7px] jogak:border-b jogak:border-[var(--jogak-color-border)] jogak:bg-[var(--jogak-color-bg)] jogak:shrink-0">
|
|
462
|
+
<div className="jogak:flex-1 jogak:text-[13px]">
|
|
463
|
+
<span className="jogak:text-[var(--jogak-color-fg-subtle)]">{title}</span>
|
|
464
|
+
<span className="jogak:text-[var(--jogak-color-border-strong)] jogak:mx-1.5 jogak:leading-none">
|
|
465
|
+
/
|
|
466
|
+
</span>
|
|
467
|
+
<span className="jogak:text-[var(--jogak-color-fg-strong)] jogak:font-semibold">
|
|
468
|
+
{jogakName}
|
|
469
|
+
</span>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
{/* 뷰포트 토글 */}
|
|
473
|
+
<div className="jogak:flex jogak:gap-0.5 jogak:bg-[var(--jogak-color-bg-subtle)] jogak:rounded-[var(--jogak-radius-lg)] jogak:p-0.5">
|
|
474
|
+
{(['mobile', 'tablet', 'desktop'] as const).map((vp) => (
|
|
475
|
+
<button
|
|
476
|
+
key={vp}
|
|
477
|
+
type="button"
|
|
478
|
+
onClick={() => { onViewportChange(vp) }}
|
|
479
|
+
aria-pressed={viewport === vp}
|
|
480
|
+
className={clsx(
|
|
481
|
+
'jogak:px-[9px] jogak:py-[3px] jogak:text-[12px] jogak:border-none jogak:rounded-[var(--jogak-radius-md)] jogak:cursor-pointer jogak:transition-all jogak:duration-100',
|
|
482
|
+
viewport === vp
|
|
483
|
+
? 'jogak:bg-[var(--jogak-color-bg-elevated)] jogak:text-[var(--jogak-color-fg-strong)] jogak:font-semibold jogak:shadow-[0_1px_2px_rgba(0,0,0,0.08)]'
|
|
484
|
+
: 'jogak:bg-transparent jogak:text-[var(--jogak-color-fg-muted)] jogak:font-normal jogak:shadow-none',
|
|
485
|
+
)}
|
|
486
|
+
>
|
|
487
|
+
{VIEWPORT_LABELS[vp]}
|
|
488
|
+
</button>
|
|
489
|
+
))}
|
|
490
|
+
</div>
|
|
491
|
+
|
|
492
|
+
{/* 배경 토글 */}
|
|
493
|
+
<div className="jogak:flex jogak:gap-1 jogak:items-center">
|
|
494
|
+
{(['white', 'dark', 'transparent'] as const).map((bg) => (
|
|
495
|
+
<button
|
|
496
|
+
key={bg}
|
|
497
|
+
type="button"
|
|
498
|
+
onClick={() => { onBgModeChange(bg) }}
|
|
499
|
+
aria-pressed={bgMode === bg}
|
|
500
|
+
aria-label={`${bg} background`}
|
|
501
|
+
className={clsx(
|
|
502
|
+
'jogak:w-5 jogak:h-5 jogak:rounded-[var(--jogak-radius-md)] jogak:border-2 jogak:cursor-pointer jogak:p-0 jogak:shrink-0',
|
|
503
|
+
CANVAS_BG_CLASS,
|
|
504
|
+
bgMode === bg
|
|
505
|
+
? 'jogak:border-[var(--jogak-color-accent)]'
|
|
506
|
+
: 'jogak:border-[var(--jogak-color-border-strong)]',
|
|
507
|
+
)}
|
|
508
|
+
// eslint-disable-next-line no-restricted-syntax -- jogak: BG_VARS object inject (3 mini buttons)
|
|
509
|
+
style={BG_VARS[bg]}
|
|
510
|
+
/>
|
|
511
|
+
))}
|
|
512
|
+
</div>
|
|
513
|
+
|
|
514
|
+
{/* 리셋 */}
|
|
515
|
+
{showReset && (
|
|
516
|
+
<button
|
|
517
|
+
type="button"
|
|
518
|
+
onClick={onReset}
|
|
519
|
+
className="jogak:px-[10px] jogak:py-[3px] jogak:text-[12px] jogak:border jogak:border-[var(--jogak-color-border-strong)] jogak:rounded-[var(--jogak-radius-md)] jogak:bg-[var(--jogak-color-bg)] jogak:cursor-pointer jogak:text-[var(--jogak-color-fg)] jogak:leading-none"
|
|
520
|
+
>
|
|
521
|
+
Reset
|
|
522
|
+
</button>
|
|
523
|
+
)}
|
|
524
|
+
</div>
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── JogakRenderer ─────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
interface JogakRendererProps {
|
|
531
|
+
readonly entry: RegistryEntry
|
|
532
|
+
readonly args: Readonly<Record<string, unknown>>
|
|
533
|
+
readonly source: string | undefined
|
|
534
|
+
readonly theme: PrismTheme
|
|
535
|
+
readonly previewIsolation: 'none' | 'shadow' | 'iframe'
|
|
536
|
+
readonly userPreviewUrl: string
|
|
537
|
+
readonly previewEntryPath: string
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* 알파.9: previewIsolation 모드별로 사용자 콘텐츠 마운트 방식을 분기한다.
|
|
542
|
+
*
|
|
543
|
+
* - `'iframe'` (default) — 어댑터 dev URL의 `<IframeMount>`로 별도 document.
|
|
544
|
+
* - `'shadow'` (deprecated) — `<ShadowMount>` 안에 마운트.
|
|
545
|
+
* - `'none'` (deprecated) — 같은 document에 직접 마운트.
|
|
546
|
+
*/
|
|
547
|
+
function JogakRenderer({ entry, args, source, theme, previewIsolation, userPreviewUrl, previewEntryPath }: JogakRendererProps): ReactElement {
|
|
548
|
+
const [showCode, setShowCode] = useState(false)
|
|
549
|
+
|
|
550
|
+
const previewBody = (
|
|
551
|
+
<div className="jogak:relative">
|
|
552
|
+
<PreviewMount
|
|
553
|
+
entry={entry}
|
|
554
|
+
args={args}
|
|
555
|
+
previewIsolation={previewIsolation}
|
|
556
|
+
userPreviewUrl={userPreviewUrl}
|
|
557
|
+
previewEntryPath={previewEntryPath}
|
|
558
|
+
/>
|
|
559
|
+
<button
|
|
560
|
+
type="button"
|
|
561
|
+
onClick={() => { setShowCode((v) => !v) }}
|
|
562
|
+
aria-pressed={showCode}
|
|
563
|
+
aria-label={showCode ? 'Hide source code' : 'Show source code'}
|
|
564
|
+
className={clsx(
|
|
565
|
+
'jogak:absolute jogak:bottom-2 jogak:right-2 jogak:px-[9px] jogak:py-1',
|
|
566
|
+
'jogak:text-[11px] jogak:font-[family-name:var(--jogak-font-mono)] jogak:font-semibold jogak:tracking-[0.02em]',
|
|
567
|
+
'jogak:text-[var(--jogak-color-bg)] jogak:border-none jogak:rounded-[5px] jogak:cursor-pointer',
|
|
568
|
+
'jogak:shadow-[0_1px_4px_rgba(0,0,0,0.2)] jogak:transition-[background-color] jogak:duration-150 jogak:leading-none',
|
|
569
|
+
showCode ? 'jogak:bg-[var(--jogak-color-accent)]' : 'jogak:bg-[#1e293b]',
|
|
570
|
+
)}
|
|
571
|
+
>
|
|
572
|
+
{'</>'}
|
|
573
|
+
</button>
|
|
574
|
+
</div>
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
return (
|
|
578
|
+
<div>
|
|
579
|
+
{previewBody}
|
|
580
|
+
{/* 코드 패널 — preview-content 하단으로 펼쳐짐 */}
|
|
581
|
+
{showCode && (
|
|
582
|
+
<div className="jogak:mt-2 jogak:rounded-[var(--jogak-radius-xl)] jogak:overflow-hidden jogak:h-[320px] jogak:shadow-[0_0_0_1px_rgba(0,0,0,0.08),_0_4px_16px_rgba(0,0,0,0.12)]">
|
|
583
|
+
<SourceViewer source={source} theme={theme} />
|
|
584
|
+
</div>
|
|
585
|
+
)}
|
|
586
|
+
</div>
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ── PreviewMount ──────────────────────────────────────────
|
|
591
|
+
//
|
|
592
|
+
// previewIsolation 모드별 콘텐츠 마운트. chrome 외곽 (border/radius/padding)은 모드
|
|
593
|
+
// 별 호스트 element에 동일하게 적용해 VR baseline 변경을 zero로 유지한다.
|
|
594
|
+
|
|
595
|
+
interface PreviewMountProps {
|
|
596
|
+
readonly entry: RegistryEntry
|
|
597
|
+
readonly args: Readonly<Record<string, unknown>>
|
|
598
|
+
readonly previewIsolation: 'none' | 'shadow' | 'iframe'
|
|
599
|
+
readonly userPreviewUrl: string
|
|
600
|
+
readonly previewEntryPath: string
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const PREVIEW_HOST_CLASS =
|
|
604
|
+
'jogak:border jogak:border-dashed jogak:border-[var(--jogak-color-border)] ' +
|
|
605
|
+
'jogak:rounded-[var(--jogak-radius-xl)] jogak:p-4 jogak:pb-9'
|
|
606
|
+
|
|
607
|
+
function PreviewMount({ entry, args, previewIsolation, userPreviewUrl, previewEntryPath }: PreviewMountProps): ReactElement {
|
|
608
|
+
if (previewIsolation === 'shadow') {
|
|
609
|
+
return (
|
|
610
|
+
<ShadowMount
|
|
611
|
+
data-testid="preview-content"
|
|
612
|
+
className={PREVIEW_HOST_CLASS}
|
|
613
|
+
>
|
|
614
|
+
<ShadowAdapterContent entry={entry} args={args} />
|
|
615
|
+
</ShadowMount>
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (previewIsolation === 'iframe') {
|
|
620
|
+
return (
|
|
621
|
+
<IframeMount
|
|
622
|
+
entry={entry}
|
|
623
|
+
args={args}
|
|
624
|
+
userPreviewUrl={userPreviewUrl}
|
|
625
|
+
previewEntryPath={previewEntryPath}
|
|
626
|
+
data-testid="preview-content"
|
|
627
|
+
className={`${PREVIEW_HOST_CLASS} jogak:block jogak:w-full jogak:bg-transparent jogak:min-h-[256px]`}
|
|
628
|
+
/>
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// 'none' — deprecated 경로 (알파.7.1 동등 동작 보존, back-compat)
|
|
633
|
+
return <NoneAdapterContent entry={entry} args={args} />
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function NoneAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
|
|
637
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
638
|
+
|
|
639
|
+
useEffect(() => {
|
|
640
|
+
const container = containerRef.current
|
|
641
|
+
if (container === null) return
|
|
642
|
+
reactAdapter.render(entry, args, container)
|
|
643
|
+
return () => {
|
|
644
|
+
// 알파.7.1: React 18 concurrent unmount race(`Attempted to synchronously unmount...`)
|
|
645
|
+
// 회피 — fiber commit 끝난 직후로 defer.
|
|
646
|
+
queueMicrotask(() => { reactAdapter.unmount(container) })
|
|
647
|
+
}
|
|
648
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
649
|
+
}, [entry])
|
|
650
|
+
|
|
651
|
+
useEffect(() => {
|
|
652
|
+
const container = containerRef.current
|
|
653
|
+
if (container === null) return
|
|
654
|
+
reactAdapter.render(entry, args, container)
|
|
655
|
+
}, [entry, args])
|
|
656
|
+
|
|
657
|
+
return (
|
|
658
|
+
<div
|
|
659
|
+
ref={containerRef}
|
|
660
|
+
data-testid="preview-content"
|
|
661
|
+
className={PREVIEW_HOST_CLASS}
|
|
662
|
+
/>
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Shadow 모드 — ShadowMount의 ShadowRoot 안에서 react-adapter.render를 호출하는
|
|
668
|
+
* 작은 wrapper. ShadowMount 안 portal 내부에 위치하므로 useRef는 ShadowRoot scope.
|
|
669
|
+
*/
|
|
670
|
+
function ShadowAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
|
|
671
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
672
|
+
|
|
673
|
+
useEffect(() => {
|
|
674
|
+
const c = ref.current
|
|
675
|
+
if (c === null) return
|
|
676
|
+
reactAdapter.render(entry, args, c)
|
|
677
|
+
return () => {
|
|
678
|
+
// 알파.7.1: unmount race 회피
|
|
679
|
+
queueMicrotask(() => { reactAdapter.unmount(c) })
|
|
680
|
+
}
|
|
681
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
682
|
+
}, [entry])
|
|
683
|
+
|
|
684
|
+
useEffect(() => {
|
|
685
|
+
const c = ref.current
|
|
686
|
+
if (c === null) return
|
|
687
|
+
reactAdapter.render(entry, args, c)
|
|
688
|
+
}, [entry, args])
|
|
689
|
+
|
|
690
|
+
return <div ref={ref} data-testid="preview-content-shadow" />
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ── SourceViewer ──────────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
interface SourceViewerProps {
|
|
696
|
+
readonly source: string | undefined
|
|
697
|
+
readonly theme: PrismTheme
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function SourceViewer({ source, theme }: SourceViewerProps): ReactElement {
|
|
701
|
+
const [copied, setCopied] = useState(false)
|
|
702
|
+
const bgColor = (theme.plain.backgroundColor as string | undefined) ?? '#1e293b'
|
|
703
|
+
|
|
704
|
+
if (source === undefined) {
|
|
705
|
+
return (
|
|
706
|
+
<div
|
|
707
|
+
className="jogak:h-full jogak:flex jogak:items-center jogak:justify-center jogak:bg-[var(--jogak-source-bg)] jogak:text-[#94a3b8] jogak:text-[13px]"
|
|
708
|
+
// eslint-disable-next-line no-restricted-syntax -- jogak: source-bg CSS var (prism theme)
|
|
709
|
+
style={{ '--jogak-source-bg': bgColor } as CSSVarStyle}
|
|
710
|
+
>
|
|
711
|
+
Source not available
|
|
712
|
+
</div>
|
|
713
|
+
)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const handleCopy = (): void => {
|
|
717
|
+
void navigator.clipboard.writeText(source).then(() => {
|
|
718
|
+
setCopied(true)
|
|
719
|
+
setTimeout(() => { setCopied(false) }, 2000)
|
|
720
|
+
})
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return (
|
|
724
|
+
<div className="jogak:relative jogak:h-full">
|
|
725
|
+
<button
|
|
726
|
+
type="button"
|
|
727
|
+
onClick={handleCopy}
|
|
728
|
+
className="jogak:absolute jogak:top-[10px] jogak:right-3 jogak:z-[1] jogak:px-[9px] jogak:py-[3px] jogak:text-[11px] jogak:bg-[rgba(255,255,255,0.1)] jogak:text-[#e2e8f0] jogak:border jogak:border-[rgba(255,255,255,0.18)] jogak:rounded-[var(--jogak-radius-md)] jogak:cursor-pointer jogak:leading-none"
|
|
729
|
+
>
|
|
730
|
+
{copied ? '✓ Copied' : 'Copy'}
|
|
731
|
+
</button>
|
|
732
|
+
|
|
733
|
+
<Highlight code={source.trim()} language="tsx" theme={theme}>
|
|
734
|
+
{({ style, tokens, getLineProps, getTokenProps }) => (
|
|
735
|
+
<pre
|
|
736
|
+
className="jogak:m-0 jogak:py-3 jogak:px-0 jogak:text-[12.5px] jogak:leading-[1.7] jogak:font-[family-name:var(--jogak-font-mono)] jogak:h-full jogak:box-border jogak:overflow-auto"
|
|
737
|
+
// eslint-disable-next-line no-restricted-syntax -- jogak: prism-react-renderer external interface (pre)
|
|
738
|
+
style={style}
|
|
739
|
+
>
|
|
740
|
+
{tokens.map((line, i) => (
|
|
741
|
+
<div
|
|
742
|
+
key={i}
|
|
743
|
+
{...getLineProps({ line })}
|
|
744
|
+
className="jogak:flex jogak:pr-6"
|
|
745
|
+
// eslint-disable-next-line no-restricted-syntax -- jogak: prism-react-renderer external interface (line)
|
|
746
|
+
style={getLineProps({ line }).style}
|
|
747
|
+
>
|
|
748
|
+
<span className="jogak:select-none jogak:min-w-10 jogak:pl-[14px] jogak:pr-[14px] jogak:text-right jogak:text-[rgba(148,163,184,0.45)] jogak:shrink-0 jogak:leading-[1.7]">
|
|
749
|
+
{i + 1}
|
|
750
|
+
</span>
|
|
751
|
+
<span>
|
|
752
|
+
{line.map((token, key) => (
|
|
753
|
+
<span key={key} {...getTokenProps({ token })} />
|
|
754
|
+
))}
|
|
755
|
+
</span>
|
|
756
|
+
</div>
|
|
757
|
+
))}
|
|
758
|
+
</pre>
|
|
759
|
+
)}
|
|
760
|
+
</Highlight>
|
|
761
|
+
</div>
|
|
762
|
+
)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Re-export type for ui consumers that may want to type their own wrappers.
|
|
766
|
+
export type { UseEntryState }
|