TextKit

TextKitEditableTable

TextKitEditableTable is an editable, Notion-style rich table for a table embed. Where the embed placeholder shows a one-line label in the text and the embed popup usually renders content read-only, this component lets the user actually mutate the table: add/remove rows and columns, merge and split cells (true colspan/rowspan), toggle header cells, and edit cell text inline.

It is designed to live inside the embed popup and to work equally well with touch (mobile/tablet) and pointer (web/desktop). Every change auto-syncs back to the host document — there is no manual “save” step — and the component carries its own undo/redo for the table’s structure and content.

Table of contents

How it fits in

A table is an embedded block: a table node stored verbatim in the document and rendered inline as a clickable placeholder. Tapping the placeholder opens TextKitEmbedPopup, and for EmbedTypes.Table the popup body is TextKitEditableTable. The chain is:

document JSON ──▶ EmbedInfo.rawJson ──▶ TextKitEditableTable(rawJson, onSync)
       ▲                                          │
       └──────────── onSync(newJson) ◀────────────┘   (state.updateActiveEmbed)

So editing a cell updates the table’s JSON, which flows through onSync back into the embed, which updates the document — and it all round-trips losslessly.

Zero-config: through the embed popup

If you already render TextKitEmbedPopup next to the editor (see the main README), table editing is on automaticallyTextKitEmbedPopup wires TextKitEditableTable for EmbedTypes.Table and defaults onSync to state.updateActiveEmbed(...):

import com.jjrodcast.textkit.ui.TextKitEditor
import com.jjrodcast.textkit.ui.TextKitEmbedPopup

Box {
    TextKitEditor(state = state)
    TextKitEmbedPopup(state = state) // table embeds become editable, changes auto-sync to the document
}

To insert a table so there’s something to edit, use insertEmbed (e.g. from a / slash command):

import com.jjrodcast.textkit.editor.core.parser.EmbedTypes

state.insertEmbed(embedType = EmbedTypes.Table, rawJson = tableJson, label = "📊 Table")

Everything below is only needed if you want to host the table yourself or understand what happens under the hood.

Standalone usage

TextKitEditableTable is a self-contained composable with just two required parameters:

import com.jjrodcast.textkit.ui.table.TextKitEditableTable

@Composable
fun MyTableEditor(tableJson: String) {
    var current by remember { mutableStateOf(tableJson) }

    TextKitEditableTable(
        rawJson = current,             // ProseMirror `table` JSON
        onSync = { updated -> current = updated }, // called after EVERY edit
        modifier = Modifier.padding(12.dp),
    )
}
Parameter Type Description
rawJson String The ProseMirror table JSON. It seeds the editor; a genuine external change to it is reloaded in place. Malformed input falls back to a starter 3×3 grid.
onSync (String) -> Unit Called with the updated table JSON after every edit (auto-sync).
modifier Modifier Layout modifier for the root.

Wrap it in TextKitTheme { } (or TextKitScreen) so it can read colors and typography — see Theming.

Interaction model

The UI is gutter-driven, like Notion, so selecting a line never fights with editing a cell:

Element Where What it does
Column handle thin strip along the top Tap to select that column; tap more to grow the selection.
Row handle thin strip along the left Tap to select that row; tap more to grow the selection.
Corner handle top-left corner Clears the current selection (shows a ✕ while a selection exists).
+ handles far right (column) and bottom (row) edges Append a new column / row.
Cell body inside each cell Plain inline editable text.
Action rail icon column to the right of the grid Acts on the current selection (see below).

Action rail buttons (each enabled only when applicable):

Icon Action Enabled when
Merge Merge the selected rectangle into one cell The selection is a clean rectangle covering >1 cell
Split Split a merged cell back into single cells Exactly one merged cell is selected
Header (Title) Toggle header styling on the selected block Anything is selected
Delete Delete the selected rows or columns Anything is selected
Undo Undo the last table change There is history to undo
Redo Redo the last undone change There is a redo step

The rail is a single column when the popup is tall enough, and falls back to two columns in short layouts (typically mobile landscape) so every action stays reachable without scrolling.

Selection is line-based: you pick whole rows and/or columns, and the acted-upon region is their bounding box. A hint under the grid reminds users how to select.

Auto-sync (no manual save)

