|
| 1 | +import type { ModuleBuiltinTab, ModuleCustomTab } from '~/../src/types' |
| 2 | +import { computed, watch } from 'vue' |
| 3 | +import { useRouter } from '#app/composables/router' |
| 4 | +import { useEnabledTabs } from '~/composables/state-tabs' |
| 5 | + |
| 6 | +// `devframe:frame-nav` — the embedded-app half of the shared-iframe soft-nav |
| 7 | +// protocol (devframe#128 / vitejs/devtools#464). The Vite DevTools dock owns |
| 8 | +// one kept-alive iframe (the anchor); this shim announces one member dock per |
| 9 | +// DevTools tab and switches views by client-side navigation, so no per-tab |
| 10 | +// iframe/reload is needed. The shim is transport-only: it takes no hub/RPC |
| 11 | +// dependency, just `postMessage`. |
| 12 | +// |
| 13 | +// The announced set comes from `useEnabledTabs()` — the same conditional list |
| 14 | +// the SideNav used — so a tab's `show()` condition, hidden/pinned settings and |
| 15 | +// experimental gating decide whether its dock appears, and the manifest is |
| 16 | +// re-announced whenever that set changes. |
| 17 | +const CHANNEL = 'devframe:frame-nav' |
| 18 | +const VERSION = 1 |
| 19 | +// Must match the anchor's `frameId` registered in `module-main.ts`. |
| 20 | +const FRAME_ID = 'nuxt:devtools' |
| 21 | + |
| 22 | +const ICON_CLASS_RE = /\s.*$/ |
| 23 | +const FIRST_DASH_RE = /-/ |
| 24 | + |
| 25 | +interface FrameNavTab { |
| 26 | + id: string |
| 27 | + title: string |
| 28 | + icon?: string |
| 29 | + category?: string |
| 30 | + defaultOrder?: number |
| 31 | + navTarget: { path: string } |
| 32 | +} |
| 33 | + |
| 34 | +/** The DevTools settings page, surfaced as its own dock member. */ |
| 35 | +const SETTINGS_TAB: FrameNavTab = { |
| 36 | + id: 'settings', |
| 37 | + title: 'Settings', |
| 38 | + icon: 'carbon:settings', |
| 39 | + category: '~builtin', |
| 40 | + // sort last within its sub-category (higher `order` renders earlier) |
| 41 | + defaultOrder: 1000, |
| 42 | + navTarget: { path: '/settings' }, |
| 43 | +} |
| 44 | + |
| 45 | +/** Normalise a tab icon (UnoCSS `i-carbon-foo` / `carbon-foo`) to iconify `carbon:foo`. */ |
| 46 | +function normalizeIcon(icon: string | undefined): string | undefined { |
| 47 | + if (!icon) |
| 48 | + return undefined |
| 49 | + let token = icon.replace(ICON_CLASS_RE, '') |
| 50 | + if (/^(?:https?:)?\/\//.test(token) || token.startsWith('/') || token.startsWith('data:')) |
| 51 | + return token |
| 52 | + if (token.startsWith('i-')) |
| 53 | + token = token.slice(2) |
| 54 | + if (!token.includes(':')) |
| 55 | + token = token.replace(FIRST_DASH_RE, ':') |
| 56 | + return token |
| 57 | +} |
| 58 | + |
| 59 | +function tabPath(tab: ModuleBuiltinTab | ModuleCustomTab): string { |
| 60 | + return 'path' in tab && tab.path ? tab.path : `/modules/custom-${tab.name}` |
| 61 | +} |
| 62 | + |
| 63 | +/** |
| 64 | + * Start the frame-nav shim. No-op unless running inside an iframe. Announces the |
| 65 | + * (conditional) tab manifest, answers `navigate` with client-side navigation, |
| 66 | + * and reports `navigated` so the dock highlight follows in-app navigation. |
| 67 | + */ |
| 68 | +export function setupFrameNav(): void { |
| 69 | + if (typeof window === 'undefined' || window.parent === window) |
| 70 | + return |
| 71 | + |
| 72 | + const router = useRouter() |
| 73 | + const tabs = useEnabledTabs() |
| 74 | + |
| 75 | + const manifest = computed<FrameNavTab[]>(() => [ |
| 76 | + ...tabs.value.map(tab => ({ |
| 77 | + id: tab.name, |
| 78 | + title: tab.title ?? tab.name, |
| 79 | + icon: normalizeIcon(tab.icon), |
| 80 | + defaultOrder: 'defaultOrder' in tab ? tab.defaultOrder : undefined, |
| 81 | + // Mirror `getCategorizedTabs`: an uncategorised tab belongs to `app`, so |
| 82 | + // the `Nuxt` group's `categoryOrder` weights apply to it. |
| 83 | + category: tab.category || 'app', |
| 84 | + navTarget: { path: tabPath(tab) }, |
| 85 | + })), |
| 86 | + SETTINGS_TAB, |
| 87 | + ]) |
| 88 | + |
| 89 | + function currentTabId(): string | undefined { |
| 90 | + const path = router.currentRoute.value.path |
| 91 | + return manifest.value.find(entry => entry.navTarget.path === path)?.id |
| 92 | + } |
| 93 | + |
| 94 | + function post(message: Record<string, unknown>) { |
| 95 | + window.parent.postMessage({ channel: CHANNEL, v: VERSION, frameId: FRAME_ID, from: 'frame', ...message }, '*') |
| 96 | + } |
| 97 | + |
| 98 | + function announce(type: 'ready' | 'manifest') { |
| 99 | + post({ type, tabs: manifest.value, current: currentTabId() }) |
| 100 | + } |
| 101 | + |
| 102 | + window.addEventListener('message', (ev: MessageEvent) => { |
| 103 | + const data = ev.data |
| 104 | + if (!data || data.channel !== CHANNEL || data.v !== VERSION || data.frameId !== FRAME_ID || data.from !== 'host') |
| 105 | + return |
| 106 | + if (data.type === 'hello') { |
| 107 | + announce('ready') |
| 108 | + } |
| 109 | + else if (data.type === 'navigate') { |
| 110 | + const path = data.navTarget?.path |
| 111 | + if (typeof path === 'string') |
| 112 | + router.push(path).then(() => post({ type: 'navigated', tabId: data.tabId })) |
| 113 | + } |
| 114 | + }) |
| 115 | + |
| 116 | + // Announce proactively in case the host attached before this shim loaded. |
| 117 | + announce('ready') |
| 118 | + |
| 119 | + // Re-announce when the conditional tab set changes (show()/settings/etc). |
| 120 | + watch(() => manifest.value.map(entry => entry.id).join(','), () => announce('manifest')) |
| 121 | + |
| 122 | + // Report in-app navigation so the dock highlight follows. |
| 123 | + watch(() => router.currentRoute.value.path, () => { |
| 124 | + const id = currentTabId() |
| 125 | + if (id) |
| 126 | + post({ type: 'navigated', tabId: id }) |
| 127 | + }) |
| 128 | +} |
0 commit comments