Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

Table

The table plugin adds Notion's simple table, a grid of text cells. Tab between cells, add rows and columns from the table's edges, drag rows and columns by their grips, move and delete them from the grips' menus, resize columns within the page, header row and column. Opt-in.

createTablePlugin() adds Notion’s simple table: a grid of cells, each holding text (its lines are line breaks), in rows and columns. You make one with the slash menu or the block menu’s Turn into (“Table”), or by pasting an HTML table. Hover it to add a row (the + under it) or a column (the + beside it), and to reach a row’s and a column’s grip: drag it to move the row or column, click it for its menu (insert, move, delete). The grips, their menus and the + are your snippets, createTablePlugin({ grip, menu, add }): the plugin places them and gives them the drag, the keys and the ARIA, and without them shows none (Menus and handles). Drag a column’s right edge to resize it, as far as the page leaves room. The table’s block menu inserts and deletes rows and columns where the caret was, and has Notion’s Header row and Header column switches.

The plugin is not a default plugin: list it in plugins.

<script lang="ts">
	import { Edytor, createTablePlugin } from 'edytor';
	import 'edytor/themes/notion.css';

	const plugins = [createTablePlugin()];
</script>

<div class="edytor-notion">
	<Edytor {plugins} />
</div>

List it in every view that may open a document holding tables. A view without it does not know the kinds: it shows a table’s cells one under the other, as unknown kinds (the document still applies the rules below). Its stylesheet is imported with it.

Options

createTablePlugin(options) takes a TablePluginOptions; createTablePlugin() is the plugin with the defaults.

PropType
rows?number

The rows of a new table (the Table command).

Typenumber
Default3
columns?number

The columns of a new table.

Typenumber
Default3
columnWidth?number

A column's width in pixels when it sets none (Notion's).

Typenumber
Default120
minColumnWidth?number

The narrowest a resize leaves a column, in pixels.

Typenumber
Default48
grip?Snippet<[TableGripPayload]>

A row's and a column's grip, centered where the grip stands (without it, no grip); {@attach payload.grip} keeps its drag, its menu, its keys and its ARIA. See Menus and handles.

TypeSnippet<[TableGripPayload]>
menu?Snippet<[TableChrome]>

A grip's menu, under its grip (without it, a grip's click opens nothing); {@attach chrome.popup} and {@attach chrome.keys} on its element, each row of chrome.items attaches its option. See Menus and handles.

TypeSnippet<[TableChrome]>
add?Snippet<[TableAddPayload]>

The + under the table and beside it, laid along that edge (without it, no +). See Menus and handles.

TypeSnippet<[TableAddPayload]>
import { createTablePlugin, type Plugin } from 'edytor';

export const table: Plugin = createTablePlugin({ rows: 2, columns: 4, columnWidth: 160 });

The kinds

Kind Element Data Role
table div[data-edytor-table][role=table] { columns: { id: string; width?: number }[]; headerRow?: boolean; headerColumn?: boolean } A table (table: true): renders no content, default child tableRow
tableRow div[data-edytor-table-row][role=row] A container that renders no content, default child tableCell
tableCell div[data-edytor-table-cell][role=cell] { column: string } A text island: its text holds its lines as line breaks

table is a table: its kind record says table: true, a block role like layout, and its defaultChild is its row kind, whose defaultChild is the cell kind. The core never names the three kinds: every rule below is read from the role, so a kind of your own with table: true behaves the same. The rows are exported as data: tableKinds and tableSemantics on edytor/crdt/edytor, both part of defaultSemantics, so a headless document and the room read tables as a view does.

The columns are data: the table lists them in order in data.columns, each with an id and an optional width in pixels, and each cell names its column in data.column. Moving a column reorders that list and moves no cell; deleting one removes it from the list with its cells. Rows are blocks, in the table’s order. A header cell is one in the first row of a table whose headerRow is set, or in the first column of one whose headerColumn is; it renders as a columnheader or rowheader.

tableBlock builds a table as JSON, with column ids derived from their position (c1, c2, …), so a value holding it seeds the same table every time:

import { createDocument, tableBlock } from 'edytor';

const document = createDocument({
	value: {
		children: [
			tableBlock({
				cells: [
					['Name', 'Role'],
					['Ada', 'Engineering']
				],
				headerRow: true,
				widths: [160]
			})
		]
	}
});

