vitepress-theme-element-plus 1.3.2 → 1.4.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.
@@ -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>
@@ -99,10 +108,6 @@ const pageName = computed(() =>
99
108
  }
100
109
 
101
110
  @media (min-width: 1440px) {
102
- .VPDoc.has-aside {
103
- padding: 64px 48px 48px 64px;
104
- }
105
-
106
111
  .VPDoc.has-aside {
107
112
  padding: 64px 0 48px 64px;
108
113
  }
@@ -112,9 +117,23 @@ const pageName = computed(() =>
112
117
  justify-content: center;
113
118
  }
114
119
 
120
+ .VPDoc.has-mobile-preview .container {
121
+ gap: 32px;
122
+ }
123
+
115
124
  .VPDoc .aside {
116
125
  display: block;
117
126
  }
127
+
128
+ .VPDoc.has-aside.has-mobile-preview .aside {
129
+ display: none;
130
+ }
131
+ }
132
+
133
+ @media (min-width: 1440px) and (max-width: 1679px) {
134
+ .VPDoc.has-aside.has-mobile-preview {
135
+ padding-right: 32px;
136
+ }
118
137
  }
119
138
 
120
139
  @media (min-width: 1440px) {
@@ -132,6 +151,17 @@ const pageName = computed(() =>
132
151
  width: 100%;
133
152
  }
134
153
 
154
+ .preview {
155
+ margin-bottom: 24px;
156
+ order: 2;
157
+ }
158
+
159
+ .preview-container {
160
+ width: 100%;
161
+ position: sticky;
162
+ top: calc(var(--vp-nav-height) + 32px);
163
+ }
164
+
135
165
  .aside {
136
166
  position: relative;
137
167
  display: none;
@@ -179,6 +209,12 @@ const pageName = computed(() =>
179
209
  }
180
210
  }
181
211
 
212
+ @media (min-width: 1680px) {
213
+ .VPDoc.has-aside.has-mobile-preview .aside {
214
+ display: block;
215
+ }
216
+ }
217
+
182
218
  .content-container {
183
219
  margin: 0 auto;
184
220
  }
@@ -41,7 +41,7 @@ const linkUnderline = computed(() => {
41
41
  .footer {
42
42
  background-color: var(--vp-c-bg-soft);
43
43
  box-sizing: border-box;
44
- padding: 42px 64px 64px;
44
+ padding: 0px 64px 64px;
45
45
 
46
46
  &.is-home {
47
47
  background-color: var(--bg-color);
@@ -60,6 +60,7 @@ const linkUnderline = computed(() => {
60
60
  display: inline-block;
61
61
  vertical-align: top;
62
62
  margin-right: 130px;
63
+ margin-top: 42px;
63
64
  width: 200px;
64
65
 
65
66
  h4 {
@@ -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,194 @@
1
+ <script setup lang="ts">
2
+ import type { SiteData } from 'vitepress'
3
+ import { useData, useRoute } from 'vitepress'
4
+ import { computed, ref, watch } from 'vue'
5
+ import { resolveMobilePreviewId } from '../mobile-preview'
6
+
7
+ const { frontmatter, isDark, site, theme } = useData()
8
+ const route = useRoute()
9
+ const frameRef = ref<HTMLIFrameElement>()
10
+ const previewConfig = computed(() => theme.value.mobilePreview ?? {})
11
+
12
+ const demoId = computed(() => {
13
+ const value = frontmatter.value.mobileDemo
14
+ return typeof value === 'string'
15
+ ? resolveMobilePreviewId(value, previewConfig.value.demoRoot)
16
+ : ''
17
+ })
18
+
19
+ const frameWidth = computed(() => `${previewConfig.value.deviceWidth ?? 390}px`)
20
+ const viewportHeight = computed(() => `${previewConfig.value.deviceHeight ?? 760}px`)
21
+
22
+ const previewHref = computed(() => {
23
+ const previewPath = normalizePreviewPath(previewConfig.value.previewPath)
24
+ const search = new URLSearchParams({
25
+ demo: demoId.value,
26
+ theme: isDark.value ? 'dark' : 'light',
27
+ })
28
+
29
+ return `${resolveLocalePreviewPath(route.path, previewPath, site.value)}?${search.toString()}`
30
+ })
31
+
32
+ const frameStyle = computed(() => ({
33
+ '--vp-mobile-preview-width': frameWidth.value,
34
+ '--vp-mobile-preview-height': viewportHeight.value,
35
+ }))
36
+
37
+ function normalizePreviewPath(value: unknown): string {
38
+ if (typeof value !== 'string' || !value.trim())
39
+ return 'preview/'
40
+
41
+ const trimmedValue = value.trim()
42
+ const normalizedPath = trimmedValue
43
+ .replace(/^\/+/, '')
44
+ .replace(/\/?$/, '/')
45
+
46
+ return trimmedValue.startsWith('/')
47
+ ? `/${normalizedPath}`
48
+ : normalizedPath
49
+ }
50
+
51
+ function normalizeLocalePrefix(value: string): string {
52
+ return `/${value.replace(/^\/+|\/+$/g, '')}/`
53
+ }
54
+
55
+ function getLocalePrefix(path: string, siteData: SiteData): string {
56
+ const localePrefixes = Object.keys(siteData.locales ?? {})
57
+ .filter(locale => locale !== 'root')
58
+ .map(normalizeLocalePrefix)
59
+ .sort((left, right) => right.length - left.length)
60
+
61
+ return localePrefixes.find(prefix => path.startsWith(prefix)) ?? '/'
62
+ }
63
+
64
+ function resolveLocalePreviewPath(path: string, previewPath: string, siteData: SiteData): string {
65
+ if (previewPath.startsWith('/'))
66
+ return previewPath
67
+
68
+ const localePrefix = getLocalePrefix(path, siteData)
69
+ return new URL(previewPath, `https://mobile-preview.local${localePrefix}`).pathname
70
+ }
71
+
72
+ function syncTheme(): void {
73
+ frameRef.value?.contentWindow?.postMessage({
74
+ type: 'vp-mobile-preview-theme',
75
+ value: isDark.value ? 'dark' : 'light',
76
+ }, '*')
77
+ }
78
+
79
+ watch(isDark, syncTheme)
80
+ </script>
81
+
82
+ <template>
83
+ <section v-if="demoId" class="VPMobilePreviewFrame" :style="frameStyle">
84
+ <div class="preview-actions">
85
+ <span class="preview-actions__label">Mobile Preview</span>
86
+ <a class="preview-actions__link" :href="previewHref" target="_blank" rel="noreferrer">
87
+ Open
88
+ </a>
89
+ </div>
90
+
91
+ <div class="preview-phone">
92
+ <div class="preview-phone__camera" />
93
+ <iframe
94
+ ref="frameRef"
95
+ class="preview-phone__viewport"
96
+ :src="previewHref"
97
+ title="Mobile demo preview"
98
+ loading="lazy"
99
+ @load="syncTheme"
100
+ />
101
+ </div>
102
+
103
+ <a class="preview-mobile-link" :href="previewHref" target="_blank" rel="noreferrer">
104
+ Open mobile preview
105
+ </a>
106
+ </section>
107
+ </template>
108
+
109
+ <style scoped lang="scss">
110
+ .preview-actions {
111
+ display: none;
112
+ align-items: center;
113
+ justify-content: space-between;
114
+ margin-bottom: 12px;
115
+ padding: 0 4px;
116
+ }
117
+
118
+ .preview-actions__label {
119
+ color: var(--vp-c-text-2);
120
+ font-size: 12px;
121
+ font-weight: 600;
122
+ letter-spacing: 0.08em;
123
+ text-transform: uppercase;
124
+ }
125
+
126
+ .preview-actions__link,
127
+ .preview-mobile-link {
128
+ color: var(--vp-c-brand-1);
129
+ font-size: 13px;
130
+ font-weight: 600;
131
+ text-decoration: none;
132
+ }
133
+
134
+ .preview-phone {
135
+ display: none;
136
+ position: relative;
137
+ width: var(--vp-mobile-preview-width);
138
+ padding: 14px 10px;
139
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(255, 255, 255, 0.64)), rgba(255, 255, 255, 0.7);
140
+ border: 1px solid rgba(15, 23, 42, 0.08);
141
+ border-radius: 32px;
142
+ box-shadow:
143
+ 0 24px 80px rgba(15, 23, 42, 0.12),
144
+ inset 0 1px 0 rgba(255, 255, 255, 0.7);
145
+ backdrop-filter: blur(20px);
146
+ }
147
+
148
+ :global(.dark) .preview-phone {
149
+ background: linear-gradient(180deg, rgba(24, 24, 27, 0.92), rgba(24, 24, 27, 0.8)), rgba(24, 24, 27, 0.86);
150
+ border-color: rgba(255, 255, 255, 0.12);
151
+ box-shadow:
152
+ 0 24px 80px rgba(0, 0, 0, 0.45),
153
+ inset 0 1px 0 rgba(255, 255, 255, 0.06);
154
+ }
155
+
156
+ .preview-phone__camera {
157
+ position: absolute;
158
+ top: 8px;
159
+ left: 50%;
160
+ width: 104px;
161
+ height: 18px;
162
+ border-radius: 999px;
163
+ background: rgba(15, 23, 42, 0.92);
164
+ transform: translateX(-50%);
165
+ }
166
+
167
+ .preview-phone__viewport {
168
+ display: block;
169
+ width: 100%;
170
+ height: var(--vp-mobile-preview-height);
171
+ overflow: hidden;
172
+ background: var(--vp-c-bg);
173
+ border: 0;
174
+ border-radius: 24px;
175
+ }
176
+
177
+ .preview-mobile-link {
178
+ display: inline-flex;
179
+ }
180
+
181
+ @media (min-width: 1440px) {
182
+ .preview-actions {
183
+ display: flex;
184
+ }
185
+
186
+ .preview-phone {
187
+ display: block;
188
+ }
189
+
190
+ .preview-mobile-link {
191
+ display: none;
192
+ }
193
+ }
194
+ </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.1",
5
5
  "description": "A VitePress theme for Element Plus",
6
6
  "author": "Hezhengxu",
7
7
  "license": "MIT",