Skip to content

Commit

Permalink
feat: context menu (#1475)
Browse files Browse the repository at this point in the history
Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com>
  • Loading branch information
KermanX and antfu committed Apr 11, 2024
1 parent 7f5622f commit 8e43c03
Show file tree
Hide file tree
Showing 25 changed files with 442 additions and 75 deletions.
4 changes: 4 additions & 0 deletions docs/.vitepress/config.ts
Expand Up @@ -156,6 +156,10 @@ const Customizations: (DefaultTheme.NavItemWithLink | DefaultTheme.NavItemChildr
text: 'Configure Code Runners',
link: '/custom/config-code-runners',
},
{
text: 'Configure Context Menu',
link: '/custom/config-context-menu',
},
{
text: 'Vue Global Context',
link: '/custom/vue-context',
Expand Down
33 changes: 33 additions & 0 deletions docs/custom/config-context-menu.md
@@ -0,0 +1,33 @@
# Configure Context Menu

<Environment type="client" />

Customize the context menu items in Slidev.

Create `./setup/context-menu.ts` with the following content:

```ts
import { defineContextMenuSetup } from '@slidev/types'
import { computed } from 'vue'
import Icon3DCursor from '~icons/carbon/3d-cursor'

export default defineContextMenuSetup((items) => {
const { isPresenter } = useNav()
return computed(() => [
...items.value,
{
small: false,
icon: Icon3DCursor, // Used as `title` if `small` is `true`
label: 'Custom Menu Item', // or a Vue component
action() {
alert('Custom Menu Item Clicked!')
},
disabled: isPresenter.value,
},
])
})
```

This will append a new menu item to the context menu.

To disable context menu globally, set `contextMenu` to `false` in the frontmatter. `contextMenu` can also be set to `dev` or `build` to only enable the context menu in development or build mode.
2 changes: 2 additions & 0 deletions docs/custom/index.md
Expand Up @@ -55,6 +55,8 @@ remoteAssets: false
selectable: true
# enable slide recording, can be boolean, 'dev' or 'build'
record: dev
# enable Slidev's context menu, can be boolean, 'dev' or 'build'
contextMenu: true

# force color schema for the slides, can be 'auto', 'light', or 'dark'
colorSchema: auto
Expand Down
4 changes: 3 additions & 1 deletion packages/client/composables/useDragElements.ts
Expand Up @@ -52,7 +52,9 @@ export function useDragElementsUpdater(no: number) {
return
frontmatter.dragPos[id] = posStr
newPatch = {
dragPos: frontmatter.dragPos,
frontmatter: {
dragPos: frontmatter.dragPos,
},
}
}
else {
Expand Down
20 changes: 20 additions & 0 deletions packages/client/composables/useNav.ts
Expand Up @@ -60,6 +60,11 @@ export interface SlidevContextNav {
goFirst: () => Promise<void>
/** Go to the last slide */
goLast: () => Promise<void>

/** Enter presenter mode */
enterPresenter: () => void
/** Exit presenter mode */
exitPresenter: () => void
}

export interface SlidevContextNavState {
Expand Down Expand Up @@ -194,6 +199,19 @@ export function useNavBase(
}
}

function enterPresenter() {
router?.push({
path: getSlidePath(currentSlideNo.value, true),
query: { ...router.currentRoute.value.query },
})
}
function exitPresenter() {
router?.push({
path: getSlidePath(currentSlideNo.value, false),
query: { ...router.currentRoute.value.query },
})
}

return {
slides,
total,
Expand Down Expand Up @@ -222,6 +240,8 @@ export function useNavBase(
goFirst,
nextSlide,
prevSlide,
enterPresenter,
exitPresenter,
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/client/constants.ts
Expand Up @@ -76,4 +76,5 @@ export const HEADMATTER_FIELDS = [
'drawings',
'htmlAttrs',
'mdc',
'contextMenu',
]
2 changes: 2 additions & 0 deletions packages/client/env.ts
Expand Up @@ -4,6 +4,8 @@ import configs from '#slidev/configs'

export { configs }

export const mode = __DEV__ ? 'dev' : 'build'

export const slideAspect = ref(configs.aspectRatio ?? (16 / 9))
export const slideWidth = ref(configs.canvasWidth ?? 980)

Expand Down
110 changes: 110 additions & 0 deletions packages/client/internals/ContextMenu.vue
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { onClickOutside, useElementBounding, useEventListener, useWindowFocus } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { closeContextMenu, currentContextMenu } from '../logic/contextMenu'
import { useDynamicSlideInfo } from '../composables/useSlideInfo'
import { windowSize } from '../state'
import { configs } from '../env'
const container = ref<HTMLElement>()
onClickOutside(container, closeContextMenu)
useEventListener(document, 'mousedown', (ev) => {
if (ev.buttons & 2)
closeContextMenu()
}, {
passive: true,
capture: true,
})
const isExplicitEnabled = computed(() => configs.contextMenu != null)
const windowFocus = useWindowFocus()
watch(windowFocus, (hasFocus) => {
if (!hasFocus)
closeContextMenu()
})
const firstSlide = useDynamicSlideInfo(1)
function disableContextMenu() {
const info = firstSlide.info.value
if (!info)
return
firstSlide.update({
frontmatter: {
contextMenu: false,
},
})
}
const { width, height } = useElementBounding(container)
const left = computed(() => {
const x = currentContextMenu.value?.x
if (!x)
return 0
if (x + width.value > windowSize.width.value)
return windowSize.width.value - width.value
return x
})
const top = computed(() => {
const y = currentContextMenu.value?.y
if (!y)
return 0
if (y + height.value > windowSize.height.value)
return windowSize.height.value - height.value
return y
})
</script>

<template>
<div
v-if="currentContextMenu"
ref="container"
:style="`left:${left}px;top:${top}px`"
class="fixed z-100 w-60 flex flex-wrap justify-items-start p-1 animate-fade-in animate-duration-100 backdrop-blur bg-main bg-opacity-75! border border-main rounded-md shadow overflow-hidden select-none"
@contextmenu.prevent=""
@click="closeContextMenu"
>
<template v-for="item, index of currentContextMenu.items.value" :key="index">
<div v-if="item === 'separator'" :key="index" class="w-full my1 border-t border-main" />
<div
v-else-if="item.small"
class="p-2 w-[40px] h-[40px] inline-block text-center cursor-pointer rounded"
:class="item.disabled ? `op40` : `hover:bg-active`"
:title="(item.label as string)"
@click="item.action"
>
<component :is="item.icon" />
</div>
<div
v-else
class="w-full grid grid-cols-[35px_1fr] p-2 pl-0 cursor-pointer rounded"
:class="item.disabled ? `op40` : `hover:bg-active`"
@click="item.action"
>
<div class="mx-auto">
<component :is="item.icon" />
</div>
<div v-if="typeof item.label === 'string'">
{{ item.label }}
</div>
<component :is="item.label" v-else />
</div>
</template>
<template v-if="!isExplicitEnabled">
<div class="w-full my1 border-t border-main" />
<div class="w-full text-xs p2">
<div class="text-main text-opacity-50!">
Hold <kbd class="border px1 py0.5 border-main rounded text-primary">Shift</kbd> and right click to open the native context menu
<button
v-if="__DEV__"
class="underline op50 hover:op100 mt1 block"
@click="disableContextMenu()"
>
Disable custom context menu
</button>
</div>
</div>
</template>
</div>
</template>
2 changes: 2 additions & 0 deletions packages/client/internals/Controls.vue
Expand Up @@ -5,6 +5,7 @@ import { configs } from '../env'
import QuickOverview from './QuickOverview.vue'
import InfoDialog from './InfoDialog.vue'
import Goto from './Goto.vue'
import ContextMenu from './ContextMenu.vue'
const WebCamera = shallowRef<any>()
const RecordingDialog = shallowRef<any>()
Expand All @@ -20,4 +21,5 @@ if (__SLIDEV_FEATURE_RECORD__) {
<WebCamera v-if="WebCamera" />
<RecordingDialog v-if="RecordingDialog" v-model="showRecordingDialog" />
<InfoDialog v-if="configs.info" v-model="showInfoDialog" />
<ContextMenu />
</template>
17 changes: 6 additions & 11 deletions packages/client/internals/NavControls.vue
Expand Up @@ -5,7 +5,6 @@ import { downloadPDF } from '../utils'
import { activeElement, breakpoints, fullscreen, presenterLayout, showEditor, showInfoDialog, showPresenterCursor, toggleOverview, togglePresenterLayout } from '../state'
import { configs } from '../env'
import { useNav } from '../composables/useNav'
import { getSlidePath } from '../logic/slides'
import { useDrawings } from '../composables/useDrawings'
import Settings from './Settings.vue'
import MenuButton from './MenuButton.vue'
Expand All @@ -21,7 +20,6 @@ const props = defineProps({
})
const {
currentRoute,
currentSlideNo,
hasNext,
hasPrev,
Expand All @@ -31,6 +29,8 @@ const {
next,
prev,
total,
enterPresenter,
exitPresenter,
} = useNav()
const {
brush,
Expand All @@ -40,11 +40,6 @@ const {
const md = breakpoints.smaller('md')
const { isFullscreen, toggle: toggleFullscreen } = fullscreen
const presenterPassword = computed(() => currentRoute.value.query.password)
const query = computed(() => presenterPassword.value ? `?password=${presenterPassword.value}` : '')
const presenterLink = computed(() => `${getSlidePath(currentSlideNo.value, true)}${query.value}`)
const nonPresenterLink = computed(() => `${getSlidePath(currentSlideNo.value, false)}${query.value}`)
const root = ref<HTMLDivElement>()
function onMouseLeave() {
if (root.value && activeElement.value && root.value.contains(activeElement.value))
Expand Down Expand Up @@ -124,12 +119,12 @@ if (__SLIDEV_FEATURE_DRAWINGS__)
</template>

<template v-if="!isEmbedded">
<RouterLink v-if="isPresenter" :to="nonPresenterLink" class="slidev-icon-btn" title="Play Mode">
<IconButton v-if="isPresenter" title="Play Mode" @click="exitPresenter">
<carbon:presentation-file />
</RouterLink>
<RouterLink v-if="__SLIDEV_FEATURE_PRESENTER__ && isPresenterAvailable" :to="presenterLink" class="slidev-icon-btn" title="Presenter Mode">
</IconButton>
<IconButton v-if="__SLIDEV_FEATURE_PRESENTER__ && isPresenterAvailable" title="Presenter Mode" @click="enterPresenter">
<carbon:user-speaker />
</RouterLink>
</IconButton>

<IconButton
v-if="__DEV__ && __SLIDEV_FEATURE_EDITOR__"
Expand Down
34 changes: 34 additions & 0 deletions packages/client/logic/contextMenu.ts
@@ -0,0 +1,34 @@
import type { ContextMenuItem } from '@slidev/types'
import type { ComputedRef } from 'vue'
import { shallowRef } from 'vue'
import setupContextMenu from '../setup/context-menu'
import { configs, mode } from '../env'

export const currentContextMenu = shallowRef<null | {
x: number
y: number
items: ComputedRef<ContextMenuItem[]>
}>(null)

export function openContextMenu(x: number, y: number) {
currentContextMenu.value = {
x,
y,
items: setupContextMenu(),
}
}

export function closeContextMenu() {
currentContextMenu.value = null
}

export function onContextMenu(ev: MouseEvent) {
if (configs.contextMenu !== true && configs.contextMenu !== undefined && configs.contextMenu !== mode)
return
if (ev.shiftKey || ev.defaultPrevented)
return

openContextMenu(ev.pageX, ev.pageY)
ev.preventDefault()
ev.stopPropagation()
}
6 changes: 4 additions & 2 deletions packages/client/pages/play.vue
Expand Up @@ -9,6 +9,7 @@ import SlideContainer from '../internals/SlideContainer.vue'
import NavControls from '../internals/NavControls.vue'
import SlidesShow from '../internals/SlidesShow.vue'
import PrintStyle from '../internals/PrintStyle.vue'
import { onContextMenu } from '../logic/contextMenu'
import { useNav } from '../composables/useNav'
import { useDrawings } from '../composables/useDrawings'
Expand All @@ -22,9 +23,9 @@ function onClick(e: MouseEvent) {
if (showEditor.value)
return
if ((e.target as HTMLElement)?.id === 'slide-container') {
if (e.button === 0 && (e.target as HTMLElement)?.id === 'slide-container') {
// click right to next, left to previous
if ((e.screenX / window.innerWidth) > 0.6)
if ((e.pageX / window.innerWidth) > 0.6)
next()
else
prev()
Expand Down Expand Up @@ -57,6 +58,7 @@ if (__SLIDEV_FEATURE_DRAWINGS__)
:scale="slideScale"
:is-main="true"
@pointerdown="onClick"
@contextmenu="onContextMenu"
>
<template #default>
<SlidesShow render-context="slide" />
Expand Down
4 changes: 4 additions & 0 deletions packages/client/pages/presenter.vue
Expand Up @@ -7,6 +7,7 @@ import { decreasePresenterFontSize, increasePresenterFontSize, presenterLayout,
import { configs } from '../env'
import { sharedState } from '../state/shared'
import { registerShortcuts } from '../logic/shortcuts'
import { onContextMenu } from '../logic/contextMenu'
import { getSlideClass } from '../utils'
import { useTimer } from '../logic/utils'
import { createFixedClicks } from '../composables/useClicks'
Expand All @@ -21,6 +22,7 @@ import SlidesShow from '../internals/SlidesShow.vue'
import DrawingControls from '../internals/DrawingControls.vue'
import IconButton from '../internals/IconButton.vue'
import ClicksSlider from '../internals/ClicksSlider.vue'
import ContextMenu from '../internals/ContextMenu.vue'
import { useNav } from '../composables/useNav'
import { useDrawings } from '../composables/useDrawings'
Expand Down Expand Up @@ -112,6 +114,7 @@ onMounted(() => {
<SlideContainer
key="main"
class="h-full w-full p-2 lg:p-4 flex-auto"
@contextmenu="onContextMenu"
>
<template #default>
<SlidesShow render-context="presenter" />
Expand Down Expand Up @@ -209,6 +212,7 @@ onMounted(() => {
</div>
<Goto />
<QuickOverview v-model="showOverview" />
<ContextMenu />
</template>

<style scoped>
Expand Down

0 comments on commit 8e43c03

Please sign in to comment.