A table written without columns (by hand) shows each row’s cells in their order; the first column operation on it lists its columns (c1, c2, … by position, as tableBlock names them, so two people doing it at once list the same ones) and names each cell’s column, in its own step.

What shows

The document shows a table by read-time rules, on every replica, whatever the stored tree holds (the table.* rows of the delete contract):

  • A row shows its cells in the table’s column order, whatever their stored order.
  • A cell shows only in a row of a table, for a column the table lists, once. A cell of a deleted column, a second cell for one column, or a cell left outside a row by a race does not show, with its text.
  • Undo never hides a collaborator’s text. Undoing a column insert takes the column out of the list, but a cell of it that holds another person’s text stays, in a column shown after the listed ones (its other rows padded), until that text is deleted. Typing in one of its padded cells lists the column again, last; deleting the column deletes its cells.
  • A row shows only in a table, and only while it shows a cell; a table only while it shows a row.
  • A row is padded to the table’s columns. A row showing no cell for a column (a collaborator’s row inserted while another person added a column) shows an empty placeholder there, the view’s alone: the first press in it creates the cell and puts the caret in it.
  • Any other block stored in a table or a row shows right after the table.

Editing

Keys In a cell
Typing, marks, inline atoms As in any text
Enter, Shift+Enter A line break in the cell: a cell never splits
Backspace at a cell’s start, Delete at its end Nothing: nothing merges into or out of a cell
Backspace at the start of the block after a table The caret goes to the end of the last cell
Tab The end of the next cell, row by row; in the last cell, a new row after it, the caret in its first cell (Notion)
Shift+Tab The end of the previous cell; in the first, nothing (the caret stays where it is)
↑ on a cell’s first line, ↓ on its last The cell above or below in the same column, on its last (first) line, nearest the caret’s horizontal position; out of the table, the browser moves the caret. A line is a line as it wraps on screen: in a wrapped cell the arrows move through its lines first, and the caret where the first (last) line wraps goes to that line before it leaves the cell
←, → Reading order: from a row’s last cell to the next row’s first

A text range across cells deletes the text it covers in each cell and removes no cell or row; with one end outside the table, the blocks between the end and the table go and nothing merges into a cell; a table wholly inside the range goes with it. The slash menu does not open in a cell, and a cell is never turned into another kind. A block selection never holds a row or a cell: selecting one (Mod+A twice in a cell) selects the table, so a block action (Delete, Duplicate, a move) acts on the whole table. A paste into a cell joins its lines with line breaks.

Rows and columns

Hover a table:

  • + under the table: a row at its end, the caret in its first cell.
  • + beside the table: a column at its end.
  • A row’s grip, left of the row: drag it up or down to move the row; click it for its menu: Insert above, Insert below, Move up, Move down, Delete row.
  • A column’s grip, above the column: drag it left or right to move the column, its cells with it; click it for its menu: Insert left, Insert right, Move left, Move right, Delete column.
  • A column’s right edge: drag to resize it (a live preview, written at the release, one undo step; Escape puts it back). Each edge is also a focusable separator: → and ← widen or narrow the column by 8px, with Shift by 32px.

While a grip drags, a blue line shows between the two rows (columns) where it would land, and a picture of the row (column) follows the pointer; the table’s box scrolls sideways, and the page up and down, when the pointer nears their edges. The drop is the menu’s move: one undo step, the caret staying in its cell. A drop at the row’s own place, more than 32px outside the table, or after Escape moves nothing. The menus’ Move items stay the keyboard’s way.

A grip is a button (aria-haspopup="menu", aria-expanded): Enter, Space or ↓ on it opens its menu, Escape gives the keys back to the editor. The menu is a menu that takes the focus and names its keyboard’s row (aria-activedescendant): ↓ and ↑ walk the items that can run (Move up on the first row cannot: aria-disabled), Home and End reach the ends, Enter or Space runs the item, Escape or Tab closes the menu and gives the keys back to the editor. The grips, the menus and the + bars take your own markup: see Menus and handles.

