Files
veridian/app/components/Markdown/ShikiHighlight.vue
T

165 lines
5.5 KiB
Vue

<script lang="ts" setup>
import { hashSync } from '~/utils/hash';
const props = defineProps<{ code: string; lang: string }>();
const renderId = hashSync(props.code + props.lang);
const codeBlockRef = ref<HTMLDivElement | null>(null);
const codeHeight: Ref<string | number> = ref('auto');
const copied = ref(false);
const collapsed = ref(false);
const { data: parsed } = useAsyncData(`shiki-${renderId}`, async () => {
const { html, displayLang } = await parseCode();
return { html, displayLang };
});
const lineNumberWidth = computed(() => {
if (!parsed.value?.html) return 1;
// Count newlines in the generated HTML or the source code
// Using props.code is safer and faster than parsing the HTML string
return props.code.split('\n').length.toString().length;
});
watch(() => props.code, async () => {
const { html: codeHtml } = await parseCode();
parsed.value = { html: codeHtml, displayLang: parsed.value!.displayLang };
});
async function parseCode() {
const shiki = await getShikiHighlighter();
let lang = props.lang.toLowerCase();
let displayLang = lang;
try {
const shikiLang = shiki.getLanguage(lang);
displayLang = shikiLang.name;
} catch {
lang = 'text';
}
const html = shiki.codeToHtml(props.code.trim(), {
lang,
themes: { dark: 'vitesse-dark', light: 'vitesse-light' },
});
return { html, displayLang };
}
let copyTimeout: NodeJS.Timeout | null = null;
function copyCode() {
copied.value = true;
navigator.clipboard.writeText(props.code);
if (copyTimeout) clearTimeout(copyTimeout);
copyTimeout = setTimeout(() => {
copied.value = false;
copyTimeout = null;
}, 2000);
}
function collapseCode() {
if (!codeBlockRef.value) return;
collapsed.value = !collapsed.value;
if (collapsed.value) {
codeHeight.value = codeBlockRef.value.scrollHeight;
nextTick(() => {
// since we are changing the height of an element, even though its
// to its own height, we are triggering a reflow, which means that
// if we didnt use requestAnimationFrame, the height would be set
// to 0 before the reflow is complete, which would cause the reflow
// to be ignored, and another one to be triggered with the new height
// of zero, causing the codeblock to snap shut immediately rather than
// animating. Not requestAnimationFrame because it doesnt work
// on firefox, but setTimeout works on both chrome and firefox
setTimeout(() => {
codeHeight.value = 0;
});
});
} else {
codeBlockRef.value!.addEventListener('transitionend', () => {
if (collapsed.value) return;
codeHeight.value = 'auto';
}, { once: true })
const targetHeight = codeBlockRef.value.scrollHeight;
codeHeight.value = targetHeight;
}
}
const codeStyle = computed(() => {
if (typeof codeHeight.value === 'number') {
return `height: ${codeHeight.value}px;`;
} else {
return `height: ${codeHeight.value};`;
}
});
onUnmounted(() => {
if (copyTimeout) clearTimeout(copyTimeout);
});
</script>
<template>
<div class="flex flex-col my-2 rounded-xl overflow-hidden">
<div class="flex items-center pl-3 pr-1.5 py-1.5 text-sm font-sans bg-[var(--color-highlight)] justify-between">
<div class="capitalize">
{{ parsed?.displayLang }}
</div>
<div class="flex gap-2">
<button @click="copyCode()"
class="flex items-center px-1 gap-0.5 rounded-md hover:bg-[var(--color-highlight)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
Copy
<Icon v-if="!copied" name="mynaui:copy" class="text-4 text-[var(--color-text-subtle)]" />
<Icon v-else name="mynaui:check" class="text-4 text-emerald-500" />
</button>
<button @click="collapseCode()"
class="flex items-center justify-center h-5.5 w-5.5 rounded-md hover:bg-[var(--color-highlight)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<Icon name="mynaui:chevron-down"
:class="['text-4 h-4 w-4 text-[var(--color-text-subtle)] transition-transform duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', collapsed ? '-rotate-90' : '']" />
</button>
</div>
</div>
<div ref="codeBlockRef"
class="font-mono overflow-hidden code-container transition-height duration-300 ease-in-out"
:style="`--line-number-width: ${lineNumberWidth}ch; ${codeStyle}`" :id="`code-${renderId}`"
v-html="parsed?.html">
</div>
</div>
</template>
<style>
.code-container>pre {
overflow-x: auto;
scrollbar-width: thin;
padding: 1rem;
line-height: 0;
counter-reset: lines;
}
.code-container>pre>code .line::before {
counter-increment: lines;
content: counter(lines);
width: var(--line-number-width);
margin-right: 1.5rem;
display: inline-block;
text-align: right;
}
.dark .code-container>pre>code .line::before {
color: rgba(255, 255, 255, 0.25);
}
.light .code-container>pre>code .line::before {
color: rgba(0, 0, 0, 0.25);
}
.code-container>pre>code {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 0;
font-variant-ligatures: none;
}
</style>