- CodeEditor: CodeMirror 6 with Catppuccin theme, 15+ languages, Ctrl+S save, dirty tracking, save-on-blur - PdfViewer: pdfjs-dist canvas rendering, zoom 0.5-3x, HiDPI, lazy page load - CsvTable: RFC 4180 parser, delimiter auto-detect, sortable columns, sticky header - FileBrowser: real filesystem via files.list/read/write RPC, lazy dir loading, file type routing (code→editor, pdf→viewer, csv→table, images→display) - 10MB size gate, binary detection, base64 encoding for non-text files
243 lines
5.9 KiB
Svelte
243 lines
5.9 KiB
Svelte
<script lang="ts">
|
|
interface Props {
|
|
content: string;
|
|
filename: string;
|
|
}
|
|
|
|
let { content, filename }: Props = $props();
|
|
|
|
/** RFC 4180 CSV parser with quoted field support. */
|
|
function parseCsv(text: string, delimiter: string): string[][] {
|
|
const rows: string[][] = [];
|
|
let i = 0;
|
|
const len = text.length;
|
|
|
|
while (i < len) {
|
|
const row: string[] = [];
|
|
while (i < len) {
|
|
let field = '';
|
|
if (text[i] === '"') {
|
|
i++; // skip opening quote
|
|
while (i < len) {
|
|
if (text[i] === '"') {
|
|
if (i + 1 < len && text[i + 1] === '"') {
|
|
field += '"';
|
|
i += 2;
|
|
} else {
|
|
i++; // skip closing quote
|
|
break;
|
|
}
|
|
} else {
|
|
field += text[i];
|
|
i++;
|
|
}
|
|
}
|
|
} else {
|
|
while (i < len && text[i] !== delimiter && text[i] !== '\n' && text[i] !== '\r') {
|
|
field += text[i];
|
|
i++;
|
|
}
|
|
}
|
|
row.push(field);
|
|
|
|
if (i < len && text[i] === delimiter) {
|
|
i++;
|
|
} else {
|
|
if (i < len && text[i] === '\r') i++;
|
|
if (i < len && text[i] === '\n') i++;
|
|
break;
|
|
}
|
|
}
|
|
if (row.length > 0 && !(row.length === 1 && row[0] === '')) {
|
|
rows.push(row);
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/** Detect delimiter from first line: comma, semicolon, tab, or pipe. */
|
|
function detectDelimiter(text: string): string {
|
|
const firstLine = text.split('\n')[0] ?? '';
|
|
const counts: [string, number][] = [
|
|
[',', (firstLine.match(/,/g) ?? []).length],
|
|
[';', (firstLine.match(/;/g) ?? []).length],
|
|
['\t', (firstLine.match(/\t/g) ?? []).length],
|
|
['|', (firstLine.match(/\|/g) ?? []).length],
|
|
];
|
|
counts.sort((a, b) => b[1] - a[1]);
|
|
return counts[0][1] > 0 ? counts[0][0] : ',';
|
|
}
|
|
|
|
let delimiter = $derived(detectDelimiter(content));
|
|
let parsed = $derived(parseCsv(content, delimiter));
|
|
let headers = $derived(parsed[0] ?? []);
|
|
let dataRows = $derived(parsed.slice(1));
|
|
let totalRows = $derived(dataRows.length);
|
|
let colCount = $derived(Math.max(...parsed.map(r => r.length), 0));
|
|
|
|
// Sort state
|
|
let sortCol = $state<number | null>(null);
|
|
let sortAsc = $state(true);
|
|
|
|
let sortedRows = $derived.by(() => {
|
|
if (sortCol === null) return dataRows;
|
|
const col = sortCol;
|
|
const asc = sortAsc;
|
|
return [...dataRows].sort((a, b) => {
|
|
const va = a[col] ?? '';
|
|
const vb = b[col] ?? '';
|
|
const na = Number(va);
|
|
const nb = Number(vb);
|
|
if (!isNaN(na) && !isNaN(nb)) {
|
|
return asc ? na - nb : nb - na;
|
|
}
|
|
return asc ? va.localeCompare(vb) : vb.localeCompare(va);
|
|
});
|
|
});
|
|
|
|
function toggleSort(col: number) {
|
|
if (sortCol === col) {
|
|
sortAsc = !sortAsc;
|
|
} else {
|
|
sortCol = col;
|
|
sortAsc = true;
|
|
}
|
|
}
|
|
|
|
function sortIndicator(col: number): string {
|
|
if (sortCol !== col) return '';
|
|
return sortAsc ? ' ▲' : ' ▼';
|
|
}
|
|
</script>
|
|
|
|
<div class="csv-table-wrapper">
|
|
<div class="csv-toolbar">
|
|
<span class="csv-info">
|
|
{totalRows} row{totalRows !== 1 ? 's' : ''} x {colCount} col{colCount !== 1 ? 's' : ''}
|
|
</span>
|
|
<span class="csv-filename">{filename}</span>
|
|
</div>
|
|
|
|
<div class="csv-scroll">
|
|
<table class="csv-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="row-num">#</th>
|
|
{#each headers as header, i}
|
|
<th onclick={() => toggleSort(i)} class="sortable">
|
|
{header}{sortIndicator(i)}
|
|
</th>
|
|
{/each}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each sortedRows as row, rowIdx (rowIdx)}
|
|
<tr>
|
|
<td class="row-num">{rowIdx + 1}</td>
|
|
{#each { length: colCount } as _, colIdx}
|
|
<td>{row[colIdx] ?? ''}</td>
|
|
{/each}
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.csv-table-wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
background: var(--ctp-base);
|
|
}
|
|
|
|
.csv-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.25rem 0.625rem;
|
|
background: var(--ctp-mantle);
|
|
border-bottom: 1px solid var(--ctp-surface0);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.csv-info {
|
|
font-size: 0.7rem;
|
|
color: var(--ctp-overlay1);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.csv-filename {
|
|
font-size: 0.65rem;
|
|
color: var(--ctp-overlay0);
|
|
font-family: var(--term-font-family, monospace);
|
|
}
|
|
|
|
.csv-scroll {
|
|
flex: 1;
|
|
overflow: auto;
|
|
}
|
|
|
|
.csv-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.725rem;
|
|
font-family: var(--term-font-family, monospace);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.csv-table thead {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
}
|
|
|
|
.csv-table th {
|
|
background: var(--ctp-mantle);
|
|
color: var(--ctp-subtext1);
|
|
font-weight: 600;
|
|
text-align: left;
|
|
padding: 0.3125rem 0.5rem;
|
|
border-bottom: 1px solid var(--ctp-surface1);
|
|
user-select: none;
|
|
}
|
|
|
|
.csv-table th.sortable {
|
|
cursor: pointer;
|
|
transition: background 0.12s;
|
|
}
|
|
|
|
.csv-table th.sortable:hover {
|
|
background: var(--ctp-surface0);
|
|
color: var(--ctp-text);
|
|
}
|
|
|
|
.csv-table td {
|
|
padding: 0.25rem 0.5rem;
|
|
border-bottom: 1px solid var(--ctp-surface0);
|
|
color: var(--ctp-text);
|
|
max-width: 20rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.csv-table tbody tr:hover td {
|
|
background: color-mix(in srgb, var(--ctp-surface0) 50%, transparent);
|
|
}
|
|
|
|
.row-num {
|
|
color: var(--ctp-overlay0);
|
|
font-size: 0.625rem;
|
|
text-align: right;
|
|
width: 2.5rem;
|
|
min-width: 2.5rem;
|
|
padding-right: 0.625rem;
|
|
border-right: 1px solid var(--ctp-surface0);
|
|
}
|
|
|
|
thead .row-num {
|
|
border-bottom: 1px solid var(--ctp-surface1);
|
|
}
|
|
</style>
|