generated from corrad-software/corrad-af-2024
529 lines
14 KiB
Vue
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> |