Owner Burhan

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_ref renderer. Because the read-only embed reuses the same useCreateTableEditor factory in readOnly mode, 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_content is 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 — no linked_* tracking is added to the tables model. 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)

  1. 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).
  2. B — Resize merged tables. Column width and row height resize works correctly even after cells are merged; plus numeric width/height controls.
  3. C — Default dividers. Tables show clear vertical and horizontal dividers by default (closely-spaced columns no longer look messy).
  4. D — Border contrast. Grey cell fills and the grey header no longer blend into the grey borders.
  5. E — Row/column formatting. Select a whole row or column (or a dragged range) and apply formatting to every cell at once.
  6. F — MCQ/Docket links in cells. Insert an MCQ or Docket UID link into a cell (the same flow as Block/MCQ).
  7. G — In-cell text formatting. Bulleted/numbered lists with nesting, horizontal + vertical alignment, and line-height presets, inside cells.

Functional Requirements

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Cell fills and the header row stay visibly distinct from cell borders.
  6. 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.
  7. 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.
  8. Everything above renders correctly in the read-only table_ref embed 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/indent indent-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.

Links (decided): MCQ and Docket UID links; opaque in JSON (no backend reference tracking on the tables model).

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-border token; 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-border token, 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_content JSON the editor emits.

WhereChangeNotes
Table cell nodeMay now contain block children: p, indent-list paragraphs, imgWas effectively one inline-only p. Empty cell still normalizes to one p.
Table cell nodeNew optional verticalAlign prop (top|middle|bottom)Custom; rendered as CSS vertical-align. Absent = default.
Paragraph nodes in cellsOptional align, lineHeight, indent/list-style propsExisting plugin props, now allowed inside cells.
In-cell img nodeExisting Image-Bank node {type:'img', short_uid, …}No caption emitted in cells. Multiple allowed.
In-cell link nodeExisting {type:'a', url, uid, uidType:'mcq'|'docket'}Opaque — not tracked on the tables model.
BordersDefault dividers are presentational (CSS), not storedExplicit per-side borders persist as today’s borders data.
colSizes / row sizeUnchanged shape; written via correct span-aware indexB 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

#MilestoneOutcomeStatusPlan
1Foundations (F0 + F1)Cells accept rich block content; a fixed formatting toolbar exists in the Table Bank editorpending
2In-cell formatting (G)Editors apply lists (with nesting), horizontal + vertical alignment, and line-height inside cellspending
3Images in cells (A)Editors insert multiple Image-Bank images into a cell, clamped to width, no captionpending
4Links in cells (F)Editors insert MCQ and Docket UID links into cells; they navigate from read-only embedspending
5Dividers + contrast (C + D)All tables show clear dividers; fills/header stay distinct from borderspending
6Row/column formatting (E)Editors select a row/column/range and format every cell at oncepending
7Resize after merge (B)Merged tables resize by drag and by numeric width/height controlspending
8Read-only renderer parityEvery 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

RiskLikelihoodImpactMitigation
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).MediumHigh — 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.MediumMedium — can’t remove a dividerDistinguish “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.MediumMedium — drag still wrong on complex mergesNumeric 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.MediumMedium — embeds look broken vs editorDedicated 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.LowMedium — slow renderReuse 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.LowMedium — extra custom codePrefer 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 termCode (planned touchpoint)
Table Bank editorsrc/components/table-bank/table-editor.tsx
Table editor factorysrc/components/editor/use-create-table-editor.ts
Cell element / borders / vertical alignsrc/components/plate-ui/table-cell-element.tsx
Table element / floating structure toolbarsrc/components/plate-ui/table-element.tsx
Row element / row handlesrc/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 embedsrc/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.