From 89a8f00ff3962035fe6a20d338a82862ffc976c6 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 2 May 2025 15:50:50 +0200 Subject: [PATCH] feat: [Electron Demo] - rendering optimizations and error handling. --- docz/static/electron/index.html | 4 +- docz/static/electron/index.js | 311 +++++++++++++++++--------------- docz/static/electron/styles.css | 6 + 3 files changed, 169 insertions(+), 152 deletions(-) diff --git a/docz/static/electron/index.html b/docz/static/electron/index.html index 872a07d..cb3fd57 100644 --- a/docz/static/electron/index.html +++ b/docz/static/electron/index.html @@ -1,6 +1,5 @@ - @@ -27,6 +26,7 @@
+

@@ -39,5 +39,5 @@ - + diff --git a/docz/static/electron/index.js b/docz/static/electron/index.js index 2bd606f..9cf5f8c 100644 --- a/docz/static/electron/index.js +++ b/docz/static/electron/index.js @@ -1,21 +1,37 @@ const XLSX = require("xlsx"); +// TODO: Replace deprecated @electron/remote with contextBridge‑based IPC in production. const electron = require("@electron/remote"); -const { ipcRenderer } = require('electron'); +const { ipcRenderer } = require("electron"); +const path = require("path"); -// --- Supported Extensions --- +// --------------------------------------------------------------------------- +// Supported file extensions +// --------------------------------------------------------------------------- const EXTENSIONS = "xls|xlsx|xlsm|xlsb|xml|csv|txt|dif|sylk|slk|prn|ods|fods|htm|html|numbers".split( - "|" + "|", ); -const dropContainer = document.getElementById("drop-container"); -const dropzone = document.getElementById("drop"); -const fileStatus = document.getElementById("fileStatus"); -const exportBtn = document.getElementById("exportBtn"); +// --------------------------------------------------------------------------- +// DOM references +// --------------------------------------------------------------------------- +const dropContainer = document.getElementById("drop-container"); +const fileStatus = document.getElementById("fileStatus"); +const exportBtn = document.getElementById("exportBtn"); const spinnerOverlay = document.getElementById("spinner-overlay"); -const htmlout = document.getElementById("htmlout"); +const htmlout = document.getElementById("htmlout"); +const onError = document.getElementById("onError"); -// open external links in default browser +// --------------------------------------------------------------------------- +// State & helpers +// --------------------------------------------------------------------------- +let currentWorkbook = null; // SheetJS workbook in memory +const isSpreadsheet = (ext) => EXTENSIONS.includes(ext.toLowerCase()); +const nextPaint = () => new Promise(requestAnimationFrame); + +// --------------------------------------------------------------------------- +// Open external links in default browser (security) +// --------------------------------------------------------------------------- document.addEventListener("click", (e) => { if (e.target.tagName === "A" && e.target.href.startsWith("http")) { e.preventDefault(); @@ -23,195 +39,190 @@ document.addEventListener("click", (e) => { } }); -/** - * Export current HTML table as a spreadsheet file using Electron API. - */ -async function exportFile() { - const wb = XLSX.utils.table_to_book(htmlout.getElementsByTagName("TABLE")[0]); - const o = await electron.dialog.showSaveDialog({ +// --------------------------------------------------------------------------- +// Export logic – uses cached workbook (no DOM traversal) +// --------------------------------------------------------------------------- +async function exportWorkbookAsFile() { + if (!currentWorkbook) return displayError("No workbook loaded!"); + // -- 1. use electron save as dialog to get file path + const { filePath, canceled } = await electron.dialog.showSaveDialog({ title: "Save file as", filters: [{ name: "Spreadsheets", extensions: EXTENSIONS }], }); - XLSX.writeFile(wb, o.filePath); - electron.dialog.showMessageBox({ - message: "Exported data to " + o.filePath, - buttons: ["OK"], - }); + // -- 2. if canceled or no file path, return + if (canceled || !filePath) return; + // -- 3. write workbook to file + try { + XLSX.writeFile(currentWorkbook, filePath); + electron.dialog.showMessageBox({ message: `Exported to ${filePath}` }); + } catch (err) { + // -- 4. if error, display error + displayError(`Failed to export: ${err.message}`); + } } +exportBtn.addEventListener("click", exportWorkbookAsFile); -exportBtn.addEventListener("click", exportFile, false); - +// --------------------------------------------------------------------------- +// Render workbook --> HTML tables +// --------------------------------------------------------------------------- function renderWorkbookToTables(wb) { - htmlout.innerHTML = ""; - const sheetNames = wb.SheetNames; - sheetNames.forEach((sheetName) => { - const sheet = wb.Sheets[sheetName]; - const table = XLSX.utils.sheet_to_html(sheet); - htmlout.innerHTML += `
- ${sheetName} -
${table}
-
`; - }); + // -- 1. convert each sheet to HTML + const html = wb.SheetNames.map((name) => { + const sheet = wb.Sheets[name]; + const table = XLSX.utils.sheet_to_html(sheet, { id: `${name}-tbl` }); + // -- 2. wrap in details element + return `
+ ${name} +
${table}
+
`; + }).join(""); // -- 3. join into single string + // -- 4. render to DOM + htmlout.innerHTML = html; // single write → single re‑flow of the DOM } -// --- File Import Logic --- -/** - * Handle file selection dialog and render the selected spreadsheet. - */ +// --------------------------------------------------------------------------- +// Generic UI helpers +// --------------------------------------------------------------------------- +const displayError = (msg) => (onError ? ((onError.textContent = msg), (onError.hidden = false)) : console.error(msg)); +const hideDropUI = () => dropContainer && (dropContainer.style.display = "none"); +const showDropUI = () => dropContainer && (dropContainer.style.display = "block"); +const hideExportBtn = () => (exportBtn.disabled = true); +const showExportBtn = () => (exportBtn.disabled = false); +const showSpinner = () => (spinnerOverlay.style.display = "flex"); +const hideSpinner = () => (spinnerOverlay.style.display = "none"); +const hideOutputUI = () => (htmlout.innerHTML = ""); +const hideLoadedFileUI = () => (fileStatus.innerHTML = ""); +const getLoadedFileUI = (fileName) => `
+ ${fileName} + +
`; + +function showLoadedFileUI(fileName) { + fileStatus.innerHTML = getLoadedFileUI(fileName); + hideDropUI(); + showExportBtn(); +} + +// --------------------------------------------------------------------------- +// Event delegation for unload button – avoids per‑render listener leaks +// --------------------------------------------------------------------------- +fileStatus.addEventListener("click", (e) => { + if (e.target.classList.contains("unload-btn")) { + hideLoadedFileUI(); + hideExportBtn(); + showDropUI(); + hideOutputUI(); + currentWorkbook = null; + } +}); + +// --------------------------------------------------------------------------- +// File‑open dialog handler +// --------------------------------------------------------------------------- async function handleReadBtn() { - const o = await electron.dialog.showOpenDialog({ + // -- 1. show file open dialog to get the file path + const { filePaths, canceled } = await electron.dialog.showOpenDialog({ title: "Select a file", filters: [{ name: "Spreadsheets", extensions: EXTENSIONS }], properties: ["openFile"], }); - if (o.filePaths.length == 0) throw new Error("No file was selected!"); + // -- 2. if canceled or no file path, return + if (canceled || !filePaths.length) return; + // -- 3. if multiple files selected, return error + if (filePaths.length !== 1) return displayError("Please choose a single file."); + showSpinner(); - // yield to event loop to render spinner - await new Promise((resolve) => setTimeout(resolve, 200)); + await nextPaint(); // ensure spinner paints try { - const filePath = o.filePaths[0]; - const fileName = filePath.split(/[/\\]/).pop(); - renderWorkbookToTables(XLSX.readFile(filePath)); - showLoadedFileUI(fileName); - showExportBtn(); + // -- 4. read the first selected file + const filePath = filePaths[0]; + currentWorkbook = XLSX.readFile(filePath); + renderWorkbookToTables(currentWorkbook); + showLoadedFileUI(path.basename(filePath)); } finally { hideSpinner(); hideDropUI(); + // -- 5. reset error UI state + onError && (onError.hidden = true); } } -// --- UI Templates and Helpers --- - -const getLoadedFileUI = (fileName) => { - return `
- ${fileName} - -
`; -}; - -const hideDropUI = () => { - if (dropContainer) dropContainer.style.display = "none"; -}; - -const showDropUI = () => { - if (dropContainer) dropContainer.style.display = "block"; -}; - -const hideLoadedFileUI = () => { - if (fileStatus) fileStatus.innerHTML = ""; -}; - -const hideOutputUI = () => { - if (htmlout) htmlout.innerHTML = ""; -}; - -const showLoadedFileUI = (fileName) => { - const loadedFileUI = getLoadedFileUI(fileName); - fileStatus.innerHTML = loadedFileUI; - const unloadBtn = fileStatus.querySelector("#unloadBtn"); - if (unloadBtn) { - unloadBtn.addEventListener("click", () => { - hideLoadedFileUI(); - hideExportBtn(); - showDropUI(); - hideOutputUI(); - }); - } - hideDropUI(); - showExportBtn(); -}; - -const hideExportBtn = () => { - if (exportBtn) exportBtn.disabled = true; -}; - -const showExportBtn = () => { - if (exportBtn) exportBtn.disabled = false; -}; - -function showSpinner() { - if (spinnerOverlay) spinnerOverlay.style.display = "flex"; -} - -function hideSpinner() { - if (spinnerOverlay) spinnerOverlay.style.display = "none"; -} - -// --- Event Listener Helpers --- -/** - * Add an event listener to an element if it exists. - */ -function addListener(id, event, handler) { +// --------------------------------------------------------------------------- +// Drag‑and‑drop + file input +// --------------------------------------------------------------------------- +function addListener(id, evt, fn) { const el = document.getElementById(id); - if (el) el.addEventListener(event, handler, false); + if (el) el.addEventListener(evt, fn); } -/** - * Attach drag-and-drop and file input listeners to the UI. - */ -function attachDropListeners() { +function attachFileListeners() { + // file input element addListener("readIn", "change", (e) => { showSpinner(); - // Defer to next tick to ensure spinner renders before heavy work - setTimeout(() => readFile(e.target.files), 0); + nextPaint().then(() => readFile(e.target.files)); }); addListener("readBtn", "click", handleReadBtn); - const handleDrag = (e) => { - e.stopPropagation(); + + // drag‑and‑drop (applied to whole window for simplicity) + const onDrag = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; - document.body.classList.add("drag-over"); - }; - const handleDragLeave = (e) => { - e.stopPropagation(); - e.preventDefault(); - document.body.classList.remove("drag-over"); }; + + ["dragenter", "dragover"].forEach((t) => + document.body.addEventListener(t, onDrag, { passive: false }) + ); + document.body.addEventListener( "drop", (e) => { - e.stopPropagation(); e.preventDefault(); - document.body.classList.remove("drag-over"); - readFile(e.dataTransfer.files); + readFile(e.dataTransfer.files).catch((err) => displayError(err.message)); }, - false + { passive: false } ); - document.body.addEventListener("dragenter", handleDrag, false); - document.body.addEventListener("dragover", handleDrag, false); - document.body.addEventListener("dragleave", handleDragLeave, false); - document.body.addEventListener("dragend", handleDragLeave, false); } -// --- File Reader for Drag-and-Drop and Input --- -/** - * Read file(s) from input or drag-and-drop and render as table. - */ +// --------------------------------------------------------------------------- +// Read File from input or DnD +// --------------------------------------------------------------------------- async function readFile(files) { - if (!files || files.length === 0) return; - const f = files[0]; + // -- 1. if no files, return + if (!files || !files.length) return; + // -- 2. get the first file + const file = files[0]; + // -- 3. if not a spreadsheet, return error + const ext = path.extname(file.name).slice(1); + if (!isSpreadsheet(ext)) return displayError(`Unsupported file type .${ext}`); + showSpinner(); try { - const data = await f.arrayBuffer(); - renderWorkbookToTables(XLSX.read(data)); - showLoadedFileUI(f.name); + // -- 4. read the file + const data = await file.arrayBuffer(); + currentWorkbook = XLSX.read(data); + // -- 5. render the workbook to tables + renderWorkbookToTables(currentWorkbook); + // -- 6. show the loaded file UI + showLoadedFileUI(file.name); } finally { hideSpinner(); + // reset error UI state + onError && (onError.hidden = true); } } -// --- Initial Setup --- -attachDropListeners(); - -// handle file opening events from the main process -ipcRenderer.on('file-opened', async (_evt, filePath) => { - console.log('Received file-opened event:', filePath); +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- +attachFileListeners(); +// the file-opened event is sent from the main process when a file is opened using "open with" +ipcRenderer.on("file-opened", async (_e, filePath) => { showSpinner(); - // yield to event loop to render spinner - await new Promise(r => setTimeout(r, 50)); - renderWorkbookToTables(XLSX.readFile(filePath)); // already in your helper list - showLoadedFileUI(filePath.split(/[/\\]/).pop()); + await nextPaint(); // ensure spinner paints + currentWorkbook = XLSX.readFile(filePath); + renderWorkbookToTables(currentWorkbook); + showLoadedFileUI(path.basename(filePath)); hideSpinner(); hideDropUI(); showExportBtn(); -}); \ No newline at end of file +}); diff --git a/docz/static/electron/styles.css b/docz/static/electron/styles.css index 1f3bc0c..f138a05 100644 --- a/docz/static/electron/styles.css +++ b/docz/static/electron/styles.css @@ -333,6 +333,12 @@ summary:focus-within { animation: spin 1s linear infinite; } +#onError { + color: var(--danger); + width: 100%; + text-align: center; +} + @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); }