feat(electrobun): file management — CodeMirror editor, PDF viewer, CSV table, real file I/O
- 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
This commit is contained in:
parent
29a3370e79
commit
252fca70df
22 changed files with 8116 additions and 227 deletions
298
ui-electrobun/src/mainview/PdfViewer.svelte
Normal file
298
ui-electrobun/src/mainview/PdfViewer.svelte
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy, untrack } from 'svelte';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import pdfjsWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
|
||||
import { appRpc } from './rpc.ts';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl;
|
||||
|
||||
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 observer: IntersectionObserver | null = null;
|
||||
let renderedPages = new Set<number>();
|
||||
let renderingPages = new Set<number>();
|
||||
|
||||
const SCALE_STEP = 0.25;
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 3.0;
|
||||
|
||||
async function loadPdf(fp: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
cleanup();
|
||||
|
||||
try {
|
||||
// Read file as base64 via RPC, then convert to Uint8Array
|
||||
const result = await appRpc.request["files.read"]({ path: fp });
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
return;
|
||||
}
|
||||
if (!result.content) {
|
||||
error = 'Empty file';
|
||||
return;
|
||||
}
|
||||
|
||||
let data: Uint8Array;
|
||||
if (result.encoding === 'base64') {
|
||||
const binary = atob(result.content);
|
||||
data = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) data[i] = binary.charCodeAt(i);
|
||||
} else {
|
||||
// Shouldn't happen for PDF but handle gracefully
|
||||
const encoder = new TextEncoder();
|
||||
data = encoder.encode(result.content);
|
||||
}
|
||||
|
||||
const loadingTask = pdfjsLib.getDocument({ data });
|
||||
pdfDoc = await loadingTask.promise;
|
||||
pageCount = pdfDoc.numPages;
|
||||
createPlaceholders();
|
||||
} catch (e) {
|
||||
error = `Failed to load PDF: ${e}`;
|
||||
console.warn('PDF load error:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function createPlaceholders() {
|
||||
if (!pdfDoc || !container) return;
|
||||
container.innerHTML = '';
|
||||
renderedPages.clear();
|
||||
renderingPages.clear();
|
||||
|
||||
observer?.disconnect();
|
||||
observer = new IntersectionObserver(onIntersect, {
|
||||
root: container,
|
||||
rootMargin: '200px 0px',
|
||||
});
|
||||
|
||||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'pdf-page-slot';
|
||||
placeholder.dataset.page = String(i);
|
||||
placeholder.style.width = '100%';
|
||||
placeholder.style.minHeight = '20rem';
|
||||
container.appendChild(placeholder);
|
||||
observer.observe(placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
function onIntersect(entries: IntersectionObserverEntry[]) {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const pageNum = Number((entry.target as HTMLElement).dataset.page);
|
||||
if (!pageNum || renderedPages.has(pageNum) || renderingPages.has(pageNum)) continue;
|
||||
renderPage(pageNum, entry.target as HTMLElement);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPage(pageNum: number, slot: HTMLElement) {
|
||||
if (!pdfDoc) return;
|
||||
renderingPages.add(pageNum);
|
||||
|
||||
try {
|
||||
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`;
|
||||
|
||||
slot.innerHTML = '';
|
||||
slot.style.minHeight = '';
|
||||
slot.appendChild(canvas);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const task = page.render({ canvasContext: ctx, viewport });
|
||||
await task.promise;
|
||||
renderedPages.add(pageNum);
|
||||
observer?.unobserve(slot);
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'name' in e && (e as { name: string }).name !== 'RenderingCancelledException') {
|
||||
console.warn(`Failed to render page ${pageNum}:`, e);
|
||||
}
|
||||
} finally {
|
||||
renderingPages.delete(pageNum);
|
||||
}
|
||||
}
|
||||
|
||||
function rerender() {
|
||||
renderedPages.clear();
|
||||
renderingPages.clear();
|
||||
createPlaceholders();
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
if (currentScale >= MAX_SCALE) return;
|
||||
currentScale = Math.min(MAX_SCALE, currentScale + SCALE_STEP);
|
||||
rerender();
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (currentScale <= MIN_SCALE) return;
|
||||
currentScale = Math.max(MIN_SCALE, currentScale - SCALE_STEP);
|
||||
rerender();
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
currentScale = 1.0;
|
||||
rerender();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
renderedPages.clear();
|
||||
renderingPages.clear();
|
||||
if (container) container.innerHTML = '';
|
||||
if (pdfDoc) {
|
||||
pdfDoc.destroy();
|
||||
pdfDoc = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => { loadPdf(filePath); });
|
||||
|
||||
let lastPath = $state(untrack(() => filePath));
|
||||
$effect(() => {
|
||||
const p = filePath;
|
||||
if (p !== lastPath) {
|
||||
lastPath = p;
|
||||
loadPdf(p);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => { cleanup(); });
|
||||
</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-slot) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.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