Skip to content

Commit

Permalink
feat: standalone presenter mode (#552)
Browse files Browse the repository at this point in the history
* feat: use BroadcastChannel instead of server in static build

* feat: sync drawings while using static build

* refactor: refactor shared and drawing states

* feat: manage drawing persist mode

* fix: use persist mode only for drawings
  • Loading branch information
tonai committed May 5, 2022
1 parent dccce6b commit 4eb284c
Show file tree
Hide file tree
Showing 11 changed files with 152 additions and 88 deletions.
15 changes: 0 additions & 15 deletions packages/client/env.ts
Expand Up @@ -3,23 +3,8 @@ import { computed } from 'vue'
import { objectMap } from '@antfu/utils'
// @ts-expect-error missing types
import _configs from '/@slidev/configs'
import _serverState from 'server-reactive:nav'
import _serverDrawingState from 'server-reactive:drawings?diff'
import type { ServerReactive } from 'vite-plugin-vue-server-ref'

export interface ServerState {
page: number
clicks: number
cursor?: {
x: number
y: number
}
}

export const serverState = _serverState as ServerReactive<ServerState>
export const serverDrawingState = _serverDrawingState as ServerReactive<Record<number, string | undefined>>
export const configs = _configs as SlidevConfig

export const slideAspect = configs.aspectRatio ?? (16 / 9)
export const slideWidth = configs.canvasWidth ?? 980
export const slideHeight = Math.round(slideWidth / slideAspect)
Expand Down
4 changes: 2 additions & 2 deletions packages/client/internals/NavControls.vue
Expand Up @@ -108,15 +108,15 @@ if (__SLIDEV_FEATURE_DRAWINGS__)
<VerticalDivider />
</template>

<template v-if="__DEV__ && !isEmbedded">
<template v-if="!isEmbedded">
<RouterLink v-if="isPresenter" :to="nonPresenterLink" class="icon-btn" title="Play Mode">
<carbon:presentation-file />
</RouterLink>
<RouterLink v-if="!isPresenter" :to="presenterLink" class="icon-btn" title="Presenter Mode">
<carbon:user-speaker />
</RouterLink>

<button v-if="!isPresenter" class="icon-btn <md:hidden" @click="showEditor = !showEditor">
<button v-if="__DEV__ && !isPresenter" class="icon-btn <md:hidden" @click="showEditor = !showEditor">
<carbon:text-annotation-toggle />
</button>
</template>
Expand Down
5 changes: 3 additions & 2 deletions packages/client/internals/Presenter.vue
Expand Up @@ -4,7 +4,8 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useMouse, useWindowFocus } from '@vueuse/core'
import { clicks, clicksTotal, currentPage, currentRoute, hasNext, nextRoute, total, useSwipeControls } from '../logic/nav'
import { showOverview, showPresenterCursor } from '../state'
import { configs, serverState, themeVars } from '../env'
import { configs, themeVars } from '../env'
import { sharedState } from '../state/shared'
import { registerShortcuts } from '../logic/shortcuts'
import { getSlideClass } from '../utils'
import { useTimer } from '../logic/utils'
Expand Down Expand Up @@ -72,7 +73,7 @@ onMounted(() => {
return { x, y }
},
(pos) => {
serverState.cursor = pos
sharedState.cursor = pos
},
)
})
Expand Down
6 changes: 3 additions & 3 deletions packages/client/internals/PresenterMouse.vue
@@ -1,15 +1,15 @@
<script setup lang="ts">
import { serverState } from '../env'
import { sharedState } from '../state/shared'
</script>

<template>
<div
v-if="serverState.cursor"
v-if="sharedState.cursor"
class="absolute top-0 left-0 right-0 bottom-0 pointer-events-none text-xl"
>
<ph:cursor-fill
class="absolute"
:style="{ left: `${serverState.cursor.x}%`, top: `${serverState.cursor.y}%` }"
:style="{ left: `${sharedState.cursor.x}%`, top: `${sharedState.cursor.y}%` }"
/>
</div>
</template>
38 changes: 16 additions & 22 deletions packages/client/logic/drawings.ts
@@ -1,8 +1,9 @@
import { computed, markRaw, nextTick, reactive, ref, watch, watchEffect } from 'vue'
import { computed, markRaw, nextTick, reactive, ref, watch } from 'vue'
import type { Brush, Options as DrauuOptions, DrawingMode } from 'drauu'
import { createDrauu } from 'drauu'
import { toReactive, useStorage } from '@vueuse/core'
import { configs, serverDrawingState as drawingState } from '../env'
import { drawingState, onPatch, patch } from '../state/drawings'
import { configs } from '../env'
import { currentPage, isPresenter } from './nav'