A resize never makes the table wider than the room it has: the page’s text column, or the column of a layout (or the toggle, the callout) it sits in. The dragged column grows into the room the table leaves free, then into the column on its right, which narrows by as much (down to minColumnWidth): both widths are written together, one undo step. Narrowing a column gives nothing back: its neighbour keeps its width. The last column has no neighbour, so it grows only into free room. The preview and the widths written are the same, and no column narrows below minColumnWidth. A table already wider than its place (widths set on a wider screen, by you or someone else, or a narrower window) keeps its stored widths and scrolls sideways inside its own box, which never reaches past the page; there it gets no wider: a column grows only by what its neighbour gives. Only what the box shows has chrome: a column edge or grip scrolled out of view has none, and the + beside the table shows once its last column is in view.

Deleting a table’s last row or last column deletes the table. Every action is one undo step, refused in a readonly view (which shows none of these controls). The table’s own block handle drags, duplicates and deletes the whole table; rows and cells have no handle.

The block menu of a table (BlockDefinition.menu, rows of KindMenuAction) adds the keyboard’s way to the rows and columns: Insert row above, Insert row below, Insert column left, Insert column right, Delete row and Delete column, at the row and column of the cell the caret was last in (the grip that opens the menu keeps it; with no caret in the table, its last row and column). An insert closes the menu and puts the caret in the new cell. Then two switches, Header row and Header column, each one data write and one undo step; the menu stays open.

Clipboard

  • An HTML <table> pasted outside a table imports as a table: a column per cell of its widest row, its first row a header row when every cell of it is a <th> (a header column when every row starts with one). Marks inside the cells are kept.
  • A copied table exports <table><tbody><tr><td>…, the header row’s and header column’s cells as <th>, and plain text with cells separated by tabs, rows by line breaks.
  • Pasted into a cell, anything (a table included) lands as its text, its lines joined by line breaks.
  • Cells copied out of a table with a text range paste elsewhere as lines of text, never as stray cells.
  • An Edytor fragment (a copy from one editor to another) keeps the table as stored.

Collaboration

Every replica applies the read-time rules above, so two people editing a table at once see the same grid after they sync:

  • One person inserts a row while another deletes a column: the new row’s cell for the deleted column does not show.
  • One person inserts a column while another inserts a row: the new row is padded in the new column until someone types there.
  • One person types in a cell while another deletes its row: the row goes, with the text.
  • One person moves a column while another inserts a row: the new row shows its cells in the moved order.
  • One person inserts a column, another types in it, and the first undoes the insert: the typed text stays, its column shown last (see What shows).
  • Two people filling the same padded cell at once each create a cell; one shows (the first by block id), and text typed in the other before they synced does not show.
  • On a table written without columns, a column deleted while another person runs any other column operation stays listed, emptied: both list the columns at once, and the delete loses to the other’s list.

These rules come from the document’s roles: list the plugin in every view; a headless document holds them by default (defaultSemantics), as the room does. See concurrent editing.

Styling

table.css lays the table out as a CSS grid of the columns’ widths, each row a subgrid, in a box as wide as the table’s place: the table gives that box no width of its own (contain: inline-size), so a page that sizes the editor by its content does not grow with a wide table, and the grid scrolls sideways inside the box when it is wider. It also places the chrome: the grips, the + bars, the menus, a column edge’s resize line and a drag’s drop line. The table’s look (the cells’ borders and padding, a header cell’s shade, the resize and drop lines’ blue) is the Notion theme’s: without it the grid shows its cells unbordered and the lines unpainted.

