feat(quickdock): 文件预览接入 Prism 语法高亮,修复深色模式运算符底色与 show_file 高亮失效
This commit is contained in:
parent
26f31a09ec
commit
165bcdb264
@ -4,21 +4,10 @@
|
|||||||
<span class="sfc-file-icon" aria-hidden="true"></span>
|
<span class="sfc-file-icon" aria-hidden="true"></span>
|
||||||
<span class="sfc-name" :title="displayName">{{ displayName }}</span>
|
<span class="sfc-name" :title="displayName">{{ displayName }}</span>
|
||||||
<div class="sfc-actions">
|
<div class="sfc-actions">
|
||||||
<button
|
<button v-if="canCopy" type="button" class="sfc-btn" @click="copyContent">
|
||||||
v-if="canCopy"
|
|
||||||
type="button"
|
|
||||||
class="sfc-btn"
|
|
||||||
@click="copyContent"
|
|
||||||
>
|
|
||||||
{{ copied ? '已复制' : '复制' }}
|
{{ copied ? '已复制' : '复制' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" class="sfc-btn sfc-btn-download" @click="handleDownload">下载</button>
|
||||||
type="button"
|
|
||||||
class="sfc-btn sfc-btn-download"
|
|
||||||
@click="handleDownload"
|
|
||||||
>
|
|
||||||
下载
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -72,14 +61,13 @@
|
|||||||
<template v-else-if="fileType === 'pdf'">
|
<template v-else-if="fileType === 'pdf'">
|
||||||
<PdfPreview :source="contentUrl" />
|
<PdfPreview :source="contentUrl" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import Prism from 'prismjs';
|
import { highlightCode, prismLangForPath } from '@/utils/prismHighlight';
|
||||||
import { renderMarkdown } from '../../composables/useMarkdownRenderer';
|
import { renderMarkdown } from '../../composables/useMarkdownRenderer';
|
||||||
import PdfPreview from './PdfPreview.vue';
|
import PdfPreview from './PdfPreview.vue';
|
||||||
|
|
||||||
@ -127,30 +115,32 @@ const canPreview = computed(() => {
|
|||||||
return ['text', 'code', 'json', 'csv', 'markdown', 'image', 'pdf'].includes(fileType.value);
|
return ['text', 'code', 'json', 'csv', 'markdown', 'image', 'pdf'].includes(fileType.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const canCopy = computed(() => ['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value));
|
const canCopy = computed(() =>
|
||||||
|
['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value)
|
||||||
|
);
|
||||||
|
|
||||||
const isAndroidApp = computed(() => {
|
const isAndroidApp = computed(() => {
|
||||||
return typeof (window as any).AndroidDownloadBridge !== 'undefined' ||
|
return (
|
||||||
typeof (window as any).AndroidThemeBridge !== 'undefined';
|
typeof (window as any).AndroidDownloadBridge !== 'undefined' ||
|
||||||
|
typeof (window as any).AndroidThemeBridge !== 'undefined'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const metaText = computed(() => {
|
// 根据路径推断 Prism 语言(修复历史遗留:原代码引用了未定义的 displayLang,
|
||||||
if (!rawContent.value) return '';
|
// 高亮从未真正生效,静默 fallback 为纯文本)
|
||||||
const sizeKB = (new Blob([rawContent.value]).size / 1024).toFixed(1);
|
const displayLang = computed(() => prismLangForPath(props.path));
|
||||||
return `${sizeKB} KB`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const highlightedCode = computed(() => {
|
const highlightedCode = computed(() => {
|
||||||
if (!rawContent.value) return '';
|
if (!rawContent.value) return '';
|
||||||
let content = rawContent.value;
|
let content = rawContent.value;
|
||||||
if (fileType.value === 'json') {
|
if (fileType.value === 'json') {
|
||||||
try { content = JSON.stringify(JSON.parse(content), null, 2); } catch {}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const grammar = Prism.languages[displayLang.value];
|
content = JSON.stringify(JSON.parse(content), null, 2);
|
||||||
if (grammar) return Prism.highlight(content, grammar, displayLang.value);
|
} catch {
|
||||||
} catch {}
|
// JSON 解析失败时保留原内容
|
||||||
return content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
}
|
||||||
|
}
|
||||||
|
return highlightCode(content, displayLang.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderedMarkdown = computed(() => {
|
const renderedMarkdown = computed(() => {
|
||||||
@ -160,7 +150,7 @@ const renderedMarkdown = computed(() => {
|
|||||||
|
|
||||||
const csvParsed = computed(() => {
|
const csvParsed = computed(() => {
|
||||||
if (fileType.value !== 'csv' || !rawContent.value) return null;
|
if (fileType.value !== 'csv' || !rawContent.value) return null;
|
||||||
const lines = rawContent.value.split(/\r?\n/).filter(l => l.trim());
|
const lines = rawContent.value.split(/\r?\n/).filter((l) => l.trim());
|
||||||
const parseLine = (line: string): string[] => {
|
const parseLine = (line: string): string[] => {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
let current = '';
|
let current = '';
|
||||||
@ -168,13 +158,17 @@ const csvParsed = computed(() => {
|
|||||||
for (let i = 0; i < line.length; i++) {
|
for (let i = 0; i < line.length; i++) {
|
||||||
const ch = line[i];
|
const ch = line[i];
|
||||||
if (inQuote) {
|
if (inQuote) {
|
||||||
if (ch === '"' && line[i + 1] === '"') { current += '"'; i++; }
|
if (ch === '"' && line[i + 1] === '"') {
|
||||||
else if (ch === '"') inQuote = false;
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else if (ch === '"') inQuote = false;
|
||||||
else current += ch;
|
else current += ch;
|
||||||
} else {
|
} else {
|
||||||
if (ch === '"') inQuote = true;
|
if (ch === '"') inQuote = true;
|
||||||
else if (ch === ',') { result.push(current); current = ''; }
|
else if (ch === ',') {
|
||||||
else current += ch;
|
result.push(current);
|
||||||
|
current = '';
|
||||||
|
} else current += ch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.push(current);
|
result.push(current);
|
||||||
@ -193,20 +187,54 @@ const csvTruncated = computed(() => csvParsed.value?.truncated || false);
|
|||||||
function inferShowFileType(path: string): string {
|
function inferShowFileType(path: string): string {
|
||||||
const extMap: Record<string, string> = {
|
const extMap: Record<string, string> = {
|
||||||
'.txt': 'text',
|
'.txt': 'text',
|
||||||
'.md': 'markdown', '.markdown': 'markdown',
|
'.md': 'markdown',
|
||||||
'.json': 'json', '.json5': 'json',
|
'.markdown': 'markdown',
|
||||||
'.csv': 'csv', '.tsv': 'csv',
|
'.json': 'json',
|
||||||
'.yaml': 'text', '.yml': 'text', '.toml': 'text',
|
'.json5': 'json',
|
||||||
'.ini': 'text', '.cfg': 'text', '.conf': 'text', '.log': 'text',
|
'.csv': 'csv',
|
||||||
'.js': 'code', '.ts': 'code', '.jsx': 'code', '.tsx': 'code',
|
'.tsv': 'csv',
|
||||||
'.vue': 'code', '.py': 'code', '.rb': 'code', '.go': 'code',
|
'.yaml': 'text',
|
||||||
'.rs': 'code', '.java': 'code', '.c': 'code', '.h': 'code',
|
'.yml': 'text',
|
||||||
'.cpp': 'code', '.hpp': 'code', '.cs': 'code', '.php': 'code',
|
'.toml': 'text',
|
||||||
'.swift': 'code', '.kt': 'code', '.sh': 'code', '.bash': 'code',
|
'.ini': 'text',
|
||||||
'.html': 'code', '.htm': 'code', '.css': 'code', '.scss': 'code',
|
'.cfg': 'text',
|
||||||
'.less': 'code', '.xml': 'code', '.svg': 'image', '.sql': 'code',
|
'.conf': 'text',
|
||||||
'.png': 'image', '.jpg': 'image', '.jpeg': 'image',
|
'.log': 'text',
|
||||||
'.gif': 'image', '.webp': 'image', '.bmp': 'image', '.ico': 'image',
|
'.js': 'code',
|
||||||
|
'.ts': 'code',
|
||||||
|
'.jsx': 'code',
|
||||||
|
'.tsx': 'code',
|
||||||
|
'.vue': 'code',
|
||||||
|
'.py': 'code',
|
||||||
|
'.rb': 'code',
|
||||||
|
'.go': 'code',
|
||||||
|
'.rs': 'code',
|
||||||
|
'.java': 'code',
|
||||||
|
'.c': 'code',
|
||||||
|
'.h': 'code',
|
||||||
|
'.cpp': 'code',
|
||||||
|
'.hpp': 'code',
|
||||||
|
'.cs': 'code',
|
||||||
|
'.php': 'code',
|
||||||
|
'.swift': 'code',
|
||||||
|
'.kt': 'code',
|
||||||
|
'.sh': 'code',
|
||||||
|
'.bash': 'code',
|
||||||
|
'.html': 'code',
|
||||||
|
'.htm': 'code',
|
||||||
|
'.css': 'code',
|
||||||
|
'.scss': 'code',
|
||||||
|
'.less': 'code',
|
||||||
|
'.xml': 'code',
|
||||||
|
'.svg': 'image',
|
||||||
|
'.sql': 'code',
|
||||||
|
'.png': 'image',
|
||||||
|
'.jpg': 'image',
|
||||||
|
'.jpeg': 'image',
|
||||||
|
'.gif': 'image',
|
||||||
|
'.webp': 'image',
|
||||||
|
'.bmp': 'image',
|
||||||
|
'.ico': 'image',
|
||||||
'.avif': 'image',
|
'.avif': 'image',
|
||||||
'.pdf': 'pdf'
|
'.pdf': 'pdf'
|
||||||
};
|
};
|
||||||
@ -244,7 +272,12 @@ async function loadContent() {
|
|||||||
const resp = await fetch(buildContentUrl());
|
const resp = await fetch(buildContentUrl());
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
let msg = resp.statusText;
|
let msg = resp.statusText;
|
||||||
try { const j = await resp.json(); msg = j.error || msg; } catch {}
|
try {
|
||||||
|
const j = await resp.json();
|
||||||
|
msg = j.error || msg;
|
||||||
|
} catch {
|
||||||
|
// 响应体非 JSON 时沿用 statusText
|
||||||
|
}
|
||||||
error.value = msg || '加载失败';
|
error.value = msg || '加载失败';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -278,7 +311,12 @@ async function handleDownload() {
|
|||||||
const resp = await fetch(url);
|
const resp = await fetch(url);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
let msg = resp.statusText;
|
let msg = resp.statusText;
|
||||||
try { const j = await resp.json(); msg = j.error || msg; } catch {}
|
try {
|
||||||
|
const j = await resp.json();
|
||||||
|
msg = j.error || msg;
|
||||||
|
} catch {
|
||||||
|
// 响应体非 JSON 时沿用 statusText
|
||||||
|
}
|
||||||
throw new Error(msg || '下载失败');
|
throw new Error(msg || '下载失败');
|
||||||
}
|
}
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
@ -301,8 +339,12 @@ async function copyContent() {
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(rawContent.value);
|
await navigator.clipboard.writeText(rawContent.value);
|
||||||
copied.value = true;
|
copied.value = true;
|
||||||
setTimeout(() => { copied.value = false; }, 2000);
|
setTimeout(() => {
|
||||||
} catch {}
|
copied.value = false;
|
||||||
|
}, 2000);
|
||||||
|
} catch {
|
||||||
|
// 剪贴板不可用时静默忽略
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFullImage() {
|
function openFullImage() {
|
||||||
@ -378,7 +420,9 @@ onMounted(() => {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, border-color 0.15s;
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
@ -445,7 +489,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes sfc-spin {
|
@keyframes sfc-spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sfc-code {
|
.sfc-code {
|
||||||
@ -527,5 +573,4 @@ onMounted(() => {
|
|||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -34,9 +34,18 @@
|
|||||||
<div v-if="loading" class="qd-preview__loading">加载中…</div>
|
<div v-if="loading" class="qd-preview__loading">加载中…</div>
|
||||||
<div v-else-if="error" class="qd-preview__error">{{ error }}</div>
|
<div v-else-if="error" class="qd-preview__error">{{ error }}</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-for="(line, i) in lines" :key="i" class="qd-code-line">
|
<!-- 行号列 + 整段高亮 pre:Prism 的 token 可能跨行(多行注释/字符串), -->
|
||||||
<span class="line-no">{{ i + 1 }}</span>
|
<!-- 不能按行拆分 v-html,行 hover 用绝对定位高亮条实现 -->
|
||||||
<span class="line-text">{{ line }}</span>
|
<div class="qd-code" @mousemove="onCodeMouseMove" @mouseleave="hoverLine = -1">
|
||||||
|
<div
|
||||||
|
v-if="hoverLine >= 0"
|
||||||
|
class="qd-code-hoverline"
|
||||||
|
:style="{ top: `${hoverLine * LINE_HEIGHT}px` }"
|
||||||
|
></div>
|
||||||
|
<div class="qd-code-gutter" aria-hidden="true">
|
||||||
|
<span v-for="n in lineCount" :key="n">{{ n }}</span>
|
||||||
|
</div>
|
||||||
|
<pre class="qd-code-pre"><code v-html="highlightedHtml"></code></pre>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@ -49,6 +58,12 @@
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useQuickDockStore } from '@/stores/quickDock';
|
import { useQuickDockStore } from '@/stores/quickDock';
|
||||||
|
import { highlightCode, prismLangForPath } from '@/utils/prismHighlight';
|
||||||
|
|
||||||
|
/** 与 quickdock.css 中 .qd-code 的 font-size(12px) × line-height(1.75) 保持同步 */
|
||||||
|
const LINE_HEIGHT = 21;
|
||||||
|
/** 超过该字符数的文件跳过语法高亮(仍显示纯文本与行号),避免大文件解析卡顿 */
|
||||||
|
const HIGHLIGHT_MAX_CHARS = 100 * 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件预览侧边栏(占位列,位于快捷窗口列右侧、git/终端面板左侧)
|
* 文件预览侧边栏(占位列,位于快捷窗口列右侧、git/终端面板左侧)
|
||||||
@ -107,9 +122,24 @@ const PREVIEWABLE_EXTS = new Set([
|
|||||||
const quickDock = useQuickDockStore();
|
const quickDock = useQuickDockStore();
|
||||||
const { previewPath } = storeToRefs(quickDock);
|
const { previewPath } = storeToRefs(quickDock);
|
||||||
|
|
||||||
const lines = ref<string[]>([]);
|
const rawText = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
const hoverLine = ref(-1);
|
||||||
|
|
||||||
|
const previewLang = computed(() => {
|
||||||
|
if (rawText.value.length > HIGHLIGHT_MAX_CHARS) return null;
|
||||||
|
return prismLangForPath(previewPath.value || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const highlightedHtml = computed(() => {
|
||||||
|
const html = highlightCode(rawText.value, previewLang.value);
|
||||||
|
// pre 中末尾的单个 \n 不产生视觉空行;文本以换行结尾时补一个,
|
||||||
|
// 保证行号列与代码视觉行一一对齐
|
||||||
|
return rawText.value.endsWith('\n') ? html + '\n' : html;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lineCount = computed(() => (rawText.value ? rawText.value.split('\n').length : 0));
|
||||||
|
|
||||||
const fileName = computed(() => {
|
const fileName = computed(() => {
|
||||||
const p = previewPath.value || '';
|
const p = previewPath.value || '';
|
||||||
@ -134,8 +164,9 @@ watch(
|
|||||||
previewPath,
|
previewPath,
|
||||||
async (path) => {
|
async (path) => {
|
||||||
const mySeq = ++loadSeq;
|
const mySeq = ++loadSeq;
|
||||||
lines.value = [];
|
rawText.value = '';
|
||||||
error.value = '';
|
error.value = '';
|
||||||
|
hoverLine.value = -1;
|
||||||
if (!path) {
|
if (!path) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -154,7 +185,7 @@ watch(
|
|||||||
if (mySeq !== loadSeq) {
|
if (mySeq !== loadSeq) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lines.value = text.split('\n');
|
rawText.value = text;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (mySeq !== loadSeq) {
|
if (mySeq !== loadSeq) {
|
||||||
return;
|
return;
|
||||||
@ -169,6 +200,13 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function onCodeMouseMove(event: MouseEvent) {
|
||||||
|
const el = event.currentTarget as HTMLElement;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const idx = Math.floor((event.clientY - rect.top) / LINE_HEIGHT);
|
||||||
|
hoverLine.value = idx >= 0 && idx < lineCount.value ? idx : -1;
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
quickDock.closePreview();
|
quickDock.closePreview();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -851,29 +851,49 @@
|
|||||||
color: var(--state-danger);
|
color: var(--state-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qd-code-line {
|
/* 行高 = 12px × 1.75 = 21px,与 FilePreviewPanel.vue 中 LINE_HEIGHT 保持同步 */
|
||||||
|
.qd-code {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qd-code-line:hover {
|
.qd-code-hoverline {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 21px;
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qd-code-line .line-no {
|
.qd-code-gutter {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qd-code-line .line-text {
|
.qd-code-gutter span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qd-code-pre {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
padding-right: 14px;
|
padding-right: 14px;
|
||||||
|
font: inherit;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
2
static/src/env.d.ts
vendored
2
static/src/env.d.ts
vendored
@ -5,9 +5,9 @@ declare module '*.vue' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare module 'prismjs';
|
declare module 'prismjs';
|
||||||
|
declare module 'prismjs/components/*';
|
||||||
declare module 'katex/contrib/auto-render';
|
declare module 'katex/contrib/auto-render';
|
||||||
declare module '*.md' {
|
declare module '*.md' {
|
||||||
const content: string;
|
const content: string;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
static/src/styles/base/_prism-overrides.scss
Normal file
15
static/src/styles/base/_prism-overrides.scss
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// 覆盖 prismjs 默认主题(prism.css)的运算符底色。
|
||||||
|
//
|
||||||
|
// 默认主题中 `.token.operator / .entity / .url`(含 `.language-css .token.string`)
|
||||||
|
// 带有 `background: hsla(0, 0%, 100%, .5)` 半透明白底——浅色主题下几乎不可见,
|
||||||
|
// 深色主题下会渲染成一层灰色色块(典型如 `=` / `==` 出现灰底)。
|
||||||
|
// 这里只去掉底色、保留字符变色,高亮颜色本身仍由 prism.css 提供。
|
||||||
|
// 该覆盖全局生效:代码块、show_file 卡片、快捷窗口文件预览共用同一套 Prism 主题。
|
||||||
|
|
||||||
|
.token.operator,
|
||||||
|
.token.entity,
|
||||||
|
.token.url,
|
||||||
|
.language-css .token.string,
|
||||||
|
.style .token.string {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
@use './base/reset';
|
@use './base/reset';
|
||||||
@use './base/tokens';
|
@use './base/tokens';
|
||||||
@use './base/global';
|
@use './base/global';
|
||||||
|
@use './base/prism-overrides';
|
||||||
@use './layout/app-shell';
|
@use './layout/app-shell';
|
||||||
@use './layout/panels';
|
@use './layout/panels';
|
||||||
@use './components/sidebar/conversation';
|
@use './components/sidebar/conversation';
|
||||||
|
|||||||
136
static/src/utils/prismHighlight.ts
Normal file
136
static/src/utils/prismHighlight.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Prism 语法高亮共享模块。
|
||||||
|
*
|
||||||
|
* 集中完成两件事:
|
||||||
|
* 1. 注册常用语言包(prismjs 核心只自带 markup/css/clike/javascript,
|
||||||
|
* 其余语言必须显式 import;在此统一注册后,CodeBlock / ShowFileCard /
|
||||||
|
* 快捷窗口文件预览等所有使用 Prism 的地方自动获益)。
|
||||||
|
* 2. 提供「路径 → Prism 语言 id」映射与「文本 → 高亮 HTML」的纯函数。
|
||||||
|
*
|
||||||
|
* 颜色样式复用 main.ts 引入的 prismjs/themes/prism.css(与代码块渲染同款),
|
||||||
|
* 本模块不产出任何颜色相关代码。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Prism from 'prismjs';
|
||||||
|
|
||||||
|
// ---- 语言包注册(注意 import 顺序即依赖顺序:被依赖的语言必须先加载) ----
|
||||||
|
import 'prismjs/components/prism-markup-templating'; // php 依赖
|
||||||
|
import 'prismjs/components/prism-c';
|
||||||
|
import 'prismjs/components/prism-cpp'; // 依赖 c
|
||||||
|
import 'prismjs/components/prism-java';
|
||||||
|
import 'prismjs/components/prism-csharp';
|
||||||
|
import 'prismjs/components/prism-kotlin';
|
||||||
|
import 'prismjs/components/prism-swift';
|
||||||
|
import 'prismjs/components/prism-go';
|
||||||
|
import 'prismjs/components/prism-rust';
|
||||||
|
import 'prismjs/components/prism-php'; // 依赖 markup-templating
|
||||||
|
import 'prismjs/components/prism-python';
|
||||||
|
import 'prismjs/components/prism-ruby';
|
||||||
|
import 'prismjs/components/prism-bash';
|
||||||
|
import 'prismjs/components/prism-powershell';
|
||||||
|
import 'prismjs/components/prism-batch';
|
||||||
|
import 'prismjs/components/prism-yaml';
|
||||||
|
import 'prismjs/components/prism-json';
|
||||||
|
import 'prismjs/components/prism-sql';
|
||||||
|
import 'prismjs/components/prism-scss';
|
||||||
|
import 'prismjs/components/prism-sass';
|
||||||
|
import 'prismjs/components/prism-less';
|
||||||
|
import 'prismjs/components/prism-typescript';
|
||||||
|
import 'prismjs/components/prism-jsx';
|
||||||
|
import 'prismjs/components/prism-tsx'; // 依赖 jsx + typescript
|
||||||
|
import 'prismjs/components/prism-toml';
|
||||||
|
import 'prismjs/components/prism-ini';
|
||||||
|
import 'prismjs/components/prism-markdown';
|
||||||
|
|
||||||
|
/** 扩展名(小写、带点)→ Prism 语言 id */
|
||||||
|
const EXT_TO_PRISM_LANG: Record<string, string> = {
|
||||||
|
'.js': 'javascript',
|
||||||
|
'.mjs': 'javascript',
|
||||||
|
'.cjs': 'javascript',
|
||||||
|
'.jsx': 'jsx',
|
||||||
|
'.ts': 'typescript',
|
||||||
|
'.mts': 'typescript',
|
||||||
|
'.cts': 'typescript',
|
||||||
|
'.tsx': 'tsx',
|
||||||
|
'.vue': 'markup',
|
||||||
|
'.py': 'python',
|
||||||
|
'.rb': 'ruby',
|
||||||
|
'.go': 'go',
|
||||||
|
'.rs': 'rust',
|
||||||
|
'.java': 'java',
|
||||||
|
'.c': 'c',
|
||||||
|
'.h': 'c',
|
||||||
|
'.cpp': 'cpp',
|
||||||
|
'.hpp': 'cpp',
|
||||||
|
'.cc': 'cpp',
|
||||||
|
'.hh': 'cpp',
|
||||||
|
'.cxx': 'cpp',
|
||||||
|
'.cs': 'csharp',
|
||||||
|
'.php': 'php',
|
||||||
|
'.swift': 'swift',
|
||||||
|
'.kt': 'kotlin',
|
||||||
|
'.kts': 'kotlin',
|
||||||
|
'.sh': 'bash',
|
||||||
|
'.bash': 'bash',
|
||||||
|
'.zsh': 'bash',
|
||||||
|
'.ps1': 'powershell',
|
||||||
|
'.bat': 'batch',
|
||||||
|
'.cmd': 'batch',
|
||||||
|
'.html': 'markup',
|
||||||
|
'.htm': 'markup',
|
||||||
|
'.xhtml': 'markup',
|
||||||
|
'.xml': 'markup',
|
||||||
|
'.svg': 'markup',
|
||||||
|
'.css': 'css',
|
||||||
|
'.scss': 'scss',
|
||||||
|
'.sass': 'sass',
|
||||||
|
'.less': 'less',
|
||||||
|
'.json': 'json',
|
||||||
|
'.json5': 'json',
|
||||||
|
'.yaml': 'yaml',
|
||||||
|
'.yml': 'yaml',
|
||||||
|
'.toml': 'toml',
|
||||||
|
'.ini': 'ini',
|
||||||
|
'.cfg': 'ini',
|
||||||
|
'.conf': 'ini',
|
||||||
|
'.sql': 'sql',
|
||||||
|
'.md': 'markdown',
|
||||||
|
'.markdown': 'markdown'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据文件路径推断 Prism 语言 id。
|
||||||
|
* @returns 语言 id;无法推断(如 .txt/.log/.csv 或无扩展名)返回 null。
|
||||||
|
*/
|
||||||
|
export function prismLangForPath(path: string): string | null {
|
||||||
|
const name = path.split('/').pop() || '';
|
||||||
|
const dotIdx = name.lastIndexOf('.');
|
||||||
|
if (dotIdx < 0) return null;
|
||||||
|
const ext = name.slice(dotIdx).toLowerCase();
|
||||||
|
return EXT_TO_PRISM_LANG[ext] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTML 转义(Prism 不可用时的兜底渲染) */
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对整段代码做语法高亮,返回可安全用于 v-html 的 HTML 字符串。
|
||||||
|
* 语言未注册或高亮过程抛错时,回退为转义后的纯文本。
|
||||||
|
*/
|
||||||
|
export function highlightCode(content: string, lang: string | null): string {
|
||||||
|
if (lang) {
|
||||||
|
const grammar = Prism.languages[lang];
|
||||||
|
if (grammar) {
|
||||||
|
try {
|
||||||
|
return Prism.highlight(content, grammar, lang);
|
||||||
|
} catch {
|
||||||
|
// fallthrough 到纯文本兜底
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return escapeHtml(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Prism };
|
||||||
Loading…
Reference in New Issue
Block a user