feat(files-tab): add PDF viewer and CSV table view
pdfjs-dist for canvas-based multi-page PDF rendering with zoom controls. RFC 4180 CSV parser with delimiter auto-detection and sortable columns. FilesTab routes Binary+pdf to PdfViewer, Text+csv to CsvTable.
This commit is contained in:
parent
929f54e195
commit
378c59bb97
5 changed files with 804 additions and 11 deletions
271
v2/package-lock.json
generated
271
v2/package-lock.json
generated
|
|
@ -31,6 +31,7 @@
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
|
"pdfjs-dist": "^5.5.207",
|
||||||
"shiki": "^4.0.1"
|
"shiki": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1851,6 +1852,256 @@
|
||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "0.1.96",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.96.tgz",
|
||||||
|
"integrity": "sha512-6NNmNxvoJKeucVjxaaRUt3La2i5jShgiAbaY3G/72s1Vp3U06XPrAIxkAjBxpDcamEn/t+WJ4OOlGmvILo4/Ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "0.1.96",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "0.1.96",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "0.1.96",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.96",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "0.1.96",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "0.1.96",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.96",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "0.1.96",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "0.1.96",
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc": "0.1.96",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "0.1.96"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "0.1.96",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.96.tgz",
|
||||||
|
"integrity": "sha512-ew1sPrN3dGdZ3L4FoohPfnjq0f9/Jk7o+wP7HkQZokcXgIUD6FIyICEWGhMYzv53j63wUcPvZeAwgewX58/egg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "0.1.96",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.96.tgz",
|
||||||
|
"integrity": "sha512-Q/wOXZ5PzTqpdmA5eUOcegCf4Go/zz3aZ5DlzSeDpOjFmfwMKh8EzLAoweQ+mJVagcHQyzoJhaTEnrO68TNyNg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "0.1.96",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.96.tgz",
|
||||||
|
"integrity": "sha512-UrXiQz28tQEvGM1qvyptewOAfmUrrd5+wvi6Rzjj2VprZI8iZ2KIvBD2lTTG1bVF95AbeDeG7PJA0D9sLKaOFA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "0.1.96",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.96.tgz",
|
||||||
|
"integrity": "sha512-I90ODxweD8aEP6XKU/NU+biso95MwCtQ2F46dUvhec1HesFi0tq/tAJkYic/1aBSiO/1kGKmSeD1B0duOHhEHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "0.1.96",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.96.tgz",
|
||||||
|
"integrity": "sha512-Dx/0+RFV++w3PcRy+4xNXkghhXjA5d0Mw1bs95emn5Llinp1vihMaA6WJt3oYv2LAHc36+gnrhIBsPhUyI2SGw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "0.1.96",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.96.tgz",
|
||||||
|
"integrity": "sha512-UvOi7fii3IE2KDfEfhh8m+LpzSRvhGK7o1eho99M2M0HTik11k3GX+2qgVx9EtujN3/bhFFS1kSO3+vPMaJ0Mg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "0.1.96",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.96.tgz",
|
||||||
|
"integrity": "sha512-MBSukhGCQ5nRtf9NbFYWOU080yqkZU1PbuH4o1ROvB4CbPl12fchDR35tU83Wz8gWIM9JTn99lBn9DenPIv7Ig==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "0.1.96",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.96.tgz",
|
||||||
|
"integrity": "sha512-I/ccu2SstyKiV3HIeVzyBIWfrJo8cN7+MSQZPnabewWV6hfJ2nY7Df2WqOHmobBRUw84uGR6zfQHsUEio/m5Vg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "0.1.96",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.96.tgz",
|
||||||
|
"integrity": "sha512-H3uov7qnTl73GDT4h52lAqpJPsl1tIUyNPWJyhQ6gHakohNqqRq3uf80+NEpzcytKGEOENP1wX3yGwZxhjiWEQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||||
|
"version": "0.1.96",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.96.tgz",
|
||||||
|
"integrity": "sha512-ATp6Y+djOjYtkfV/VRH7CZ8I1MEtkUQBmKUbuWw5zWEHHqfL0cEcInE4Cxgx7zkNAhEdBbnH8HMVrqNp+/gwxA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "0.1.96",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.96.tgz",
|
||||||
|
"integrity": "sha512-UYGdTltVd+Z8mcIuoqGmAXXUvwH5CLf2M6mIB5B0/JmX5J041jETjqtSYl7gN+aj3k1by/SG6sS0hAwCqyK7zw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
|
|
@ -6759,6 +7010,13 @@
|
||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-readable-to-web-readable-stream": {
|
||||||
|
"version": "0.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
||||||
|
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/normalize-package-data": {
|
"node_modules/normalize-package-data": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-7.0.1.tgz",
|
||||||
|
|
@ -7101,6 +7359,19 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "5.5.207",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz",
|
||||||
|
"integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0 || >=22.13.0 || >=24"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.95",
|
||||||
|
"node-readable-to-web-readable-stream": "^0.4.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pend": {
|
"node_modules/pend": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
|
"pdfjs-dist": "^5.5.207",
|
||||||
"shiki": "^4.0.1"
|
"shiki": "^4.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
253
v2/src/lib/components/Workspace/CsvTable.svelte
Normal file
253
v2/src/lib/components/Workspace/CsvTable.svelte
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
content: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { content, filename }: Props = $props();
|
||||||
|
|
||||||
|
/** Parse CSV with basic RFC 4180 quoting support */
|
||||||
|
function parseCsv(text: 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] === '"') {
|
||||||
|
// Quoted field
|
||||||
|
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 {
|
||||||
|
// Unquoted field
|
||||||
|
while (i < len && text[i] !== ',' && text[i] !== '\n' && text[i] !== '\r') {
|
||||||
|
field += text[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.push(field);
|
||||||
|
|
||||||
|
if (i < len && text[i] === ',') {
|
||||||
|
i++; // skip comma, continue row
|
||||||
|
} else {
|
||||||
|
// End of row
|
||||||
|
if (i < len && text[i] === '\r') i++;
|
||||||
|
if (i < len && text[i] === '\n') i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Skip empty trailing rows
|
||||||
|
if (row.length > 0 && !(row.length === 1 && row[0] === '')) {
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect delimiter: comma vs semicolon vs tab */
|
||||||
|
function detectDelimiter(text: string): string {
|
||||||
|
const firstLine = text.split('\n')[0] ?? '';
|
||||||
|
const commas = (firstLine.match(/,/g) ?? []).length;
|
||||||
|
const semicolons = (firstLine.match(/;/g) ?? []).length;
|
||||||
|
const tabs = (firstLine.match(/\t/g) ?? []).length;
|
||||||
|
if (tabs > commas && tabs > semicolons) return '\t';
|
||||||
|
if (semicolons > commas) return ';';
|
||||||
|
return ',';
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = $derived.by(() => {
|
||||||
|
// Normalize delimiter to comma before parsing
|
||||||
|
const delim = detectDelimiter(content);
|
||||||
|
const normalized = delim === ',' ? content : content.replaceAll(delim, ',');
|
||||||
|
return parseCsv(normalized);
|
||||||
|
});
|
||||||
|
|
||||||
|
let headers = $derived(parsed[0] ?? []);
|
||||||
|
let dataRows = $derived(parsed.slice(1));
|
||||||
|
let totalRows = $derived(dataRows.length);
|
||||||
|
|
||||||
|
// Column count from widest row
|
||||||
|
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] ?? '';
|
||||||
|
// Try numeric comparison
|
||||||
|
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' : ''} × {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>
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
import { getSetting } from '../../adapters/settings-bridge';
|
import { getSetting } from '../../adapters/settings-bridge';
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import CodeEditor from './CodeEditor.svelte';
|
import CodeEditor from './CodeEditor.svelte';
|
||||||
|
import PdfViewer from './PdfViewer.svelte';
|
||||||
|
import CsvTable from './CsvTable.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
|
@ -189,7 +191,8 @@
|
||||||
if (['md', 'markdown'].includes(ext)) return '📝';
|
if (['md', 'markdown'].includes(ext)) return '📝';
|
||||||
if (['json', 'toml', 'yaml', 'yml'].includes(ext)) return '⚙️';
|
if (['json', 'toml', 'yaml', 'yml'].includes(ext)) return '⚙️';
|
||||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico'].includes(ext)) return '🖼️';
|
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico'].includes(ext)) return '🖼️';
|
||||||
if (ext === 'pdf') return '📄';
|
if (ext === 'pdf') return '📕';
|
||||||
|
if (ext === 'csv') return '📊';
|
||||||
if (['css', 'scss', 'less'].includes(ext)) return '🎨';
|
if (['css', 'scss', 'less'].includes(ext)) return '🎨';
|
||||||
if (['html', 'htm'].includes(ext)) return '🌐';
|
if (['html', 'htm'].includes(ext)) return '🌐';
|
||||||
return '📄';
|
return '📄';
|
||||||
|
|
@ -206,6 +209,14 @@
|
||||||
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp'].includes(ext);
|
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp'].includes(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPdfExt(path: string): boolean {
|
||||||
|
return path.split('.').pop()?.toLowerCase() === 'pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCsvLang(lang: string): boolean {
|
||||||
|
return lang === 'csv';
|
||||||
|
}
|
||||||
|
|
||||||
// Editor change handler
|
// Editor change handler
|
||||||
function handleEditorChange(tabPath: string, newContent: string) {
|
function handleEditorChange(tabPath: string, newContent: string) {
|
||||||
const tab = fileTabs.find(t => t.path === tabPath);
|
const tab = fileTabs.find(t => t.path === tabPath);
|
||||||
|
|
@ -344,7 +355,11 @@
|
||||||
<span class="viewer-detail">{formatSize(activeTab.content.size)}</span>
|
<span class="viewer-detail">{formatSize(activeTab.content.size)}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if activeTab.content?.type === 'Binary'}
|
{:else if activeTab.content?.type === 'Binary'}
|
||||||
{#if isImageExt(activeTab.path)}
|
{#if isPdfExt(activeTab.path)}
|
||||||
|
{#key activeTabPath}
|
||||||
|
<PdfViewer filePath={activeTab.path} />
|
||||||
|
{/key}
|
||||||
|
{:else if isImageExt(activeTab.path)}
|
||||||
<div class="viewer-image">
|
<div class="viewer-image">
|
||||||
<img src={convertFileSrc(activeTab.path)} alt={activeTab.name} />
|
<img src={convertFileSrc(activeTab.path)} alt={activeTab.name} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -352,15 +367,21 @@
|
||||||
<div class="viewer-state">{activeTab.content.message}</div>
|
<div class="viewer-state">{activeTab.content.message}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if activeTab.content?.type === 'Text'}
|
{:else if activeTab.content?.type === 'Text'}
|
||||||
{#key activeTabPath}
|
{#if isCsvLang(activeTab.content.lang)}
|
||||||
<CodeEditor
|
{#key activeTabPath}
|
||||||
content={activeTab.editContent}
|
<CsvTable content={activeTab.editContent} filename={activeTab.name} />
|
||||||
lang={activeTab.content.lang}
|
{/key}
|
||||||
onchange={(c) => handleEditorChange(activeTab!.path, c)}
|
{:else}
|
||||||
onsave={saveActiveTab}
|
{#key activeTabPath}
|
||||||
onblur={() => handleEditorBlur(activeTab!.path)}
|
<CodeEditor
|
||||||
/>
|
content={activeTab.editContent}
|
||||||
{/key}
|
lang={activeTab.content.lang}
|
||||||
|
onchange={(c) => handleEditorChange(activeTab!.path, c)}
|
||||||
|
onsave={saveActiveTab}
|
||||||
|
onblur={() => handleEditorBlur(activeTab!.path)}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if activeTab}
|
{#if activeTab}
|
||||||
|
|
|
||||||
247
v2/src/lib/components/Workspace/PdfViewer.svelte
Normal file
247
v2/src/lib/components/Workspace/PdfViewer.svelte
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
|
||||||
|
// Configure worker — use the bundled worker from pdfjs-dist
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
'pdfjs-dist/pdf.worker.min.mjs',
|
||||||
|
import.meta.url,
|
||||||
|
).href;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { filePath }: Props = $props();
|
||||||
|
|
||||||
|
let container: HTMLDivElement | undefined = $state();
|
||||||
|
let pageCount = $state(0);
|
||||||
|
let currentScale = $state(1.0);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null;
|
||||||
|
let renderTask: { cancel: () => void } | null = null;
|
||||||
|
|
||||||
|
const SCALE_STEP = 0.25;
|
||||||
|
const MIN_SCALE = 0.5;
|
||||||
|
const MAX_SCALE = 3.0;
|
||||||
|
|
||||||
|
async function loadPdf(path: string) {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
// Clean up previous document
|
||||||
|
if (pdfDoc) {
|
||||||
|
pdfDoc.destroy();
|
||||||
|
pdfDoc = null;
|
||||||
|
}
|
||||||
|
if (container) {
|
||||||
|
container.querySelectorAll('.pdf-page-canvas').forEach(c => c.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const assetUrl = convertFileSrc(path);
|
||||||
|
const loadingTask = pdfjsLib.getDocument(assetUrl);
|
||||||
|
pdfDoc = await loadingTask.promise;
|
||||||
|
pageCount = pdfDoc.numPages;
|
||||||
|
await renderAllPages();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Failed to load PDF: ${e}`;
|
||||||
|
console.warn('PDF load error:', e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderAllPages() {
|
||||||
|
if (!pdfDoc || !container) return;
|
||||||
|
|
||||||
|
// Clear existing canvases
|
||||||
|
container.querySelectorAll('.pdf-page-canvas').forEach(c => c.remove());
|
||||||
|
|
||||||
|
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||||
|
await renderPage(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPage(pageNum: number) {
|
||||||
|
if (!pdfDoc || !container) return;
|
||||||
|
|
||||||
|
const page = await pdfDoc.getPage(pageNum);
|
||||||
|
const viewport = page.getViewport({ scale: currentScale * window.devicePixelRatio });
|
||||||
|
const displayViewport = page.getViewport({ scale: currentScale });
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.className = 'pdf-page-canvas';
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
canvas.style.width = `${displayViewport.width}px`;
|
||||||
|
canvas.style.height = `${displayViewport.height}px`;
|
||||||
|
|
||||||
|
container.appendChild(canvas);
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
renderTask = page.render({ canvasContext: ctx, viewport });
|
||||||
|
try {
|
||||||
|
await renderTask.promise;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// Ignore cancelled renders
|
||||||
|
if (e && typeof e === 'object' && 'name' in e && (e as { name: string }).name !== 'RenderingCancelledException') {
|
||||||
|
console.warn(`Failed to render page ${pageNum}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
if (currentScale >= MAX_SCALE) return;
|
||||||
|
currentScale = Math.min(MAX_SCALE, currentScale + SCALE_STEP);
|
||||||
|
renderAllPages();
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
if (currentScale <= MIN_SCALE) return;
|
||||||
|
currentScale = Math.max(MIN_SCALE, currentScale - SCALE_STEP);
|
||||||
|
renderAllPages();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZoom() {
|
||||||
|
currentScale = 1.0;
|
||||||
|
renderAllPages();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadPdf(filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// React to filePath changes
|
||||||
|
let lastPath = $state(filePath);
|
||||||
|
$effect(() => {
|
||||||
|
const p = filePath;
|
||||||
|
if (p !== lastPath) {
|
||||||
|
lastPath = p;
|
||||||
|
loadPdf(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (renderTask) {
|
||||||
|
try { renderTask.cancel(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (pdfDoc) {
|
||||||
|
pdfDoc.destroy();
|
||||||
|
pdfDoc = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pdf-viewer">
|
||||||
|
<div class="pdf-toolbar">
|
||||||
|
<span class="pdf-info">
|
||||||
|
{#if loading}
|
||||||
|
Loading…
|
||||||
|
{:else if error}
|
||||||
|
Error
|
||||||
|
{:else}
|
||||||
|
{pageCount} page{pageCount !== 1 ? 's' : ''}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<div class="pdf-zoom-controls">
|
||||||
|
<button class="zoom-btn" onclick={zoomOut} disabled={currentScale <= MIN_SCALE} title="Zoom out">−</button>
|
||||||
|
<button class="zoom-label" onclick={resetZoom} title="Reset zoom">{Math.round(currentScale * 100)}%</button>
|
||||||
|
<button class="zoom-btn" onclick={zoomIn} disabled={currentScale >= MAX_SCALE} title="Zoom in">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="pdf-error">{error}</div>
|
||||||
|
{:else}
|
||||||
|
<div class="pdf-pages" bind:this={container}></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pdf-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ctp-crust);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-info {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-zoom-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-btn, .zoom-label {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.1875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-btn:hover:not(:disabled), .zoom-label:hover {
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-label {
|
||||||
|
min-width: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-pages {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-pages :global(.pdf-page-canvas) {
|
||||||
|
box-shadow: 0 1px 4px color-mix(in srgb, var(--ctp-crust) 60%, transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--ctp-red);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue