Skip to content

Commit

Permalink
feat: support highlight within magic move
Browse files Browse the repository at this point in the history
  • Loading branch information
antfu committed Mar 5, 2024
1 parent 14041b4 commit 68ed625
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 45 deletions.
10 changes: 7 additions & 3 deletions demo/starter/slides.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,9 @@ Powered by [shiki-magic-move](https://shiki-magic-move.netlify.app/), Slidev sup

Add multiple code blocks and wrap them with <code>````md magic-move</code> (four backticks) to enable the magic move. For example:

````md magic-move {at:0}
```ts
````md magic-move
```ts {*|2|*}
// step 1
const author = reactive({
name: 'John Doe',
books: [
Expand All @@ -202,7 +203,8 @@ const author = reactive({
})
```

```ts
```ts {*|1-2|3-4|3-4,8}
// step 2
export default {
data() {
return {
Expand All @@ -220,6 +222,7 @@ export default {
```

```ts
// step 3
export default {
data: () => ({
author: {
Expand All @@ -237,6 +240,7 @@ export default {
Non-code blocks are ignored.

```vue
<!-- step 4 -->
<script setup>
const author = {
name: 'John Doe',
Expand Down
48 changes: 23 additions & 25 deletions packages/client/builtin/CodeBlockWrapper.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,12 @@ Learn more: https://sli.dev/guide/syntax.html#line-highlighting
-->

<script setup lang="ts">
import { parseRangeString } from '@slidev/parser/core'
import { useClipboard } from '@vueuse/core'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import type { PropType } from 'vue'
import { configs } from '../env'
import { makeId } from '../logic/utils'
import { CLASS_VCLICK_HIDDEN, CLASS_VCLICK_TARGET } from '../constants'
import { makeId, updateCodeHighlightRange } from '../logic/utils'
import { CLASS_VCLICK_HIDDEN } from '../constants'
import { useSlideContext } from '../context'

const props = defineProps({
Expand Down Expand Up @@ -87,28 +86,27 @@ onMounted(() => {
if (hide)
rangeStr = props.ranges[index.value + 1] ?? finallyRange.value

const isDuoTone = el.value.querySelector('.shiki-dark')
const targets = isDuoTone ? Array.from(el.value.querySelectorAll('.shiki')) : [el.value]
const startLine = props.startLine
for (const target of targets) {
const lines = Array.from(target.querySelectorAll('code > .line'))
const highlights: number[] = parseRangeString(lines.length + startLine - 1, rangeStr)
lines.forEach((line, idx) => {
const highlighted = highlights.includes(idx + startLine)
line.classList.toggle(CLASS_VCLICK_TARGET, true)
line.classList.toggle('highlighted', highlighted)
line.classList.toggle('dishonored', !highlighted)
})
if (props.maxHeight) {
const highlightedEls = Array.from(target.querySelectorAll('.line.highlighted')) as HTMLElement[]
const height = highlightedEls.reduce((acc, el) => el.offsetHeight + acc, 0)
if (height > el.value.offsetHeight) {
highlightedEls[0].scrollIntoView({ behavior: 'smooth', block: 'start' })
}
else if (highlightedEls.length > 0) {
const middleEl = highlightedEls[Math.round((highlightedEls.length - 1) / 2)]
middleEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
const pre = el.value.querySelector('.shiki')!
const lines = Array.from(pre.querySelectorAll('code > .line'))
const linesCount = lines.length

updateCodeHighlightRange(
rangeStr,
linesCount,
props.startLine,
no => [lines[no]],
)

// Scroll to the highlighted line if `maxHeight` is set
if (props.maxHeight) {
const highlightedEls = Array.from(pre.querySelectorAll('.line.highlighted')) as HTMLElement[]
const height = highlightedEls.reduce((acc, el) => el.offsetHeight + acc, 0)
if (height > el.value.offsetHeight) {
highlightedEls[0].scrollIntoView({ behavior: 'smooth', block: 'start' })
}
else if (highlightedEls.length > 0) {
const middleEl = highlightedEls[Math.round((highlightedEls.length - 1) / 2)]
middleEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
})
Expand Down
76 changes: 63 additions & 13 deletions packages/client/builtin/ShikiMagicMove.vue
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
<script setup lang="ts">
import { ShikiMagicMovePrecompiled } from 'shiki-magic-move/vue'
import type { KeyedTokensInfo } from 'shiki-magic-move/types'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import lz from 'lz-string'
import { useSlideContext } from '../context'
import { makeId } from '../logic/utils'
import { makeId, updateCodeHighlightRange } from '../logic/utils'
import 'shiki-magic-move/style.css'
const props = defineProps<{
stepsLz: string
at?: string | number
stepsLz: string
stepRanges: string[][]
}>()
const steps = JSON.parse(lz.decompressFromBase64(props.stepsLz)) as KeyedTokensInfo[]
const { $clicksContext: clicks, $scale: scale } = useSlideContext()
const id = makeId()
const index = ref(0)
const stepIndex = ref(0)
const container = ref<HTMLElement>()
// Normalized the ranges, to at least have one range
const ranges = computed(() => props.stepRanges.map(i => i.length ? i : ['all']))
onUnmounted(() => {
clicks!.unregister(id)
Expand All @@ -26,24 +32,68 @@ onMounted(() => {
if (!clicks || clicks.disabled)
return
const { start, end, delta } = clicks.resolve(props.at ?? '+1', steps.length - 1)
if (ranges.value.length !== steps.length)
throw new Error('[slidev] The length of stepRanges does not match the length of steps, this is an internal error.')
const clickCounts = ranges.value.map(s => s.length).reduce((a, b) => a + b, 0)
const { start, end, delta } = clicks.resolve(props.at ?? '+1', clickCounts - 1)
clicks.register(id, { max: end, delta })
watchEffect(() => {
if (clicks.disabled)
index.value = steps.length - 1
else
index.value = Math.min(Math.max(0, clicks.current - start + 1), steps.length - 1)
})
watch(
() => clicks.current,
() => {
// Calculate the step and rangeStr based on the current click count
const clickCount = clicks.current - start
let step = steps.length - 1
let _currentClickSum = 0
let rangeStr = 'all'
for (let i = 0; i < ranges.value.length; i++) {
const current = ranges.value[i]
if (clickCount < _currentClickSum + current.length - 1) {
step = i
rangeStr = current[clickCount - _currentClickSum + 1]
break
}
_currentClickSum += current.length || 1
}
stepIndex.value = step
const pre = container.value?.querySelector('.shiki') as HTMLElement
if (!pre)
return
const children = (Array.from(pre.children) as HTMLElement[])
.slice(1) // Remove the first anchor
.filter(i => !i.className.includes('shiki-magic-move-leave')) // Filter the leaving elements
// Group to lines between `<br>`
const lines = children.reduce((acc, el) => {
if (el.tagName === 'BR')
acc.push([])
else
acc[acc.length - 1].push(el)
return acc
}, [[]] as HTMLElement[][])
// Update highlight range
updateCodeHighlightRange(
rangeStr,
lines.length,
1,
no => lines[no],
)
},
{ immediate: true },
)
})
</script>

<template>
<div class="slidev-code-wrapper slidev-code-magic-move">
<div ref="container" class="slidev-code-wrapper slidev-code-magic-move relative">
<ShikiMagicMovePrecompiled
class="slidev-code relative shiki overflow-visible"
:steps="steps"
:step="index"
:step="stepIndex"
:options="{ globalScale: scale }"
/>
</div>
Expand Down
24 changes: 24 additions & 0 deletions packages/client/logic/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { parseRangeString } from '@slidev/parser/core'
import { useTimestamp } from '@vueuse/core'
import { computed, ref } from 'vue'
import { CLASS_VCLICK_TARGET } from '../constants'

export function useTimer() {
const tsStart = ref(Date.now())
Expand Down Expand Up @@ -48,3 +50,25 @@ export function normalizeAtProp(at: string | number = '+1'): [isRelative: boolea
n,
]
}

export function updateCodeHighlightRange(
rangeStr: string,
linesCount: number,
startLine: number,
getTokenOfLine: (line: number) => Element[],
) {
const highlights: number[] = parseRangeString(linesCount + startLine - 1, rangeStr)
for (let line = 0; line < linesCount; line++) {
const tokens = getTokenOfLine(line)
const isHighlighted = highlights.includes(line + startLine)
for (const token of tokens) {
token.classList.toggle(CLASS_VCLICK_TARGET, true)
token.classList.toggle('slidev-code-highlighted', isHighlighted)
token.classList.toggle('slidev-code-dishonored', !isHighlighted)

// for backward compatibility
token.classList.toggle('highlighted', isHighlighted)
token.classList.toggle('dishonored', !isHighlighted)
}
}
}
4 changes: 2 additions & 2 deletions packages/client/styles/code.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ html:not(.dark) .shiki span {
overflow: auto;
}

.slidev-code .line.highlighted {
.slidev-code .slidev-code-highlighted {
}
.slidev-code .line.dishonored {
.slidev-code .slidev-code-dishonored {
opacity: 0.3;
pointer-events: none;
}
Expand Down
9 changes: 7 additions & 2 deletions packages/slidev/node/plugins/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,15 @@ export function transformMagicMove(
if (!matches.length)
throw new Error('Magic Move block must contain at least one code block')

const ranges = matches.map(i => normalizeRangeStr(i[2]))
const steps = matches.map(i =>
codeToKeyedTokens(shiki, i[5].trimEnd(), {
...shikiOptions,
lang: i[1] as any,
}),
)
const compressed = lz.compressToBase64(JSON.stringify(steps))
return `<ShikiMagicMove v-bind="${options}" steps-lz="${compressed}" />`
return `<ShikiMagicMove v-bind="${options}" steps-lz="${compressed}" :step-ranges='${JSON.stringify(ranges)}' />`
},
)
}
Expand All @@ -279,14 +280,18 @@ export function transformHighlighter(md: string) {
return md.replace(
reCodeBlock,
(full, lang = '', rangeStr: string = '', options = '', attrs = '', code: string) => {
const ranges = !rangeStr.trim() ? [] : rangeStr.trim().split(/\|/g).map(i => i.trim())
const ranges = normalizeRangeStr(rangeStr)
code = code.trimEnd()
options = options.trim() || '{}'
return `\n<CodeBlockWrapper v-bind="${options}" :ranges='${JSON.stringify(ranges)}'>\n\n\`\`\`${lang}${attrs}\n${code}\n\`\`\`\n\n</CodeBlockWrapper>`
},
)
}

function normalizeRangeStr(rangeStr = '') {
return !rangeStr.trim() ? [] : rangeStr.trim().split(/\|/g).map(i => i.trim())
}

export function getCodeBlocks(md: string) {
const codeblocks = Array
.from(md.matchAll(/^```[\s\S]*?^```/mg))
Expand Down

0 comments on commit 68ed625

Please sign in to comment.