WIP: [Electron Demo] - Updating UX | Visual Design of the Demo. #38

Draft
syntaxbullet wants to merge 14 commits from syntaxbullet/docs.sheetjs.com:master into master
8 changed files with 923 additions and 190 deletions

@ -40,22 +40,47 @@ app to read and write workbooks. The app will look like the screenshots below:
## Integration Details
The [SheetJS NodeJS Module](/docs/getting-started/installation/nodejs) can be
imported from the main or the renderer thread.
Electron uses a multi-process architecture, with the main process handling system level operations and I/O, and the renderer process handling UI and web content.
Review

The original sentence linking to the installation guide must be retained in some form. Maybe add a sentence that says Even if you can technically import from the renderer thread, it is discouraged.

The original sentence linking to the installation guide must be retained in some form. Maybe add a sentence that says Even if you can technically import from the renderer thread, it is discouraged.
The SheetJS `readFile` and `writeFile` methods will use the Electron `fs` module
where available.
**Renderer Process Limitations**
<details>
<summary><b>Renderer Configuration</b> (click to show)</summary>
The renderer process is sandboxed and cannot run any non-browser code.
Electron 9 and later require the preference `nodeIntegration: true` in order to
`require('xlsx')` in the renderer process.
**Main Process Limitations**
Electron 12 and later also require `worldSafeExecuteJavascript: true` and
`contextIsolation: true`.
The main process can run any NodeJS code, but it cannot access the DOM or any browser APIs.
</details>
To allow communication between the main and renderer processes, Electron recommends building a [context bridge](https://www.electronjs.org/docs/latest/api/context-bridge) to expose low-level system calls and NodeJS APIs to the renderer process. Such as the [SheetJS NodeJS Module](/docs/getting-started/installation/nodejs) which we will be using here.
Exposed APIs are available as `SheetJSDemoAPI` on the window object and proxied from the main process.
```js title="preload.js -- contextBridge API"
const { contextBridge, ipcRenderer, shell } = require('electron');
// import nodejs modules we wish to expose APIs from.
const path = require('path');
const XLSX = require('xlsx');
// The contextBridge API allows us to expose APIs to the renderer process.
// highlight-next-line
contextBridge.exposeInMainWorld('SheetJSDemoAPI', {
// request OS file dialogs from the main process
openFile: (filters) => ipcRenderer.invoke('dialog:openFile', filters),
saveFile: (filters) => ipcRenderer.invoke('dialog:saveFile', filters),
message: (msg) => ipcRenderer.invoke('dialog:message', msg),
// open external links in the default browser
openExternal: (url) => shell.openExternal(url),
// listen for file open events from the main process
onFileOpened: (cb) => ipcRenderer.on('file-opened', (_e, fp) => cb(fp)),
// You can use this to expose nodejs APIs to the renderer process.
basename: (p) => path.basename(p),
extname: (p) => path.extname(p),
// Here for example we are exposing the sheetjs package to the renderer process.
// highlight-next-line
xlsx: XLSX,
});
```
### Reading Files
@ -73,7 +98,7 @@ For example, assuming a file input element on the page:
The event handler would process the event as if it were a web event:
```js
```js title="index.js -- renderer process"
async function handleFile(e) {
const file = e.target.files[0];
const data = await file.arrayBuffer();
@ -87,8 +112,9 @@ document.getElementById("xlf").addEventListener("change", handleFile, false);
**Drag and Drop**
The [drag and drop snippet](/docs/solutions/input#example-user-submissions)
applies to DIV elements on the page.
In the demo the [drag and drop snippet](/docs/solutions/input#example-user-submissions)
applies to the entire window via the `document.body` element. However it can easily be
applied to any element on the page.
For example, assuming a DIV on the page:
@ -98,7 +124,9 @@ For example, assuming a DIV on the page:
The event handler would process the event as if it were a web event:
```js
```js title="index.js -- renderer process"
const XLSX = window.SheetJSDemoAPI.xlsx; // use xlsx package from bridge process
async function handleDrop(e) {
e.stopPropagation();
e.preventDefault();
@ -117,88 +145,105 @@ document.getElementById("drop").addEventListener("drop", handleDrop, false);
[`XLSX.readFile`](/docs/api/parse-options) reads workbooks from the file system.
`showOpenDialog` shows a Save As dialog and returns the selected file name.
Unlike the Web APIs, the `showOpenDialog` flow can be initiated by app code:
```js
/* from the renderer thread */
const electron = require('@electron/remote');
We can now use the exposed APIs from our preload script above to show the open dialog and try to parse the workbook from within the renderer process.
```js title="index.js -- renderer process"
// our exposed bridge APIs are available as SheetJSDemoAPI on the window object
const openFile = window.SheetJSDemoAPI.openFile; // request the open file dialog from the main process
// We can also access the SheetJS package from the exposed bridge APIs
// highlight-next-line
const XLSX = window.SheetJSDemoAPI.xlsx;
/* this function will show the open dialog and try to parse the workbook */
async function importFile() {
/* show Save As dialog */
const result = await electron.dialog.showOpenDialog({
title: 'Select a file',
filters: [{
/* show open file dialog */
const result = await openFile([{
name: "Spreadsheets",
extensions: ["xlsx", "xls", "xlsb", /* ... other formats ... */]
}]
});
}]);
/* result.filePaths is an array of selected files */
if(result.filePaths.length == 0) throw new Error("No file was selected!");
// highlight-next-line

it's XLSX. Move this sentence so that it flows with the previous codeblock (since the openFile comment references the next one)

it's `XLSX`. Move this sentence so that it flows with the previous codeblock (since the `openFile` comment references the next one)
return XLSX.readFile(result.filePaths[0]);
}
```
In order to interact with the file system, the `xlsx` package here depends on the Node.js. Which means we need to utilize the Bridge here and make it possible to call these methods from the renderer process. The appropriate IPC event can be found below.
:::note pass
```js title="main.js -- main process"
const { ipcMain, dialog } = require('electron');
`showOpenDialog` originally returned an array of paths:
```js
var dialog = require('electron').remote.dialog;
function importFile(workbook) {
var result = dialog.showOpenDialog({ properties: ['openFile'] });
return XLSX.readFile(result[0]);
}
ipcMain.handle('dialog:openFile', (_e, filters) =>
dialog.showOpenDialog({ title: 'Select a file', filters, properties: ['openFile'] })
);
```
This method was renamed to `showOpenDialogSync` in Electron 6.
:::
### Writing Files
[`XLSX.writeFile`](/docs/api/write-options) writes workbooks to the file system.
`showSaveDialog` shows a Save As dialog and returns the selected file name:
```js
/* from the renderer thread */
const electron = require('@electron/remote');
The implementation for saving files looks very similar to the one above thanks to our bridge API.
```js title="index.js -- renderer process"
// our exposed bridge APIs are available as SheetJSDemoAPI on the window object
const saveFile = window.SheetJSDemoAPI.saveFile; // request the save file dialog from the main process
const XLSX = window.SheetJSDemoAPI.xlsx;
/* this function will show the save dialog and try to write the workbook */
async function exportFile(workbook) {
/* show Save As dialog */
const result = await electron.dialog.showSaveDialog({
title: 'Save file as',
filters: [{
const result = await saveFile([{
name: "Spreadsheets",
extensions: ["xlsx", "xls", "xlsb", /* ... other formats ... */]
}]
});
/* write file */
// highlight-next-line
XLSX.writeFile(workbook, result.filePath);
}]);
if(result.filePaths.length == 0) throw new Error("No file was selected!");
XLSX.writeFile(workbook, result.filePaths[0]);
}
```
And here is the implementation of the `saveFile` event listener in `main.js`:
```js title="main.js -- main process"
const { ipcMain, dialog } = require('electron');
:::note pass
`showSaveDialog` originally returned the selected path:
```js
var dialog = require('electron').remote.dialog;
function exportFile(workbook) {
var result = dialog.showSaveDialog();
XLSX.writeFile(workbook, result);
}
ipcMain.handle('dialog:saveFile', (_e, filters) =>
dialog.showSaveDialog({ title: 'Save file as', filters })
);

rework the flow so that you start with "the app can respond to open-with events" and then discuss how installers can register for the file extensions

rework the flow so that you start with "the app can respond to open-with events" and then discuss how installers can register for the file extensions
```
This method was renamed to `showSaveDialogSync` in Electron 6.
### Working with OS level file open events.
Electron makes it possible to handle OS level file open events, such as the "open with" context menu or `open` CLI command.
The example below shows the configuration required to register your application as a handler supporting such events for all file extensions SheetJS supports.
:::caution
It is also possible to open files using the "open with" context menu without registering the application as a handler for the specified file types. This however, requires manually selecting the application binary as a target to open the file with.
**This action might not be supported by some file managers on Linux based systems.**
:::
```json title="package.json"
{
// ...existing content
"build": {
"appId": "com.sheetjs.electron",
"fileAssociations": [
{
"ext": [ // supported extensions to register with the OS.
"xls","xlsx","xlsm","xlsb","xml","csv","txt","dif",
"sylk","slk","prn","ods","fods","htm","html","numbers"
],
"name": "Spreadsheet / Delimited File",
"description": "Spreadsheets and delimited text files opened by SheetJS-Electron",
"role": "Editor"
}
],
"mac": { "target": "dmg" },
"win": { "target": "nsis" },
"linux": { "target": "deb" }
},
}
```
This makes it possible to generate installers for MacOS, Windows and Linux which will automatically register the application as a handler for the specified file types avoiding manual registration processes that differ across operating systems.
## Complete Example
:::note Tested Deployments
@ -208,29 +253,26 @@ This demo was tested in the following environments:
| OS and Version | Architecture | Electron | Date |
|:---------------|:-------------|:---------|:-----------|
| macOS 15.3 | `darwin-x64` | `35.1.2` | 2025-03-31 |
| macOS 14.5 | `darwin-arm` | `35.1.2` | 2025-03-30 |
| Windows 11 | `win11-x64` | `33.2.1` | 2025-02-11 |
| macOS 15.4 | `darwin-arm` | `36.1.0` | 2025-05-03 |
| Windows 11 | `win11-x64` | `36.1.0` | 2025-05-03 |
| Windows 11 | `win11-arm` | `33.2.1` | 2025-02-23 |
| Linux (HoloOS) | `linux-x64` | `33.2.1` | 2025-01-02 |
| Linux (Debian) | `linux-arm` | `33.2.1` | 2025-02-16 |
:::
This demo includes a drag-and-drop box as well as a file input box, mirroring
the [SheetJS Data Preview Live Demo](https://oss.sheetjs.com/sheetjs/)
The core data in this demo is an editable HTML table. The readers build up the
table using `sheet_to_html` (with `editable:true` option) and the writers scrape
the table using `table_to_book`.
The demo project is wired for `electron-forge` to build the standalone binary.
You can also use `electron-builder` to build a packaged installer binary.
1) Download the demo files:
- [`package.json`](pathname:///electron/package.json) : project structure
- [`main.js`](pathname:///electron/main.js) : main process script
- [`index.html`](pathname:///electron/index.html) : window page
- [`index.js`](pathname:///electron/index.js) : script loaded in render context
- [`preload.js`](pathname:///electron/preload.js) : preload script (ContextBridge API worker)
- [`styles.css`](pathname:///electron/styles.css) : stylesheet
:::caution pass
@ -248,6 +290,8 @@ curl -LO https://docs.sheetjs.com/electron/package.json
curl -LO https://docs.sheetjs.com/electron/main.js
curl -LO https://docs.sheetjs.com/electron/index.html
curl -LO https://docs.sheetjs.com/electron/index.js
curl -LO https://docs.sheetjs.com/electron/preload.js
curl -LO https://docs.sheetjs.com/electron/styles.css
```
:::note pass
@ -265,6 +309,8 @@ curl.exe -LO https://docs.sheetjs.com/electron/package.json
curl.exe -LO https://docs.sheetjs.com/electron/main.js
curl.exe -LO https://docs.sheetjs.com/electron/index.html
curl.exe -LO https://docs.sheetjs.com/electron/index.js
curl.exe -LO https://docs.sheetjs.com/electron/preload.js
curl.exe -LO https://docs.sheetjs.com/electron/styles.css
```
:::
@ -322,12 +368,12 @@ The program will run on ARM64 Windows.
#### Electron API
7) Click "Click here to select a file from your computer". With the file picker,
7) Click "Click here to select a file. With the file picker,
navigate to the Downloads folder and select `pres.numbers`.
The application should show data in a table.
The application should show a dropdown component for each worksheet contained in your file, clicking on it should display its data within a table.
8) Click "Export Data!" and click "Save" in the popup. By default, it will try
8) Click "Export" and click "Save" in the popup. By default, it will try
to write to `Untitled.xls` in the Downloads folder.
:::note pass
@ -341,27 +387,51 @@ If there is no default name, enter `Untitled.xls` and click "Save".
The app will show a popup once the data is exported. Open the file in a
spreadsheet editor and compare the data to the table shown in the application.
#### Drag and Drop
#### Open with menu
9) Close the application, end the terminal process and re-launch (see step 6)
10) Open the Downloads folder in a file explorer or finder window.
11) Click and drag the `pres.numbers` file from the Downloads folder to the
bordered "Drop a spreadsheet file" box. The file data should be displayed.
11) Right-click the `pres.numbers` file and select "Open with".
#### File Input Element
12) Select your application binary by navigating to the folder where the application was built (see step 4).
12) Close the application, end the terminal process and re-launch (see step 6)
:::info
On some Linux based systems, depending on the file manager in use selecting the binary directly may not be possible.
:::
13) Click "Choose File". With the file picker, navigate to the Downloads folder
The application should show a dropdown component for each worksheet contained in your file, clicking on it should display its data within a table.
#### Drag and Drop
13) Close the application, end the terminal process and re-launch (see step 6)
14) Open the Downloads folder in a file explorer or finder window.
15) Click and drag the `pres.numbers` file from the Downloads folder
into the application window.
The application should show a dropdown component for each worksheet contained in your file, clicking on it should display its data within a table.
:::info
On some Linux based systems, the experience can differ depending on the window manager / desktop environment in use.
:::
#### File Picker Element
16) Close the application, end the terminal process and re-launch (see step 6)
17) Click "Choose File". With the file picker, navigate to the Downloads folder
and select `pres.numbers`.
The application should show a dropdown component for each worksheet contained in your file, clicking on it should display its data within a table.
## Electron Breaking Changes
The first version of this demo used Electron `1.7.5`. The current demo includes
the required changes for Electron `35.1.2`.
the required changes for Electron `36.1.0`.
There are no Electron-specific workarounds in the library, but Electron broke
backwards compatibility multiple times. A summary of changes is noted below.
@ -389,4 +459,8 @@ Electron 14 and later must use `@electron/remote` instead of `remote`. An
:::
For demos built on top of Electron 36 and later we isolate the processes entirely and the demo no longer requires `@electron/remote`.
However, `nodeIntegration: false` by default now means that the renderer process no longer has access to NodeJS APIs.
To expose NodeJS APIs to the renderer process, we use the contextBridge API to expose APIs from the main process to the renderer process. [See more](https://www.electronjs.org/docs/latest/api/context-bridge). This has been best practice since Electron 25.
[^1]: See ["Makers"](https://www.electronforge.io/config/makers) in the Electron Forge documentation. On Linux, the demo generates `rpm` and `deb` distributables. On Arch Linux and the Steam Deck, `sudo pacman -Syu rpm-tools dpkg fakeroot` installed required packages. On Debian and Ubuntu, `sudo apt-get install rpm` sufficed.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 32 KiB

@ -1,37 +1,43 @@
<!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" />
<meta http-equiv="Content-Security-Policy" content="script-src 'self' https:">
<meta name="robots" content="noindex">
<title>SheetJS Electron Demo</title>
<style>
#drop{
border:2px dashed #bbb;
-moz-border-radius:5px;
-webkit-border-radius:5px;
border-radius:5px;
padding:25px;
text-align:center;
font:20pt bold,"Vollkorn";color:#bbb
}
a { text-decoration: none }
</style>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:ital,wght@0,100..900;1,100..900&family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<pre>
<b><a href="https://sheetjs.com">SheetJS Electron Demo</a></b>
<br />
<button id="readBtn">Click here to select a file from your computer</button><br />
<div id="drop">Drop a spreadsheet file here to see sheet data</div>
<input type="file" name="xlfile" id="readIn" /> ... or click here to select a file
</pre>
<p><input type="submit" value="Export Data!" id="exportBtn" disabled="true"></p>
<div id="htmlout"></div>
<br />
<div id="spinner-overlay" class="spinner-overlay" style="display:none;"><div class="spinner"></div></div>
<header>
<h1 class="text-heading">SheetJS Electron Demo</h1>
<p class="text-muted text-condensed">Load a spreadsheet to view its contents</p>
</header>
<section class="file-upload" id="drop-container">
<div id="drop">
<input type="file" id="readIn" style="display:none" tabindex="0" aria-label="Select spreadsheet file">
<p class="text-muted text-condensed">Drag and drop a file here</p>
<p class="text-muted text-small">or</p>
<button type="button" id="readBtn" tabindex="0" aria-label="Open file picker">Select a file</button>
</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>
</section>
</body>
<footer>
<ul>
<li><a href="https://docs.sheetjs.com/docs/" class="text-condensed"><img src="https://git.sheetjs.com/assets/img/logo.svg" alt="SheetJS" width="20" height="20"> SheetJS CE Docs</a></li>
<li><a href="https://www.electronjs.org/docs" class="text-condensed"><img src="https://www.electronjs.org/assets/img/logo.svg" alt="Electron" width="20" height="20"> Electron Docs</a></li>
</ul>
</footer>
<script src="index.js"></script>
</body>
</html>

@ -1,73 +1,232 @@
/* sheetjs (C) 2013-present SheetJS -- https://sheetjs.com */
const XLSX = require('xlsx');
const electron = require('@electron/remote');
const XLSX = window.SheetJSDemoAPI.xlsx;
const basename = window.SheetJSDemoAPI.basename;
const extname = window.SheetJSDemoAPI.extname;
const onFileOpened = window.SheetJSDemoAPI.onFileOpened;
/* list of supported extensions */
const EXTENSIONS = "xls|xlsx|xlsm|xlsb|xml|csv|txt|dif|sylk|slk|prn|ods|fods|htm|html|numbers".split("|");
// ---------------------------------------------------------------------------
// Supported file extensions
// ---------------------------------------------------------------------------
const EXTENSIONS =
"xls|xlsx|xlsm|xlsb|xml|csv|txt|dif|sylk|slk|prn|ods|fods|htm|html|numbers".split(
"|"
);
/* write file with Electron API */
async function exportFile() {
const HTMLOUT = document.getElementById('htmlout');
const wb = XLSX.utils.table_to_book(HTMLOUT.getElementsByTagName("TABLE")[0]);
const o = await electron.dialog.showSaveDialog({
title: 'Save file as',
filters: [{
// ---------------------------------------------------------------------------
// 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();
window.SheetJSDemoAPI.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 window.SheetJSDemoAPI.saveFile([
{
name: "Spreadsheets",
extensions: EXTENSIONS
}]
});
XLSX.writeFile(wb, o.filePath);
electron.dialog.showMessageBox({ message: "Exported data to " + o.filePath, buttons: ["OK"] });
extensions: EXTENSIONS,
},
]);
if (canceled || !filePath) return;
// -- 2. write workbook to file
try {
XLSX.writeFile(currentWorkbook, filePath);
window.SheetJSDemoAPI.message(`Exported to ${filePath}`);
} catch (err) {
displayError(`Failed to export: ${err.message}`);
}
}
document.getElementById('exportBtn').addEventListener('click', exportFile, false);
exportBtn.addEventListener("click", exportWorkbookAsFile);
// ---------------------------------------------------------------------------
// Render workbook --> HTML tables
// ---------------------------------------------------------------------------
function renderWorkbookToTables(wb) {
// -- 1. map through each sheet
const html = wb.SheetNames.map((name) => {
const sheet = wb.Sheets[name];
// -- 2. convert sheet to HTML
const table = XLSX.utils.sheet_to_html(sheet, { id: `${name}-tbl` });
return `<details class="sheetjs-sheet-container">
<summary class="sheetjs-sheet-name">${name}</summary>
<div class="sheetjs-tab-content">${table}</div>
</details>`;
}).join("");
// CAUTION!: in production environments please sanitize the HTML output to prevent XSS attacks from maliciously crafted spreadsheets.
htmlout.innerHTML = html; // single write → single reflow of the DOM
/* common handler to create HTML tables on the page */
function process_wb(wb) {
const HTMLOUT = document.getElementById('htmlout');
const XPORT = document.getElementById('exportBtn');
XPORT.disabled = false;
HTMLOUT.innerHTML = "";
wb.SheetNames.forEach(function(sheetName) {
const htmlstr = XLSX.utils.sheet_to_html(wb.Sheets[sheetName],{editable:true});
HTMLOUT.innerHTML += htmlstr;
});
}
/* read file with Electron API */
// ---------------------------------------------------------------------------
// 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() {
const o = await electron.dialog.showOpenDialog({
title: 'Select a file',
filters: [{
// -- 1. show file open dialog to get the file path
const { filePaths, canceled } = await window.SheetJSDemoAPI.openFile([
{
name: "Spreadsheets",
extensions: EXTENSIONS
}],
properties: ['openFile']
extensions: EXTENSIONS,
},
]);
if (canceled || !filePaths.length) return;
if (filePaths.length !== 1)
return displayError("Please choose a single file.");
showSpinner();
await nextPaint(); // ensure spinner paints
try {
const filePath = filePaths[0];
// -- 2. read the first selected file
currentWorkbook = XLSX.readFile(filePath);
renderWorkbookToTables(currentWorkbook);
showLoadedFileUI(basename(filePath));
} finally {
hideSpinner();
hideDropUI();
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));
});
if(o.filePaths.length == 0) throw new Error("No file was selected!");
process_wb(XLSX.readFile(o.filePaths[0]));
}
document.getElementById('readBtn').addEventListener('click', handleReadBtn, false);
addListener("readBtn", "click", handleReadBtn);
/* read file with Web APIs */
// 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) {
const f = files[0];
const data = await f.arrayBuffer();
process_wb(XLSX.read(data));
// -- 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 = 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);
}
}
// file input element
document.getElementById('readIn').addEventListener('change', (e) => { readFile(e.target.files); }, false);
// drag and drop
const drop = document.getElementById('drop');
drop.addEventListener('drop', (e) => {
e.stopPropagation(); e.preventDefault();
readFile(e.dataTransfer.files);
}, false);
const handleDrag = (e) => {
e.stopPropagation(); e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
};
drop.addEventListener('dragenter', handleDrag, false);
drop.addEventListener('dragover', handleDrag, false);
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
attachFileListeners();
// the file-opened event is sent from the main process when a file is opened using "open with"
onFileOpened(async (_e, filePath) => {
showSpinner();
await nextPaint(); // ensure spinner paints
currentWorkbook = XLSX.readFile(filePath);
renderWorkbookToTables(currentWorkbook);
showLoadedFileUI(path.basename(filePath));
hideSpinner();
hideDropUI();
showExportBtn();
});

