Files
Cloud-Blog/app/components/content/ProsePre1.vue
2026-01-01 00:13:50 +08:00

276 lines
6.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>