docs.sheetjs.com/docz/static/electron/index.js

229 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const XLSX = require("xlsx");
// TODO: Replace deprecated @electron/remote with contextBridgebased IPC in production.
const electron = require("@electron/remote");
const { ipcRenderer } = require("electron");
const path = require("path");
// ---------------------------------------------------------------------------
// Supported file extensions
// ---------------------------------------------------------------------------
const EXTENSIONS =
"xls|xlsx|xlsm|xlsb|xml|csv|txt|dif|sylk|slk|prn|ods|fods|htm|html|numbers".split(
"|",
);
// ---------------------------------------------------------------------------
// 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 onError = document.getElementById("onError");
// ---------------------------------------------------------------------------
// 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();
electron.shell.openExternal(e.target.href);
}
});
// ---------------------------------------------------------------------------
// 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 }],
});
// -- 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);
// ---------------------------------------------------------------------------
// Render workbook --> HTML tables
// ---------------------------------------------------------------------------
function renderWorkbookToTables(wb) {
// -- 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 reflow of the DOM
}
// ---------------------------------------------------------------------------
// 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 perrender listener leaks
// ---------------------------------------------------------------------------
fileStatus.addEventListener("click", (e) => {
if (e.target.classList.contains("unload-btn")) {
hideLoadedFileUI();
hideExportBtn();
showDropUI();
hideOutputUI();
currentWorkbook = null;
}
});
// ---------------------------------------------------------------------------
// Fileopen dialog handler
// ---------------------------------------------------------------------------
async function handleReadBtn() {
// -- 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"],
});
// -- 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();
await nextPaint(); // ensure spinner paints
try {
// -- 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);
}
}
// ---------------------------------------------------------------------------
// Draganddrop + file input
// ---------------------------------------------------------------------------
function addListener(id, evt, fn) {
const el = document.getElementById(id);
if (el) el.addEventListener(evt, fn);
}
function attachFileListeners() {
// file input element
addListener("readIn", "change", (e) => {
showSpinner();
nextPaint().then(() => readFile(e.target.files));
});
addListener("readBtn", "click", handleReadBtn);
// draganddrop (applied to whole window for simplicity)
const onDrag = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
};
["dragenter", "dragover"].forEach((t) =>
document.body.addEventListener(t, onDrag, { passive: false })
);
document.body.addEventListener(
"drop",
(e) => {
e.preventDefault();
readFile(e.dataTransfer.files).catch((err) => displayError(err.message));
},
{ passive: false }
);
}
// ---------------------------------------------------------------------------
// Read File from input or DnD
// ---------------------------------------------------------------------------
async function readFile(files) {
// -- 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 {
// -- 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);
}
}
// ---------------------------------------------------------------------------
// 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();
await nextPaint(); // ensure spinner paints
currentWorkbook = XLSX.readFile(filePath);
renderWorkbookToTables(currentWorkbook);
showLoadedFileUI(path.basename(filePath));
hideSpinner();
hideDropUI();
showExportBtn();
});