@ -1,29 +1,120 @@
/* sheetjs (C) 2013-present SheetJS -- https://sheetjs.com */
var electron = require('electron');
var XLSX = require('xlsx');
var app = electron.app;
require('@electron/remote/main').initialize(); // required for Electron 14+
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const XLSX = require('xlsx');
var win = null;
const EXT_REGEX = /\.(xls|xlsx|xlsm|xlsb|xml|csv|txt|dif|sylk|slk|prn|ods|fods|htm|html|numbers)$/i;
const pendingPaths = []; // paths of files that were opened before the window was created. These are queued and loaded once the window is created.
let win = null; // reference to the main window, to make sure we don't create multiple windows.
/* In electron, the main process is the only process that can directly interface with the operating system.
The renderer process is sandboxed and cannot run any non-browser code.
To allow the renderer process to interface with the operating system, we use the contextBridge API to expose the API to the renderer process.
https://www.electronjs.org/docs/latest/api/context-bridge
*/
/* ----------------------------------------------------------------------------- */
/* IPC handlers that allow communication between main and renderer processes */
/* ----------------------------------------------------------------------------- */
/* These three functions can be used to interface with the operating system from the renderer process.
the ipcMain.handle() function is used to register a handler for a specific event.
when the renderer process calls the corresponding function, the main process will receive the event and execute the handler.
In this case, we are listening to events which allow the renderer process to open/save file dialogs and show modal messages.
*/
ipcMain.handle('dialog:openFile', (_e, filters) =>
dialog.showOpenDialog({ title: 'Select a file', filters, properties: ['openFile'] })
);
ipcMain.handle('dialog:saveFile', (_e, filters) =>
dialog.showSaveDialog({ title: 'Save file as', filters })
);
ipcMain.handle('dialog:message', (_e, msg) =>
dialog.showMessageBox({ message: msg, buttons: ['OK'] })
);
/* ----------------------------------------------------------------------------- */
/* Utility functions */
/* ----------------------------------------------------------------------------- */
function sendToRenderer(fp) {
if (win && win.webContents) win.webContents.send('file-opened', fp);
else pendingPaths.push(fp);
}
/*
On Windows and Linux, opening a file using the "open with" menu option or `open` command will pass the file path as a startup argument to the app.
We need to parse it, and test if it is a spreadsheet file.
*/
function firstSpreadsheetFromArgv() {
const args = process.defaultApp ? process.argv.slice(2) : process.argv.slice(1);
return args.find((a) => EXT_REGEX.test(a));
}
/* ----------------------------------------------------------------------------- */
/* Single-instance guard */
/* ----------------------------------------------------------------------------- */
// Windows and Linux only: If the app is already running, we need to prevent a new instance from launching when opening a file via the "open with" menu option or `open` command.
if (!app.requestSingleInstanceLock()) app.quit();
else {
app.on('second-instance', (_e, argv) => {
const fp = argv.find((a) => EXT_REGEX.test(a));
if (fp) sendToRenderer(fp);
if (win) { win.show(); win.focus(); }
});
}
// macOS file / url events
app.on('open-file', (evt, fp) => { evt.preventDefault(); sendToRenderer(fp); });
app.on('open-url', (evt, url) => { evt.preventDefault(); sendToRenderer(url.replace('file://', '')); });
/* ----------------------------------------------------------------------------- */
/* Create the window */
/* ----------------------------------------------------------------------------- */
function createWindow() {
if (win) return;
win = new electron.BrowserWindow({
width: 800, height: 600,
win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
worldSafeExecuteJavaScript: true, // required for Electron 12+
contextIsolation: false, // required for Electron 12+
nodeIntegration: true,
enableRemoteModule: true
preload: path.join(__dirname, './preload.js'), // preload script that will be executed in the renderer process before the page is loaded and act as a bridge between the main and renderer processes within a worker thread.
contextIsolation: true, // isolate and enable bridge, keeping the renderer process sandboxed and separated from the main process.
nodeIntegration: false, // no Node.js in renderer process.
nodeIntegrationInWorker: true, // enable Node.js in worker threads.
worldSafeExecuteJavaScript: true
}
});
win.loadURL("file://" + __dirname + "/index.html");
require('@electron/remote/main').enable(win.webContents); // required for Electron 14+
win.webContents.openDevTools();
win.on('closed', function () { win = null; });
win.loadFile('index.html');
if (process.env.NODE_ENV === 'development') win.webContents.openDevTools();
win.on('closed', () => { win = null; });
win.webContents.once('did-finish-load', () => {
pendingPaths.splice(0).forEach(sendToRenderer);
});
}
if (app.setAboutPanelOptions) app.setAboutPanelOptions({ applicationName: 'sheetjs-electron', applicationVersion: "XLSX " + XLSX.version, copyright: "(C) 2017-present SheetJS LLC" });
app.on('open-file', function () { console.log(arguments); });
app.on('ready', createWindow);
/* ----------------------------------------------------------------------------- */
/* App lifecycle */
/* ----------------------------------------------------------------------------- */
app.whenReady().then(() => {
const fp = firstSpreadsheetFromArgv();
if (fp) pendingPaths.push(fp);
createWindow();
});
if (app.setAboutPanelOptions) {
app.setAboutPanelOptions({
applicationName: 'sheetjs-electron',
applicationVersion: `XLSX ${XLSX.version}`,
copyright: '(C) 2017present SheetJS LLC'
});
}
app.on('activate', createWindow);
app.on('window-all-closed', function () { if (process.platform !== 'darwin') app.quit(); });
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });

@ -6,13 +6,13 @@
"version": "0.0.0",
"main": "main.js",
"dependencies": {
"@electron/remote": "2.1.2",
"xlsx": "https://sheet.lol/balls/xlsx-0.20.3.tgz"
},
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make"
"make": "electron-forge make",
"dist": "electron-builder"
},
"devDependencies": {
"@electron-forge/cli": "7.8.0",
@ -20,8 +20,28 @@
"@electron-forge/maker-rpm": "7.8.0",
"@electron-forge/maker-squirrel": "7.8.0",
"@electron-forge/maker-zip": "7.8.0",
"electron": "35.1.2"
"electron": "36.1.0",
"electron-builder": "^26.0.12"
},
Review

no blank lines

no blank lines
"build": {
"appId": "com.sheetjs.electron",
"fileAssociations": [
{
"ext": [
"xls","xlsx","xlsm","xlsb","xml","csv","txt","dif",
"sylk","slk","prn","ods","fods","htm","html","numbers"
],
"name": "Spreadsheet / Delimited File",
"description": "Spreadsheets and delimited text files opened by SheetJS-Electron",
"role": "Editor"
}
],
"mac": { "target": "dmg" },
"win": { "target": "nsis" },
"linux": { "target": "deb" }
},
"config": {
"forge": {
"packagerConfig": {},
@ -49,4 +69,4 @@
]
}
}
}
}