export const brushColors = [
Expand All @@ -29,7 +30,9 @@ export const brush = toReactive(useStorage<Brush>('slidev-drawing-brush', {
}))

const _mode = ref<DrawingMode | 'arrow'>('stylus')
const syncUp = computed(() => configs.drawings.syncAll || isPresenter.value)
let disableDump = false

export const drawingMode = computed({
get() {
return _mode.value
Expand All @@ -56,7 +59,8 @@ export const drauu = markRaw(createDrauu(drauuOptions))

export function clearDrauu() {
drauu.clear()
drawingState.$patch({ [currentPage.value]: '' })
if (syncUp.value)
patch(currentPage.value, '')
}

export function updateState() {
Expand All @@ -80,35 +84,25 @@ drauu.on('changed', () => {
if (!disableDump) {
const dump = drauu.dump()
const key = currentPage.value
if ((drawingState[key] || '') !== dump) {
if (__DEV__)
drawingState.$patch({ [key]: drauu.dump() })
else
drawingState[key] = drauu.dump()
}
if ((drawingState[key] || '') !== dump && syncUp.value)
patch(key, drauu.dump())
}
})

if (__DEV__) {
drawingState.$onPatch((patch) => {
disableDump = true
if (patch[currentPage.value] != null)
drauu.load(patch[currentPage.value] || '')
disableDump = false
updateState()
})
}
onPatch((state) => {
disableDump = true
if (state[currentPage.value] != null)
drauu.load(state[currentPage.value] || '')
disableDump = false
updateState()
})

nextTick(() => {
watch(currentPage, () => {
if (!drauu.mounted)
return
loadCanvas()
}, { immediate: true })

watchEffect(() => {
drawingState.$syncUp = configs.drawings.syncAll || isPresenter.value
})
})

drauu.on('start', () => isDrawing.value = true)
Expand Down
10 changes: 6 additions & 4 deletions packages/client/logic/note.ts
Expand Up @@ -35,10 +35,12 @@ export function useSlideInfo(id: number | undefined): UseSlideInfo {
).then(r => r.json())
}

import.meta.hot?.on('slidev-update', (payload) => {
if (payload.id === id)
info.value = payload.data
})
if (__DEV__) {
import.meta.hot?.on('slidev-update', (payload) => {
if (payload.id === id)
info.value = payload.data
})
}

return {
info,
Expand Down
23 changes: 9 additions & 14 deletions packages/client/routes.ts
Expand Up @@ -19,22 +19,17 @@ export const routes: RouteRecordRaw[] = [
{ name: 'print', path: '/print', component: Print },
{ path: '', redirect: { path: '/1' } },
{ path: '/:pathMatch(.*)', redirect: { path: '/1' } },
{
name: 'presenter',
path: '/presenter/:no',
component: () => import('./internals/Presenter.vue'),
},
{
path: '/presenter',
redirect: { path: '/presenter/1' },
},
]

if (import.meta.env.DEV) {
routes.push(
{
name: 'presenter',
path: '/presenter/:no',
component: () => import('./internals/Presenter.vue'),
},
{
path: '/presenter',
redirect: { path: '/presenter/1' },
},
)
}

export const router = createRouter({
history: __SLIDEV_HASH_ROUTE__ ? createWebHashHistory(import.meta.env.BASE_URL) : createWebHistory(import.meta.env.BASE_URL),
routes,
Expand Down
48 changes: 22 additions & 26 deletions packages/client/setup/root.ts
@@ -1,9 +1,11 @@
/* __imports__ */
import { useHead } from '@vueuse/head'
import { watch } from 'vue'
import { useHead } from '@vueuse/head'
import { configs } from '../env'
import { initSharedState, onPatch, patch } from '../state/shared'
import { initDrawingState } from '../state/drawings'
import { clicks, currentPage, getPath, isPresenter } from '../logic/nav'
import { router } from '../routes'
import { configs, serverState } from '../env'

export default function setupRoot() {
// @ts-expect-error injected in runtime
Expand All @@ -12,36 +14,30 @@ export default function setupRoot() {

/* __injections__ */

useHead({
title: configs.titleTemplate.replace('%s', configs.title || 'Slidev'),
})
const title = configs.titleTemplate.replace('%s', configs.title || 'Slidev')
useHead({ title })
initSharedState(`${title} - shared`)
initDrawingState(`${title} - drawings`)

// update shared state
function updateSharedState() {
if (isPresenter.value) {
patch('page', +currentPage.value)
patch('clicks', clicks.value)
}
}
router.afterEach(updateSharedState)
watch(clicks, updateSharedState)

function onServerStateChanged() {
if (isPresenter.value)
return
if (+serverState.page !== +currentPage.value || clicks.value !== serverState.clicks) {
onPatch((state) => {
if (+state.page !== +currentPage.value || clicks.value !== state.clicks) {
router.replace({
path: getPath(serverState.page),
path: getPath(state.page),
query: {
...router.currentRoute.value.query,
clicks: serverState.clicks || 0,
clicks: state.clicks || 0,
},
})
}
}
function updateServerState() {
if (isPresenter.value) {
serverState.page = +currentPage.value
serverState.clicks = clicks.value
}
}

// upload state to server
router.afterEach(updateServerState)
watch(clicks, updateServerState)

// sync with server state
router.isReady().then(() => {
watch(serverState, onServerStateChanged, { deep: true })
})
}
7 changes: 7 additions & 0 deletions packages/client/state/drawings.ts
@@ -0,0 +1,7 @@
import serverDrawingState from 'server-reactive:drawings?diff'
import { createSyncState } from './syncState'

export type DrawingsState = Record<number, string | undefined>

const { init, onPatch, patch, state } = createSyncState<DrawingsState>(serverDrawingState, {}, __SLIDEV_FEATURE_DRAWINGS_PERSIST__)
export { init as initDrawingState, onPatch, patch, state as drawingState }
17 changes: 17 additions & 0 deletions packages/client/state/shared.ts
@@ -0,0 +1,17 @@
import serverState from 'server-reactive:nav'
import { createSyncState } from './syncState'

export interface SharedState {
page: number
clicks: number
cursor?: {
x: number
y: number
}
}

const { init, onPatch, patch, state } = createSyncState<SharedState>(serverState, {
page: 1,
clicks: 0,
})
export { init as initSharedState, onPatch, patch, state as sharedState }
67 changes: 67 additions & 0 deletions packages/client/state/syncState.ts
@@ -0,0 +1,67 @@
import { reactive, toRaw, watch } from 'vue'

export function createSyncState<State extends object>(serverState: State, defaultState: State, persist = false) {
const onPatchCallbacks: ((state: State) => void)[] = []
let patching = false
let updating = false
let patchingTimeout: NodeJS.Timeout
let updatingTimeout: NodeJS.Timeout

const state = __DEV__
? reactive<State>(serverState) as State
: reactive<State>(defaultState) as State

function onPatch(fn: (state: State) => void) {
onPatchCallbacks.push(fn)
}

function patch<K extends keyof State>(key: K, value: State[K]) {
clearTimeout(patchingTimeout)
patching = true
state[key] = value
patchingTimeout = setTimeout(() => patching = false, 0)
}

function onUpdate(patch: Partial<State>) {
if (!patching) {
clearTimeout(updatingTimeout)
updating = true
Object.entries(patch).forEach(([key, value]) => {
state[key as keyof State] = value as State[keyof State]
})
updatingTimeout = setTimeout(() => updating = false, 0)
}
}

function init(channelKey: string) {
let stateChannel: BroadcastChannel
if (!__DEV__ && !persist) {
stateChannel = new BroadcastChannel(channelKey)
stateChannel.addEventListener('message', (event: MessageEvent<Partial<State>>) => onUpdate(event.data))
}
else if (!__DEV__ && persist) {
window.addEventListener('storage', (event) => {
if (event && event.key === channelKey && event.newValue)
onUpdate(JSON.parse(event.newValue) as Partial<State>)
})
}

function onDrawingStateChanged() {
if (!persist && stateChannel && !updating)
stateChannel.postMessage(toRaw(state))
else if (persist && !updating)
window.localStorage.setItem(channelKey, JSON.stringify(state))
if (!patching)
onPatchCallbacks.forEach((fn: (state: State) => void) => fn(state))
}

watch(state, onDrawingStateChanged, { deep: true })
if (!__DEV__ && persist) {
const serialzedState = window.localStorage.getItem(channelKey)
if (serialzedState)
onUpdate(JSON.parse(serialzedState) as Partial<State>)
}
}

return { init, onPatch, patch, state }
}

0 comments on commit 4eb284c

Please sign in to comment.