feat: add inline file preview for text-based files
- Add fetchFileText() to download API - Add preview modal to MarkdownRenderer for .txt/.md/.json/.csv etc. - File card: click card body → preview, click download button → download
This commit is contained in:
@@ -53,3 +53,17 @@ export async function downloadFile(filePath: string, fileName?: string): Promise
|
|||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
URL.revokeObjectURL(blobUrl)
|
URL.revokeObjectURL(blobUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get preview file content.
|
||||||
|
* Throws with error message on failure.
|
||||||
|
*/
|
||||||
|
export async function fetchFileText(filePath: string, fileName?: string): Promise<string> {
|
||||||
|
const url = getDownloadUrl(filePath, fileName)
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
||||||
|
throw new Error(body.error || `Preview failed: ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.text()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useMessage } from 'naive-ui'
|
import { NDrawer, NSpin, useMessage } from 'naive-ui'
|
||||||
import type MarkdownIt from 'markdown-it'
|
import type MarkdownIt from 'markdown-it'
|
||||||
import MarkdownItConstructor from 'markdown-it'
|
import MarkdownItConstructor from 'markdown-it'
|
||||||
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
|
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
|
||||||
@@ -13,8 +13,11 @@ import {
|
|||||||
decodeMermaidSource,
|
decodeMermaidSource,
|
||||||
isMermaidFence,
|
isMermaidFence,
|
||||||
renderMermaidPlaceholder,
|
renderMermaidPlaceholder,
|
||||||
|
SUPPORT_PREVIEW_FILE_TYPES,
|
||||||
} from './mermaidRenderer'
|
} from './mermaidRenderer'
|
||||||
import { downloadFile, getDownloadUrl } from '@/api/hermes/download'
|
import { downloadFile, getDownloadUrl, fetchFileText } from '@/api/hermes/download'
|
||||||
|
|
||||||
|
const PREVIEW_AREA_WIDTH = 500
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
content: string
|
content: string
|
||||||
@@ -56,6 +59,12 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
|||||||
const markdownBody = ref<HTMLElement | null>(null)
|
const markdownBody = ref<HTMLElement | null>(null)
|
||||||
const componentId = `hermes-mermaid-${Math.random().toString(36).slice(2)}`
|
const componentId = `hermes-mermaid-${Math.random().toString(36).slice(2)}`
|
||||||
const previewUrl = ref<string | null>(null)
|
const previewUrl = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Preview config variable
|
||||||
|
const textPreviewContent = ref<string | null>(null)
|
||||||
|
const textPreviewLoading = ref(false)
|
||||||
|
const textPreviewVisible = ref(false)
|
||||||
|
|
||||||
let renderGeneration = 0
|
let renderGeneration = 0
|
||||||
let unmounted = false
|
let unmounted = false
|
||||||
|
|
||||||
@@ -308,11 +317,26 @@ async function handleMarkdownClick(event: MouseEvent): Promise<void> {
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const path = fileCard.getAttribute('data-path')
|
const path = fileCard.getAttribute('data-path')
|
||||||
const fileName = fileCard.getAttribute('data-filename')
|
const fileName = fileCard.getAttribute('data-filename')
|
||||||
if (path) {
|
|
||||||
|
const isDownloadBtn = target.closest('.att-download-btn')
|
||||||
|
|
||||||
|
if (isDownloadBtn && path) { // Only download file with download icon clicked.
|
||||||
message.info(t('download.downloading'))
|
message.info(t('download.downloading'))
|
||||||
downloadFile(path, fileName || undefined).catch((err: Error) => {
|
downloadFile(path, fileName || undefined).catch((err: Error) => {
|
||||||
message.error(err.message || t('download.downloadFailed'))
|
message.error(err.message || t('download.downloadFailed'))
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path) {
|
||||||
|
const ext = fileName.split('.').pop()?.toLowerCase()
|
||||||
|
if (SUPPORT_PREVIEW_FILE_TYPES.includes(ext || '')) {
|
||||||
|
previewTextFile(path, fileName)
|
||||||
|
} else { // Download file immediately
|
||||||
|
downloadFile(path, fileName || undefined).catch((err: Error) => {
|
||||||
|
message.error(err.message || t('download.downloadFailed'))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -360,10 +384,38 @@ async function handleMarkdownClick(event: MouseEvent): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get file content and show preview area.
|
||||||
|
async function previewTextFile(path: string, fileName: string): Promise<void> {
|
||||||
|
textPreviewLoading.value = true
|
||||||
|
textPreviewVisible.value = true
|
||||||
|
try {
|
||||||
|
textPreviewContent.value = await fetchFileText(path, fileName)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.message || t('download.downloadFailed'))
|
||||||
|
} finally {
|
||||||
|
textPreviewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="markdownBody" class="markdown-body" v-html="renderedHtml" @click="handleMarkdownClick"></div>
|
<div ref="markdownBody" class="markdown-body" v-html="renderedHtml" @click="handleMarkdownClick"></div>
|
||||||
|
<!-- File preview area -->
|
||||||
|
<NDrawer
|
||||||
|
v-model:show="textPreviewVisible"
|
||||||
|
:width="PREVIEW_AREA_WIDTH"
|
||||||
|
placement="right"
|
||||||
|
:show-mask="false"
|
||||||
|
:trap-focus="false"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
📄 {{ t('download.preview') }}
|
||||||
|
</template>
|
||||||
|
<NSpin :show="textPreviewLoading">
|
||||||
|
<pre v-if="textPreviewContent !== null" class="text-preview-body">{{ textPreviewContent }}</pre>
|
||||||
|
</NSpin>
|
||||||
|
</NDrawer>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="previewUrl" class="image-preview-overlay" @click.self="previewUrl = null">
|
<div v-if="previewUrl" class="image-preview-overlay" @click.self="previewUrl = null">
|
||||||
<img :src="previewUrl" class="image-preview-img" @click="previewUrl = null" />
|
<img :src="previewUrl" class="image-preview-img" @click="previewUrl = null" />
|
||||||
@@ -590,4 +642,18 @@ async function handleMarkdownClick(event: MouseEvent): Promise<void> {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-preview-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 0;
|
||||||
|
font-family: $font-code;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
color: $text-primary;
|
||||||
|
background: $code-bg;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const MERMAID_LANGUAGE = 'mermaid'
|
|||||||
export const MERMAID_MAX_DIAGRAMS_PER_MESSAGE = 4
|
export const MERMAID_MAX_DIAGRAMS_PER_MESSAGE = 4
|
||||||
export const MERMAID_MAX_SOURCE_LENGTH = 20_000
|
export const MERMAID_MAX_SOURCE_LENGTH = 20_000
|
||||||
export const MERMAID_RENDER_TIMEOUT_MS = 5_000
|
export const MERMAID_RENDER_TIMEOUT_MS = 5_000
|
||||||
|
export const SUPPORT_PREVIEW_FILE_TYPES = ['txt', 'md', 'json', 'csv', 'log', 'py', 'yaml', 'yml', 'toml', 'sh', 'xml', 'html', 'css', 'js', 'ts', 'rs', 'go', 'java', 'c', 'cpp', 'h']
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
function escapeHtml(value: string): string {
|
||||||
return value
|
return value
|
||||||
|
|||||||
Reference in New Issue
Block a user