update
This commit is contained in:
17
app/app.config.ts
Normal file
17
app/app.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// @keep-sorted
|
||||||
|
export default defineAppConfig({
|
||||||
|
component: {
|
||||||
|
codeblock: {
|
||||||
|
/** 代码块触发折叠的行数 */
|
||||||
|
triggerRows: 32,
|
||||||
|
/** 代码块折叠后的行数 */
|
||||||
|
collapsedRows: 16,
|
||||||
|
/** 启用代码块缩进导航会关闭空格渲染 */
|
||||||
|
enableIndentGuide: true,
|
||||||
|
/** 代码块缩进导航(Indent Guige)竖线匹配空格数 */
|
||||||
|
indent: 4,
|
||||||
|
/** tab渲染宽度 */
|
||||||
|
tabSize: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
19
app/assets/css/code.css
Normal file
19
app/assets/css/code.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@layer utilities {
|
||||||
|
pre code .line {
|
||||||
|
@apply block relative min-h-[1rem];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code .line::before {
|
||||||
|
content: attr(data-line);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -6.25rem;
|
||||||
|
color: #cccccc;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark pre code .line::before {
|
||||||
|
color: #777777;
|
||||||
|
opacity: 0.43;
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
@import "@nuxt/ui";
|
@import "@nuxt/ui";
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
@plugin "@tailwindcss/forms";
|
@plugin "@tailwindcss/forms";
|
||||||
|
@tailwind utilities;
|
||||||
|
@source "../../../content/**/*";
|
||||||
/* @import "@chinese-fonts/maple-mono-cn/dist/MapleMono-CN-Regular/results.css"; */
|
/* @import "@chinese-fonts/maple-mono-cn/dist/MapleMono-CN-Regular/results.css"; */
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|||||||
275
app/components/content/ProsePre1.vue
Normal file
275
app/components/content/ProsePre1.vue
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<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>
|
||||||
@@ -36,6 +36,8 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(props.code);
|
||||||
|
|
||||||
const meta = computed(() => {
|
const meta = computed(() => {
|
||||||
if (!props.meta) return {};
|
if (!props.meta) return {};
|
||||||
|
|
||||||
@@ -126,7 +128,37 @@ if (import.meta.client) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!codeblock.value) return;
|
if (!codeblock.value) return;
|
||||||
|
|
||||||
const lines = codeblock.value.querySelectorAll(".line");
|
// 兼容不同的代码块结构(包括 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 firstNonEmptyIndex = -1;
|
||||||
let lastNonEmptyIndex = -1;
|
let lastNonEmptyIndex = -1;
|
||||||
|
|
||||||
@@ -141,12 +173,6 @@ if (import.meta.client) {
|
|||||||
|
|
||||||
// 隐藏首尾空行
|
// 隐藏首尾空行
|
||||||
lines.forEach((line, index) => {
|
lines.forEach((line, index) => {
|
||||||
// 设置行号
|
|
||||||
if (!line.getAttribute("data-line")) {
|
|
||||||
line.setAttribute("data-line", String(index + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隐藏首尾空行
|
|
||||||
if (index < firstNonEmptyIndex || index > lastNonEmptyIndex) {
|
if (index < firstNonEmptyIndex || index > lastNonEmptyIndex) {
|
||||||
(line as HTMLElement).style.display = "none";
|
(line as HTMLElement).style.display = "none";
|
||||||
}
|
}
|
||||||
@@ -294,7 +320,7 @@ if (import.meta.client) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(code) {
|
:deep(code) {
|
||||||
font-family: "Fira Code", "Monaco", monospace;
|
/* font-family: "JetBrains Mono", "Fira Code", "Monaco", monospace; */
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
font-variant-ligatures: common-ligatures;
|
font-variant-ligatures: common-ligatures;
|
||||||
75
app/components/content/ProsePre3.vue
Normal file
75
app/components/content/ProsePre3.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useClipboard } from "@vueuse/core";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
code?: string;
|
||||||
|
language?: string | null;
|
||||||
|
filename?: string | null;
|
||||||
|
highlights?: number[];
|
||||||
|
meta?: string | null;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
code: "",
|
||||||
|
language: null,
|
||||||
|
filename: null,
|
||||||
|
highlights: undefined,
|
||||||
|
meta: null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { copy, copied } = useClipboard();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute right-2 top-2 flex gap-1">
|
||||||
|
<span
|
||||||
|
v-if="filename"
|
||||||
|
class="block py-1 px-2 text-xs rounded border border-solid border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-slate-700 z-10"
|
||||||
|
>{{ filename }}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="block border border-solid border-gray-300 dark:border-gray-600 rounded p-1 bg-gray-100 dark:bg-slate-700 z-10"
|
||||||
|
@click="copy(code)">
|
||||||
|
<svg
|
||||||
|
v-if="copied"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
fill="currentColor"
|
||||||
|
class="block text-green-500"
|
||||||
|
viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
fill="currentColor"
|
||||||
|
class="block"
|
||||||
|
viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z" />
|
||||||
|
<path
|
||||||
|
d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* pre code .line::before {
|
||||||
|
content: attr(line);
|
||||||
|
position: absolute;
|
||||||
|
left: -6.25rem;
|
||||||
|
color: #cccccc;
|
||||||
|
user-select: none;
|
||||||
|
} */
|
||||||
|
@import "~/assets/css/code.css";
|
||||||
|
</style>
|
||||||
18
app/composables/useCopy.ts
Normal file
18
app/composables/useCopy.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 点击触发元素时,将文本复制到剪贴板,并在触发元素上显示提示信息。
|
||||||
|
* @param target - 提供复制文本、目标元素或组件实例。可为字符串、输入框(复制其 `value`)或其他 HTMLElement(复制其文本内容)。
|
||||||
|
*/
|
||||||
|
export default function useCopy(
|
||||||
|
target: MaybeRefOrGetter<{ $el: Element } | HTMLInputElement | Element | null> | string,
|
||||||
|
) {
|
||||||
|
const getEl = (element: any) => element?.$el ?? element;
|
||||||
|
const getText = () => {
|
||||||
|
const el = getEl(toValue(target));
|
||||||
|
|
||||||
|
if (typeof target === "string") return target;
|
||||||
|
if (el instanceof HTMLInputElement) return el.value;
|
||||||
|
return (el?.textContent as string) || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return useClipboard({ source: getText, legacy: true });
|
||||||
|
}
|
||||||
@@ -6,17 +6,19 @@ export default defineNuxtConfig({
|
|||||||
srcDir: "app",
|
srcDir: "app",
|
||||||
css: ["./app/assets/css/main.css"],
|
css: ["./app/assets/css/main.css"],
|
||||||
|
|
||||||
components: [
|
components: {
|
||||||
{
|
dirs: [
|
||||||
path: "~/components/content",
|
{
|
||||||
extensions: ["vue"],
|
path: "~/components",
|
||||||
prefix: "",
|
extensions: ["vue"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "~/components",
|
path: "~/components/content",
|
||||||
extensions: ["vue"],
|
extensions: ["vue"],
|
||||||
},
|
prefix: "Prose",
|
||||||
],
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
future: {
|
future: {
|
||||||
compatibilityVersion: 4,
|
compatibilityVersion: 4,
|
||||||
|
|||||||
Reference in New Issue
Block a user