Variable Default Sets
--edytor-table-border rgb(233, 233, 231) The cells’ borders (the Notion theme)
--edytor-table-header-background rgb(247, 246, 243) A header cell’s background (the Notion theme)
--edytor-table-resize-color rgba(35, 131, 226, 0.43) A column edge’s resize line (the Notion theme)
--edytor-table-drop-color rgba(35, 131, 226, 0.43) The drop line of a row’s or column’s drag (the Notion theme)
Selector Matches
[data-edytor-table] The table’s block element (data-edytor-table-header-row, data-edytor-table-header-column when set)
[data-edytor-table-grid] The grid (its style sets the columns’ widths)
[data-edytor-table-row] A row
[data-edytor-table-cell] A cell (data-edytor-column: its position; data-edytor-table-header="true" for a header cell)
[data-edytor-table-pad] A padded cell’s placeholder
[data-edytor-table-scroll] The table’s box, which scrolls the grid sideways
[data-edytor-table-resize] A column edge’s resize band, in the overlay (data-dragging="true" while it drags); its ::after is the line, shown on hover, focus and drag
[data-edytor-table-grip-host], [data-edytor-table-add-host], [data-edytor-table-menu-host] Where your grip (a point it centers on, data-dragging="true" on the grip while it drags), + bar and menu stand
[data-edytor-table-drop] The drop line of a grip’s drag (row or column, data-gap: its position among the rows or columns)
[data-edytor-drag-preview] The picture following the pointer during a grip’s drag: the row’s or the column’s cells as they look, as a block drag’s ghost
[data-edytor-table-menu] Your grip’s menu (="row" or "column", set by popup; its keyboard’s row is the item whose selected is true)

Document API

The table operations come with the plugin, not with the document: each one prepares one plan over any document’s operations (an EdytorDocument, a view’s edytor.document, a room’s transact callback), composed of the document’s own patchData, insertBlocks, deleteBlocks and moveBlocks, which document.apply(…) writes as one transaction and one undo step. The document needs roles holding a table kind (a view with the plugin, or defaultSemantics, a headless document’s default):

import {
	createDocument,
	deleteTableColumn,
	insertTableColumn,
	insertTableRow,
	moveTableColumn,
	tableBlock
} from 'edytor';

const document = createDocument({
	value: { children: [{ ...tableBlock({ cells: [['a', 'b']] }), id: 't' }] }
});

document.apply(insertTableRow(document, 't', 1)); // a row of empty cells at the end
document.apply(insertTableColumn(document, 't', 0, 200)); // a 200px column first, a cell in every row
document.apply(moveTableColumn(document, 't', 'c2', 0)); // reorder: no cell moves
document.apply(deleteTableColumn(document, 't', 'c1'));
document.tableGrid('t'); // { columns: [...], rows: [{ id, cells: [...] }] }

Each operation takes the document first and answers a plan (or a refusal) that apply writes; its ids are the new row or cell, the rows, else the table.

API What it does
insertTableRow(document, table, index) A row with one empty cell per column at index among the rows
deleteTableRows(document, rows) The rows with their cells; a table left with no row goes too
insertTableColumn(document, table, index, width?) A new column in data.columns and an empty cell for it in every row
deleteTableColumn(document, table, column) The column (by id or position) and its cells (every entry of data.columns naming it); the last column takes the table
moveTableColumn(document, table, column, to) One reorder of data.columns
moveTableRows(document, rows, to) A move among the table’s rows (refused out of it)
fillTableCell(document, row, column) Creates a padded cell (in a column shown unlisted, lists it again, last)
isTable(id), isTableRow(id), isTableCell(id), tableOf(id) The document’s reads: whether a block is a table, its row or its cell, and the table holding it
tableColumns(table) The document’s read of the listed columns ({ id, width? }[]), or null
tableGrid(table) The document’s read of the column ids and each shown row’s cells by column, null for a padded cell

Header flags and widths are data patches: document.patchData(table, [{ path: ['headerRow'], value: true }]), and a width on the column’s entry by its item id, [{ path: ['columns', document.dataItemIds(table, ['columns'])[i], 'width'], value: 200 }] (a position works too, but counts every entry the stored array holds). tableGrid lists the listed columns, then any column it shows unlisted (an undone insert’s, holding someone’s text); deleteTableColumn and fillTableCell take its id or position, moveTableColumn refuses it. A cell never moves as a block, nothing merges into or out of one and it never splits (canPlace, canMerge, splitBlock refuse); a row moves only within its table. In a view, the plugin’s commands dispatch them as insertTableRow, deleteTableRows, insertTableColumn, deleteTableColumn, moveTableColumn, moveTableRows and fillTableCell, so hooks see and may refuse them.

Limitations

  • No merged cells, no cell selection (a mouse drag across cells is a text range), no cell colours, no sorting or formulas: this is Notion’s simple table, not a database.
  • Copying part of a table copies the cells’ text, not a smaller table.
  • A client of an earlier pre-release shows a stored table as unknown kinds, one cell under the other.

Was this page helpful?