276 lines
6.0 KiB
Vue
276 lines
6.0 KiB
Vue
<script setup lang="ts">
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
code?: string;
|
||
language?: string;
|
||
filename?: string;
|
||
highlights?: number[];
|
||
meta?: string;
|
||
class?: string;
|
||
}>(),
|
||
{
|
||
code: "",
|
||
language: "text", // Nuxt Content 已经做了此处理
|
||
highlights: () => [],
|
||
},
|
||
);
|
||
|
||
interface CodeblockMeta {
|
||
icon?: string;
|
||
wrap?: boolean;
|
||
[meta: string]: string | boolean | undefined;
|
||
}
|
||
|
||
const meta = computed(() => {
|
||
if (!props.meta) return {};
|
||
|
||
return props.meta.split(" ").reduce((acc: CodeblockMeta, item) => {
|
||
const [key, value] = item.split("=");
|
||
acc[key!] = value ?? true;
|
||
return acc;
|
||
}, {});
|
||
});
|
||
|
||
const appConfig = useAppConfig();
|
||
const compConf = appConfig.component.codeblock;
|
||
|
||
const rows = computed(() => props.code.split("\n").length - 1);
|
||
const collapsible = computed(() => !meta.value.expand && rows.value > compConf.triggerRows);
|
||
const [isCollapsed, toggleCollapsed] = useToggle(collapsible.value);
|
||
|
||
const icon = computed(
|
||
() => meta.value.icon || getFileIcon(props.filename) || getLangIcon(props.language),
|
||
);
|
||
const isWrap = ref(meta.value.wrap);
|
||
|
||
const codeblock = useTemplateRef("codeblock");
|
||
const { copy, copied } = useCopy(codeblock);
|
||
|
||
const shikiStore = useShikiStore();
|
||
const rawHtml = ref(escapeHtml(props.code));
|
||
|
||
function getIndent() {
|
||
if (meta.value.indent) return meta.value.indent;
|
||
|
||
if (["json", "jsonc", "yaml", "yml"].includes(props.language)) return 2;
|
||
|
||
return compConf.indent;
|
||
}
|
||
|
||
onMounted(async () => {
|
||
const shiki = await shikiStore.load();
|
||
await shikiStore.loadLang(props.language);
|
||
// 处理 Markdown 高亮内代码块中的语言
|
||
// 加载 TeX 语言有概率导致 LaTeX 语言高亮炸掉
|
||
if (props.language === "markdown" || props.language.startsWith("md")) {
|
||
const mdLangRegex = /^\s*`{3,}(\S+)/gm;
|
||
const langs = Array.from(props.code.matchAll(mdLangRegex))
|
||
.map((match) => match[1])
|
||
.filter((lang) => lang !== undefined);
|
||
await shikiStore.loadLang(...langs);
|
||
}
|
||
|
||
rawHtml.value = shiki.codeToHtml(
|
||
props.code.trimEnd(),
|
||
shikiStore.getOptions(
|
||
props.language,
|
||
[compConf.enableIndentGuide ? "ignoreRenderWhitespace" : "ignoreRenderIndentGuides"],
|
||
{ meta: { indent: getIndent() } },
|
||
),
|
||
);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<figure
|
||
class="z-codeblock"
|
||
:class="{ collapsed: collapsible && isCollapsed, collapsible }"
|
||
:style="{
|
||
'--collapsed-rows': compConf.collapsedRows,
|
||
'--tab-size': (meta.indent as string) || compConf.tabSize,
|
||
}">
|
||
<figcaption>
|
||
<span v-if="filename" class="filename"> <Icon :name="icon" /> {{ filename }} </span>
|
||
<span v-else />
|
||
<!-- 语言不采用绝对定位,因为和文件名占据互斥空间 -->
|
||
<span v-if="language" class="language">{{ language }}</span>
|
||
<div class="operations">
|
||
<button @click="isWrap = !isWrap">
|
||
{{ isWrap ? "横向滚动" : "自动换行" }}
|
||
</button>
|
||
<button @click="copy()">
|
||
{{ copied ? "已复制" : "复制" }}
|
||
</button>
|
||
</div>
|
||
</figcaption>
|
||
|
||
<!-- 嘿嘿,不要换行 -->
|
||
<pre
|
||
ref="codeblock"
|
||
class="shiki scrollcheck-x"
|
||
:class="[props.class, { wrap: isWrap }]"
|
||
v-html="rawHtml" />
|
||
|
||
<button
|
||
v-if="collapsible"
|
||
class="toggle-btn"
|
||
:aria-label="isCollapsed ? '展开代码块' : '折叠代码块'"
|
||
@click="toggleCollapsed()">
|
||
<Icon
|
||
class="toggle-icon"
|
||
:class="{ 'is-collapsed': isCollapsed }"
|
||
name="ph:caret-double-up-bold" />
|
||
<span class="toggle-tip">{{ rows }} 行</span>
|
||
</button>
|
||
</figure>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
.z-codeblock {
|
||
--line-height: 1.4em;
|
||
|
||
position: relative;
|
||
overflow: clip;
|
||
margin: 0.5em 0;
|
||
border-radius: 0.5em;
|
||
background-color: var(--c-bg-2);
|
||
font-size: 0.8125rem;
|
||
line-height: var(--line-height);
|
||
tab-size: var(--tab-size, 4);
|
||
|
||
&.collapsed {
|
||
pre {
|
||
overflow: hidden;
|
||
max-height: calc(var(--line-height) * var(--collapsed-rows) + 3rem);
|
||
mask-image: linear-gradient(to top, transparent 2rem, #fff 4rem);
|
||
animation: none;
|
||
}
|
||
|
||
.toggle-btn {
|
||
margin: 0.5em;
|
||
}
|
||
}
|
||
|
||
&.collapsible pre {
|
||
padding-bottom: 2rem;
|
||
}
|
||
}
|
||
|
||
figcaption {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 1em;
|
||
position: sticky;
|
||
top: 0;
|
||
padding: 0 1em;
|
||
z-index: 2;
|
||
|
||
> .filename {
|
||
padding: 0.2em 0.8em;
|
||
border-radius: 0 0 0.5em 0.5em;
|
||
background-color: var(--c-border);
|
||
word-break: break-all;
|
||
}
|
||
|
||
> .language {
|
||
opacity: 0.4;
|
||
height: 0;
|
||
transform: translateY(0.2em);
|
||
}
|
||
|
||
> .operations {
|
||
position: absolute;
|
||
opacity: 0;
|
||
inset-inline-end: 0;
|
||
padding: 0 0.6em;
|
||
border-end-start-radius: 0.5em;
|
||
background-color: var(--c-bg-2);
|
||
transition: opacity 0.2s;
|
||
|
||
:hover > & {
|
||
opacity: 1;
|
||
}
|
||
|
||
> button {
|
||
opacity: 0.4;
|
||
padding: 0.2em 0.4em;
|
||
transition: opacity 0.2s;
|
||
|
||
&:hover {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pre {
|
||
// 如果填写 0 会在 calc() 时出错
|
||
--start-offset: 4em;
|
||
|
||
overflow: auto;
|
||
padding: 1rem;
|
||
padding-inline-start: var(--start-offset);
|
||
|
||
&.wrap {
|
||
white-space: pre-wrap;
|
||
}
|
||
}
|
||
|
||
:deep(.line) {
|
||
&::before {
|
||
content: attr(data-line);
|
||
position: absolute;
|
||
inset-inline-start: 0;
|
||
width: var(--start-offset);
|
||
padding-inline-end: 1em;
|
||
background-color: var(--c-bg-2);
|
||
text-align: end;
|
||
color: var(--c-text-3);
|
||
z-index: 1;
|
||
}
|
||
|
||
&.highlight {
|
||
&::before {
|
||
color: inherit;
|
||
}
|
||
|
||
outline: 0.2em solid var(--ld-bg-active);
|
||
background-color: var(--ld-bg-active);
|
||
}
|
||
}
|
||
|
||
.toggle-btn {
|
||
position: absolute;
|
||
inset: auto 0 0;
|
||
margin: 0.8em;
|
||
padding: 0.2em;
|
||
border-radius: 0.5em;
|
||
background-color: var(--c-bg-3);
|
||
text-align: center;
|
||
color: var(--c-text-2);
|
||
}
|
||
|
||
.toggle-icon {
|
||
transition: all 0.2s;
|
||
|
||
&.is-collapsed {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
:hover > & {
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
.toggle-tip {
|
||
position: absolute;
|
||
opacity: 0;
|
||
inset: auto 0;
|
||
transition: opacity 0.2s;
|
||
|
||
:hover > & {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
</style>
|