返回 slidev
previewWebview.ts
根目录 / packages / vscode / src / views / previewWebview.ts
1 import type { PreviewMode } from '../html/ready'
2 import { computed, defineService, extensionContext, reactive, ref, useIsDarkTheme, useVscodeContext, useWebviewView, watch, watchEffect } from 'reactive-vscode'
3 import { commands, env, Uri, window } from 'vscode'
4 import { useFocusedSlide } from '../composables/useFocusedSlide'
5 import { useServerDetector } from '../composables/useServerDetector'
6 import { config } from '../configs'
7 import { generateErrorHtml } from '../html/error'
8 import { generateReadyHtml } from '../html/ready'
9 import { activeData, activeProject } from '../projects'
10 import { getSlidesTitle } from '../utils/getSlidesTitle'
11 import { logger } from './logger'
12
13 export const usePreviewWebview = defineService(() => {
14 const { focusedSlideNo, viewportSlideNo, focusSlide, revealViewportSlide } = useFocusedSlide()
15 const isDarkTheme = useIsDarkTheme()
16
17 const { redetect } = useServerDetector()
18 const port = computed(() => activeProject.value?.port.value || config.port)
19 const detected = computed(() => activeProject.value ? activeProject.value.detected.value : null)
20 const message = computed(() => detected.value?.message ?? '')
21 const compatMode = useVscodeContext('slidev:preview:compat', () => !!detected.value?.compatMode)
22 const ready = useVscodeContext('slidev:preview:ready', () => activeProject.value && !!detected.value?.ready)
23 const savedPreviewMode = extensionContext.value?.globalState.get<PreviewMode>('slidev:preview:mode')
24 const previewMode = ref<PreviewMode>(savedPreviewMode === 'overview' ? 'overview' : 'slide')
25 const hashRoute = computed(() => activeData.value?.slides[0]?.frontmatter.routerMode === 'hash')
26 const overviewInitialSlideNo = ref<number>()
27 useVscodeContext('slidev:preview:sync', () => config['preview-sync'])
28 useVscodeContext('slidev:preview:overview', () => previewMode.value === 'overview')
29
30 const previewNavState = reactive({
31 no: 0,
32 clicks: 0,
33 hasNext: true,
34 hasPrev: false,
35 })
36 let overviewSlideNoForUrl: number | undefined
37
38 function getOverviewSlideNoForUrl() {
39 const slideNo = Number(
40 overviewSlideNoForUrl
41 ?? viewportSlideNo.value
42 ?? focusedSlideNo.value
43 ?? previewNavState.no,
44 )
45 return Number.isFinite(slideNo) && slideNo > 0 ? slideNo : undefined
46 }
47
48 function updateOverviewInitialSlideNo() {
49 overviewInitialSlideNo.value = previewMode.value === 'overview'
50 ? getOverviewSlideNoForUrl()
51 : undefined
52 }
53
54 updateOverviewInitialSlideNo()
55
56 const html = computed(() => ready.value
57 ? generateReadyHtml(port.value, previewMode.value, hashRoute.value, overviewInitialSlideNo.value)
58 : generateErrorHtml(message.value),
59 )
60
61 useVscodeContext('slidev:preview:has-prev-slide', () => previewMode.value === 'slide' && previewNavState.no > 1)
62 useVscodeContext('slidev:preview:has-next-slide', () => previewMode.value === 'slide' && activeData.value && previewNavState.no < activeData.value.slides.length)
63 useVscodeContext('slidev:preview:has-next-click', () => previewMode.value === 'slide' && previewNavState.hasNext)
64 useVscodeContext('slidev:preview:has-prev-click', () => previewMode.value === 'slide' && previewNavState.hasPrev)
65
66 const initializedClientId = ref('')
67 let pendingOverviewScroll: { no: number } | undefined
68 let overviewScrollTimer: ReturnType<typeof setTimeout> | undefined
69 let syncEditorToOverviewUntil = 0
70
71 function cancelPendingOverviewScroll() {
72 pendingOverviewScroll = undefined
73 if (overviewScrollTimer) {
74 clearTimeout(overviewScrollTimer)
75 overviewScrollTimer = undefined
76 }
77 }
78
79 const { view, postMessage, forceReload } = useWebviewView(
80 'slidev-preview',
81 html,
82 {
83 retainContextWhenHidden: true,
84 webviewOptions: {
85 enableScripts: true,
86 localResourceRoots: [extensionContext.value!.extensionUri],
87 },
88 async onDidReceiveMessage(data) {
89 if (data.type === 'command') {
90 commands.executeCommand(`slidev.${data.command}`, ...(data.args ?? []))
91 }
92 else if (data.type === 'update-state') {
93 if (initializedClientId.value === data.clientId) {
94 Object.assign(previewNavState, data.navState)
95 }
96 else {
97 initializedClientId.value = data.clientId
98 if (previewMode.value === 'slide' && config['preview-sync'] && focusedSlideNo.value != null)
99 postSlidevMessage('navigate', { no: focusedSlideNo.value })
100 }
101 }
102 else if (data.type === 'overview-scroll') {
103 syncEditorToOverviewUntil = Date.now() + 300
104 cancelPendingOverviewScroll()
105 const no = Number(data.no)
106 if (Number.isFinite(no))
107 overviewSlideNoForUrl = no
108 if (previewMode.value === 'overview' && config['preview-sync'] && Number.isFinite(no))
109 revealViewportSlide(no)
110 }
111 else if (data.type === 'open-external' && typeof data.url === 'string') {
112 env.openExternal(Uri.parse(data.url))
113 }
114 },
115 },
116 )
117
118 const pageId = ref(0)
119 async function refresh() {
120 if (!ready.value)
121 await redetect(port.value)
122 if (!view.value)
123 return
124 updateOverviewInitialSlideNo()
125 forceReload()
126 logger.info(`Webview refreshed. Current URL: http://localhost:${port.value}`)
127 setTimeout(() => pageId.value++, 300)
128 }
129 watch([ready, view, port, previewMode], refresh)
130
131 function postSlidevMessage(type: string, data: Record<string, unknown>) {
132 return postMessage({
133 target: 'slidev',
134 sender: 'vscode',
135 type,
136 ...data,
137 })
138 }
139
140 function scrollOverviewToSlide(no: number) {
141 if (Date.now() < syncEditorToOverviewUntil)
142 return
143 overviewSlideNoForUrl = no
144 pendingOverviewScroll = { no }
145 if (overviewScrollTimer)
146 return
147 overviewScrollTimer = setTimeout(() => {
148 overviewScrollTimer = undefined
149 const scroll = pendingOverviewScroll
150 pendingOverviewScroll = undefined
151 if (scroll && Date.now() >= syncEditorToOverviewUntil && previewMode.value === 'overview' && config['preview-sync'])
152 postSlidevMessage('overview-scroll', scroll)
153 }, 50)
154 }
155
156 watch([pageId], () => {
157 postSlidevMessage('css-vars', { '--slidev-slide-container-background': 'transparent' })
158 if (previewMode.value === 'overview' && config['preview-sync'] && viewportSlideNo.value != null)
159 scrollOverviewToSlide(viewportSlideNo.value)
160 })
161 watch([pageId, isDarkTheme], ([_, dark]) => postSlidevMessage('color-schema', { color: dark ? 'dark' : 'light' }))
162
163 watchEffect(() => {
164 if (view.value) {
165 if (previewMode.value === 'overview' && activeData.value) {
166 view.value.title = getSlidesTitle(activeData.value)
167 return
168 }
169 const slideNo = ready.value && previewMode.value === 'slide' && previewNavState.no > 0 ? ` (${previewNavState.no}/${activeData.value?.slides.length})` : ''
170 const compatInfo = compatMode.value ? ' (compat mode)' : ''
171 view.value.title = `Preview${slideNo}${compatInfo}`
172 }
173 })
174
175 function useNavOperation(operation: string, ...args: unknown[]) {
176 return () => {
177 if (previewMode.value !== 'slide')
178 return
179 if (compatMode.value)
180 window.showErrorMessage('Unsupported operation: Slidev server too old.')
181 postSlidevMessage('navigate', { operation, args })
182 }
183 }
184
185 watch(
186 () => previewNavState.no,
187 (no) => {
188 if (previewMode.value === 'slide' && no > 0 && ready.value && config['preview-sync'] && activeProject.value) {
189 focusSlide(activeProject.value, no)
190 }
191 },
192 )
193 watch(
194 [
195 () => config['preview-sync'],
196 previewMode,
197 () => previewMode.value === 'slide' ? focusedSlideNo.value : viewportSlideNo.value,
198 ],
199 ([enabled, mode, no]) => {
200 if (enabled && mode === 'slide' && no != null && previewNavState.no !== no) {
201 postSlidevMessage('navigate', { no, clicks: 999999 })
202 }
203 else if (enabled && mode === 'overview' && no != null) {
204 scrollOverviewToSlide(no)
205 }
206 },
207 )
208
209 return {
210 view,
211 refresh,
212 setPreviewMode: (mode: PreviewMode) => {
213 initializedClientId.value = ''
214 if (mode !== previewMode.value)
215 overviewSlideNoForUrl = undefined
216 overviewInitialSlideNo.value = mode === 'overview'
217 ? getOverviewSlideNoForUrl()
218 : undefined
219 previewMode.value = mode
220 void extensionContext.value?.globalState.update('slidev:preview:mode', mode)
221 },
222 nextClick: useNavOperation('next'),
223 prevClick: useNavOperation('prev'),
224 nextSlide: useNavOperation('nextSlide', true),
225 prevSlide: useNavOperation('prevSlide', true),
226 openExternal: () => {
227 const hashRoute = activeData.value?.slides[0]?.frontmatter.routerMode === 'hash'
228 const no = previewMode.value === 'overview'
229 ? getOverviewSlideNoForUrl()
230 : previewNavState.no
231 if (!no)
232 return
233 const slideNo = activeData.value ? Math.min(Math.max(Math.round(no), 1), activeData.value.slides.length) : no
234 const query = previewNavState.clicks > 0 ? `?clicks=${previewNavState.clicks}` : ''
235 const url = `http://localhost:${port.value}/${hashRoute ? '#' : ''}${slideNo}${previewMode.value === 'slide' ? query : ''}`
236 return env.openExternal(Uri.parse(url))
237 },
238 }
239 })
240
240 lines TYPESCRIPT