329 lines
9.2 KiB
Vue
329 lines
9.2 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, onMounted } from "vue";
|
|
|
|
interface CodeblockMeta {
|
|
icon?: string;
|
|
wrap?: boolean;
|
|
indent?: string | boolean;
|
|
expand?: boolean;
|
|
[meta: string]: string | boolean | undefined;
|
|
}
|
|
|
|
const props = defineProps({
|
|
code: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
language: {
|
|
type: String,
|
|
default: "text",
|
|
},
|
|
filename: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
highlights: {
|
|
type: Array as () => number[],
|
|
default: () => [],
|
|
},
|
|
meta: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
class: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
});
|
|
|
|
console.log(props.code);
|
|
|
|
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 rows = computed(() => props.code.split("\n").length - 1);
|
|
const collapsible = computed(() => !meta.value.expand && rows.value > 20);
|
|
const [isCollapsed, toggleCollapsed] = useToggle(collapsible.value);
|
|
|
|
const isWrap = ref(meta.value.wrap ?? false);
|
|
|
|
const codeblock = useTemplateRef("codeblock");
|
|
const copied = ref(false);
|
|
|
|
async function copy() {
|
|
if (!codeblock.value) return;
|
|
try {
|
|
const text = codeblock.value.textContent || "";
|
|
await navigator.clipboard.writeText(text);
|
|
copied.value = true;
|
|
setTimeout(() => {
|
|
copied.value = false;
|
|
}, 2000);
|
|
} catch (error) {
|
|
console.error("复制失败:", error);
|
|
}
|
|
}
|
|
|
|
function getFileIcon(filename?: string): string | undefined {
|
|
if (!filename) return undefined;
|
|
|
|
const ext = filename.split(".").pop()?.toLowerCase();
|
|
const iconMap: Record<string, string> = {
|
|
json: "i-ph-file-json",
|
|
ts: "i-ph-file-ts",
|
|
js: "i-ph-file-js",
|
|
vue: "i-ph-file-vue",
|
|
py: "i-ph-file-py",
|
|
sh: "i-ph-terminal-window-duotone",
|
|
bash: "i-ph-terminal-window-duotone",
|
|
yml: "i-ph-file-yaml",
|
|
yaml: "i-ph-file-yaml",
|
|
md: "i-ph-file-md",
|
|
html: "i-ph-file-html",
|
|
css: "i-ph-file-css",
|
|
xml: "i-ph-file-xml",
|
|
};
|
|
|
|
return iconMap[ext!];
|
|
}
|
|
|
|
function getLangIcon(language?: string): string | undefined {
|
|
if (!language) return undefined;
|
|
|
|
const iconMap: Record<string, string> = {
|
|
javascript: "i-ph-file-js",
|
|
typescript: "i-ph-file-ts",
|
|
python: "i-ph-file-py",
|
|
json: "i-ph-file-json",
|
|
bash: "i-ph-terminal-window-duotone",
|
|
shell: "i-ph-terminal-window-duotone",
|
|
sh: "i-ph-terminal-window-duotone",
|
|
vue: "i-ph-file-vue",
|
|
html: "i-ph-file-html",
|
|
css: "i-ph-file-css",
|
|
scss: "i-ph-file-css",
|
|
yaml: "i-ph-file-yaml",
|
|
yml: "i-ph-file-yaml",
|
|
xml: "i-ph-file-xml",
|
|
markdown: "i-ph-file-md",
|
|
md: "i-ph-file-md",
|
|
};
|
|
|
|
return iconMap[language];
|
|
}
|
|
|
|
const icon = computed(() => {
|
|
return meta.value.icon || getFileIcon(props.filename) || getLangIcon(props.language);
|
|
});
|
|
|
|
// 为每一行添加 data-line 属性并隐藏首尾空行
|
|
if (import.meta.client) {
|
|
onMounted(() => {
|
|
if (!codeblock.value) return;
|
|
|
|
// 兼容不同的代码块结构(包括 text 类型)
|
|
let lines = Array.from(codeblock.value.querySelectorAll(".line"));
|
|
|
|
// 如果没有 .line 元素,按 \n 分割创建行
|
|
if (lines.length === 0) {
|
|
const html = codeblock.value.innerHTML;
|
|
const textLines = html.split("\n");
|
|
|
|
// 清空预先存在的内容
|
|
codeblock.value.innerHTML = "";
|
|
|
|
// 为每一行创建包装元素
|
|
textLines.forEach((lineHtml, index) => {
|
|
const lineEl = document.createElement("div");
|
|
lineEl.className = "line";
|
|
lineEl.innerHTML = lineHtml;
|
|
lineEl.setAttribute("data-line", String(index + 1));
|
|
codeblock.value!.appendChild(lineEl);
|
|
});
|
|
|
|
lines = Array.from(codeblock.value.querySelectorAll(".line"));
|
|
} else {
|
|
// 原有逻辑:为 .line 元素设置 data-line
|
|
lines.forEach((line, index) => {
|
|
if (!line.getAttribute("data-line")) {
|
|
line.setAttribute("data-line", String(index + 1));
|
|
}
|
|
});
|
|
}
|
|
|
|
// 隐藏首尾空行
|
|
let firstNonEmptyIndex = -1;
|
|
let lastNonEmptyIndex = -1;
|
|
|
|
// 找到第一个和最后一个非空行
|
|
lines.forEach((line, index) => {
|
|
const text = (line.textContent || "").trim();
|
|
if (text) {
|
|
if (firstNonEmptyIndex === -1) firstNonEmptyIndex = index;
|
|
lastNonEmptyIndex = index;
|
|
}
|
|
});
|
|
|
|
// 隐藏首尾空行
|
|
lines.forEach((line, index) => {
|
|
if (index < firstNonEmptyIndex || index > lastNonEmptyIndex) {
|
|
(line as HTMLElement).style.display = "none";
|
|
}
|
|
});
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<figure
|
|
class="group relative overflow-hidden my-4 rounded-lg bg-(--c-bg-2,#0f1419) text-sm leading-relaxed border border-(--c-border,#2a2e38)"
|
|
:class="{
|
|
'is-collapsed': collapsible && isCollapsed,
|
|
'is-collapsible': collapsible,
|
|
}"
|
|
:style="{ '--tab-size': (meta.indent as string) || 4 }">
|
|
<!-- 顶部标题栏 -->
|
|
<figcaption
|
|
class="sticky top-0 z-20 flex items-center justify-between gap-3 px-4 py-2.5 bg-(--c-bg-2,#0f1419) border-b border-(--c-border,#2a2e38) backdrop-blur-sm">
|
|
<div class="flex items-center gap-2.5 flex-1 min-w-0">
|
|
<!-- 文件名 -->
|
|
<span
|
|
v-if="filename"
|
|
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-(--c-bg-3,#1a1f2e) text-xs font-mono text-(--c-text-1,#e8eaed) whitespace-nowrap border border-(--c-border,#2a2e38)">
|
|
<Icon v-if="icon" :name="icon" class="w-3.5 h-3.5 shrink-0 opacity-80" />
|
|
<span class="truncate font-semibold">{{ filename }}</span>
|
|
</span>
|
|
<!-- 语言标签 -->
|
|
<span
|
|
v-if="language"
|
|
class="text-xs text-(--c-text-2,#a8adb8) font-mono font-medium tracking-wide">
|
|
{{ language }}
|
|
</span>
|
|
</div>
|
|
<!-- 操作按钮 -->
|
|
<div class="flex items-center gap-1 opacity-60 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
class="px-2.5 py-1.5 rounded-md text-xs font-mono bg-(--c-bg-3,#1a1f2e) hover:bg-(--c-border,#2a2e38) transition-colors text-(--c-text-2,#a8adb8) hover:text-(--c-text-1,#e8eaed) border border-transparent hover:border-(--c-border,#2a2e38)"
|
|
title="切换代码换行"
|
|
@click="isWrap = !isWrap">
|
|
{{ isWrap ? "换行" : "滚动" }}
|
|
</button>
|
|
<button
|
|
class="px-2.5 py-1.5 rounded-md text-xs font-mono bg-(--c-bg-3,#1a1f2e) hover:bg-(--c-border,#2a2e38) transition-colors text-(--c-text-2,#a8adb8) hover:text-(--c-text-1,#e8eaed) border border-transparent hover:border-(--c-border,#2a2e38)"
|
|
title="复制代码"
|
|
@click="copy()">
|
|
{{ copied ? "✓ 已复制" : "复制" }}
|
|
</button>
|
|
</div>
|
|
</figcaption>
|
|
|
|
<!-- 代码块 -->
|
|
<pre
|
|
ref="codeblock"
|
|
class="overflow-auto m-0 text-(--c-text-1,#e8eaed) shiki"
|
|
:style="{
|
|
paddingLeft: '1rem',
|
|
paddingRight: '1rem',
|
|
paddingTop: '0.3rem',
|
|
paddingBottom: '0.3rem',
|
|
// paddingBottom: collapsible ? '3rem' : '0.5rem',
|
|
fontSize: '14px',
|
|
tabSize: (meta.indent as string) || '4',
|
|
lineHeight: '1.5',
|
|
}"
|
|
:class="[props.class, { 'whitespace-pre-wrap': isWrap }]">
|
|
<slot />
|
|
</pre>
|
|
|
|
<!-- 折叠/展开按钮 -->
|
|
<button
|
|
v-if="collapsible"
|
|
class="absolute bottom-3 left-1/2 -translate-x-1/2 px-3 py-2 rounded-lg bg-(--c-bg-3,#1a1f2e) border border-(--c-border,#2a2e38) text-(--c-text-2,#a8adb8) hover:text-(--c-text-1,#e8eaed) hover:bg-(--c-border,#2a2e38) transition-all duration-200 cursor-pointer flex items-center gap-2 text-xs font-mono font-medium"
|
|
:aria-label="isCollapsed ? '展开代码块' : '折叠代码块'"
|
|
@click="toggleCollapsed()">
|
|
<Icon
|
|
class="w-4 h-4 transition-transform duration-200"
|
|
:class="{ 'rotate-180': isCollapsed }"
|
|
name="ph:caret-down-bold" />
|
|
<span>{{ rows }} 行</span>
|
|
</button>
|
|
</figure>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.is-collapsed {
|
|
:deep(pre) {
|
|
max-height: calc(1.5em * 20 + 3.5rem);
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
:deep(pre::after) {
|
|
content: "";
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 3.5rem;
|
|
background: linear-gradient(
|
|
to bottom,
|
|
transparent 0%,
|
|
rgba(15, 20, 25, 0.3) 60%,
|
|
var(--c-bg-2, #0f1419) 100%
|
|
);
|
|
pointer-events: none;
|
|
}
|
|
}
|
|
|
|
:deep(.line) {
|
|
position: relative;
|
|
display: block;
|
|
padding-left: 3.5rem;
|
|
}
|
|
|
|
:deep(.line::before) {
|
|
content: attr(data-line);
|
|
position: absolute;
|
|
left: 0;
|
|
width: 3rem;
|
|
padding-right: 0.5rem;
|
|
text-align: right;
|
|
color: var(--c-text-3, #6c7280);
|
|
user-select: none;
|
|
font-size: 0.85em;
|
|
font-family: "Fira Code", "Monaco", monospace;
|
|
font-weight: 500;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
:deep(.line:empty) {
|
|
display: none;
|
|
}
|
|
|
|
:deep(.line.highlight) {
|
|
background-color: rgba(248, 113, 113, 0.08);
|
|
border-left: 2px solid rgba(248, 113, 113, 0.3);
|
|
margin-left: -3.5rem;
|
|
padding-left: calc(3.5rem - 2px);
|
|
}
|
|
|
|
:deep(.line.highlight::before) {
|
|
color: var(--c-text-2, #a8adb8);
|
|
font-weight: 700;
|
|
}
|
|
|
|
:deep(code) {
|
|
/* font-family: "JetBrains Mono", "Fira Code", "Monaco", monospace; */
|
|
font-size: 0.95em;
|
|
letter-spacing: 0.01em;
|
|
font-variant-ligatures: common-ligatures;
|
|
}
|
|
</style>
|