@jogak/ui 0.1.0-alpha.6 → 0.1.0-alpha.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +113 -36
- package/dist/app/App.d.ts +11 -1
- package/dist/components/Preview/IframeMount.d.ts +37 -0
- package/dist/components/Preview/ShadowMount.d.ts +25 -0
- package/dist/components/Preview/index.d.ts +9 -1
- package/dist/host/index.d.ts +20 -0
- package/dist/host/index.js +1 -1
- package/dist/host/index.mjs +51 -45
- package/dist/index.js +1 -1
- package/dist/index.mjs +424 -313
- package/package.json +4 -3
- package/preview-frame.html +17 -0
- package/src/app/App.tsx +12 -0
- package/src/app/main.tsx +15 -8
- package/src/app/preview-frame.tsx +46 -0
- package/src/components/Preview/IframeMount.tsx +84 -0
- package/src/components/Preview/ShadowMount.tsx +57 -0
- package/src/components/Preview/index.tsx +146 -35
- package/src/vite-env.d.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jogak/ui",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.7.1",
|
|
4
4
|
"description": "Showcase viewer UI for Jogak — Sidebar / Preview / Controls / Actions and the JogakApp shell.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jogak",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"files": [
|
|
43
43
|
"dist",
|
|
44
44
|
"index.html",
|
|
45
|
+
"preview-frame.html",
|
|
45
46
|
"src/app",
|
|
46
47
|
"src/components",
|
|
47
48
|
"src/hooks",
|
|
@@ -64,8 +65,8 @@
|
|
|
64
65
|
"prism-react-renderer": "^2.4.1",
|
|
65
66
|
"tailwindcss": "^4.0.0",
|
|
66
67
|
"@tailwindcss/vite": "^4.0.0",
|
|
67
|
-
"@jogak/core": "0.1.0-alpha.
|
|
68
|
-
"@jogak/react": "0.1.0-alpha.
|
|
68
|
+
"@jogak/core": "0.1.0-alpha.7.1",
|
|
69
|
+
"@jogak/react": "0.1.0-alpha.7.1"
|
|
69
70
|
},
|
|
70
71
|
"devDependencies": {
|
|
71
72
|
"@types/node": "^20.14.0",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>jogak preview</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
9
|
+
html, body { margin: 0; }
|
|
10
|
+
body { font-family: system-ui, sans-serif; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="jogak-preview-root"></div>
|
|
15
|
+
<script type="module" src="/src/app/preview-frame.tsx"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
package/src/app/App.tsx
CHANGED
|
@@ -20,6 +20,16 @@ export interface JogakAppProps {
|
|
|
20
20
|
readonly entries?: readonly RegistryEntry[]
|
|
21
21
|
readonly metas?: readonly RegistryEntryMeta[]
|
|
22
22
|
readonly codeTheme?: string
|
|
23
|
+
/**
|
|
24
|
+
* 알파.7: Preview 영역 격리 모드. default `'none'`.
|
|
25
|
+
*
|
|
26
|
+
* - `'none'` — Preview 콘텐츠를 chrome 같은 document에 렌더 (알파.6까지의 동작).
|
|
27
|
+
* - `'shadow'` — ShadowRoot 안에 마운트. 사용자 globalCss/reset이 chrome 침범 차단.
|
|
28
|
+
* - `'iframe'` — 별도 document(iframe)에 마운트. 가장 강한 격리.
|
|
29
|
+
*
|
|
30
|
+
* 자세한 트레이드오프는 `@jogak/ui` README의 "previewIsolation 사용 가이드" 참조.
|
|
31
|
+
*/
|
|
32
|
+
readonly previewIsolation?: 'none' | 'shadow' | 'iframe'
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
function readUrlParams(): { entryId: string; jogakName: string | null } | null {
|
|
@@ -42,6 +52,7 @@ export function JogakApp({
|
|
|
42
52
|
entries,
|
|
43
53
|
metas,
|
|
44
54
|
codeTheme = 'vsDark',
|
|
55
|
+
previewIsolation = 'shadow',
|
|
45
56
|
}: JogakAppProps = {}): ReactElement {
|
|
46
57
|
// ── 4가지 모드 결정 (계약 §5.2) ─────────────────────────────────────
|
|
47
58
|
// 1) entries가 주어지면: 새 ComponentRegistry에 register (eager, 기존 동작)
|
|
@@ -144,6 +155,7 @@ export function JogakApp({
|
|
|
144
155
|
onReset={handleReset}
|
|
145
156
|
codeTheme={codeTheme}
|
|
146
157
|
onResolveJogak={handleResolveJogak}
|
|
158
|
+
previewIsolation={previewIsolation}
|
|
147
159
|
/>
|
|
148
160
|
) : (
|
|
149
161
|
<div className="jogak:flex jogak:items-center jogak:justify-center jogak:h-full jogak:text-[var(--jogak-color-fg-subtle)]">
|
package/src/app/main.tsx
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
import { StrictMode } from 'react'
|
|
2
2
|
import { createRoot } from 'react-dom/client'
|
|
3
3
|
import 'virtual:jogak'
|
|
4
|
-
import { _jogakCodeTheme } from 'virtual:jogak'
|
|
4
|
+
import { _jogakCodeTheme, _jogakPreviewIsolation } from 'virtual:jogak'
|
|
5
5
|
import '../styles/jogak.css'
|
|
6
|
-
// 알파.6: 사용자 globalCss opt-in.
|
|
7
|
-
// JogakPluginOptions.globalCss=false (default) → 빈 모듈 (no-op, SPA 번들 영향 zero).
|
|
8
|
-
// true / string / string[] → plugin이 사용자 css를 import한다.
|
|
9
|
-
// jogak.css 뒤에 둬서 사용자가 jogak chrome 기본값을 명시적으로 override 가능 —
|
|
10
|
-
// 단, jogak utility는 prefix=jogak로 격리되어 사용자 utility와 충돌하지 않는다.
|
|
11
|
-
import 'virtual:jogak/global-css'
|
|
12
6
|
import { JogakApp } from './App.js'
|
|
13
7
|
|
|
8
|
+
// 알파.7.1: 사용자 globalCss는 isolation === 'none'일 때만 outer document에 inject.
|
|
9
|
+
// - 'shadow'/'iframe' 모드에서는 ShadowMount/preview-frame.tsx가 자기 scope에서
|
|
10
|
+
// 사용자 css를 자체 import하므로 outer document inject가 불필요하고, 오히려
|
|
11
|
+
// chrome을 침범한다 (알파.7 결함).
|
|
12
|
+
// - top-level await로 가드 — Vite는 string literal specifier의 dynamic import를
|
|
13
|
+
// 정적 분석하여 별도 chunk + css HMR 표준 경로로 처리한다.
|
|
14
|
+
if (_jogakPreviewIsolation === 'none') {
|
|
15
|
+
await import('virtual:jogak/global-css')
|
|
16
|
+
}
|
|
17
|
+
|
|
14
18
|
const rootEl = document.getElementById('root')
|
|
15
19
|
if (rootEl === null) throw new Error('#root element not found')
|
|
16
20
|
|
|
17
21
|
createRoot(rootEl).render(
|
|
18
22
|
<StrictMode>
|
|
19
|
-
<JogakApp
|
|
23
|
+
<JogakApp
|
|
24
|
+
codeTheme={_jogakCodeTheme}
|
|
25
|
+
previewIsolation={_jogakPreviewIsolation}
|
|
26
|
+
/>
|
|
20
27
|
</StrictMode>,
|
|
21
28
|
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 알파.7: previewIsolation='iframe' 모드 — iframe document entry.
|
|
3
|
+
*
|
|
4
|
+
* - 부모 Preview 컴포넌트의 `<IframeMount>`가 `iframe.contentWindow.__jogak_setProps__`
|
|
5
|
+
* 를 호출해 entry/args를 주입한다.
|
|
6
|
+
* - iframe과 부모는 동일 origin (Vite dev server) → contentWindow 직접 접근 가능.
|
|
7
|
+
* postMessage는 cross-origin/iframe sandbox 시나리오에서만 필요.
|
|
8
|
+
* - 사용자 globalCss(`virtual:jogak/global-css`)만 import — jogak.css는 chrome 전용
|
|
9
|
+
* 이라 iframe에서는 미필요. 사용자 reset이 iframe document에 free하게 적용됨.
|
|
10
|
+
*/
|
|
11
|
+
import { reactAdapter } from '@jogak/react'
|
|
12
|
+
import type { RegistryEntry } from '@jogak/core'
|
|
13
|
+
import 'virtual:jogak'
|
|
14
|
+
import 'virtual:jogak/global-css'
|
|
15
|
+
|
|
16
|
+
interface SetPropsArgs {
|
|
17
|
+
readonly entry: RegistryEntry
|
|
18
|
+
readonly args: Readonly<Record<string, unknown>>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare global {
|
|
22
|
+
interface Window {
|
|
23
|
+
__jogak_setProps__?: (args: SetPropsArgs) => void
|
|
24
|
+
__jogak_unmount__?: () => void
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const rootEl = document.getElementById('jogak-preview-root')
|
|
29
|
+
if (rootEl === null) throw new Error('#jogak-preview-root not found')
|
|
30
|
+
|
|
31
|
+
let currentEl: HTMLDivElement | null = null
|
|
32
|
+
|
|
33
|
+
window.__jogak_setProps__ = ({ entry, args }) => {
|
|
34
|
+
if (currentEl === null) {
|
|
35
|
+
currentEl = document.createElement('div')
|
|
36
|
+
rootEl.replaceChildren(currentEl)
|
|
37
|
+
}
|
|
38
|
+
reactAdapter.render(entry, args, currentEl)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
window.__jogak_unmount__ = () => {
|
|
42
|
+
if (currentEl !== null) {
|
|
43
|
+
reactAdapter.unmount(currentEl)
|
|
44
|
+
currentEl = null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import type { ReactElement } from 'react'
|
|
3
|
+
import type { RegistryEntry } from '@jogak/core'
|
|
4
|
+
|
|
5
|
+
export interface IframeMountProps {
|
|
6
|
+
readonly entry: RegistryEntry
|
|
7
|
+
readonly args: Readonly<Record<string, unknown>>
|
|
8
|
+
readonly className?: string
|
|
9
|
+
readonly 'data-testid'?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SetPropsArgs {
|
|
13
|
+
readonly entry: RegistryEntry
|
|
14
|
+
readonly args: Readonly<Record<string, unknown>>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
declare global {
|
|
18
|
+
interface Window {
|
|
19
|
+
__jogak_setProps__?: (args: SetPropsArgs) => void
|
|
20
|
+
__jogak_unmount__?: () => void
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 알파.7: previewIsolation='iframe' 모드의 mount 컴포넌트.
|
|
26
|
+
*
|
|
27
|
+
* - `<iframe src="/preview-frame.html">`을 마운트.
|
|
28
|
+
* - iframe load 후 `iframe.contentWindow.__jogak_setProps__({ entry, args })`를
|
|
29
|
+
* 호출해 entry/args를 주입한다 (postMessage 미사용 — 동일 origin이므로
|
|
30
|
+
* contentWindow 직접 접근 가능).
|
|
31
|
+
* - entry/args 변경 시 setProps 재호출 (load 완료 이후).
|
|
32
|
+
*
|
|
33
|
+
* HMR:
|
|
34
|
+
* - iframe document 자체도 Vite dev server module을 import하므로 사용자 컴포넌트
|
|
35
|
+
* 파일 변경 시 fast refresh가 iframe 안에서 작동.
|
|
36
|
+
* - previewIsolation 모드 자체 변경은 가상 모듈 invalidate → full reload.
|
|
37
|
+
*
|
|
38
|
+
* sandbox 미설정:
|
|
39
|
+
* - 사용자 컴포넌트가 fetch/clipboard/storage 등 자유롭게 사용해야 하므로 sandbox X.
|
|
40
|
+
*/
|
|
41
|
+
export function IframeMount({
|
|
42
|
+
entry,
|
|
43
|
+
args,
|
|
44
|
+
className,
|
|
45
|
+
'data-testid': dataTestId,
|
|
46
|
+
}: IframeMountProps): ReactElement {
|
|
47
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null)
|
|
48
|
+
const readyRef = useRef(false)
|
|
49
|
+
|
|
50
|
+
// iframe load 후 첫 setProps
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const iframe = iframeRef.current
|
|
53
|
+
if (iframe === null) return
|
|
54
|
+
const handleLoad = (): void => {
|
|
55
|
+
readyRef.current = true
|
|
56
|
+
iframe.contentWindow?.__jogak_setProps__?.({ entry, args })
|
|
57
|
+
}
|
|
58
|
+
iframe.addEventListener('load', handleLoad)
|
|
59
|
+
return () => {
|
|
60
|
+
iframe.removeEventListener('load', handleLoad)
|
|
61
|
+
// 알파.7.1: unmount race 회피 — iframe contentWindow 정리도 microtask defer.
|
|
62
|
+
queueMicrotask(() => {
|
|
63
|
+
iframe.contentWindow?.__jogak_unmount__?.()
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
67
|
+
}, [])
|
|
68
|
+
|
|
69
|
+
// entry/args 변경 시 setProps 재호출 (load 후에만)
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!readyRef.current) return
|
|
72
|
+
iframeRef.current?.contentWindow?.__jogak_setProps__?.({ entry, args })
|
|
73
|
+
}, [entry, args])
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<iframe
|
|
77
|
+
ref={iframeRef}
|
|
78
|
+
src="/preview-frame.html"
|
|
79
|
+
title="Preview"
|
|
80
|
+
className={className}
|
|
81
|
+
data-testid={dataTestId}
|
|
82
|
+
/>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import type { ReactElement, ReactNode, CSSProperties } from 'react'
|
|
4
|
+
|
|
5
|
+
export interface ShadowMountProps {
|
|
6
|
+
readonly children: ReactNode
|
|
7
|
+
readonly className?: string
|
|
8
|
+
readonly style?: CSSProperties
|
|
9
|
+
readonly 'data-testid'?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 알파.7.1: previewIsolation='shadow' 모드의 mount 컴포넌트.
|
|
14
|
+
*
|
|
15
|
+
* 책임: 양방향 격리만 제공 (Preview ↔ outer document 양방향 cascade 차단).
|
|
16
|
+
* - 사용자 globalCss는 main.tsx 가드로 outer document에 inject되지 않음.
|
|
17
|
+
* - shadow root 안에는 jogak chrome css도 사용자 css도 없음 (둘 다 외부에서 격리).
|
|
18
|
+
* - 사용자 컴포넌트의 utility class 컴파일은 결함 B (알파.8 사이클).
|
|
19
|
+
*
|
|
20
|
+
* 알파.7 결함 정정:
|
|
21
|
+
* - `syncStyleSheets`/`MutationObserver`/`adoptedStyleSheets` 흡수 로직 제거.
|
|
22
|
+
* 알파.7은 outer document에 사용자 css가 있는 한 chrome을 침범했고, shadow
|
|
23
|
+
* 안의 흡수 로직은 의미가 없었음. 알파.7.1: outer에 사용자 css 자체가 없음.
|
|
24
|
+
*
|
|
25
|
+
* Radix portal 한계 (사용자 인지 필요, README 명시):
|
|
26
|
+
* - default Portal target = document.body (shadow 외부). 사용자가 명시적으로
|
|
27
|
+
* `<Portal container={shadowRootEl}>`을 전달해야 portal 내용도 격리됨.
|
|
28
|
+
*/
|
|
29
|
+
export function ShadowMount({
|
|
30
|
+
children,
|
|
31
|
+
className,
|
|
32
|
+
style,
|
|
33
|
+
'data-testid': dataTestId,
|
|
34
|
+
}: ShadowMountProps): ReactElement {
|
|
35
|
+
const hostRef = useRef<HTMLDivElement | null>(null)
|
|
36
|
+
const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(null)
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const host = hostRef.current
|
|
40
|
+
if (host === null) return
|
|
41
|
+
const sr = host.shadowRoot ?? host.attachShadow({ mode: 'open' })
|
|
42
|
+
setShadowRoot(sr)
|
|
43
|
+
// shadow root는 host element와 함께 GC — 명시 detach 불필요.
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
ref={hostRef}
|
|
49
|
+
className={className}
|
|
50
|
+
data-testid={dataTestId}
|
|
51
|
+
// eslint-disable-next-line no-restricted-syntax -- jogak: ShadowMount caller-supplied style passthrough (host wrapper, content goes through ShadowRoot portal)
|
|
52
|
+
style={style}
|
|
53
|
+
>
|
|
54
|
+
{shadowRoot !== null ? createPortal(children, shadowRoot) : null}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -8,6 +8,8 @@ import type { UseEntryState } from '@jogak/react'
|
|
|
8
8
|
import type { RegistryEntry, RegistryEntryMeta, ArgType } from '@jogak/core'
|
|
9
9
|
import { Controls } from '../Controls/index.js'
|
|
10
10
|
import { Actions } from '../Actions/index.js'
|
|
11
|
+
import { ShadowMount } from './ShadowMount.js'
|
|
12
|
+
import { IframeMount } from './IframeMount.js'
|
|
11
13
|
|
|
12
14
|
export interface PreviewProps {
|
|
13
15
|
readonly entryId: string
|
|
@@ -21,6 +23,14 @@ export interface PreviewProps {
|
|
|
21
23
|
* 첫 jogak로 자동 보정하기 위한 콜백. 부모가 selectedJogakName / URL을 갱신.
|
|
22
24
|
*/
|
|
23
25
|
readonly onResolveJogak?: (entryId: string, jogakName: string) => void
|
|
26
|
+
/**
|
|
27
|
+
* 알파.7: Preview 영역 격리 모드. default `'none'`.
|
|
28
|
+
*
|
|
29
|
+
* - `'none'` — 기존 동작 (chrome과 같은 document, 알파.6 chrome 보호 rule 적용).
|
|
30
|
+
* - `'shadow'` — ShadowRoot에 마운트. 사용자 globalCss reset이 chrome 침범 차단.
|
|
31
|
+
* - `'iframe'` — `/preview-frame.html` iframe에 마운트. 강한 격리.
|
|
32
|
+
*/
|
|
33
|
+
readonly previewIsolation?: 'none' | 'shadow' | 'iframe'
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
type ViewportKey = 'mobile' | 'tablet' | 'desktop'
|
|
@@ -110,6 +120,7 @@ export function Preview({
|
|
|
110
120
|
onReset,
|
|
111
121
|
codeTheme,
|
|
112
122
|
onResolveJogak,
|
|
123
|
+
previewIsolation = 'shadow',
|
|
113
124
|
}: PreviewProps): ReactElement {
|
|
114
125
|
const state = useEntry(entryId)
|
|
115
126
|
const [viewport, setViewport] = useState<ViewportKey>('desktop')
|
|
@@ -175,6 +186,7 @@ export function Preview({
|
|
|
175
186
|
onBgModeChange={setBgMode}
|
|
176
187
|
onBottomTabChange={setBottomTab}
|
|
177
188
|
prismTheme={prismTheme}
|
|
189
|
+
previewIsolation={previewIsolation}
|
|
178
190
|
/>
|
|
179
191
|
)
|
|
180
192
|
}
|
|
@@ -262,6 +274,7 @@ interface ReadyFrameProps {
|
|
|
262
274
|
readonly onBgModeChange: (bg: BgMode) => void
|
|
263
275
|
readonly onBottomTabChange: (tab: 'controls' | 'actions') => void
|
|
264
276
|
readonly prismTheme: PrismTheme
|
|
277
|
+
readonly previewIsolation: 'none' | 'shadow' | 'iframe'
|
|
265
278
|
}
|
|
266
279
|
|
|
267
280
|
function ReadyFrame({
|
|
@@ -278,6 +291,7 @@ function ReadyFrame({
|
|
|
278
291
|
onBgModeChange,
|
|
279
292
|
onBottomTabChange,
|
|
280
293
|
prismTheme,
|
|
294
|
+
previewIsolation,
|
|
281
295
|
}: ReadyFrameProps): ReactElement {
|
|
282
296
|
// jogakName이 비어있으면 (deep link `?entry=...&jogak` 누락) 첫 jogak로 보정.
|
|
283
297
|
const resolvedJogakName = jogakName ?? entry.jogaks[0]?.name ?? null
|
|
@@ -349,6 +363,7 @@ function ReadyFrame({
|
|
|
349
363
|
args={mergedArgs}
|
|
350
364
|
source={entry.source}
|
|
351
365
|
theme={prismTheme}
|
|
366
|
+
previewIsolation={previewIsolation}
|
|
352
367
|
/>
|
|
353
368
|
</div>
|
|
354
369
|
</div>
|
|
@@ -498,17 +513,113 @@ interface JogakRendererProps {
|
|
|
498
513
|
readonly args: Readonly<Record<string, unknown>>
|
|
499
514
|
readonly source: string | undefined
|
|
500
515
|
readonly theme: PrismTheme
|
|
516
|
+
readonly previewIsolation: 'none' | 'shadow' | 'iframe'
|
|
501
517
|
}
|
|
502
518
|
|
|
503
|
-
|
|
504
|
-
|
|
519
|
+
/**
|
|
520
|
+
* 알파.7: previewIsolation 모드별로 사용자 콘텐츠 마운트 방식을 분기한다.
|
|
521
|
+
*
|
|
522
|
+
* - `'none'` — 같은 document에 직접 마운트 (알파.6까지의 동작 그대로).
|
|
523
|
+
* - `'shadow'` — `<ShadowMount>` 안에 마운트해 ShadowRoot 격리.
|
|
524
|
+
* - `'iframe'` — `<IframeMount>`로 별도 document에 마운트.
|
|
525
|
+
*
|
|
526
|
+
* Show source 토글, 코드 패널 등 chrome 부분은 모드 무관하게 외부에 둔다.
|
|
527
|
+
*/
|
|
528
|
+
function JogakRenderer({ entry, args, source, theme, previewIsolation }: JogakRendererProps): ReactElement {
|
|
505
529
|
const [showCode, setShowCode] = useState(false)
|
|
506
530
|
|
|
531
|
+
const previewBody = (
|
|
532
|
+
<div className="jogak:relative">
|
|
533
|
+
<PreviewMount
|
|
534
|
+
entry={entry}
|
|
535
|
+
args={args}
|
|
536
|
+
previewIsolation={previewIsolation}
|
|
537
|
+
/>
|
|
538
|
+
<button
|
|
539
|
+
type="button"
|
|
540
|
+
onClick={() => { setShowCode((v) => !v) }}
|
|
541
|
+
aria-pressed={showCode}
|
|
542
|
+
aria-label={showCode ? 'Hide source code' : 'Show source code'}
|
|
543
|
+
className={clsx(
|
|
544
|
+
'jogak:absolute jogak:bottom-2 jogak:right-2 jogak:px-[9px] jogak:py-1',
|
|
545
|
+
'jogak:text-[11px] jogak:font-[family-name:var(--jogak-font-mono)] jogak:font-semibold jogak:tracking-[0.02em]',
|
|
546
|
+
'jogak:text-[var(--jogak-color-bg)] jogak:border-none jogak:rounded-[5px] jogak:cursor-pointer',
|
|
547
|
+
'jogak:shadow-[0_1px_4px_rgba(0,0,0,0.2)] jogak:transition-[background-color] jogak:duration-150 jogak:leading-none',
|
|
548
|
+
showCode ? 'jogak:bg-[var(--jogak-color-accent)]' : 'jogak:bg-[#1e293b]',
|
|
549
|
+
)}
|
|
550
|
+
>
|
|
551
|
+
{'</>'}
|
|
552
|
+
</button>
|
|
553
|
+
</div>
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
return (
|
|
557
|
+
<div>
|
|
558
|
+
{previewBody}
|
|
559
|
+
{/* 코드 패널 — preview-content 하단으로 펼쳐짐 */}
|
|
560
|
+
{showCode && (
|
|
561
|
+
<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)]">
|
|
562
|
+
<SourceViewer source={source} theme={theme} />
|
|
563
|
+
</div>
|
|
564
|
+
)}
|
|
565
|
+
</div>
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ── PreviewMount ──────────────────────────────────────────
|
|
570
|
+
//
|
|
571
|
+
// previewIsolation 모드별 콘텐츠 마운트. chrome 외곽 (border/radius/padding)은 모드
|
|
572
|
+
// 별 호스트 element에 동일하게 적용해 VR baseline 변경을 zero로 유지한다.
|
|
573
|
+
|
|
574
|
+
interface PreviewMountProps {
|
|
575
|
+
readonly entry: RegistryEntry
|
|
576
|
+
readonly args: Readonly<Record<string, unknown>>
|
|
577
|
+
readonly previewIsolation: 'none' | 'shadow' | 'iframe'
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const PREVIEW_HOST_CLASS =
|
|
581
|
+
'jogak:border jogak:border-dashed jogak:border-[var(--jogak-color-border)] ' +
|
|
582
|
+
'jogak:rounded-[var(--jogak-radius-xl)] jogak:p-4 jogak:pb-9'
|
|
583
|
+
|
|
584
|
+
function PreviewMount({ entry, args, previewIsolation }: PreviewMountProps): ReactElement {
|
|
585
|
+
if (previewIsolation === 'shadow') {
|
|
586
|
+
return (
|
|
587
|
+
<ShadowMount
|
|
588
|
+
data-testid="preview-content"
|
|
589
|
+
className={PREVIEW_HOST_CLASS}
|
|
590
|
+
>
|
|
591
|
+
<ShadowAdapterContent entry={entry} args={args} />
|
|
592
|
+
</ShadowMount>
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (previewIsolation === 'iframe') {
|
|
597
|
+
return (
|
|
598
|
+
<IframeMount
|
|
599
|
+
entry={entry}
|
|
600
|
+
args={args}
|
|
601
|
+
data-testid="preview-content"
|
|
602
|
+
className={`${PREVIEW_HOST_CLASS} jogak:block jogak:w-full jogak:bg-transparent jogak:min-h-[256px]`}
|
|
603
|
+
/>
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// 'none' — 기존 동작 그대로
|
|
608
|
+
return <NoneAdapterContent entry={entry} args={args} />
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function NoneAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
|
|
612
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
613
|
+
|
|
507
614
|
useEffect(() => {
|
|
508
615
|
const container = containerRef.current
|
|
509
616
|
if (container === null) return
|
|
510
617
|
reactAdapter.render(entry, args, container)
|
|
511
|
-
return () => {
|
|
618
|
+
return () => {
|
|
619
|
+
// 알파.7.1: React 18 concurrent unmount race(`Attempted to synchronously unmount...`)
|
|
620
|
+
// 회피 — fiber commit 끝난 직후로 defer.
|
|
621
|
+
queueMicrotask(() => { reactAdapter.unmount(container) })
|
|
622
|
+
}
|
|
512
623
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
513
624
|
}, [entry])
|
|
514
625
|
|
|
@@ -519,41 +630,41 @@ function JogakRenderer({ entry, args, source, theme }: JogakRendererProps): Reac
|
|
|
519
630
|
}, [entry, args])
|
|
520
631
|
|
|
521
632
|
return (
|
|
522
|
-
<div
|
|
523
|
-
{
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
data-testid="preview-content"
|
|
528
|
-
className="jogak:border jogak:border-dashed jogak:border-[var(--jogak-color-border)] jogak:rounded-[var(--jogak-radius-xl)] jogak:p-4 jogak:pb-9"
|
|
529
|
-
/>
|
|
530
|
-
<button
|
|
531
|
-
type="button"
|
|
532
|
-
onClick={() => { setShowCode((v) => !v) }}
|
|
533
|
-
aria-pressed={showCode}
|
|
534
|
-
aria-label={showCode ? 'Hide source code' : 'Show source code'}
|
|
535
|
-
className={clsx(
|
|
536
|
-
'jogak:absolute jogak:bottom-2 jogak:right-2 jogak:px-[9px] jogak:py-1',
|
|
537
|
-
'jogak:text-[11px] jogak:font-[family-name:var(--jogak-font-mono)] jogak:font-semibold jogak:tracking-[0.02em]',
|
|
538
|
-
'jogak:text-[var(--jogak-color-bg)] jogak:border-none jogak:rounded-[5px] jogak:cursor-pointer',
|
|
539
|
-
'jogak:shadow-[0_1px_4px_rgba(0,0,0,0.2)] jogak:transition-[background-color] jogak:duration-150 jogak:leading-none',
|
|
540
|
-
showCode ? 'jogak:bg-[var(--jogak-color-accent)]' : 'jogak:bg-[#1e293b]',
|
|
541
|
-
)}
|
|
542
|
-
>
|
|
543
|
-
{'</>'}
|
|
544
|
-
</button>
|
|
545
|
-
</div>
|
|
546
|
-
|
|
547
|
-
{/* 코드 패널 — preview-content 하단으로 펼쳐짐 */}
|
|
548
|
-
{showCode && (
|
|
549
|
-
<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)]">
|
|
550
|
-
<SourceViewer source={source} theme={theme} />
|
|
551
|
-
</div>
|
|
552
|
-
)}
|
|
553
|
-
</div>
|
|
633
|
+
<div
|
|
634
|
+
ref={containerRef}
|
|
635
|
+
data-testid="preview-content"
|
|
636
|
+
className={PREVIEW_HOST_CLASS}
|
|
637
|
+
/>
|
|
554
638
|
)
|
|
555
639
|
}
|
|
556
640
|
|
|
641
|
+
/**
|
|
642
|
+
* Shadow 모드 — ShadowMount의 ShadowRoot 안에서 react-adapter.render를 호출하는
|
|
643
|
+
* 작은 wrapper. ShadowMount 안 portal 내부에 위치하므로 useRef는 ShadowRoot scope.
|
|
644
|
+
*/
|
|
645
|
+
function ShadowAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
|
|
646
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
647
|
+
|
|
648
|
+
useEffect(() => {
|
|
649
|
+
const c = ref.current
|
|
650
|
+
if (c === null) return
|
|
651
|
+
reactAdapter.render(entry, args, c)
|
|
652
|
+
return () => {
|
|
653
|
+
// 알파.7.1: unmount race 회피
|
|
654
|
+
queueMicrotask(() => { reactAdapter.unmount(c) })
|
|
655
|
+
}
|
|
656
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
657
|
+
}, [entry])
|
|
658
|
+
|
|
659
|
+
useEffect(() => {
|
|
660
|
+
const c = ref.current
|
|
661
|
+
if (c === null) return
|
|
662
|
+
reactAdapter.render(entry, args, c)
|
|
663
|
+
}, [entry, args])
|
|
664
|
+
|
|
665
|
+
return <div ref={ref} data-testid="preview-content-shadow" />
|
|
666
|
+
}
|
|
667
|
+
|
|
557
668
|
// ── SourceViewer ──────────────────────────────────────────
|
|
558
669
|
|
|
559
670
|
interface SourceViewerProps {
|
package/src/vite-env.d.ts
CHANGED
|
@@ -3,4 +3,13 @@
|
|
|
3
3
|
declare module 'virtual:jogak' {
|
|
4
4
|
/** 플러그인 설정에서 지정한 prism-react-renderer 테마 이름 */
|
|
5
5
|
export const _jogakCodeTheme: string
|
|
6
|
+
/**
|
|
7
|
+
* 알파.7: Preview 영역 격리 모드 ('none' | 'shadow' | 'iframe').
|
|
8
|
+
* `JogakPluginOptions.previewIsolation` (default 'none')의 literal emit.
|
|
9
|
+
*/
|
|
10
|
+
export const _jogakPreviewIsolation: 'none' | 'shadow' | 'iframe'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
declare module 'virtual:jogak/global-css' {
|
|
14
|
+
// empty — side-effect only
|
|
6
15
|
}
|