feat(quickdock): 文件预览接入 Prism 语法高亮,修复深色模式运算符底色与 show_file 高亮失效

This commit is contained in:
JOJO 2026-07-22 19:33:21 +08:00
parent 26f31a09ec
commit 165bcdb264
7 changed files with 351 additions and 96 deletions

View File

@ -4,21 +4,10 @@
<span class="sfc-file-icon" aria-hidden="true"></span>
<span class="sfc-name" :title="displayName">{{ displayName }}</span>
<div class="sfc-actions">
<button
v-if="canCopy"
type="button"
class="sfc-btn"
@click="copyContent"
>
<button v-if="canCopy" type="button" class="sfc-btn" @click="copyContent">
{{ copied ? '已复制' : '复制' }}
</button>
<button
type="button"
class="sfc-btn sfc-btn-download"
@click="handleDownload"
>
下载
</button>
<button type="button" class="sfc-btn sfc-btn-download" @click="handleDownload">下载</button>
</div>
</div>
@ -72,14 +61,13 @@
<template v-else-if="fileType === 'pdf'">
<PdfPreview :source="contentUrl" />
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import Prism from 'prismjs';
import { highlightCode, prismLangForPath } from '@/utils/prismHighlight';
import { renderMarkdown } from '../../composables/useMarkdownRenderer';
import PdfPreview from './PdfPreview.vue';
@ -127,30 +115,32 @@ const canPreview = computed(() => {
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(() => {
return typeof (window as any).AndroidDownloadBridge !== 'undefined' ||
typeof (window as any).AndroidThemeBridge !== 'undefined';
return (
typeof (window as any).AndroidDownloadBridge !== 'undefined' ||
typeof (window as any).AndroidThemeBridge !== 'undefined'
);
});
const metaText = computed(() => {
if (!rawContent.value) return '';
const sizeKB = (new Blob([rawContent.value]).size / 1024).toFixed(1);
return `${sizeKB} KB`;
});
// Prism displayLang
// fallback
const displayLang = computed(() => prismLangForPath(props.path));
const highlightedCode = computed(() => {
if (!rawContent.value) return '';
let content = rawContent.value;
if (fileType.value === 'json') {
try { content = JSON.stringify(JSON.parse(content), null, 2); } catch {}
try {
content = JSON.stringify(JSON.parse(content), null, 2);
} catch {
// JSON
}
}
try {
const grammar = Prism.languages[displayLang.value];
if (grammar) return Prism.highlight(content, grammar, displayLang.value);
} catch {}
return content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return highlightCode(content, displayLang.value);
});
const renderedMarkdown = computed(() => {
@ -160,7 +150,7 @@ const renderedMarkdown = computed(() => {
const csvParsed = computed(() => {
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 result: string[] = [];
let current = '';
@ -168,13 +158,17 @@ const csvParsed = computed(() => {
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuote) {
if (ch === '"' && line[i + 1] === '"') { current += '"'; i++; }
else if (ch === '"') inQuote = false;
if (ch === '"' && line[i + 1] === '"') {
current += '"';
i++;
} else if (ch === '"') inQuote = false;
else current += ch;
} else {
if (ch === '"') inQuote = true;
else if (ch === ',') { result.push(current); current = ''; }
else current += ch;
else if (ch === ',') {
result.push(current);
current = '';
} else current += ch;
}
}
result.push(current);
@ -193,20 +187,54 @@ const csvTruncated = computed(() => csvParsed.value?.truncated || false);
function inferShowFileType(path: string): string {
const extMap: Record<string, string> = {
'.txt': 'text',
'.md': 'markdown', '.markdown': 'markdown',
'.json': 'json', '.json5': 'json',
'.csv': 'csv', '.tsv': 'csv',
'.yaml': 'text', '.yml': 'text', '.toml': 'text',
'.ini': 'text', '.cfg': 'text', '.conf': 'text', '.log': 'text',
'.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',
'.md': 'markdown',
'.markdown': 'markdown',
'.json': 'json',
'.json5': 'json',
'.csv': 'csv',
'.tsv': 'csv',
'.yaml': 'text',
'.yml': 'text',
'.toml': 'text',
'.ini': 'text',
'.cfg': 'text',
'.conf': 'text',
'.log': 'text',
'.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',
'.pdf': 'pdf'
};
@ -244,7 +272,12 @@ async function loadContent() {
const resp = await fetch(buildContentUrl());
if (!resp.ok) {
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 || '加载失败';
return;
}
@ -278,7 +311,12 @@ async function handleDownload() {
const resp = await fetch(url);
if (!resp.ok) {
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 || '下载失败');
}
const blob = await resp.blob();
@ -301,8 +339,12 @@ async function copyContent() {
try {
await navigator.clipboard.writeText(rawContent.value);
copied.value = true;
setTimeout(() => { copied.value = false; }, 2000);
} catch {}
setTimeout(() => {
copied.value = false;
}, 2000);
} catch {
//
}
}
function openFullImage() {
@ -378,7 +420,9 @@ onMounted(() => {
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
transition:
background 0.15s,
border-color 0.15s;
&:hover {
background: var(--surface-muted);
@ -445,7 +489,9 @@ onMounted(() => {
}
@keyframes sfc-spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
.sfc-code {
@ -527,5 +573,4 @@ onMounted(() => {
-webkit-user-select: none;
user-select: none;
}
</style>

View File

@ -2,44 +2,53 @@
<transition name="qd-preview-slide">
<aside v-if="previewPath" class="qd-preview">
<section class="qd-preview__panel">
<header class="qd-preview__header">
<svg viewBox="0 0 16 16" fill="none">
<path
d="M4 2.5h5.5L12 5v8.5H4V2.5z"
stroke="currentColor"
stroke-width="1.3"
stroke-linejoin="round"
/>
<path
d="M9.5 2.5V5H12"
stroke="currentColor"
stroke-width="1.3"
stroke-linejoin="round"
/>
</svg>
<span class="qd-preview__name" :title="previewPath">{{ fileName }}</span>
<span class="qd-preview__path" :title="previewPath">{{ dirName }}</span>
<button class="qd-preview__close" title="关闭" @click="close">
<header class="qd-preview__header">
<svg viewBox="0 0 16 16" fill="none">
<path
d="M4 4l8 8M12 4l-8 8"
d="M4 2.5h5.5L12 5v8.5H4V2.5z"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
stroke-width="1.3"
stroke-linejoin="round"
/>
<path
d="M9.5 2.5V5H12"
stroke="currentColor"
stroke-width="1.3"
stroke-linejoin="round"
/>
</svg>
</button>
</header>
<div class="qd-preview__body">
<div v-if="loading" class="qd-preview__loading">加载中</div>
<div v-else-if="error" class="qd-preview__error">{{ error }}</div>
<template v-else>
<div v-for="(line, i) in lines" :key="i" class="qd-code-line">
<span class="line-no">{{ i + 1 }}</span>
<span class="line-text">{{ line }}</span>
</div>
</template>
</div>
<span class="qd-preview__name" :title="previewPath">{{ fileName }}</span>
<span class="qd-preview__path" :title="previewPath">{{ dirName }}</span>
<button class="qd-preview__close" title="关闭" @click="close">
<svg viewBox="0 0 16 16" fill="none">
<path
d="M4 4l8 8M12 4l-8 8"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
/>
</svg>
</button>
</header>
<div class="qd-preview__body">
<div v-if="loading" class="qd-preview__loading">加载中</div>
<div v-else-if="error" class="qd-preview__error">{{ error }}</div>
<template v-else>
<!-- 行号列 + 整段高亮 prePrism token 可能跨行多行注释/字符串 -->
<!-- 不能按行拆分 v-html hover 用绝对定位高亮条实现 -->
<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>
</template>
</div>
</section>
</aside>
</transition>
@ -49,6 +58,12 @@
import { computed, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
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/终端面板左侧
@ -107,9 +122,24 @@ const PREVIEWABLE_EXTS = new Set([
const quickDock = useQuickDockStore();
const { previewPath } = storeToRefs(quickDock);
const lines = ref<string[]>([]);
const rawText = ref('');
const loading = ref(false);
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 p = previewPath.value || '';
@ -134,8 +164,9 @@ watch(
previewPath,
async (path) => {
const mySeq = ++loadSeq;
lines.value = [];
rawText.value = '';
error.value = '';
hoverLine.value = -1;
if (!path) {
return;
}
@ -154,7 +185,7 @@ watch(
if (mySeq !== loadSeq) {
return;
}
lines.value = text.split('\n');
rawText.value = text;
} catch (err: any) {
if (mySeq !== loadSeq) {
return;
@ -169,6 +200,13 @@ watch(
{ 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() {
quickDock.closePreview();
}

View File

@ -851,29 +851,49 @@
color: var(--state-danger);
}
.qd-code-line {
/* 行高 = 12px × 1.75 = 21px与 FilePreviewPanel.vue 中 LINE_HEIGHT 保持同步 */
.qd-code {
position: relative;
display: flex;
align-items: flex-start;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 12px;
line-height: 1.75;
}
.qd-code-line:hover {
.qd-code-hoverline {
position: absolute;
left: 0;
right: 0;
height: 21px;
background: var(--hover-bg);
pointer-events: none;
}
.qd-code-line .line-no {
.qd-code-gutter {
position: relative;
z-index: 1;
flex: none;
width: 44px;
padding-right: 12px;
box-sizing: border-box;
text-align: right;
color: var(--text-tertiary);
user-select: none;
}
.qd-code-line .line-text {
.qd-code-gutter span {
display: block;
}
.qd-code-pre {
position: relative;
z-index: 1;
flex: 1;
min-width: 0;
margin: 0;
padding-right: 14px;
font: inherit;
white-space: pre;
color: var(--text-primary);
}

2
static/src/env.d.ts vendored
View File

@ -5,9 +5,9 @@ declare module '*.vue' {
}
declare module 'prismjs';
declare module 'prismjs/components/*';
declare module 'katex/contrib/auto-render';
declare module '*.md' {
const content: string;
export default content;
}

View 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;
}

View File

@ -1,6 +1,7 @@
@use './base/reset';
@use './base/tokens';
@use './base/global';
@use './base/prism-overrides';
@use './layout/app-shell';
@use './layout/panels';
@use './components/sidebar/conversation';

View File

@ -0,0 +1,136 @@
/**
* Prism
*
*
* 1. prismjs markup/css/clike/javascript
* importCodeBlock / 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* 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 };