EDMS/components/dms/preview/TextViewer.vue
2025-06-05 14:57:08 +08:00

529 lines
14 KiB
Vue

<template>
<div class="text-viewer w-full h-full bg-white dark:bg-gray-900 relative">
<!-- Toolbar -->
<div v-if="mode === 'edit'" class="border-b border-gray-200 dark:border-gray-700 p-2 bg-gray-50 dark:bg-gray-800">
<div class="flex items-center space-x-4">
<!-- File Type Info -->
<div class="flex items-center space-x-2">
<Icon
:name="getFileTypeIcon(document.name)"
:class="['w-5 h-5', getFileTypeColor(document.name)]"
/>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ getFileTypeName(document.name) }}
</span>
</div>
<!-- Text Options -->
<div v-if="isPlainText" class="flex items-center space-x-2">
<label class="text-sm text-gray-700 dark:text-gray-300">
Word Wrap:
<input
v-model="wordWrap"
type="checkbox"
class="ml-1"
/>
</label>
<label class="text-sm text-gray-700 dark:text-gray-300">
Line Numbers:
<input
v-model="showLineNumbers"
type="checkbox"
class="ml-1"
/>
</label>
</div>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 flex overflow-hidden">
<!-- Line Numbers -->
<div
v-if="showLineNumbers && isPlainText"
class="bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-600 px-2 py-4 text-right text-sm text-gray-500 dark:text-gray-400 font-mono select-none"
:style="{ minWidth: lineNumberWidth + 'px' }"
>
<div
v-for="lineNum in totalLines"
:key="lineNum"
class="leading-6"
>
{{ lineNum }}
</div>
</div>
<!-- Text Content -->
<div class="flex-1 overflow-auto">
<!-- Markdown Preview -->
<div
v-if="isMarkdown && mode === 'view'"
class="prose dark:prose-invert max-w-none p-6"
v-html="markdownHtml"
></div>
<!-- JSON Formatted View -->
<div
v-else-if="isJson && mode === 'view'"
class="p-4"
>
<pre class="text-sm font-mono text-gray-800 dark:text-gray-200 whitespace-pre-wrap"><code>{{ formattedJson }}</code></pre>
</div>
<!-- CSV Table View -->
<div
v-else-if="isCsv && mode === 'view'"
class="p-4"
>
<div class="overflow-auto">
<table class="min-w-full border-collapse border border-gray-300 dark:border-gray-600">
<thead>
<tr class="bg-gray-50 dark:bg-gray-800">
<th
v-for="(header, index) in csvHeaders"
:key="index"
class="border border-gray-300 dark:border-gray-600 px-3 py-2 text-left text-sm font-medium text-gray-900 dark:text-gray-100"
>
{{ header }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIndex) in csvRows"
:key="rowIndex"
:class="rowIndex % 2 === 0 ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800'"
>
<td
v-for="(cell, cellIndex) in row"
:key="cellIndex"
class="border border-gray-300 dark:border-gray-600 px-3 py-2 text-sm text-gray-700 dark:text-gray-300"
>
{{ cell }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Plain Text Editor/Viewer -->
<div
v-else
class="h-full"
>
<textarea
v-if="mode === 'edit'"
v-model="editableContent"
@input="handleContentChange"
@keydown="handleKeydown"
class="w-full h-full p-4 border-none resize-none focus:outline-none bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-mono text-sm leading-6"
:class="{
'whitespace-pre-wrap': wordWrap,
'whitespace-pre': !wordWrap
}"
:placeholder="getPlaceholderText()"
></textarea>
<pre
v-else
class="w-full h-full p-4 text-sm font-mono text-gray-800 dark:text-gray-200 leading-6 overflow-auto"
:class="{
'whitespace-pre-wrap': wordWrap,
'whitespace-pre': !wordWrap
}"
>{{ displayContent }}</pre>
</div>
</div>
</div>
<!-- Status Bar -->
<div class="border-t border-gray-200 dark:border-gray-700 p-2 bg-gray-50 dark:bg-gray-800">
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center space-x-4">
<span>{{ formatFileSize(document.size) }}</span>
<span>{{ totalLines }} lines</span>
<span v-if="characterCount">{{ characterCount }} characters</span>
<span v-if="wordCount">{{ wordCount }} words</span>
</div>
<div v-if="mode === 'edit'" class="flex items-center space-x-4">
<span v-if="cursorPosition">Line {{ cursorPosition.line }}, Column {{ cursorPosition.column }}</span>
<span>{{ fileEncoding }}</span>
</div>
</div>
</div>
<!-- Save Button (Edit Mode) -->
<div v-if="mode === 'edit'" class="absolute bottom-4 right-4">
<button
@click="saveDocument"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center space-x-2 shadow-lg"
>
<Icon name="mdi:content-save" class="w-4 h-4" />
<span>Save</span>
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue';
const props = defineProps({
document: {
type: Object,
required: true
},
mode: {
type: String,
default: 'view' // 'view' | 'edit'
},
content: {
type: String,
default: ''
}
});
const emit = defineEmits([
'content-changed',
'save-requested'
]);
// State
const editableContent = ref('');
const wordWrap = ref(true);
const showLineNumbers = ref(true);
const cursorPosition = ref({ line: 1, column: 1 });
const fileEncoding = ref('UTF-8');
// Computed properties
const fileExtension = computed(() => {
return props.document.name?.split('.').pop()?.toLowerCase() || '';
});
const isMarkdown = computed(() => {
return ['md', 'markdown'].includes(fileExtension.value);
});
const isJson = computed(() => {
return fileExtension.value === 'json';
});
const isCsv = computed(() => {
return fileExtension.value === 'csv';
});
const isPlainText = computed(() => {
return ['txt', 'log', 'conf', 'ini'].includes(fileExtension.value);
});
const displayContent = computed(() => {
return props.content || editableContent.value || '';
});
const totalLines = computed(() => {
return displayContent.value.split('\n').length;
});
const characterCount = computed(() => {
return displayContent.value.length;
});
const wordCount = computed(() => {
return displayContent.value.trim().split(/\s+/).filter(word => word.length > 0).length;
});
const lineNumberWidth = computed(() => {
return Math.max(totalLines.value.toString().length * 8 + 16, 40);
});
const markdownHtml = computed(() => {
if (!isMarkdown.value) return '';
// Simple markdown to HTML conversion (in real app, use a proper markdown library)
let html = displayContent.value;
// Headers
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
// Bold
html = html.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>');
// Italic
html = html.replace(/\*(.*)\*/gim, '<em>$1</em>');
// Code
html = html.replace(/`(.*?)`/gim, '<code>$1</code>');
// Line breaks
html = html.replace(/\n/gim, '<br>');
return html;
});
const formattedJson = computed(() => {
if (!isJson.value) return displayContent.value;
try {
const parsed = JSON.parse(displayContent.value);
return JSON.stringify(parsed, null, 2);
} catch (error) {
return displayContent.value;
}
});
const csvHeaders = computed(() => {
if (!isCsv.value || !displayContent.value) return [];
const lines = displayContent.value.split('\n');
if (lines.length === 0) return [];
return parseCsvLine(lines[0]);
});
const csvRows = computed(() => {
if (!isCsv.value || !displayContent.value) return [];
const lines = displayContent.value.split('\n').slice(1); // Skip header
return lines.filter(line => line.trim()).map(line => parseCsvLine(line));
});
// Methods
const getFileTypeIcon = (fileName) => {
const extension = fileName?.split('.').pop()?.toLowerCase();
const iconMap = {
txt: 'mdi:file-document-outline',
md: 'mdi:language-markdown',
markdown: 'mdi:language-markdown',
json: 'mdi:code-json',
xml: 'mdi:file-xml',
csv: 'mdi:file-table',
log: 'mdi:file-document',
conf: 'mdi:file-cog',
ini: 'mdi:file-cog',
default: 'mdi:file-document-outline'
};
return iconMap[extension] || iconMap.default;
};
const getFileTypeColor = (fileName) => {
const extension = fileName?.split('.').pop()?.toLowerCase();
const colorMap = {
txt: 'text-gray-500',
md: 'text-purple-500',
markdown: 'text-purple-500',
json: 'text-yellow-500',
xml: 'text-orange-500',
csv: 'text-green-500',
log: 'text-gray-500',
conf: 'text-blue-500',
ini: 'text-blue-500',
default: 'text-gray-500'
};
return colorMap[extension] || colorMap.default;
};
const getFileTypeName = (fileName) => {
const extension = fileName?.split('.').pop()?.toLowerCase();
const nameMap = {
txt: 'Plain Text',
md: 'Markdown',
markdown: 'Markdown',
json: 'JSON',
xml: 'XML',
csv: 'CSV',
log: 'Log File',
conf: 'Configuration',
ini: 'INI File',
default: 'Text File'
};
return nameMap[extension] || nameMap.default;
};
const getPlaceholderText = () => {
const typeNames = {
txt: 'Enter your text here...',
md: 'Write your markdown content...',
json: 'Enter valid JSON...',
csv: 'Enter CSV data...',
default: 'Enter content...'
};
return typeNames[fileExtension.value] || typeNames.default;
};
const parseCsvLine = (line) => {
// Simple CSV parser (in real app, use a proper CSV library)
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
};
const formatFileSize = (size) => {
if (typeof size === 'string') return size;
if (!size) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index++;
}
return `${size.toFixed(1)} ${units[index]}`;
};
const handleContentChange = () => {
emit('content-changed', editableContent.value);
};
const handleKeydown = (event) => {
if (event.ctrlKey && event.key === 's') {
event.preventDefault();
saveDocument();
}
// Update cursor position for status bar
nextTick(() => {
updateCursorPosition(event.target);
});
};
const updateCursorPosition = (textarea) => {
if (!textarea) return;
const text = textarea.value.substring(0, textarea.selectionStart);
const lines = text.split('\n');
const line = lines.length;
const column = lines[lines.length - 1].length + 1;
cursorPosition.value = { line, column };
};
const saveDocument = () => {
emit('save-requested');
};
// Watchers
watch(() => props.content, (newContent) => {
editableContent.value = newContent || '';
});
// Lifecycle
onMounted(() => {
editableContent.value = props.content || '';
});
</script>
<style scoped>
.text-viewer {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
/* Markdown prose styling */
:deep(.prose) {
max-width: none;
}
:deep(.prose h1) {
font-size: 2rem;
font-weight: bold;
margin: 1.5rem 0 1rem 0;
}
:deep(.prose h2) {
font-size: 1.5rem;
font-weight: bold;
margin: 1.25rem 0 0.75rem 0;
}
:deep(.prose h3) {
font-size: 1.25rem;
font-weight: bold;
margin: 1rem 0 0.5rem 0;
}
:deep(.prose p) {
margin: 0.75rem 0;
}
:deep(.prose code) {
background: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875rem;
}
:deep(.prose strong) {
font-weight: bold;
}
:deep(.prose em) {
font-style: italic;
}
/* Custom scrollbars */
.text-viewer ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.text-viewer ::-webkit-scrollbar-track {
background: #f1f5f9;
}
.text-viewer ::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.text-viewer ::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Dark mode scrollbars */
.dark .text-viewer ::-webkit-scrollbar-track {
background: #334155;
}
.dark .text-viewer ::-webkit-scrollbar-thumb {
background: #475569;
}
.dark .text-viewer ::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
/* Table styling for CSV */
table {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
}
/* Ensure text area has proper font */
textarea {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
tab-size: 4;
}
pre {
tab-size: 4;
}
</style>