| 1 | <script setup lang="ts"> |
| 2 | import type { KeyedTokensInfo } from '@shikijs/magic-move/types' |
| 3 | import type { PropType } from 'vue' |
| 4 | import { sleep } from '@antfu/utils' |
| 5 | import { ShikiMagicMovePrecompiled } from '@shikijs/magic-move/vue' |
| 6 | import { useClipboard } from '@vueuse/core' |
| 7 | import lz from 'lz-string' |
| 8 | import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' |
| 9 | import { useNav } from '../composables/useNav' |
| 10 | import { CLICKS_MAX } from '../constants' |
| 11 | import { useSlideContext } from '../context' |
| 12 | import { configs } from '../env' |
| 13 | import TitleIcon from '../internals/TitleIcon.vue' |
| 14 | import { makeId, updateCodeHighlightRange } from '../logic/utils' |
| 15 | |
| 16 | const props = defineProps({ |
| 17 | at: { |
| 18 | type: [String, Number], |
| 19 | default: '+1', |
| 20 | }, |
| 21 | stepsLz: { |
| 22 | type: String, |
| 23 | required: true, |
| 24 | }, |
| 25 | stepRanges: { |
| 26 | type: Array as PropType<string[][]>, |
| 27 | required: true, |
| 28 | }, |
| 29 | lines: { |
| 30 | type: Boolean, |
| 31 | default: configs.lineNumbers, |
| 32 | }, |
| 33 | title: { |
| 34 | type: String, |
| 35 | default: '', |
| 36 | }, |
| 37 | duration: { |
| 38 | type: Number, |
| 39 | default: configs.magicMoveDuration, |
| 40 | }, |
| 41 | }) |
| 42 | |
| 43 | const steps = JSON.parse(lz.decompressFromBase64(props.stepsLz)) as KeyedTokensInfo[] |
| 44 | const { $clicksContext: clicks, $scale: scale, $zoom: zoom, $frontmatter } = useSlideContext() |
| 45 | const { isPrintMode } = useNav() |
| 46 | const id = makeId() |
| 47 | |
| 48 | const stepIndex = ref(0) |
| 49 | // Used to skip the animation on the first tick. |
| 50 | const isFirstTick = ref(true) |
| 51 | const container = ref<HTMLElement>() |
| 52 | |
| 53 | const showCopyButton = computed(() => { |
| 54 | const codeCopy = $frontmatter?.codeCopy ?? configs.codeCopy |
| 55 | if (!codeCopy) |
| 56 | return false |
| 57 | |
| 58 | const magicCopy = $frontmatter?.magicMoveCopy ?? configs.magicMoveCopy |
| 59 | if (!magicCopy) |
| 60 | return false |
| 61 | |
| 62 | if (magicCopy === true || magicCopy === 'always') |
| 63 | return true |
| 64 | |
| 65 | if (magicCopy === 'final') |
| 66 | return stepIndex.value === steps.length - 1 |
| 67 | |
| 68 | return false |
| 69 | }) |
| 70 | const { copied, copy } = useClipboard() |
| 71 | |
| 72 | function copyCode() { |
| 73 | // Use the code property directly from KeyedTokensInfo |
| 74 | const currentStep = steps[stepIndex.value] |
| 75 | if (!currentStep || !currentStep.code) |
| 76 | return |
| 77 | |
| 78 | copy(currentStep.code.trim()) |
| 79 | } |
| 80 | |
| 81 | // Normalized the ranges, to at least have one range |
| 82 | const ranges = computed(() => props.stepRanges.map(i => i.length ? i : ['all'])) |
| 83 | |
| 84 | onUnmounted(() => { |
| 85 | clicks?.unregister(id) |
| 86 | }) |
| 87 | |
| 88 | onMounted(() => { |
| 89 | if (!clicks) |
| 90 | return |
| 91 | |
| 92 | if (ranges.value.length !== steps.length) |
| 93 | throw new Error('[slidev] The length of stepRanges does not match the length of steps, this is an internal error.') |
| 94 | |
| 95 | const clickCounts = ranges.value.map(s => s.length).reduce((a, b) => a + b, 0) |
| 96 | const clickInfo = clicks.calculateSince(props.at, clickCounts - 1) |
| 97 | clicks.register(id, clickInfo) |
| 98 | |
| 99 | let cancelTick: () => void = () => { } |
| 100 | watch( |
| 101 | () => clicks.current, |
| 102 | () => { |
| 103 | // Calculate the step and rangeStr based on the current click count |
| 104 | const clickCount = clickInfo ? clicks.current - clickInfo.start : CLICKS_MAX |
| 105 | let step = steps.length - 1 |
| 106 | let currentClickSum = 0 |
| 107 | let rangeStr = 'all' |
| 108 | for (let i = 0; i < ranges.value.length; i++) { |
| 109 | const current = ranges.value[i] |
| 110 | if (clickCount < currentClickSum + current.length - 1) { |
| 111 | step = i |
| 112 | rangeStr = current[clickCount - currentClickSum + 1] |
| 113 | break |
| 114 | } |
| 115 | currentClickSum += current.length || 1 |
| 116 | } |
| 117 | |
| 118 | // It seems ticks may not be executed in order. Cancel previous ones, because |
| 119 | // clicks.current is first 0 then immediately updated when refreshing the slide. |
| 120 | cancelTick() |
| 121 | let isCanceled = false |
| 122 | cancelTick = () => { |
| 123 | isCanceled = true |
| 124 | } |
| 125 | |
| 126 | nextTick(async () => { |
| 127 | if (isCanceled) { |
| 128 | return |
| 129 | } |
| 130 | stepIndex.value = step |
| 131 | if (isFirstTick.value) { |
| 132 | nextTick(() => { |
| 133 | isFirstTick.value = false |
| 134 | }) |
| 135 | } |
| 136 | await sleep(0) |
| 137 | |
| 138 | const pre = container.value?.querySelector('.shiki') as HTMLElement |
| 139 | if (!pre) |
| 140 | return |
| 141 | |
| 142 | const children = (Array.from(pre.children) as HTMLElement[]) |
| 143 | .slice(1) // Remove the first anchor |
| 144 | .filter(i => !i.className.includes('shiki-magic-move-leave')) // Filter the leaving elements |
| 145 | |
| 146 | // Group to lines between `<br>` |
| 147 | const lines = children.reduce((acc, el) => { |
| 148 | if (el.tagName === 'BR') |
| 149 | acc.push([]) |
| 150 | else |
| 151 | acc[acc.length - 1].push(el) |
| 152 | return acc |
| 153 | }, [[]] as HTMLElement[][]) |
| 154 | |
| 155 | // Update highlight range |
| 156 | updateCodeHighlightRange( |
| 157 | rangeStr, |
| 158 | lines.length, |
| 159 | 1, |
| 160 | no => lines[no], |
| 161 | ) |
| 162 | }) |
| 163 | }, |
| 164 | { immediate: true }, |
| 165 | ) |
| 166 | }) |
| 167 | </script> |
| 168 | |
| 169 | <template> |
| 170 | <div ref="container" class="slidev-code-wrapper slidev-code-magic-move relative group"> |
| 171 | <div v-if="title" class="slidev-code-block-title"> |
| 172 | <TitleIcon :title="title" /> |
| 173 | <div class="leading-1em"> |
| 174 | {{ title.replace(/~([^~]+)~/g, '').trim() }} |
| 175 | </div> |
| 176 | </div> |
| 177 | <ShikiMagicMovePrecompiled |
| 178 | class="slidev-code relative shiki overflow-visible" |
| 179 | :steps="steps" |
| 180 | :step="stepIndex" |
| 181 | :animate="!isPrintMode" |
| 182 | :options="{ |
| 183 | globalScale: scale * zoom, |
| 184 | // Use duration 0 to skip animation instead of using the animate prop, |
| 185 | // because moving from non-animated to animated causes issues with |
| 186 | // new elements. Unfortunately, this causes a flash. |
| 187 | duration: isFirstTick ? 0 : $props.duration, |
| 188 | stagger: 1, |
| 189 | }" |
| 190 | /> |
| 191 | <button |
| 192 | v-if="showCopyButton" |
| 193 | class="slidev-code-copy absolute right-0 transition opacity-0 group-hover:opacity-20 hover:!opacity-100" |
| 194 | :class="title ? 'top-10' : 'top-0'" |
| 195 | :title="copied ? 'Copied' : 'Copy'" @click="copyCode()" |
| 196 | > |
| 197 | <ph-check-circle v-if="copied" class="p-2 w-8 h-8" /> |
| 198 | <ph-clipboard v-else class="p-2 w-8 h-8" /> |
| 199 | </button> |
| 200 | </div> |
| 201 | </template> |
| 202 | |
| 203 | <style> |
| 204 | .slidev-code-magic-move .shiki-magic-move-enter-from, |
| 205 | .slidev-code-magic-move .shiki-magic-move-leave-to { |
| 206 | opacity: 0; |
| 207 | } |
| 208 | </style> |
| 209 |