@jogak/ui 0.1.0-alpha.5 → 0.1.0-alpha.7
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 +43 -0
- package/README.md +171 -23
- package/dist/app/App.d.ts +11 -1
- package/dist/components/Preview/IframeMount.d.ts +37 -0
- package/dist/components/Preview/ShadowMount.d.ts +26 -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 +2 -1
- package/dist/index.mjs +456 -330
- package/package.json +4 -3
- package/preview-frame.html +17 -0
- package/src/app/App.tsx +12 -0
- package/src/app/main.tsx +11 -2
- package/src/app/preview-frame.tsx +46 -0
- package/src/components/Preview/IframeMount.tsx +82 -0
- package/src/components/Preview/ShadowMount.tsx +101 -0
- package/src/components/Preview/index.tsx +138 -34
- package/src/styles/jogak.css +20 -0
- 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",
|
|
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",
|
|
69
|
+
"@jogak/react": "0.1.0-alpha.7"
|
|
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 = 'none',
|
|
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,8 +1,14 @@
|
|
|
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'
|
|
6
12
|
import { JogakApp } from './App.js'
|
|
7
13
|
|
|
8
14
|
const rootEl = document.getElementById('root')
|
|
@@ -10,6 +16,9 @@ if (rootEl === null) throw new Error('#root element not found')
|
|
|
10
16
|
|
|
11
17
|
createRoot(rootEl).render(
|
|
12
18
|
<StrictMode>
|
|
13
|
-
<JogakApp
|
|
19
|
+
<JogakApp
|
|
20
|
+
codeTheme={_jogakCodeTheme}
|
|
21
|
+
previewIsolation={_jogakPreviewIsolation}
|
|
22
|
+
/>
|
|
14
23
|
</StrictMode>,
|
|
15
24
|
)
|
|
@@ -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,82 @@
|
|
|
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
|
+
// unmount 시 iframe 안 react root도 정리 (best-effort)
|
|
62
|
+
iframe.contentWindow?.__jogak_unmount__?.()
|
|
63
|
+
}
|
|
64
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
65
|
+
}, [])
|
|
66
|
+
|
|
67
|
+
// entry/args 변경 시 setProps 재호출 (load 후에만)
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!readyRef.current) return
|
|
70
|
+
iframeRef.current?.contentWindow?.__jogak_setProps__?.({ entry, args })
|
|
71
|
+
}, [entry, args])
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<iframe
|
|
75
|
+
ref={iframeRef}
|
|
76
|
+
src="/preview-frame.html"
|
|
77
|
+
title="Preview"
|
|
78
|
+
className={className}
|
|
79
|
+
data-testid={dataTestId}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
/** 외부 테스트 hook (호스트 div에 부여). */
|
|
10
|
+
readonly 'data-testid'?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 알파.7: previewIsolation='shadow' 모드의 mount 컴포넌트.
|
|
15
|
+
*
|
|
16
|
+
* 역할:
|
|
17
|
+
* - 호스트 `<div>`에 `attachShadow({ mode: 'open' })`로 ShadowRoot를 부착.
|
|
18
|
+
* - `createPortal`로 children을 ShadowRoot에 렌더 (React tree 유지).
|
|
19
|
+
* - 외부 document의 모든 `<style>` / cross-origin 가능 stylesheet를
|
|
20
|
+
* `adoptedStyleSheets`로 ShadowRoot에 share (jogak.css + virtual:jogak/global-css
|
|
21
|
+
* 둘 다 자동 포함).
|
|
22
|
+
* - Vite dev에서 `<style>` HMR 시 MutationObserver로 ShadowRoot도 갱신.
|
|
23
|
+
*
|
|
24
|
+
* Radix portal 한계 (사용자 인지 필요):
|
|
25
|
+
* - 사용자 컴포넌트가 `Dialog.Portal` / `Popover.Portal` 등을 default로 쓰면
|
|
26
|
+
* portal target은 `document.body` — ShadowRoot 외부. utility class는 외부
|
|
27
|
+
* document에 정의되어 적용됨, 단 z-index/focus/event boundary가 분리될 수 있음.
|
|
28
|
+
* - 회피: 사용자가 명시적으로 `<Portal container={shadowRootEl}>`을 전달.
|
|
29
|
+
*/
|
|
30
|
+
export function ShadowMount({
|
|
31
|
+
children,
|
|
32
|
+
className,
|
|
33
|
+
style,
|
|
34
|
+
'data-testid': dataTestId,
|
|
35
|
+
}: ShadowMountProps): ReactElement {
|
|
36
|
+
const hostRef = useRef<HTMLDivElement | null>(null)
|
|
37
|
+
const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(null)
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const host = hostRef.current
|
|
41
|
+
if (host === null) return
|
|
42
|
+
let sr: ShadowRoot
|
|
43
|
+
if (host.shadowRoot !== null) {
|
|
44
|
+
sr = host.shadowRoot
|
|
45
|
+
} else {
|
|
46
|
+
sr = host.attachShadow({ mode: 'open' })
|
|
47
|
+
}
|
|
48
|
+
setShadowRoot(sr)
|
|
49
|
+
syncStyleSheets(sr)
|
|
50
|
+
const observer = observeDocumentStyles(sr)
|
|
51
|
+
return () => { observer.disconnect() }
|
|
52
|
+
}, [])
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
ref={hostRef}
|
|
57
|
+
className={className}
|
|
58
|
+
data-testid={dataTestId}
|
|
59
|
+
// eslint-disable-next-line no-restricted-syntax -- jogak: ShadowMount caller-supplied style passthrough (host wrapper, content goes through ShadowRoot portal)
|
|
60
|
+
style={style}
|
|
61
|
+
>
|
|
62
|
+
{shadowRoot !== null ? createPortal(children, shadowRoot) : null}
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 외부 document의 모든 stylesheet를 ShadowRoot에 share한다.
|
|
69
|
+
*
|
|
70
|
+
* - `adoptedStyleSheets` API를 사용 (Chromium/Safari/Firefox 모던 브라우저 지원).
|
|
71
|
+
* - cross-origin sheet는 `cssRules` 접근 시 SecurityError → catch 후 skip.
|
|
72
|
+
* - Vite dev의 `<style>` HMR이 자주 발생할 수 있어 `replaceSync`를 통한 새
|
|
73
|
+
* `CSSStyleSheet` 인스턴스 생성으로 처리.
|
|
74
|
+
*/
|
|
75
|
+
function syncStyleSheets(shadowRoot: ShadowRoot): void {
|
|
76
|
+
const sheets: CSSStyleSheet[] = []
|
|
77
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
78
|
+
try {
|
|
79
|
+
const rules = sheet.cssRules
|
|
80
|
+
const cs = new CSSStyleSheet()
|
|
81
|
+
const cssText = Array.from(rules).map((r) => r.cssText).join('\n')
|
|
82
|
+
cs.replaceSync(cssText)
|
|
83
|
+
sheets.push(cs)
|
|
84
|
+
} catch {
|
|
85
|
+
// cross-origin sheet — skip
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
shadowRoot.adoptedStyleSheets = sheets
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* document.head의 변화(`<style>` HMR add/remove)를 관찰해 ShadowRoot의
|
|
93
|
+
* adoptedStyleSheets를 다시 sync.
|
|
94
|
+
*/
|
|
95
|
+
function observeDocumentStyles(shadowRoot: ShadowRoot): MutationObserver {
|
|
96
|
+
const observer = new MutationObserver(() => {
|
|
97
|
+
syncStyleSheets(shadowRoot)
|
|
98
|
+
})
|
|
99
|
+
observer.observe(document.head, { childList: true, subtree: true })
|
|
100
|
+
return observer
|
|
101
|
+
}
|
|
@@ -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 = 'none',
|
|
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,12 +513,104 @@ 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
|
|
@@ -519,41 +626,38 @@ function JogakRenderer({ entry, args, source, theme }: JogakRendererProps): Reac
|
|
|
519
626
|
}, [entry, args])
|
|
520
627
|
|
|
521
628
|
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>
|
|
629
|
+
<div
|
|
630
|
+
ref={containerRef}
|
|
631
|
+
data-testid="preview-content"
|
|
632
|
+
className={PREVIEW_HOST_CLASS}
|
|
633
|
+
/>
|
|
554
634
|
)
|
|
555
635
|
}
|
|
556
636
|
|
|
637
|
+
/**
|
|
638
|
+
* Shadow 모드 — ShadowMount의 ShadowRoot 안에서 react-adapter.render를 호출하는
|
|
639
|
+
* 작은 wrapper. ShadowMount 안 portal 내부에 위치하므로 useRef는 ShadowRoot scope.
|
|
640
|
+
*/
|
|
641
|
+
function ShadowAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
|
|
642
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
643
|
+
|
|
644
|
+
useEffect(() => {
|
|
645
|
+
const c = ref.current
|
|
646
|
+
if (c === null) return
|
|
647
|
+
reactAdapter.render(entry, args, c)
|
|
648
|
+
return () => { reactAdapter.unmount(c) }
|
|
649
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
650
|
+
}, [entry])
|
|
651
|
+
|
|
652
|
+
useEffect(() => {
|
|
653
|
+
const c = ref.current
|
|
654
|
+
if (c === null) return
|
|
655
|
+
reactAdapter.render(entry, args, c)
|
|
656
|
+
}, [entry, args])
|
|
657
|
+
|
|
658
|
+
return <div ref={ref} data-testid="preview-content-shadow" />
|
|
659
|
+
}
|
|
660
|
+
|
|
557
661
|
// ── SourceViewer ──────────────────────────────────────────
|
|
558
662
|
|
|
559
663
|
interface SourceViewerProps {
|
package/src/styles/jogak.css
CHANGED
|
@@ -82,6 +82,26 @@
|
|
|
82
82
|
* 회귀 자체 차단 + 알파.6 사용자 globalCss 충돌 가능성 zero.
|
|
83
83
|
*/
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
/*
|
|
87
|
+
* 알파.6 wrapper 보호 rule (api-contracts §8 옵션 C).
|
|
88
|
+
*
|
|
89
|
+
* 사용자 globalCss(예: shadcn `@layer base { * { @apply border-border ... } }`)가
|
|
90
|
+
* jogak chrome의 form element를 침범하는 것을 차단한다. `:where(...)` specificity 0
|
|
91
|
+
* 으로 사용자가 명시적으로 `[data-jogak-shell] button { ... }`를 작성하면 자연스럽게
|
|
92
|
+
* 이긴다.
|
|
93
|
+
*
|
|
94
|
+
* 적용 범위: chrome의 모든 form element. preview(`[data-jogak-content]`) 안의
|
|
95
|
+
* 사용자 컴포넌트는 영향 받지 않음 — `:not([data-jogak-content] *)`로 제외.
|
|
96
|
+
*
|
|
97
|
+
* 알파.5 baseline 영향: zero. chrome 컴포넌트는 본 rule이 차단하는 사용자 reset이
|
|
98
|
+
* 없을 때 동일 시각 (revert-layer는 해당 layer가 없으면 no-op).
|
|
99
|
+
*/
|
|
100
|
+
[data-jogak-shell] :where(button, input, select, textarea):not([data-jogak-content] *) {
|
|
101
|
+
border-color: revert-layer;
|
|
102
|
+
background-color: revert-layer;
|
|
103
|
+
color: revert-layer;
|
|
104
|
+
}
|
|
85
105
|
}
|
|
86
106
|
|
|
87
107
|
@layer components {
|
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
|
}
|