There is no “Sync”/”Save” buttononSync fires after every committed change (typing, add, delete, merge, split, header toggle, undo, redo). The state is the single source of truth and is deliberately not rebuilt from its own emitted JSON, so:

Echo detection is exact-match first (the string it last emitted), then canonical comparison (re-serialize both and compare), so cosmetic reformatting by the host doesn’t trigger a needless rebuild.

Undo / redo

The table keeps its own bounded history (default 100 steps) covering both structure and cell text, exposed to the rail via observable canUndo / canRedo flags. Consecutive keystrokes in the same cell coalesce into a single undo step (mirroring how the text editor groups typing); structural actions (add/delete/merge/split/header) are each their own step. History is cleared when the table is reloaded from an external change.

This is independent of the main editor’s undo/redo. Inside the table popup, undo/redo affects the table; the document-level history still sees each synced table change as one embed update.

Merge & split rules

Spans are never stored directly: the model stores a cell id in every grid slot the cell covers and derives spans from that grid, so a merge/split/add/delete can’t leave spans inconsistent.

Header cells

Toggle header styling on any selected block with the Header rail action. Header cells serialize as tableHeader (vs tableCell) and render with a more prominent color (primaryContainer + semibold), so they stand out from body rows. Toggling is all-or-nothing over the selection: if every selected cell is already a header, it turns them all off; otherwise it turns them all on.

JSON format

TextKitEditableTable reads and writes a ProseMirror-style table node. This is exactly the rawJson carried by a table embed, so it round-trips through the document unchanged:

{
  "type": "table",
  "content": [
    { "type": "tableRow", "content": [
      { "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": null },
        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Product" }] }] },
      { "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": null },
        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Region" }] }] },
      { "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": null },
        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Sales" }] }] }
    ]},
    { "type": "tableRow", "content": [
      { "type": "tableCell", "attrs": { "colspan": 2, "rowspan": 1, "colwidth": null },
        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Subtotal" }] }] },
      { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": null },
        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "1200" }] }] }
    ]}
  ]
}

Nodes

Node Role Notable attrs
table Root of the block
tableRow One row; content is a list of cells
tableCell A body cell colspan, rowspan, colwidth
tableHeader A header cell (styled) colspan, rowspan, colwidth

Parsing is tolerant: unknown keys are ignored, ragged rows are padded, holes are filled with empty cells, and completely malformed input falls back to a 3×3 starter grid.

Theming

Every part of the table — cells, gutters, handles, the action rail — reads its colors and font from TextKitTheme, so it adapts to light/dark automatically. Key role usage:

Element Role
Body cell background / text surface / onSurface
Header cell background / text primaryContainer / onPrimaryContainer
Selected line handle / highlighted cell primary (translucent fill for highlighted cells)
Cell borders & hairlines outlineVariant
Gutters, rail buttons surfaceVariant / onSurfaceVariant
Disabled rail button content onSurfaceVariant at reduced alpha

Provide the theme somewhere above the table:

TextKitTheme(darkTheme = isSystemInDarkTheme()) {
    TextKitEditableTable(rawJson = json, onSync = { /* ... */ })
}

Layout dimensions

Sizes live in one place (TextKitTableConstants) so the grid, gutters and rail size from a single source of truth:

Constant Default Meaning
ColumnWidth 132.dp Fixed width of every column
MinRowHeight 44.dp Minimum row height (rows grow to fit content)
GutterSize 26.dp Thickness of the row/column selection gutters
AddSize 26.dp Thickness of the + add-row / add-column handles
MaxTableHeight 360.dp Max height of the scrollable table area (so it never overflows the popup)
RailButtonSize 42.dp Diameter of a rail action button

The grid uses a custom Layout so merged cells span multiple rows/columns correctly, and both axes scroll when the table is larger than the popup.

Localization

All user-facing strings are in composeResources/values/strings.xml and resolved via stringResource, so they localize with the rest of your app:

Key Default
table_merge_cells_text “Merge cells”
table_split_cell_text “Split cell”
table_toggle_header_text “Toggle header”
table_delete_selection_text “Delete selection”
table_clear_selection_text “Clear selection”
table_add_text “Add”
table_selection_hint_text “Tap the edges to select rows or columns; use the icons to merge, split, or delete.”
undo_text / redo_text “Undo” / “Redo”

Behavior notes & invariants