@ -0,0 +1,20 @@
const { contextBridge, ipcRenderer, shell } = require('electron');
const path = require('path');
const XLSX = require('xlsx');
// Because the main process is sandboxed, we need to use the contextBridge API to expose the API to the renderer process.
// https://www.electronjs.org/docs/latest/api/context-bridge
contextBridge.exposeInMainWorld('SheetJSDemoAPI', {
openFile: (filters) => ipcRenderer.invoke('dialog:openFile', filters),
saveFile: (filters) => ipcRenderer.invoke('dialog:saveFile', filters),
message: (msg) => ipcRenderer.invoke('dialog:message', msg),
openExternal: (url) => shell.openExternal(url),
// expose file-opened event
onFileOpened: (cb) => ipcRenderer.on('file-opened', (_e, fp) => cb(fp)),
// expose basename from path package
basename: (p) => path.basename(p),
// expose extname from path package
extname: (p) => path.extname(p),
// expose sheetjs package functions
xlsx: XLSX,
});

@ -0,0 +1,363 @@
/* =====================
Root Variables
===================== */
:root {
--text-base: #212529;
--text-muted: #666363;
--text-accent: #0c9244;
--button-primary: #212529;
--button-text: #fff;
--button-primary-hover: #0c9244;
--button-primary-active: #075025;
--button-primary-disabled: #6c757d;
--white: #fff;
--black: #000;
--table-even: #f6f6f6;
--table-hover: #88dda98f;
--table-head: #f8f9faaf;
--danger: #c0392b;
}
/* =====================
Global Styles & Reset
===================== */
body, html {
width: 100%;
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
width: 100%;
margin: 0;
box-sizing: border-box;
font-family: "Roboto", sans-serif;
font-optical-sizing: auto;
font-style: normal;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
}
li {
list-style: none;
}
/* =====================
Typography
===================== */
h1, p {
margin: 0;
color: var(--text-base);
text-align: center;
}
.text-muted {
color: var(--text-muted) !important;
}
.text-condensed {
font-family: "Roboto Condensed", sans-serif;
font-optical-sizing: auto;
font-style: normal;
}
.text-small {
font-size: 0.875rem !important;
}
/* =====================
Links
===================== */
a {
text-decoration: none;
color: var(--text-base);
}
a:hover {
text-decoration: underline;
color: var(--text-accent);
}
/* =====================
Header
===================== */
header {
margin-top: 2rem;
}
/* =====================
Buttons & Inputs
===================== */
button, input[type="submit"] {
background-color: var(--button-primary);
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease;
color: var(--button-text);
}
button:hover, input[type="submit"]:hover {
background-color: var(--button-primary-hover);
}
button:active, input[type="submit"]:active {
background-color: var(--button-primary-active);
}
button:disabled, input[type="submit"]:disabled {
background-color: var(--button-primary-disabled);
cursor: not-allowed;
opacity: 0.3;
}
input[type="file"] {
display: none;
}
button:focus, input[type="submit"]:focus, input[type="file"]:focus {
outline: 3px solid var(--text-accent);
outline-offset: 2px;
box-shadow: 0 0 0 2px var(--text-accent);
z-index: 2;
}
/* =====================
File Upload
===================== */
.file-upload {
display: flex;
margin: 1rem auto;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
width: 75%;
max-width: 600px;
border-radius: 4px;
background-color: var(--white);
}
.file-upload input[type="file"] {
display: none;
}
#drop {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* =====================
Export Section
===================== */
.export {
margin: 2rem 0;
flex-grow: 1;
}
/* =====================
Footer
===================== */
footer {
padding: 1rem;
display: flex;
justify-content: center;
align-items: center;
position: relative;
bottom: 0;
}
footer ul {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
width: 100%;
}
footer li {
list-style: none;
}
footer li a {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* =====================
Responsive Table Container
===================== */
.table-responsive {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* =====================
SheetJS Table Styles
===================== */
table {
width: 80%;
padding: 1rem;
max-width: 900px;
border-collapse: collapse;
font-family: "Roboto", "Roboto Condensed", sans-serif;
overflow: hidden;
}
table caption {
caption-side: top;
font-family: "Roboto Condensed", sans-serif;
font-size: 1.2rem;
color: var(--text-accent);
padding: 0.5rem;
letter-spacing: 0.05em;
}
table thead {
background: var(--table-head);
}
table th, table td {
padding: 0.25rem 1rem;
border: 1px solid var(--text-muted);
text-align: left;
font-size: 1rem;
}
th {
color: var(--text-base);
font-family: "Roboto Condensed", sans-serif;
font-weight: 600;
letter-spacing: 0.03em;
}
table tbody tr:nth-child(even) {
background: var(--table-even);
}
table tbody tr:hover {
background: var(--table-hover);
}
.sheetjs-sheet-name {
font-family: "Roboto Condensed", sans-serif;
padding: 0.5rem;
font-weight: 600;
}
.sheetjs-sheet-container {
margin: 1rem auto;
width: 90%;
max-width: 900px;
cursor: pointer;
background-color: #eee;
border-radius: 4px;
}
details:focus-within {
outline: 3px solid var(--text-accent);
outline-offset: 2px;
box-shadow: 0 0 0 2px var(--text-accent);
}
a:focus {
outline: 3px solid var(--text-accent);
outline-offset: 2px;
box-shadow: 0 0 0 2px var(--text-accent);
}
summary:focus-within {
outline: none;
}
.sheetjs-tab-content {
cursor: pointer;
padding: 1rem;
overflow-x: auto;
}
/* =====================
File Status/Loaded/Unload
===================== */
.file-status {
margin-top: 10px;
font-size: 1rem;
min-height: 1.5em;
transition: color 0.2s;
}
.file-loaded {
display: flex;
gap: 1rem;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
}
.unload-btn:hover {
background: var(--danger);
color: var(--white);
}
/* Spinner Styles */
.spinner-overlay {
position: fixed;
top: 0; left: 0;
width: 100vw;
height: 100vh;
background: rgba(255,255,255,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.spinner {
border: 6px solid #f3f3f3;
border-top: 6px solid var(--text-accent);
border-radius: 50%;
width: 48px;
height: 48px;
animation: spin 1s linear infinite;
}
#onError {
color: var(--danger);
width: 100%;
text-align: center;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* =====================
Media Queries
===================== */
@media (max-width: 700px) {
table th, table td {
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
}
table caption {
font-size: 1rem;
padding: 0.25rem;
}
}