| 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 |