Skip to content

Commit

Permalink
feat: support clicks in notes (#1334)
Browse files Browse the repository at this point in the history
  • Loading branch information
antfu committed Feb 25, 2024
1 parent 8287505 commit f9818e0
Show file tree
Hide file tree
Showing 14 changed files with 143 additions and 17 deletions.
12 changes: 12 additions & 0 deletions demo/starter/slides.md
Expand Up @@ -166,6 +166,18 @@ doubled.value = 2
}
</style>

<!--
Notes can also sync with clicks [click]
This will be highlighted after the first click [click]
Highlighted with `count = ref(0)` [click]
[click:2]
Last click (skip two clicks)
-->

---

# Components
Expand Down
3 changes: 2 additions & 1 deletion packages/client/builtin/VClick.ts
Expand Up @@ -6,6 +6,7 @@

import type { PropType, VNode } from 'vue'
import { Text, defineComponent, h } from 'vue'
import { CLICKS_MAX } from '../constants'
import VClicks from './VClicks'

export default defineComponent({
Expand All @@ -31,7 +32,7 @@ export default defineComponent({
return h(
VClicks,
{
every: 99999,
every: CLICKS_MAX,
at: this.at,
hide: this.hide,
fade: this.fade,
Expand Down
7 changes: 4 additions & 3 deletions packages/client/composables/useClicks.ts
Expand Up @@ -5,6 +5,7 @@ import { ref, shallowReactive } from 'vue'
import type { RouteRecordRaw } from 'vue-router'
import { currentRoute, isPrintMode, isPrintWithClicks, queryClicks, routeForceRefresh } from '../logic/nav'
import { normalizeAtProp } from '../logic/utils'
import { CLICKS_MAX } from '../constants'

/**
* @internal
Expand Down Expand Up @@ -65,14 +66,14 @@ export function useClicksContextBase(getCurrent: () => number, clicksOverrides?:
export function usePrimaryClicks(route: RouteRecordRaw | undefined): ClicksContext {
if (route?.meta?.__clicksContext)
return route.meta.__clicksContext
const thisPath = +(route?.path ?? 99999)
const thisPath = +(route?.path ?? CLICKS_MAX)
const context = useClicksContextBase(
() => {
const currentPath = +(currentRoute.value?.path ?? 99999)
const currentPath = +(currentRoute.value?.path ?? CLICKS_MAX)
if (currentPath === thisPath)
return queryClicks.value
else if (currentPath > thisPath)
return 99999
return CLICKS_MAX
else
return 0
},
Expand Down
2 changes: 2 additions & 0 deletions packages/client/constants.ts
Expand Up @@ -22,6 +22,8 @@ export const CLASS_VCLICK_HIDDEN_EXP = 'slidev-vclick-hidden-explicitly'
export const CLASS_VCLICK_CURRENT = 'slidev-vclick-current'
export const CLASS_VCLICK_PRIOR = 'slidev-vclick-prior'

export const CLICKS_MAX = 999999

export const TRUST_ORIGINS = [
'localhost',
'127.0.0.1',
Expand Down
55 changes: 53 additions & 2 deletions packages/client/internals/NoteDisplay.vue
@@ -1,19 +1,70 @@
<script setup lang="ts">
import { computed, defineEmits, defineProps, nextTick, onMounted, ref, watch } from 'vue'
import { CLICKS_MAX } from '../constants'
const props = defineProps<{
class?: string
noteHtml?: string
note?: string
placeholder?: string
clicks?: number | string
}>()
defineEmits(['click'])
const withClicks = computed(() => props.clicks != null && props.noteHtml?.includes('slidev-note-click-mark'))
const noteDisplay = ref<HTMLElement | null>(null)
function highlightNote() {
if (!noteDisplay.value || !withClicks.value || props.clicks == null)
return
const children = Array.from(noteDisplay.value.querySelectorAll('*'))
const disabled = +props.clicks < 0 || +props.clicks >= CLICKS_MAX
if (disabled) {
children.forEach(el => el.classList.remove('slidev-note-fade'))
return
}
let count = 0
const groups = new Map<number, Element[]>()
for (const child of children) {
if (!groups.has(count))
groups.set(count, [])
groups.get(count)!.push(child)
if (child.classList.contains('slidev-note-click-mark'))
count = Number((child as HTMLElement).dataset.clicks) || (count + 1)
}
for (const [count, els] of groups)
els.forEach(el => el.classList.toggle('slidev-note-fade', +count !== +props.clicks!))
}
watch(
() => [props.noteHtml, props.clicks],
() => {
nextTick(() => {
highlightNote()
})
},
{ immediate: true },
)
onMounted(() => {
highlightNote()
})
</script>

<template>
<div
v-if="noteHtml"
class="prose overflow-auto outline-none"
:class="props.class"
ref="noteDisplay"
class="prose overflow-auto outline-none slidev-note"
:class="[props.class, withClicks ? 'slidev-note-with-clicks' : '']"
@click="$emit('click')"
v-html="noteHtml"
/>
Expand Down
4 changes: 4 additions & 0 deletions packages/client/internals/NoteEditor.vue
Expand Up @@ -20,6 +20,9 @@ const props = defineProps({
placeholder: {
default: 'No notes for this slide',
},
clicks: {
type: [Number, String],
},
autoHeight: {
default: false,
},
Expand Down Expand Up @@ -100,6 +103,7 @@ watch(
:style="props.style"
:note="note || placeholder"
:note-html="info?.noteHTML"
:clicks="props.clicks"
/>
<textarea
v-else
Expand Down
2 changes: 2 additions & 0 deletions packages/client/internals/NoteStatic.vue
Expand Up @@ -5,6 +5,7 @@ import NoteDisplay from './NoteDisplay.vue'
const props = defineProps<{
no?: number
class?: string
clicks?: number | string
}>()
const { info } = useSlideInfo(props.no)
Expand All @@ -15,5 +16,6 @@ const { info } = useSlideInfo(props.no)
:class="props.class"
:note="info?.note"
:note-html="info?.noteHTML"
:clicks="props.clicks"
/>
</template>
3 changes: 2 additions & 1 deletion packages/client/internals/OverviewClicksSlider.vue
Expand Up @@ -2,6 +2,7 @@
import type { ClicksContext } from '@slidev/types'
import type { Ref } from 'vue'
import { computed } from 'vue'
import { CLICKS_MAX } from '../constants'
const props = defineProps<{
clickContext: [Ref<number>, ClicksContext]
Expand Down Expand Up @@ -38,7 +39,7 @@ function onMousedown() {
</div>
<div
relative flex-auto h5 flex="~"
@dblclick="current = 999999"
@dblclick="current = CLICKS_MAX"
>
<div
v-for="i of range" :key="i"
Expand Down
3 changes: 2 additions & 1 deletion packages/client/internals/SlidesOverview.vue
Expand Up @@ -7,6 +7,7 @@ import { currentPage, go as goSlide, rawRoutes } from '../logic/nav'
import { currentOverviewPage, overviewRowCount } from '../logic/overview'
import { useFixedClicks } from '../composables/useClicks'
import { getSlideClass } from '../utils'
import { CLICKS_MAX } from '../constants'
import SlideContainer from './SlideContainer.vue'
import SlideWrapper from './SlideWrapper'
import DrawingPreview from './DrawingPreview.vue'
Expand Down Expand Up @@ -139,7 +140,7 @@ watchEffect(() => {
<SlideWrapper
:is="route.component"
v-if="route?.component"
:clicks-context="useFixedClicks(route, 99999)[1]"
:clicks-context="useFixedClicks(route, CLICKS_MAX)[1]"
:class="getSlideClass(route)"
:route="route"
render-context="overview"
Expand Down
3 changes: 2 additions & 1 deletion packages/client/logic/nav.ts
Expand Up @@ -7,6 +7,7 @@ import { rawRoutes, router } from '../routes'
import { configs } from '../env'
import { skipTransition } from '../composables/hmr'
import { usePrimaryClicks } from '../composables/useClicks'
import { CLICKS_MAX } from '../constants'
import { useRouteQuery } from './route'
import { isDrawing } from './drawings'

Expand Down Expand Up @@ -39,7 +40,7 @@ export const queryClicks = computed({
get() {
// eslint-disable-next-line ts/no-use-before-define
if (clicksContext.value.disabled)
return 99999
return CLICKS_MAX
let v = +(queryClicksRaw.value || 0)
if (Number.isNaN(v))
v = 0
Expand Down
4 changes: 3 additions & 1 deletion packages/client/pages/overview.vue
Expand Up @@ -15,6 +15,7 @@ import DrawingPreview from '../internals/DrawingPreview.vue'
import IconButton from '../internals/IconButton.vue'
import NoteEditor from '../internals/NoteEditor.vue'
import OverviewClicksSlider from '../internals/OverviewClicksSlider.vue'
import { CLICKS_MAX } from '../constants'
const cardWidth = 450
Expand All @@ -33,7 +34,7 @@ const clicksContextMap = new WeakMap<RouteRecordRaw, [Ref<number>, ClicksContext
function getClickContext(route: RouteRecordRaw) {
// We create a local clicks context to calculate the total clicks of the slide
if (!clicksContextMap.has(route))
clicksContextMap.set(route, useFixedClicks(route, 9999))
clicksContextMap.set(route, useFixedClicks(route, CLICKS_MAX))
return clicksContextMap.get(route)!
}
Expand Down Expand Up @@ -181,6 +182,7 @@ onMounted(() => {
class="max-w-250 w-250 text-lg rounded p3"
:auto-height="true"
:editing="edittingNote === idx"
:clicks="getClickContext(route)[0].value"
@dblclick="edittingNote !== idx ? edittingNote = idx : null"
@update:editing="edittingNote = null"
/>
Expand Down
2 changes: 2 additions & 0 deletions packages/client/pages/presenter.vue
Expand Up @@ -144,6 +144,7 @@ onMounted(() => {
:no="currentSlideId"
class="w-full max-w-full h-full overflow-auto p-2 lg:p-4"
:editing="notesEditing"
:clicks="clicksContext.current"
:style="{ fontSize: `${presenterNotesFontSize}em` }"
/>
<NoteStatic
Expand All @@ -152,6 +153,7 @@ onMounted(() => {
:no="currentSlideId"
class="w-full max-w-full h-full overflow-auto p-2 lg:p-4"
:style="{ fontSize: `${presenterNotesFontSize}em` }"
:clicks="clicksContext.current"
/>
<div class="border-t border-main py-1 px-2 text-sm">
<IconButton title="Increase font size" @click="increasePresenterFontSize">
Expand Down
33 changes: 33 additions & 0 deletions packages/client/styles/index.css
Expand Up @@ -63,6 +63,39 @@ html {
width: 100%;
}

/* Note Clicks */

.slidev-note-with-clicks .slidev-note-fade {
color: #888888ab;
}

.slidev-note-click-mark {
font-size: 0.8em;
--uno: text-violet bg-violet/10 mx1 px1 font-mono rounded flex flex-inline
items-center align-middle;
}

.slidev-note-click-mark::before {
content: '';
display: inline-block;
--un-icon: url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 32 32' width='1.2em' height='1.2em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M23 28a1 1 0 0 1-.71-.29l-6.13-6.14l-3.33 5a1 1 0 0 1-1 .44a1 1 0 0 1-.81-.7l-6-20A1 1 0 0 1 6.29 5l20 6a1 1 0 0 1 .7.81a1 1 0 0 1-.44 1l-5 3.33l6.14 6.13a1 1 0 0 1 0 1.42l-4 4A1 1 0 0 1 23 28m0-2.41L25.59 23l-7.16-7.15l5.25-3.5L7.49 7.49l4.86 16.19l3.5-5.25Z'/%3E%3C/svg%3E");
-webkit-mask: var(--un-icon) no-repeat;
mask: var(--un-icon) no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
background-color: currentColor;
color: inherit;
width: 1.2em;
height: 1.2em;
opacity: 0.8;
}

.slidev-note-click-mark::after {
content: attr(data-clicks);
display: inline-block;
transform: translateY(0.1em);
}

/* Transform the position back for Rough Notation (v-mark) */
.rough-annotation {
transform: scale(calc(1 / var(--slidev-slide-scale)));
Expand Down
27 changes: 20 additions & 7 deletions packages/slidev/node/plugins/loaders.ts
Expand Up @@ -68,10 +68,23 @@ md.use(mila, {
},
})

function renderNoteHTML(data: SlideInfo): SlideInfo {
function renderNote(text: string = '') {
let clickCount = 0
const html = md.render(text
// replace [click] marker with span
.replace(/\[click(?::(\d+))?\]/gi, (_, count = 1) => {
clickCount += Number(count)
return `<span class="slidev-note-click-mark" data-clicks="${clickCount}"></span>`
}),
)

return html
}

function withRenderedNote(data: SlideInfo): SlideInfo {
return {
...data,
noteHTML: md.render(data?.note || ''),
noteHTML: renderNote(data?.note),
}
}

Expand Down Expand Up @@ -102,7 +115,7 @@ export function createSlidesLoader(
const [, no, type] = match
const idx = Number.parseInt(no)
if (type === 'json' && req.method === 'GET') {
res.write(JSON.stringify(renderNoteHTML(data.slides[idx])))
res.write(JSON.stringify(withRenderedNote(data.slides[idx])))
return res.end()
}
if (type === 'json' && req.method === 'POST') {
Expand All @@ -117,7 +130,7 @@ export function createSlidesLoader(
await parser.save(data.markdownFiles[slide.source.filepath])

res.statusCode = 200
res.write(JSON.stringify(renderNoteHTML(slide)))
res.write(JSON.stringify(withRenderedNote(slide)))
return res.end()
}

Expand Down Expand Up @@ -183,7 +196,7 @@ export function createSlidesLoader(
data: {
id: i,
note: b!.note || '',
noteHTML: md.render(b!.note || ''),
noteHTML: renderNote(b!.note || ''),
},
})
}
Expand All @@ -195,7 +208,7 @@ export function createSlidesLoader(
event: 'slidev-update',
data: {
id: i,
data: renderNoteHTML(newData.slides[i]),
data: withRenderedNote(newData.slides[i]),
},
})
hmrPages.add(i)
Expand Down Expand Up @@ -294,7 +307,7 @@ export function createSlidesLoader(
}
else if (type === 'frontmatter') {
const slideBase = {
...renderNoteHTML(slide),
...withRenderedNote(slide),
frontmatter: undefined,
source: undefined,
// remove raw content in build, optimize the bundle size
Expand Down

0 comments on commit f9818e0

Please sign in to comment.