Notepad tables shipped — build a minimal rich text table editor in Electron
Step-by-step guide to add rich text table editing to a minimal Electron editor with keyboard shortcuts, JSON/HTML serialization, and clipboard interoperability.
Notepad tables shipped — build a minimal rich text table editor in Electron
Hook: If you've ever wished your lightweight Electron-based text editor could handle tables like Windows Notepad did in late 2025, this guide shows you how to add a minimal, production-aware rich text table editor with keyboard shortcuts, serialization, and robust clipboard compatibility — in under an afternoon.
We focus on a pragmatic, secure implementation: a small Electron app using a contenteditable editor, table insertion & navigation, copy/paste that interops with Excel/Sheets, and JSON/HTML serialization for persistence and interchange.
Why this matters in 2026
Desktop apps built with web tech are more mature than ever. In 2025–2026 we saw two trends make this kind of feature practical:
- Clipboard APIs and desktop interoperability improved — HTML+plaintext/TSV simultaneously works better across platforms and office apps.
- Electron and Chromium updates tightened security defaults (contextIsolation, clearer preload patterns), so minimal editors must explicitly bridge clipboard and file IO without enabling nodeIntegration.
This tutorial shows an approach aligned with those trends: secure preload bridges, explicit clipboard handling, and dual-format copy/paste (HTML + TSV) so your tables paste into Sheets or Notepad-like apps cleanly.
What you'll build
- A minimal Electron app shell (main + preload + renderer) with a contenteditable editor.
- Table commands: insert table, add/remove rows & columns, keyboard navigation (Tab / Shift+Tab), and shortcuts (Ctrl+T to insert).
- Serialization: save/load as JSON and export/import HTML.
- Clipboard compatibility: copy a selected table as HTML and TSV; paste HTML tables or TSV/CSV into the editor.
Starter project: files & structure
Keep the app minimal:
- package.json
- main.js — Electron main
- preload.js — expose safe clipboard/io APIs
- renderer/index.html and renderer/renderer.js — editor UI & logic
package.json (essential parts)
{
"name": "notepad-tables",
"version": "0.1.0",
"main": "main.js",
"type": "module",
"scripts": {
"start": "electron ."
},
"dependencies": {
"dompurify": "^2.4.0"
}
}
main.js
import { app, BrowserWindow, ipcMain, dialog } from 'electron'
import path from 'path'
function createWindow() {
const win = new BrowserWindow({
width: 900,
height: 700,
webPreferences: {
preload: path.join(process.cwd(), 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
})
win.loadFile('renderer/index.html')
}
app.whenReady().then(createWindow)
ipcMain.handle('save-file', async (event, { filename, data }) => {
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: filename || 'document.json'
})
if (canceled) return { canceled: true }
await fs.promises.writeFile(filePath, data, 'utf8')
return { canceled: false, filePath }
})
// Provide platform clipboard via Electron clipboard in preload (no direct exposures here)
Note: main.js keeps responsibilities minimal — it just opens the window and exposes a single file-save handler via IPC. All clipboard operations are routed through the preload bridge for security.
preload.js
import { contextBridge, clipboard, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('api', {
writeClipboard: (payload) => ipcRenderer.invoke('write-clipboard', payload),
readClipboard: () => ipcRenderer.invoke('read-clipboard'),
saveFile: (meta) => ipcRenderer.invoke('save-file', meta)
})
// In main.js you can add handlers for 'write-clipboard' / 'read-clipboard' if you need native access.
Keep contextIsolation enabled. Expose only the functions your renderer needs.
Renderer: index.html & editor UI
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Notepad Tables – Minimal</title>
<style>
body{font-family: system-ui,Segoe UI,Roboto,Arial;margin:0}
.toolbar{display:flex;gap:8px;padding:8px;border-bottom:1px solid #ddd}
.editor{padding:12px;height:calc(100vh - 56px);overflow:auto}
[contenteditable]{min-height:100%;outline:none}
table{border-collapse:collapse}
td,th{border:1px solid #bbb;padding:6px;min-width:60px}
td:focus{outline:2px solid #4a90e2}
</style>
</head>
<body>
<div class="toolbar">
<button id="insert-table" title="Insert table (Ctrl+T)">Insert table</button>
<button id="serialize-json">Save JSON</button>
<button id="export-html">Export HTML</button>
</div>
<div class="editor">
<div id="editor" contenteditable="true" aria-label="Document editor"><p>Type here...</p></div>
</div>
<script src="renderer.js"></script>
</body>
</html>
renderer.js — core logic
This file contains the meat: table insertion, keyboard navigation, serialization, and clipboard handlers. Key UX behaviors:
- Ctrl+T — Insert a 3x3 table at caret
- Tab / Shift+Tab — Move between cells, create a new row at end
- Copy — If a table cell selection exists, write HTML and TSV to clipboard
- Paste — Try HTML table, then TSV/CSV fallback; sanitize before insertion (use DOMPurify)
import DOMPurify from 'dompurify'
const editor = document.getElementById('editor')
document.getElementById('insert-table').addEventListener('click', () => insertTable(3,3))
// Keyboard shortcuts
window.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 't') {
e.preventDefault()
insertTable(3,3)
return
}
})
function createCell(content = '') {
const td = document.createElement('td')
td.contentEditable = 'true'
td.innerHTML = content
td.addEventListener('keydown', handleCellKeydown)
return td
}
function createRow(cols) {
const tr = document.createElement('tr')
for (let i = 0; i < cols; i++) tr.appendChild(createCell(''))
return tr
}
function insertTable(rows = 3, cols = 3) {
const table = document.createElement('table')
for (let r = 0; r < rows; r++) table.appendChild(createRow(cols))
// Insert at caret
const sel = window.getSelection()
if (sel.rangeCount) {
const range = sel.getRangeAt(0)
range.deleteContents()
range.insertNode(table)
// focus first cell
const first = table.querySelector('td')
placeCaretAtStart(first)
} else {
editor.appendChild(table)
}
}
function placeCaretAtStart(node) {
node.focus()
const range = document.createRange()
range.selectNodeContents(node)
range.collapse(true)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
}
function handleCellKeydown(e) {
if (e.key === 'Tab') {
e.preventDefault()
const cell = e.currentTarget
if (!e.shiftKey) moveToNextCell(cell)
else moveToPreviousCell(cell)
}
}
function moveToNextCell(cell) {
const td = cell
let next = td.nextElementSibling
let tr = td.parentElement
if (!next) {
// End of row: try next row first cell, or create a new row
if (tr.nextElementSibling) next = tr.nextElementSibling.firstElementChild
else {
const table = tr.parentElement
const cols = tr.children.length
const newRow = createRow(cols)
tr.parentElement.appendChild(newRow)
next = newRow.firstElementChild
}
}
placeCaretAtStart(next)
}
function moveToPreviousCell(cell) {
const td = cell
let prev = td.previousElementSibling
let tr = td.parentElement
if (!prev) {
// move to last cell of previous row if exists
if (tr.previousElementSibling) prev = tr.previousElementSibling.lastElementChild
}
if (prev) placeCaretAtStart(prev)
}
// Serialization: JSON
function serializeDocument() {
// Walk DOM and extract tables and content
const nodes = Array.from(editor.childNodes)
const out = nodes.map(node => {
if (node.nodeName.toLowerCase() === 'table') {
const rows = Array.from(node.rows).map(tr => Array.from(tr.cells).map(td => td.innerHTML))
return { type: 'table', rows }
}
return { type: 'html', html: node.outerHTML || node.textContent }
})
return JSON.stringify({ version: 1, content: out }, null, 2)
}
document.getElementById('serialize-json').addEventListener('click', async () => {
const data = serializeDocument()
await window.api.saveFile({ filename: 'document.json', data })
alert('Saved JSON')
})
// Export HTML
document.getElementById('export-html').addEventListener('click', () => {
const html = editor.innerHTML
const sanitized = DOMPurify.sanitize(html)
// save via file API or show
const blob = new Blob([sanitized], { type: 'text/html' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'document.html'; a.click()
URL.revokeObjectURL(url)
})
// Clipboard: copy table selection as HTML + TSV
editor.addEventListener('copy', (e) => {
const sel = window.getSelection()
if (!sel.rangeCount) return
const container = sel.getRangeAt(0).commonAncestorContainer
const table = container.nodeType === 1 ? container.closest('table') : container.parentElement.closest('table')
if (!table) return // don't special-case non-table selections
e.preventDefault()
const html = table.outerHTML
// Generate TSV
const tsv = Array.from(table.rows).map(r => Array.from(r.cells).map(td => td.innerText.replace(/\t/g,' ')).join('\t')).join('\n')
// Use the navigator clipboard if available; fallback to ipc bridge
if (navigator.clipboard && navigator.clipboard.write) {
const blobHtml = new Blob([html], { type: 'text/html' })
const blobText = new Blob([tsv], { type: 'text/plain' })
const data = [new ClipboardItem({ 'text/html': blobHtml, 'text/plain': blobText })]
navigator.clipboard.write(data).catch(() => window.api.writeClipboard({ html, text: tsv }))
} else {
window.api.writeClipboard({ html, text: tsv })
}
})
// Paste handler: prefer HTML table, else TSV/CSV fallback
editor.addEventListener('paste', async (e) => {
e.preventDefault()
let html = null
let text = null
// Try navigator.clipboard first
try {
const items = await navigator.clipboard.read() // may be blocked in some contexts
for (const item of items) {
if (item.types.includes('text/html')) {
const blob = await item.getType('text/html')
html = await blob.text()
break
} else if (item.types.includes('text/plain')) {
const blob = await item.getType('text/plain')
text = await blob.text()
}
}
} catch (err) {
// Fallback to bridge that reads plain text + html if available
const result = await window.api.readClipboard().catch(() => null)
if (result) { html = result.html; text = result.text }
}
// If we have HTML with a table, sanitize and insert
if (html && /
Preload/native clipboard handlers (main continuation)
Because clipboard access can be inconsistent across OS/configs, implement a native fallback in main:
import { clipboard, ipcMain } from 'electron'
ipcMain.handle('write-clipboard', (e, { html, text }) => {
const data = {}
if (html) data.html = html
if (text) data.text = text
clipboard.clear()
if (data.html && data.text) clipboard.write({ html: data.html, text: data.text })
else if (data.text) clipboard.writeText(data.text)
else if (data.html) clipboard.writeHTML(data.html)
return true
})
ipcMain.handle('read-clipboard', () => {
return { html: clipboard.readHTML(), text: clipboard.readText() }
})
Serialization schema and portability
We store tables as a simple JSON structure so you can version and extend it later. Example:
{
"version": 1,
"content": [
{ "type": "html", "html": "<p>Intro paragraph</p>" },
{
"type": "table",
"rows": [
["A1", "B1", "C1"],
["A2", "B2", "C2"]
]
}
]
}
Tips:
- Keep the schema explicit (version field) so future changes (cell metadata, styles) are manageable.
- When exporting HTML, sanitize to avoid XSS if documents are shared (use DOMPurify or a similar library).
Clipboard interoperability notes
To maximize compatibility with other apps (Excel, Google Sheets, Notepad's new table feature):
- Always write both HTML and plaintext (TSV) to the clipboard when copying tables. Office apps usually prefer HTML, but TSV/CSV is a reliable fallback.
- On paste, prefer HTML table detection; if none, parse TSV/CSV and convert to a table automatically.
- Sanitize any pasted HTML to remove scripts and bad attributes using DOMPurify or a similar library.
Keyboard UX & accessibility
Keyboard-first UX is essential for a Notepad-like experience:
- Implement Tab / Shift+Tab navigation between table cells.
- Provide shortcuts for structural edits (Ctrl+Alt+R to add a row, Ctrl+Alt+C to add a column — optionally).
- Ensure every table cell is
contentEditable and focusable; add ARIA attributes for screen readers if you add headers.
Security considerations
By 2026, Electron apps default to safer config. Follow these principles:
- contextIsolation: true, and expose only necessary APIs via preload.
- Sanitize any pasted or loaded HTML with a trusted library (we used DOMPurify).
- Avoid enabling nodeIntegration in the renderer.
Advanced extensions & future-proofing
Once the minimal feature set is solid, consider:
- Adding cell-level metadata (metadata in JSON schema for formulas, data types).
- Implementing a small formula engine (lightweight, sandboxed) — store results and formulas separately in serialization.
- Adding importers for Excel/ODS using libraries (xlsx) — for heavy-duty work, use server-side conversion or a native-binding in a trusted context.
- Supporting collaborative editing: replicate the JSON schema over CRDT or operational transforms for real-time sync.
Testing & QA checklist
- Paste from Excel and Google Sheets: confirm table structure preserved.
- Copy table and paste into Notepad / Word / Sheets: verify HTML & TSV fallbacks.
- Test keyboard navigation, including edge cases (last cell creating new row).
- Verify no script tags or unsafe attributes survive a paste (DOMPurify test cases).
- Check cross-platform clipboard behavior on Windows, macOS, Linux.
Real-world case study (short)
At a mid‑sized engineering team in late 2025, we added a similar table feature to an internal Electron note app. The small feature reduced developer friction for copy/pasting structured test data from Excel into issue reports. Two lessons stood out:
- Make clipboard behavior predictable — users expect pasted content to land as a table if possible.
- Keep the serialization simple; teams converted JSON exports into CI scripts to populate test fixtures. See more on shipping micro-apps and CI patterns in From Micro-App to Production.
Design small, ship fast. A simple table implementation often solves 80% of user needs.
Actionable checklist to ship this feature
- Set up an Electron app with contextIsolation and a preload bridge.
- Implement table insertion and Tab navigation inside contenteditable cells.
- Wire copy/paste to write HTML + TSV and parse HTML/TSV on paste.
- Sanitize all pasted/loaded HTML with DOMPurify.
- Add save/load as JSON and export as sanitized HTML (see notes on serialization).
- Test cross-platform clipboard behavior and edge cases; instrument with observability.
Final notes & 2026 predictions
Simple, interoperable clipboard patterns and secure preload bridges will remain the right approach for desktop web-based editors in 2026. Expect greater clipboard consistency across platforms and more native-like features in small editors as Chromium and the OS clipboards continue refining HTML/plaintext semantics.
For teams shipping productivity features, the lesson is clear: start small, make copy/paste and serialization robust, and add advanced features (formulas, collaboration) incrementally.
Next steps
Clone the starter skeleton, wire up the snippets above, and iterate. If you ship a variant, consider publishing the JSON schema and a compatibility table for export/import cases.
Call to action
Try the code in your Electron app today. Build the minimal table editor, test paste from Excel/Sheets, and share a short PR or gist with improvements (keyboard shortcuts, ARIA attributes, or a formula layer). If you want, fork the project and open an issue describing your target interoperability — I’ll review and help prioritize features for real-world usage.
Related Reading
- From Micro-App to Production: CI/CD and Governance for LLM-Built Tools
- Developer Productivity and Cost Signals in 2026
- Building Resilient Architectures: Design Patterns to Survive Multi-Provider Failures
- Indexing Manuals for the Edge Era (2026)
- Observability in 2026: Subscription Health, ETL, and Real‑Time SLOs
- Double XP Weekends and Cloud Cost: How Publishers Manage Server Load Spikes
- Backpacks for Traveling with Investment Clothing: Protect Cashmere, Suede and Designer Pieces
- Cultural Trends vs. Cultural Respect: Hosting Neighborhood Events That Celebrate Global Fads
- How to Cover Sensitive Topics on YouTube and Still Earn Ads: Navigating the New Monetization Rules
- The Ethics of Fan-Made Star Wars Ringtones: Where to Draw the Line
AdvertisementRelated Topics
#desktop#electron#tutorialccodenscripts
Contributor
Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.
AdvertisementUp Next
More stories handpicked for you
serverless•8 min readPragmatic Script Composition for Edge‑First Apps: Observability, Cost Controls, and Local Dev — 2026 Playbook
review•9 min readField Review: PocketCam Pro in 2026 — Rapid Review for Creators Who Move Fast
ai•9 min readAI Pair Programming in 2026: Scripts, Prompts, and New Workflows
2026-01-24T04:55:18.846Z