forked from sheetjs/docs.sheetjs.com
feat: [Electron Demo] - rendering optimizations and error handling.
This commit is contained in:
parent
e5a1d470ad
commit
89a8f00ff3
@ -1,6 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- sheetjs (C) 2013-present SheetJS https://sheetjs.com -->
|
||||
<!-- vim: set ts=2: -->
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
@ -27,6 +26,7 @@
|
||||
</div>
|
||||
</section>
|
||||
<div id="fileStatus" class="file-status"></div>
|
||||
<div id="onError"></div>
|
||||
<section id="htmlout" class="table-responsive"></section>
|
||||
<section class="export">
|
||||
<p><input type="submit" value="Export" id="exportBtn" disabled="true" tabindex="0" aria-label="Export spreadsheet"></p>
|
||||
@ -39,5 +39,5 @@
|
||||
</ul>
|
||||
</footer>
|
||||
<script src="index.js"></script>
|
||||
<!-- Cloudflare Pages Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "5045fe4c2b784ddb8c3c6ee7fa0593e5"}'></script><!-- Cloudflare Pages Analytics --></body>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -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 += `<details class="sheetjs-sheet-container">
|
||||
<summary class="sheetjs-sheet-name">${sheetName}</summary>
|
||||
<div class="sheetjs-tab-content">${table}</div>
|
||||
</details>`;
|
||||
});
|
||||
// -- 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 `<details class="sheetjs-sheet-container">
|
||||
<summary class="sheetjs-sheet-name">${name}</summary>
|
||||
<div class="sheetjs-tab-content">${table}</div>
|
||||
</details>`;
|
||||
}).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) => `<div class="file-loaded">
|
||||
<span class="file-name text-muted text-small">${fileName}</span>
|
||||
<button type="button" class="unload-btn">Unload</button>
|
||||
</div>`;
|
||||
|
||||
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 `<div class="file-loaded">
|
||||
<span class="file-name text-muted text-small">${fileName}</span>
|
||||
<button type="button" id="unloadBtn" class="unload-btn">Unload</button>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
@ -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); }
|
||||
|
Loading…
Reference in New Issue
Block a user