Skip to content

feat(editor): resizable table columns with proportional widths (#36) - #38

Merged
christianhuening merged 2 commits into
mainfrom
feat/table-column-resize
Oct 12, 2026
Merged

christianhuening merged 2 commits into
mainfrom
feat/table-column-resize

Conversation

@christianhuening

Copy link
Copy Markdown
Contributor

Closes #36.

What changes for users

  • In edit mode, drag the line between two table columns to resize them. Width moves between the two neighbouring columns only, so the border follows the pointer and the table stays full width. Works with mouse, touch and pen; Escape cancels a drag, and a vertical swipe that starts on a column line still scrolls.
  • Widths are proportions, not pixels: a table keeps its layout on every screen and always fills the page width (normal and wide mode). Readers see the result; it is saved with the page through Yjs.
  • Long URLs and other unbreakable text wrap inside their cell instead of running over the next column.

Why resizing never worked

KnotTable already had resizable: true, but Tiptap decides whether to install prosemirror-tables' columnResizing plugin from editor.isEditable at creation. KnotEditor creates the editor read-only for every existing page (view mode is the default) and later calls setEditable(true) without re-creating it, so the plugin was never installed. Only brand-new pages, which open in edit mode, could be resized.

How

  • columnWidths.ts: the width model, as pure functions. It reads the colwidth cell attribute as relative weights (via TableMap, so colspans and rowspans are handled), turns them into shares, moves one border with a 40px floor, and writes all columns back normalised to exactly 1000 (largest-remainder rounding).
  • TableColumnView.ts: the table node view, installed for every table whatever the editability.
    • It renders a percentage <colgroup> and a handle layer with one strip per inner border, placed at the running total of the shares, so no measuring is needed.
    • During a drag only the DOM changes. Releasing commits one transaction (one undo step, one Yjs update) that writes every cell.
    • A remote change mid-drag keeps the drag on screen (last drag wins). A remote change that alters the column count cancels the drag.
  • TableExtensions.ts: resizable: false (no more pixel resizing). It overrides addNodeView and also renderHTML, so the clipboard and getHTML() carry the same percentages.
  • prose.css:
    • Handle styles.
    • overflow-wrap: anywhere on cells.
    • The wrapper is display: flow-root and the table keeps its margin inside it, so spacing around existing tables is unchanged (checked against main in Chromium).

Compatibility

  • No schema change: colwidth keeps riding Yjs and stays out of the canonical markdown schema.
  • Pixel widths stored by the old resizing are read as weights:
    • Fully pinned tables keep their ratios exactly and now fit the screen instead of scrolling.
    • Partially dragged tables (prosemirror-tables only wrote the dragged column, e.g. [300, null, null, null]) keep the dragged column widest. Unset columns share what is left of 1000.
  • An old cached client reads the new weights as pixels and renders a ~1000px table that scrolls. This lasts until it reloads.
  • Out of scope: widths don't survive markdown export/import or "create from template", since markdown has no width concept. Tables arriving that way start equal-width. This is a candidate follow-up.

Testing

Unit (vitest/jsdom):

  • 349/349 pass.
  • 42 new tests cover the width model and the node view against a real Yjs-bound editor: drag commits once, colspans and rowspans, the 40px floor, the jitter dead zone, Escape and pointer cancel, viewers, a read-only-created editor switched to edit, remote changes mid-drag, and the HTML serialisation.

e2e:

  • New e2e/flows/table-column-resize.spec.ts covers: page opened in view mode → Edit → drag → reload → width kept.
  • It fails against main's table extension.
  • Run locally together with the doc-width, editor-toolbar, markdown-roundtrip, mobile and editor flows: 14/14.
  • Full suite locally: 81 passed. The 7 failures are unrelated:
    • OIDC and ws-reconnect need Dex/toxiproxy, which weren't running.
    • History fails on main too.
    • Excalidraw and tree-reorder "nest" are flaky on main as well; excalidraw passed on re-run.

Other checks:

  • tsc --noEmit, eslint src --max-warnings 0 and vite build are clean.
  • Rust is untouched, so cargo checks were not re-run.
  • A real-Chromium probe confirmed:
    • Handles sit within 0.3px of the column borders and span exactly the table.
    • A +120px mouse drag widens the column by exactly 120px.
    • A vertical touch swipe on a strip scrolls without writing; a horizontal finger drag resizes.
    • Spacing around tables matches main.

Known limitations / follow-ups

  • On a narrow screen, a column dragged to the 40px floor on a wide screen can render narrower than its cell padding.
  • No keyboard resizing yet; the handle layer could also get aria-hidden.
  • A border hidden by a colspan in every row still gets a (no-op) handle.

Spec: docs/superpowers/specs/2026-10-12-table-column-resize-design.md · Plan: docs/superpowers/plans/2026-10-12-table-column-resize.md

🤖 Generated with Claude Code

christianhuening and others added 2 commits October 12, 2026 22:23
Editors can drag the line between two table columns to move width from
one to the other. Widths are stored as proportions, so a table keeps its
layout on every screen and always fills the page width; readers see the
result and it is saved with the page.

Column resizing was configured but never worked on existing pages:
Tiptap installs prosemirror-tables' resizing only when the editor is
editable at creation, and pages open in view mode, so switching to Edit
never showed the handles. A table node view (TableColumnView) now renders
every table, draws one drag strip per inner border and commits a drag as
one transaction; prosemirror-tables' pixel resizing is disabled.

The existing `colwidth` cell attribute now holds relative weights,
normalised to 1000 on each drag (no schema change). Unset columns share
what is left of the total, so tables resized before this keep their
dragged column widest. Long URLs now wrap inside their cell.

Closes #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cargo-deny's advisories check started failing on two entries added to the
RustSec database after main last passed:

- RUSTSEC-2021-0157: ammonia < 4.1.7 does not escape quotes in CSS url()
  inside style attributes. Pin ammonia to 4.1.7, the patched 4.1 release;
  the only lockfile change. knot was not reachable: the public share page
  uses ammonia's default builder, which strips style attributes.
- RUSTSEC-2024-0449: unsafe-libyaml is unmaintained. It comes in through
  figment's Yaml provider (serde_yaml), figment 0.10.19 is the latest, and
  there is no known vulnerability. Ignored in deny.toml with the path,
  reachability (only the operator's KNOT_CONFIG file) and drop condition,
  following the file's convention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@christianhuening
christianhuening merged commit dc00c08 into main Oct 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make table columns sizable

1 participant