6.3 KiB
Table Fields — Engineering
How typed, interactive columns are layered onto a plain Markdown pipe table without ever leaving plain Markdown. Single-file plugin (
main.js), plain JS, no build step.
English · 中文 — ‹ User README
Architecture
flowchart TD
MD["📄 Markdown note<br/>(source of truth)<br/>pipe table + <!-- table-fields --> config comment"]
MD --> PARSE["Parse layer<br/>findSmartTables · parseConfigBlock<br/>getCellRanges"]
PARSE --> RV["Reading view<br/>registerMarkdownPostProcessor<br/>→ enhanceTables()"]
PARSE --> LP["Live Preview<br/>CM6 StateField (smartTableField)<br/>→ buildSmartTableDecorations()"]
PARSE --> SM["Source mode<br/>editorLivePreviewField guard<br/>→ no-op, raw Markdown"]
RV --> CELLS["Interactive / formatted cells<br/>checkbox · select · date · currency · percentage"]
LP --> WIDGET["SmartTableWidget (block Decoration.replace)<br/>renders its own <table>"]
WIDGET --> CELLS
WIDGET --> MENU["Right-click header<br/>showColumnMenu() → Obsidian Menu"]
CELLS -->|"toggle / pick"| WB["Write-back<br/>editor.setLine · vault.process · view.dispatch"]
MENU -->|"set type"| WCFG["Rewrite config comment<br/>serializeConfig()"]
WB --> MD
WCFG --> MD
LP -. "cursor enters table range" .-> RAW["Yield to source<br/>(decoration skipped)"]
The Markdown note is the single source of truth. Nothing is stored outside it — configuration lives in an HTML comment directly above each table, and every edit is written straight back into the pipe table. Three render surfaces read the same source; only Live Preview replaces the rendering.
How the features work
Config as an adjacent HTML comment
Each smart table is declared by a <!-- table-fields id="…" v="1" cols: … --> comment immediately
above the pipe table. findSmartTables() scans the doc for these, pairs each with the table below
it (skipping blank lines), and records the comment's line span, the header line, and the last data
row. parseConfigBlock() turns the comment body into { id, version, cols[] }. A table with no
comment is never touched.
Reading view — markdown post-processor
registerMarkdownPostProcessor fires per rendered block. enhanceTables() calls
ctx.getSectionInfo(el) to recover the table's source line range, matches the config, then
rewrites each <td> into a control (enhanceCell). This is the low-risk path and works in both
Reading view and inactive Live Preview blocks.
Live Preview — CodeMirror 6 state field
Obsidian renders Live Preview tables with its own CM6 extension, so a post-processor can't reach
them. Instead, smartTableField (a StateField) computes a DecorationSet:
- For each table whose source range the cursor is outside, it adds a block
Decoration.replace({ widget, block: true })spanning the config comment + table lines, rendering aSmartTableWidget(our own interactive<table>). - When the selection intersects a table's range, that table is skipped → Obsidian shows raw source so the user can edit it directly. This is the deliberate "controls when idle, source when editing" behavior.
editorLivePreviewFieldgates the whole thing off in Source mode.
Block-spanning replacements must come from a StateField (a view plugin throws on line-break
replacement), which is why this is a field, not a view plugin.
Write-back to the exact source range
Cell offsets are computed by getCellRanges() (character ranges between pipes). Editing a control
replaces exactly that range:
- Live Preview widget →
view.dispatch({ changes: { from, to, insert } })(dispatchCell). - Reading view → the active
MarkdownVieweditor'ssetLine, orvault.processas a fallback.
Values are stored canonically ([x]/[ ], ISO dates, plain numbers, NN%); formatting is
display-only.
Right-click column typing
SmartTableWidget.showColumnMenu() opens an Obsidian Menu on header right-click. Choosing a type
rebuilds the config object and writes it back with serializeConfig() over the comment's source
range — no settings UI, no sidebar.
The "mark table" command
markTableAsSmart() finds the table around the cursor, infers each column's type from a sample row
(inferType: checkbox / date / percentage / currency / text), and inserts a ready-made config
comment.
Key modules (all in main.js)
| Symbol | Responsibility |
|---|---|
parseConfigBlock / serializeConfig |
Parse ⇄ serialize the <!-- table-fields … --> comment |
findConfigForTable / findSmartTables |
Locate config for a table (reading view / whole-doc scan) |
getCellRanges |
Character offsets of each cell (between pipes) — the basis of write-back |
enhanceTables / enhanceCell |
Reading-view rendering + controls |
smartTableField / buildSmartTableDecorations |
Live Preview CM6 decorations |
SmartTableWidget |
The block widget: renders the interactive table + right-click menu |
renderWidgetCell / dispatchCell |
Live Preview cell controls + write-back |
MarkdownSmartTablesPlugin |
onload: registers post-processor, editor extension, command |
Extending it
- New column type: add a
casein bothenhanceCell(reading) andrenderWidgetCell(LP); add it to thetypeslist inshowColumnMenuand toinferTypeif it should be auto-detected. - New right-click action (add/delete row, edit options, sort): add a
menu.addIteminshowColumnMenu(or a cell-levelcontextmenu), compute the target source range fromgetCellRanges/ the table's line span, andview.dispatchthe text edit. Everything is a source transform — no new storage.
Dev & build
- No bundler.
require("obsidian")andrequire("@codemirror/*")resolve to Obsidian's bundled copies at runtime — never bundle your own copies or the CM6 classes won't match. - Ship
main.js,manifest.json,styles.css,versions.json. - Syntax check:
node --check main.js.
Testing
Pure logic (config parsing, pipe-cell range replacement, load/wiring) is verified with an offline
Node harness that stubs the obsidian module — no Obsidian GUI needed. The CM6 Live Preview path
and the post-processor path are verified manually in Obsidian against the example note.