Notepad tables shipped — build a minimal rich text table editor in Electron
desktopelectrontutorial

Notepad tables shipped — build a minimal rich text table editor in Electron

ccodenscripts
2026-02-08 12:00:00
11 min read
Advertisement

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:

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

  1. Paste from Excel and Google Sheets: confirm table structure preserved.
  2. Copy table and paste into Notepad / Word / Sheets: verify HTML & TSV fallbacks.
  3. Test keyboard navigation, including edge cases (last cell creating new row).
  4. Verify no script tags or unsafe attributes survive a paste (DOMPurify test cases).
  5. 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

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.

Advertisement

Related Topics

#desktop#electron#tutorial
c

codenscripts

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.

Advertisement
2026-01-24T04:55:18.846Z