vitepress-theme-element-plus 1.3.2 → 1.4.0

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.
@@ -4,11 +4,14 @@ import { computed } from 'vue'
4
4
  import { useSidebar } from '../hooks/useSidebar'
5
5
  import VPDocAside from './DocAside.vue'
6
6
  import VPDocFooter from './DocFooter.vue'
7
+ import MobilePreviewFrame from './MobilePreviewFrame.vue'
7
8
 
8
- const { theme } = useData()
9
+ const { theme, frontmatter } = useData()
9
10
 
10
11
  const route = useRoute()
11
12
  const { hasAside, leftAside, hasSidebar } = useSidebar()
13
+ const hasMobilePreview = computed(() => typeof frontmatter.value.mobileDemo === 'string' && frontmatter.value.mobileDemo.trim().length > 0)
14
+ const showAside = computed(() => hasAside.value)
12
15
 
13
16
  const pageName = computed(() =>
14
17
  route.path.replace(/[./]+/g, '_').replace(/_html$/, ''),
@@ -16,10 +19,16 @@ const pageName = computed(() =>
16
19
  </script>
17
20
 
18
21
  <template>
19
- <div class="VPDoc" :class="{ 'has-sidebar': hasSidebar, 'has-aside': hasAside }">
22
+ <div class="VPDoc" :class="{ 'has-sidebar': hasSidebar, 'has-aside': showAside, 'has-mobile-preview': hasMobilePreview }">
20
23
  <slot name="doc-top" />
21
24
  <div class="container">
22
- <div v-if="hasAside" class="aside" :class="{ 'left-aside': leftAside }">
25
+ <div v-if="hasMobilePreview" class="preview">
26
+ <div class="preview-container">
27
+ <MobilePreviewFrame />
28
+ </div>
29
+ </div>
30
+
31
+ <div v-if="showAside" class="aside" :class="{ 'left-aside': leftAside }">
23
32
  <div class="aside-container">
24
33
  <div class="aside-content">
25
34
  <VPDocAside>
@@ -112,9 +121,17 @@ const pageName = computed(() =>
112
121
  justify-content: center;
113
122
  }
114
123
 
124
+ .VPDoc.has-mobile-preview .container {
125
+ gap: 32px;
126
+ }
127
+
115
128
  .VPDoc .aside {
116
129
  display: block;
117
130
  }
131
+
132
+ .VPDoc.has-aside.has-mobile-preview .aside {
133
+ display: none;
134
+ }
118
135
  }
119
136
 
120
137
  @media (min-width: 1440px) {
@@ -132,6 +149,17 @@ const pageName = computed(() =>
132
149
  width: 100%;
133
150
  }
134
151
 
152
+ .preview {
153
+ margin-bottom: 24px;
154
+ order: 2;
155
+ }
156
+
157
+ .preview-container {
158
+ width: 100%;
159
+ position: sticky;
160
+ top: calc(var(--vp-nav-height) + 32px);
161
+ }
162
+
135
163
  .aside {
136
164
  position: relative;
137
165
  display: none;
@@ -179,6 +207,12 @@ const pageName = computed(() =>
179
207
  }
180
208
  }
181
209
 
210
+ @media (min-width: 1680px) {
211
+ .VPDoc.has-aside.has-mobile-preview .aside {
212
+ display: block;
213
+ }
214
+ }
215
+
182
216
  .content-container {
183
217
  margin: 0 auto;
184
218
  }
@@ -8,10 +8,12 @@ import { computed, provide, useSlots, watch } from 'vue'
8
8
  import { useCloseSidebarOnEscape } from '../hooks/useSidebar'
9
9
  import Content from './Content.vue'
10
10
  import LocalNav from './LocalNav.vue'
11
+ import MobilePreviewLayout from './MobilePreviewLayout.vue'
11
12
  import Nav from './Nav.vue'
12
13
  import Sidebar from './Sidebar.vue'
13
14
 
14
15
  const { frontmatter } = useData()
16
+ const isStandaloneMobilePreview = computed(() => frontmatter.value.layout === 'mobile-preview')
15
17
 
16
18
  useCloseSidebarOnEscape()
17
19
  const {
@@ -32,7 +34,8 @@ provide(layoutInfoInjectionKey, heroImageSlotExists)
32
34
  </script>
33
35
 
34
36
  <template>
35
- <div v-if="frontmatter.layout !== false" class="Layout VMLayout" :class="frontmatter.pageClass">
37
+ <MobilePreviewLayout v-if="isStandaloneMobilePreview" />
38
+ <div v-else-if="frontmatter.layout !== false" class="Layout VMLayout" :class="frontmatter.pageClass">
36
39
  <slot name="layout-top" />
37
40
  <VPSkipLink />
38
41
  <VPBackdrop class="backdrop" :show="isSidebarOpen" @click="closeSidebar()" />
@@ -0,0 +1,175 @@
1
+ <script setup lang="ts">
2
+ import { useData, useRoute } from 'vitepress'
3
+ import { computed, ref, watch } from 'vue'
4
+ import { resolveMobilePreviewId } from '../mobile-preview'
5
+
6
+ const { frontmatter, isDark, theme } = useData()
7
+ const route = useRoute()
8
+ const frameRef = ref<HTMLIFrameElement>()
9
+ const previewConfig = computed(() => theme.value.mobilePreview ?? {})
10
+
11
+ const demoId = computed(() => {
12
+ const value = frontmatter.value.mobileDemo
13
+ return typeof value === 'string'
14
+ ? resolveMobilePreviewId(value, previewConfig.value.demoRoot)
15
+ : ''
16
+ })
17
+
18
+ const frameWidth = computed(() => `${previewConfig.value.deviceWidth ?? 390}px`)
19
+ const viewportHeight = computed(() => `${previewConfig.value.deviceHeight ?? 760}px`)
20
+
21
+ const previewHref = computed(() => {
22
+ const previewPath = normalizePreviewPath(previewConfig.value.previewPath)
23
+ const search = new URLSearchParams({
24
+ demo: demoId.value,
25
+ theme: isDark.value ? 'dark' : 'light',
26
+ })
27
+
28
+ return `${resolveLocalePreviewPath(route.path, previewPath)}?${search.toString()}`
29
+ })
30
+
31
+ const frameStyle = computed(() => ({
32
+ '--vp-mobile-preview-width': frameWidth.value,
33
+ '--vp-mobile-preview-height': viewportHeight.value,
34
+ }))
35
+
36
+ function normalizePreviewPath(value: unknown): string {
37
+ if (typeof value !== 'string' || !value.trim())
38
+ return 'preview/'
39
+
40
+ return value
41
+ .trim()
42
+ .replace(/^\/+/, '')
43
+ .replace(/\/?$/, '/')
44
+ }
45
+
46
+ function resolveLocalePreviewPath(path: string, previewPath: string): string {
47
+ const matchedLocale = path.match(/^\/([^/]+)\//)
48
+ const localePrefix = matchedLocale ? `/${matchedLocale[1]}/` : '/'
49
+
50
+ return `${localePrefix}${previewPath}`
51
+ }
52
+
53
+ function syncTheme(): void {
54
+ frameRef.value?.contentWindow?.postMessage({
55
+ type: 'vp-mobile-preview-theme',
56
+ value: isDark.value ? 'dark' : 'light',
57
+ }, '*')
58
+ }
59
+
60
+ watch(isDark, syncTheme)
61
+ </script>
62
+
63
+ <template>
64
+ <section v-if="demoId" class="VPMobilePreviewFrame" :style="frameStyle">
65
+ <div class="preview-actions">
66
+ <span class="preview-actions__label">Mobile Preview</span>
67
+ <a class="preview-actions__link" :href="previewHref" target="_blank" rel="noreferrer">
68
+ Open
69
+ </a>
70
+ </div>
71
+
72
+ <div class="preview-phone">
73
+ <div class="preview-phone__camera" />
74
+ <iframe
75
+ ref="frameRef"
76
+ class="preview-phone__viewport"
77
+ :src="previewHref"
78
+ title="Mobile demo preview"
79
+ loading="lazy"
80
+ @load="syncTheme"
81
+ />
82
+ </div>
83
+
84
+ <a class="preview-mobile-link" :href="previewHref" target="_blank" rel="noreferrer">
85
+ Open mobile preview
86
+ </a>
87
+ </section>
88
+ </template>
89
+
90
+ <style scoped lang="scss">
91
+ .preview-actions {
92
+ display: none;
93
+ align-items: center;
94
+ justify-content: space-between;
95
+ margin-bottom: 12px;
96
+ padding: 0 4px;
97
+ }
98
+
99
+ .preview-actions__label {
100
+ color: var(--vp-c-text-2);
101
+ font-size: 12px;
102
+ font-weight: 600;
103
+ letter-spacing: 0.08em;
104
+ text-transform: uppercase;
105
+ }
106
+
107
+ .preview-actions__link,
108
+ .preview-mobile-link {
109
+ color: var(--vp-c-brand-1);
110
+ font-size: 13px;
111
+ font-weight: 600;
112
+ text-decoration: none;
113
+ }
114
+
115
+ .preview-phone {
116
+ display: none;
117
+ position: relative;
118
+ width: var(--vp-mobile-preview-width);
119
+ padding: 14px 10px;
120
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(255, 255, 255, 0.64)), rgba(255, 255, 255, 0.7);
121
+ border: 1px solid rgba(15, 23, 42, 0.08);
122
+ border-radius: 32px;
123
+ box-shadow:
124
+ 0 24px 80px rgba(15, 23, 42, 0.12),
125
+ inset 0 1px 0 rgba(255, 255, 255, 0.7);
126
+ backdrop-filter: blur(20px);
127
+ }
128
+
129
+ :global(.dark) .preview-phone {
130
+ background: linear-gradient(180deg, rgba(24, 24, 27, 0.92), rgba(24, 24, 27, 0.8)), rgba(24, 24, 27, 0.86);
131
+ border-color: rgba(255, 255, 255, 0.12);
132
+ box-shadow:
133
+ 0 24px 80px rgba(0, 0, 0, 0.45),
134
+ inset 0 1px 0 rgba(255, 255, 255, 0.06);
135
+ }
136
+
137
+ .preview-phone__camera {
138
+ position: absolute;
139
+ top: 8px;
140
+ left: 50%;
141
+ width: 104px;
142
+ height: 18px;
143
+ border-radius: 999px;
144
+ background: rgba(15, 23, 42, 0.92);
145
+ transform: translateX(-50%);
146
+ }
147
+
148
+ .preview-phone__viewport {
149
+ display: block;
150
+ width: 100%;
151
+ height: var(--vp-mobile-preview-height);
152
+ overflow: hidden;
153
+ background: var(--vp-c-bg);
154
+ border: 0;
155
+ border-radius: 24px;
156
+ }
157
+
158
+ .preview-mobile-link {
159
+ display: inline-flex;
160
+ }
161
+
162
+ @media (min-width: 1440px) {
163
+ .preview-actions {
164
+ display: flex;
165
+ }
166
+
167
+ .preview-phone {
168
+ display: block;
169
+ }
170
+
171
+ .preview-mobile-link {
172
+ display: none;
173
+ }
174
+ }
175
+ </style>
@@ -0,0 +1,140 @@
1
+ <script setup lang="ts">
2
+ import type { Component } from 'vue'
3
+ import { useData } from 'vitepress'
4
+ import { computed, inject, markRaw, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
5
+ import { mobilePreviewRegistryKey, resolveMobilePreviewId } from '../mobile-preview'
6
+
7
+ const { frontmatter, theme } = useData()
8
+ const registry = inject(mobilePreviewRegistryKey)
9
+ const demoId = ref('')
10
+ const errorMessage = ref('')
11
+ const isLoading = ref(true)
12
+ const demoComponent = shallowRef<Component>()
13
+ const previewConfig = computed(() => theme.value.mobilePreview ?? {})
14
+
15
+ const statusMessage = computed(() => {
16
+ if (errorMessage.value)
17
+ return errorMessage.value
18
+
19
+ return isLoading.value ? 'Loading mobile preview...' : 'No preview loaded.'
20
+ })
21
+
22
+ function applyTheme(theme: string | null): void {
23
+ if (typeof document === 'undefined')
24
+ return
25
+
26
+ document.documentElement.classList.toggle('dark', theme === 'dark')
27
+ }
28
+
29
+ async function loadDemo(value: string | null): Promise<void> {
30
+ demoId.value = resolveMobilePreviewId(value ?? '', previewConfig.value.demoRoot)
31
+ demoComponent.value = undefined
32
+ errorMessage.value = ''
33
+ isLoading.value = true
34
+
35
+ if (!demoId.value) {
36
+ errorMessage.value = 'Missing demo id.'
37
+ isLoading.value = false
38
+ return
39
+ }
40
+
41
+ if (!registry) {
42
+ errorMessage.value = 'No mobile preview registry was provided.'
43
+ isLoading.value = false
44
+ return
45
+ }
46
+
47
+ const loader = registry[demoId.value]
48
+ if (!loader) {
49
+ errorMessage.value = `Unknown demo: ${demoId.value}`
50
+ isLoading.value = false
51
+ return
52
+ }
53
+
54
+ try {
55
+ const module = await loader()
56
+ const resolvedComponent = (module as { default?: Component }).default ?? module
57
+ demoComponent.value = markRaw(resolvedComponent as Component)
58
+ }
59
+ catch (error) {
60
+ errorMessage.value = error instanceof Error
61
+ ? error.message
62
+ : 'Failed to load the requested demo.'
63
+ }
64
+ finally {
65
+ isLoading.value = false
66
+ }
67
+ }
68
+
69
+ function syncFromLocation(): void {
70
+ if (typeof window === 'undefined')
71
+ return
72
+
73
+ const search = new URLSearchParams(window.location.search)
74
+ const fallbackDemo = typeof frontmatter.value.mobileDemo === 'string'
75
+ ? frontmatter.value.mobileDemo
76
+ : null
77
+ applyTheme(search.get('theme'))
78
+ void loadDemo(search.get('demo') ?? fallbackDemo)
79
+ }
80
+
81
+ function handleMessage(event: MessageEvent): void {
82
+ if (event.data?.type !== 'vp-mobile-preview-theme')
83
+ return
84
+
85
+ applyTheme(event.data.value)
86
+ }
87
+
88
+ onMounted(() => {
89
+ syncFromLocation()
90
+ window.addEventListener('message', handleMessage)
91
+ })
92
+
93
+ onBeforeUnmount(() => {
94
+ window.removeEventListener('message', handleMessage)
95
+ })
96
+ </script>
97
+
98
+ <template>
99
+ <main class="VPMobilePreviewLayout">
100
+ <component :is="demoComponent" v-if="demoComponent" />
101
+ <div v-else class="preview-status">
102
+ <p class="preview-status__title">
103
+ Mobile Preview
104
+ </p>
105
+ <p class="preview-status__body">
106
+ {{ statusMessage }}
107
+ </p>
108
+ </div>
109
+ </main>
110
+ </template>
111
+
112
+ <style scoped lang="scss">
113
+ .VPMobilePreviewLayout {
114
+ min-height: 100%;
115
+ height: 100vh;
116
+ overflow-x: hidden;
117
+ overflow-y: auto;
118
+ }
119
+
120
+ .preview-status {
121
+ display: grid;
122
+ place-items: center;
123
+ align-content: center;
124
+ min-height: 100vh;
125
+ padding: 24px;
126
+ color: var(--vp-c-text-2);
127
+ text-align: center;
128
+ }
129
+
130
+ .preview-status__title {
131
+ margin: 0 0 12px;
132
+ color: var(--vp-c-text-1);
133
+ font-weight: 600;
134
+ }
135
+
136
+ .preview-status__body {
137
+ margin: 0;
138
+ font-size: 14px;
139
+ }
140
+ </style>
@@ -0,0 +1,60 @@
1
+ import type { InjectionKey } from 'vue'
2
+
3
+ export type MobilePreviewModule = () => Promise<unknown>
4
+
5
+ export type MobilePreviewRegistry = Record<string, MobilePreviewModule>
6
+
7
+ export interface EPMobilePreviewConfig {
8
+ /**
9
+ * Locale-relative path of the standalone preview page.
10
+ *
11
+ * @default '/preview/'
12
+ */
13
+ previewPath?: string
14
+ /**
15
+ * Outer device frame width used in the doc layout.
16
+ *
17
+ * @default 390
18
+ */
19
+ deviceWidth?: number
20
+ /**
21
+ * Inner viewport height used in the doc layout.
22
+ *
23
+ * @default 760
24
+ */
25
+ deviceHeight?: number
26
+ /**
27
+ * Root directory used to resolve the `mobileDemo` frontmatter field.
28
+ *
29
+ * @default 'demo/'
30
+ */
31
+ demoRoot?: string
32
+ }
33
+
34
+ export const mobilePreviewRegistryKey: InjectionKey<MobilePreviewRegistry> = Symbol('vitepress-theme-element-plus.mobile-preview-registry')
35
+
36
+ export function normalizeMobilePreviewId(value: string): string {
37
+ return value
38
+ .trim()
39
+ .replace(/\\/g, '/')
40
+ .replace(/^\.?\//, '')
41
+ }
42
+
43
+ export function normalizeMobilePreviewRoot(value: string | undefined): string {
44
+ if (!value?.trim())
45
+ return 'demo/'
46
+
47
+ return normalizeMobilePreviewId(value)
48
+ .replace(/\/?$/, '/')
49
+ }
50
+
51
+ export function resolveMobilePreviewId(value: string | undefined, root: string | undefined): string {
52
+ const normalizedId = normalizeMobilePreviewId(value ?? '')
53
+ if (!normalizedId)
54
+ return ''
55
+
56
+ const normalizedRoot = normalizeMobilePreviewRoot(root)
57
+ return normalizedId.startsWith(normalizedRoot)
58
+ ? normalizedId
59
+ : `${normalizedRoot}${normalizedId}`
60
+ }
package/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { DefaultTheme, Theme } from 'vitepress'
2
+ import type { EPMobilePreviewConfig, MobilePreviewModule, MobilePreviewRegistry } from './client/mobile-preview'
2
3
  import VPTheme from 'vitepress/theme'
3
4
  import Layout from './client/components/Layout.vue'
4
5
  import 'element-plus/theme-chalk/base.css'
@@ -37,9 +38,20 @@ export interface EPThemeConfig extends DefaultTheme.Config {
37
38
  * 文档版本号
38
39
  */
39
40
  version?: string
41
+ /**
42
+ * 移动端预览配置
43
+ */
44
+ mobilePreview?: EPMobilePreviewConfig
40
45
  footer?: EPThemeFooter
41
46
  }
42
47
  // #endregion snippet
43
48
 
44
49
  export { Layout }
50
+ export {
51
+ mobilePreviewRegistryKey,
52
+ normalizeMobilePreviewId,
53
+ normalizeMobilePreviewRoot,
54
+ resolveMobilePreviewId,
55
+ } from './client/mobile-preview'
56
+ export type { EPMobilePreviewConfig, MobilePreviewModule, MobilePreviewRegistry }
45
57
  export default EPTheme
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vitepress-theme-element-plus",
3
3
  "type": "module",
4
- "version": "1.3.2",
4
+ "version": "1.4.0",
5
5
  "description": "A VitePress theme for Element Plus",
6
6
  "author": "Hezhengxu",
7
7
  "license": "MIT",