Table Editor Enhancements — PRD
Table Editor Enhancements
Context
The Table Repository already ships (PR #755, table-repository.md): Tables are
standalone, reusable PlateJS objects created/edited in the Table Bank editor
(/table-bank/create, /table-bank/edit/:id), stored in the tables collection
as opaque table_content JSON, and referenced read-only elsewhere via the
table_ref node (in Blocks and MCQ question/solution). Today an editor can build
a grid, merge/split cells, set per-side borders, fill a cell’s background, toggle
header row/column, drag-reorder rows, and resize columns — plus inline text marks
(bold/italic/etc.) with no toolbar (keyboard shortcuts only).
This PRD is a delta that makes the Table Bank editor a genuinely usable content-authoring surface. It adds: images in cells, in-cell formatting (lists/alignment/spacing), MCQ/Docket links in cells, a real formatting toolbar, clearer default dividers and border contrast, row/column selection for bulk formatting, and a fix for resizing tables that contain merged cells.
Scope decision (decided): the work lands in the Table Bank editor and the read-only
table_refrenderer. Because the read-only embed reuses the sameuseCreateTableEditorfactory inreadOnlymode, every plugin registered for editing also lights up in the embedded view — so editing + display are one change, not two.
Backend decision (decided): no backend changes.
table_contentis stored as opaque JSON (validated only for “valid JSON + root node is a single{"type":"table"}”). All new content (images, links, list/alignment/spacing props, border metadata, column widths, row heights) round-trips inside that JSON. In-cell references (images, MCQ/Docket links) stay opaque in the JSON — nolinked_*tracking is added to thetablesmodel. The entire feature is frontend-only in keystone-web.
This PRD does not re-spec the Table Repository’s linkage / taxonomy / archive machinery, nor does it make tables editable inline inside MCQ/Blocks (they stay read-only references).
Scope
MVP — turn the Table Bank editor into a real authoring surface, across two shared foundations and seven capabilities:
Foundations
- F0 — Rich cell container. A cell becomes a block container (multiple paragraphs, lists, images, links mixed freely) instead of a single inline-only paragraph. This is the foundation A and G sit on.
- F1 — Formatting toolbar. A fixed toolbar at the top of the Table Bank editor that hosts text marks, lists, alignment, line-height, image insert, MCQ/Docket link insert, and the existing structure operations. The editor has no formatting toolbar today.
Capabilities (the seven reported items)
- A — Images in cells. Insert one or more images into a cell from the Image
Bank (by
short_uid, the same flow as Block/MCQ). - B — Resize merged tables. Column width and row height resize works correctly even after cells are merged; plus numeric width/height controls.
- C — Default dividers. Tables show clear vertical and horizontal dividers by default (closely-spaced columns no longer look messy).
- D — Border contrast. Grey cell fills and the grey header no longer blend into the grey borders.
- E — Row/column formatting. Select a whole row or column (or a dragged range) and apply formatting to every cell at once.
- F — MCQ/Docket links in cells. Insert an MCQ or Docket UID link into a cell (the same flow as Block/MCQ).
- G — In-cell text formatting. Bulleted/numbered lists with nesting, horizontal + vertical alignment, and line-height presets, inside cells.
Functional Requirements
- An editor can insert an image into any cell via the toolbar, entering an Image
Bank
short_uid, previewing it, and inserting; multiple images per cell are allowed; images render clamped to the cell/column width with no caption. - An editor can apply bullet/numbered lists (with indent/outdent nesting), horizontal text alignment (left/center/right/justify), vertical cell-content alignment (top/middle/bottom), and a line-height preset to cell content.
- An editor can insert an MCQ or Docket link into a cell; in read-only views the link navigates to the MCQ/Docket preview, identical to Block/MCQ behavior.
- A newly created table shows a full grid of dividers; already-saved tables also show dividers when opened or embedded, without needing a re-save; an editor can still remove specific borders per side.
- Cell fills and the header row stay visibly distinct from cell borders.
- An editor can select a row (row handle), a column (column header), or a dragged cell range, and apply any formatting — marks, color, alignment, line-height, lists — to every cell in the selection at once.
- Dragging the right edge of a merged cell changes the table’s visible width, and dragging the bottom edge changes the row’s visible height; an editor can also set a selected column’s width and a row’s height numerically.
- Everything above renders correctly in the read-only
table_refembed wherever a table appears (Block, MCQ question, MCQ solution).
Foundations
F0 — Rich cell container
Cell model (decided): cells are full rich block containers — text + bullet/numbered lists + images + links, multiple blocks stacked. Header cells keep their header semantics.
Behavior. A cell can hold any mix of paragraphs, list items, and block images, in any order. Empty cells still normalize to a single empty paragraph.
Approach (current system). In use-create-table-editor.ts register the
block-level plugins the cell now needs (paragraph already present; add list/indent,
image, link — see A/F/G). Relax the table-cell normalization so a cell accepts
block children beyond one paragraph. The NodeIdPlugin id-stamping at
use-create-table-editor.ts:79 (required for merge) must keep stamping nested
block nodes; verify ids on the new child node types and that stripIds
(table-editor.tsx:47-60) still recurses them out at save. The read-only embed
inherits all of this through the shared factory in readOnly mode.
F1 — Formatting toolbar
Toolbar (decided): a fixed top toolbar in the Table Bank editor (the MCQ editors’ pattern), hosting all formatting + insert controls in one place.
Behavior. A persistent toolbar above the editor canvas exposes: text marks (bold/italic/underline/strikethrough/sub/superscript), font color + highlight, lists, indent/outdent, horizontal alignment, vertical cell alignment, line-height, image insert, MCQ link, Docket link. Existing structure operations (insert/delete row/column, merge/split, borders, cell fill, header toggle) remain available.
Approach (current system). table-editor.tsx:250-265 currently renders only an
“Insert table” button. Replace that bar with a fixed toolbar component modeled on
mcq-fixed-toolbar-buttons.tsx / mcq-fixed-toolbar-plugin.tsx, reusing the
existing plate-ui button components (MarkToolbarButton, list/align/line-height
buttons, InlineImageToolbarButton, MCQUIDLinkToolbarButton,
DocketUIDLinkToolbarButton). The table structure controls currently live in the
floating TableFloatingToolbar (table-element.tsx:140+) and stay there (or are
mirrored into the fixed bar) — no need to remove them.
Capabilities
A — Images in cells
Images (decided): insert from the Image Bank by
short_uid(not a raw URL); block figure clamped to cell/column width; multiple per cell; no caption inside cells.
Behavior. Toolbar → image button → enter short_uid → preview → insert. The
image appears on its own line in the cell, sized to fit the cell width. Editors can
add several images to one cell and delete/replace via the existing image popover.
Approach (current system). Register ImagePlugin + CaptionPlugin and map
ImagePlugin.key → InlineImageElement in use-create-table-editor.ts. Reuse
inline-image-toolbar-button.tsx (the short_uid → preview → insert dialog) and
inline-image-popover.tsx (edit/delete) verbatim. The image node is the existing
{ type:'img', short_uid, default, variants, alt, … }; rendering reuses
InlineImageElement (fetches via imageBankApi.getImages, signs via
buildImageUrl). Suppress the caption affordance for the in-cell context and rely
on max-w-full for clamping. block-image-paste-plugin.ts (paste-blocking) should
also apply here so raw clipboard images can’t bypass the Image Bank.
G — In-cell text formatting
Lists (decided): bullet + numbered with nesting (the repo’s
@platejs/indentindent-list model). Alignment (decided): horizontal text align (L/C/R/justify) and vertical cell-content align (top/middle/bottom). Spacing (decided): line-height presets (e.g. 1.0 / 1.15 / 1.5 / 2.0).
Behavior. Inside a cell, an editor can make bulleted/numbered lists and indent/outdent to nest them; set horizontal text alignment; set the cell’s content vertical alignment; and pick a line-height preset. All are reachable from the F1 toolbar and apply to the current cell (or a selection — see E).
Approach (current system). Register IndentPlugin + IndentListPlugin (lists
as indented paragraphs — no nested <li> trees, consistent with the main editor),
the alignment plugin (align prop on paragraphs), and the line-height plugin
(lineHeight on blocks) in use-create-table-editor.ts. Horizontal align +
line-height are block props handled by existing plugins/buttons. Vertical cell
alignment is custom: add a verticalAlign prop on the cell node and apply it as
CSS vertical-align (top/middle/bottom) in table-cell-element.tsx, with a
toolbar control to set it. Toolbar buttons reuse the existing plate-ui
list/align/line-height components.
F — MCQ / Docket links in cells
Links (decided): MCQ and Docket UID links; opaque in JSON (no backend reference tracking on the
tablesmodel).
Behavior. Toolbar → MCQ link (or Docket link) → enter UID → validate →
optional link text → insert. In read-only views (Block/MCQ embeds), the link
navigates to /mcqs/{uid}/preview or /dockets/{uid}/preview with the same hover
card as Block/MCQ content.
Approach (current system). Register LinkPlugin + mcqLinkNormalizePlugin
and map LinkPlugin.key → LinkElement in use-create-table-editor.ts. Drop
MCQUIDLinkToolbarButton and DocketUIDLinkToolbarButton
(uid-link-toolbar-button.tsx) into the F1 toolbar. The node is the existing
{ type:'a', url, uid, uidType:'mcq'|'docket' }; validation hits mcqApi.list /
docketApi.list; rendering + navigation reuse LinkElement. The normalize plugin
restores uid/uidType for links loaded from older content. No tables-model
fields are added — the links live only inside table_content.
C — Default dividers
Dividers (decided): CSS base grid in the component (not seeded data), colored by the new
--table-bordertoken; fixes already-saved tables; explicit per-side “None” overrides the base.
Behavior. Every table — new and already-saved — shows light vertical and horizontal dividers by default. The existing per-side border controls still let an editor thicken, recolor (via token), or remove a specific side.
Approach (current system). PlateJS is headless — all table CSS is ours.
table-cell-element.tsx:71-78 draws borders via a before: pseudo-element only
when borders.{side}.size is set, so freshly-inserted tables (no borders data)
render no dividers. Add an always-on default border on td/th in
table-cell-element.tsx using the new --table-border token (with the <table>
already border-collapse at table-element.tsx:121); the existing before:
per-side system layers explicit overrides on top. Handle the explicit-”None” case
so a side an editor deliberately removed renders without a base line (distinguish
“no border data → show base grid” from “explicit size 0 → no line”). Because this
is presentational, it fixes existing tables with no re-save and shows identically
in read-only embeds.
D — Border contrast
Contrast (decided): a dedicated darker
--table-bordertoken, distinct from--border.
Behavior. Cell fills (including the grey presets) and the grey header row stay clearly separated from cell borders.
Approach (current system). The border color is hardcoded hsl(var(--border))
at table-cell-element.tsx:72-78; --border (src/index.css:68/116/151/180,
tailwind.config.js:48) is a near-grey that collides with the fill presets in
table-cell-fill-toolbar-button.tsx. Add a new --table-border token (a darker
grey, light + dark theme) in src/index.css and reference it for both the C base
grid and the per-side before: borders. Single token change drives C and D.
E — Row / column selection for bulk formatting
Selection (decided): row handle + column header + drag; all formatting (marks, color, alignment, line-height, lists) applies across the selection.
Behavior. Click a row’s handle to select the row, click a column header to select the column, or drag across cells for an arbitrary range; then any toolbar formatting applies to every cell in the selection.
Approach (current system). Today block-selection-plugins.tsx excludes
td and only rows are block-selectable; the existing drag range
(getTableGridAbove(editor,{format:'cell'})) feeds only merge/border/fill. Enable
cell block-selection (drop the td exclusion). Marks come for free — plate-table
ships withMarkTable, which already applies addMark/removeMark across all cells
in a selected range. For block-level formats (alignment, line-height, lists)
add a custom helper that iterates the selected cells (getTableGridAbove) and
applies the transform to each cell’s content. Add a column-header select
affordance (the column analogue of the row handle in table-row-element.tsx);
reuse the existing row selection and drag range.
B — Resize after merge
Resize (decided): fix the drag handlers (span-aware) and add numeric width/height controls.
Behavior. Dragging a merged cell’s right edge changes the table’s visible width; dragging its bottom edge changes the row’s visible height. Editors can also type/step a selected column’s width and a row’s height.
Approach (current system). Root cause: plate-table’s
useTableCellElementResizable resize handlers use the cell’s start index and
ignore span — handleResizeRight (plate-table dist/react/index.mjs:3557-3572)
resizes colIndex / colIndex+1, and handleResizeBottom (:3575) targets
rowIndex. For a cell merged across columns 0–1 the right edge then shifts an
internal, hidden boundary, leaving the merged cell’s outer width
(colSizes[0]+colSizes[1], per :1220) unchanged → “resize does nothing.” Fix by
overriding the right/bottom handlers in table-cell-element.tsx to target
colIndex + colSpan - 1 and rowIndex + rowSpan - 1, calling the correct
exported transforms setTableColSize / setTableRowSize (:2521+). Additionally
add numeric width/height inputs to the F1 toolbar (write the selected column’s
colSizes[col] and the row’s size directly) for deterministic, merge-proof
control. If the handler override can’t fully bypass the hook’s internal math, fall
back to patch-package on @udecode/plate-table (see Risks).
Read-only renderer parity
The table_ref element (table-ref-element.tsx, plugin table-ref-plugin.tsx)
renders a banked table read-only via useCreateTableEditor({ readOnly:true }), so
plugin-driven content (images, links, lists, alignment, line-height) and the
component-driven styling (dividers, border token, vertical align) appear in the
embed automatically. Required checks: images load read-only inside the embed
(it already caches table fetches ~60s; many in-cell images add image-bank
requests — see Risks); links navigate from the embed; lists/alignment/line-height
render; dividers + contrast match the editor. No new renderer is built — this is a
verification + minor-fix milestone, not new architecture.
Data Model (requirements-level)
No new persisted fields — backend untouched. The only “data” change is the shape of
table_contentJSON the editor emits.
| Where | Change | Notes |
|---|---|---|
| Table cell node | May now contain block children: p, indent-list paragraphs, img | Was effectively one inline-only p. Empty cell still normalizes to one p. |
| Table cell node | New optional verticalAlign prop (top|middle|bottom) | Custom; rendered as CSS vertical-align. Absent = default. |
| Paragraph nodes in cells | Optional align, lineHeight, indent/list-style props | Existing plugin props, now allowed inside cells. |
In-cell img node | Existing Image-Bank node {type:'img', short_uid, …} | No caption emitted in cells. Multiple allowed. |
| In-cell link node | Existing {type:'a', url, uid, uidType:'mcq'|'docket'} | Opaque — not tracked on the tables model. |
| Borders | Default dividers are presentational (CSS), not stored | Explicit per-side borders persist as today’s borders data. |
colSizes / row size | Unchanged shape; written via correct span-aware index | B fix corrects which index is written, not the schema. |
tables model / API: unchanged. Save still extracts the single table node
and strips ids (table-editor.tsx); content still passes the backend’s
“valid JSON + root {"type":"table"}” check.
Delivery Milestones
| # | Milestone | Outcome | Status | Plan |
|---|---|---|---|---|
| 1 | Foundations (F0 + F1) | Cells accept rich block content; a fixed formatting toolbar exists in the Table Bank editor | pending | — |
| 2 | In-cell formatting (G) | Editors apply lists (with nesting), horizontal + vertical alignment, and line-height inside cells | pending | — |
| 3 | Images in cells (A) | Editors insert multiple Image-Bank images into a cell, clamped to width, no caption | pending | — |
| 4 | Links in cells (F) | Editors insert MCQ and Docket UID links into cells; they navigate from read-only embeds | pending | — |
| 5 | Dividers + contrast (C + D) | All tables show clear dividers; fills/header stay distinct from borders | pending | — |
| 6 | Row/column formatting (E) | Editors select a row/column/range and format every cell at once | pending | — |
| 7 | Resize after merge (B) | Merged tables resize by drag and by numeric width/height controls | pending | — |
| 8 | Read-only renderer parity | Every enhancement renders correctly in table_ref embeds (Block, MCQ question, MCQ solution) | pending | — |
Plan key: all milestones are FE (frontend) in keystone-web; no BE work. Full design in this PRD; per-milestone design specs to be created via
/plan.
Open Questions
- None remaining — all resolved during PRD review (scope, image source, cell model, list/alignment/spacing scope, toolbar placement, link types, reference tracking, divider mechanism, border contrast, selection model, resize approach).
Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
Cell normalization regressions. Allowing block children + ids in cells can interact badly with plate-table’s merge/split (which is keyed on cell ids via _cellIndices). | Medium | High — corrupt/non-rectangular tables block save (ErrorCode 9401) | Keep NodeIdPlugin stamping all nested blocks; verify stripIds recurses new types; lean on the existing isTableRectangular save guard; test merge/split with images/lists in cells. |
CSS base grid vs explicit “None”. A presentational base border can override an editor’s deliberate borderless side, or double-draw with the before: system. | Medium | Medium — can’t remove a divider | Distinguish “no border data → base grid” from “explicit size 0 → no line”; test add/remove per side over the base. |
| Resize override insufficient. The span-aware handler override may not fully bypass plate-table’s internal index math. | Medium | Medium — drag still wrong on complex merges | Numeric width/height controls are the deterministic fallback; if needed, patch-package the handler in @udecode/plate-table. |
Read-only embed parity drift. Embedded tables must render images/links/lists/styling; the table_ref readOnly factory should inherit them but edge cases (image fetch, link nav) can differ. | Medium | Medium — embeds look broken vs editor | Dedicated parity milestone (8) across Block + MCQ question + MCQ solution. |
| In-cell image fetch volume. Many Image-Bank images per table → many signed-URL fetches in editor and embed. | Low | Medium — slow render | Reuse InlineImageElement caching + the table_ref ~60s fetch cache; lazy-load below-the-fold. |
| plate-table v45 constraints. Some range/selection/resize APIs may be limited at v45 while other packages are at v46/v49. | Low | Medium — extra custom code | Prefer custom helpers over upgrading mid-stream; isolate any patch to one package. |
Technical Details (as built)
Not yet built. This half is filled in after implementation (PR(s), branch, design-spec + plan paths, the PRD-term → code map, and every deviation). The pre-build code touchpoints we identified are recorded in each capability’s Approach section above.
Naming: PRD vs code (pre-build map)
| PRD term | Code (planned touchpoint) |
|---|---|
| Table Bank editor | src/components/table-bank/table-editor.tsx |
| Table editor factory | src/components/editor/use-create-table-editor.ts |
| Cell element / borders / vertical align | src/components/plate-ui/table-cell-element.tsx |
| Table element / floating structure toolbar | src/components/plate-ui/table-element.tsx |
| Row element / row handle | src/components/plate-ui/table-row-element.tsx |
| Cell block-selection toggle (E) | src/components/editor/plugins/block-selection-plugins.tsx |
| Image insert dialog / element / popover (A) | inline-image-toolbar-button.tsx, inline-image-element.tsx, inline-image-popover.tsx |
| MCQ/Docket link button / element / normalize (F) | uid-link-toolbar-button.tsx, link-element.tsx, mcq-link-normalize-plugin.ts |
| Read-only embed | src/components/plate-ui/table-ref-element.tsx, table-ref-plugin.tsx |
| New border token (C + D) | --table-border in src/index.css; referenced in table-cell-element.tsx |
| Resize fix (B) | handlers in table-cell-element.tsx calling setTableColSize / setTableRowSize (@udecode/plate-table) |
Decisions & deviations from the PRD (as built)
- To be completed after implementation.
Migration
No data backfill — frontend-only. Default dividers (C) are presentational, so
existing saved tables gain dividers on load with no re-save. New optional node
props (verticalAlign, in-cell align/lineHeight/list props, in-cell img/a)
are additive and absent on existing content.