diff --git a/CHANGELOG.md b/CHANGELOG.md
index c078023..92eb8f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,8 +14,29 @@ so this log can be regenerated from history (e.g. with `git-cliff`).
with when they last edited. Visible to every role that can open the page;
names only. Pages created before this release list only contributions made
from now on, and say so. `GET /api/docs/{id}/contributors`.
+- **Resizable table columns** ([#36](https://github.com/opendefensecloud/knot/issues/36)).
+ In edit mode, drag the line between two 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. Works with mouse, touch and pen (a vertical swipe on
+ a column line still scrolls); `Escape` cancels a drag. Widths do not survive
+ a trip through Markdown (export/import, creating a page from a template) —
+ tables arriving that way start with equal columns.
+
+### Changed
+- Tables whose columns were dragged before this release now fill the page width
+ instead of being pinned to the pixel widths of whoever dragged them and
+ scrolling sideways on smaller screens. A table with every column dragged
+ keeps its ratios exactly; where only some were, those keep their share of a
+ 1000px table and the rest split what is left.
### Fixed
+- **Column resizing never worked on existing pages.** The drag handles were
+ only installed when the editor was created editable, and pages open in view
+ mode, so switching to Edit never showed them — only brand-new pages could be
+ resized.
+- Long URLs and other unbreakable text in a table cell now wrap inside the cell
+ instead of running over the neighbouring column.
- **Edits typed while disconnected never reached the server.** The client kept
them locally but only sent changes while its socket was open, and the server
never asked a reconnecting client for what it was missing. They were lost on
@@ -37,6 +58,16 @@ so this log can be regenerated from history (e.g. with `git-cliff`).
leaked a task and a socket on the server for every disconnect, until
restart.
+### Security
+- **`ammonia` 4.1.7** closes RUSTSEC-2021-0157 (quotes in CSS `url()` were not
+ escaped inside `style` attributes, allowing CSS injection). knot was not
+ reachable through it: the public share page sanitizes with ammonia's default
+ builder, which does not allow `style` attributes at all.
+- RUSTSEC-2024-0449 (`unsafe-libyaml` unmaintained) is acknowledged in
+ `deny.toml`. It arrives through figment's YAML provider, has no known
+ vulnerability and no maintained replacement reachable from figment 0.10.19,
+ and only ever parses the operator's own `KNOT_CONFIG` file.
+
## [0.7.0] - 2026-10-02
### Added
diff --git a/Cargo.lock b/Cargo.lock
index 243081a..86f2ee2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -40,9 +40,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
-version = "4.1.4"
+version = "4.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
+checksum = "36322ccf536de7b7afd259098615203e4cc5caca1b78482b58334a720c66e40a"
dependencies = [
"cssparser",
"html5ever",
diff --git a/deny.toml b/deny.toml
index 7c836d5..c6dcc85 100644
--- a/deny.toml
+++ b/deny.toml
@@ -39,6 +39,17 @@ ignore = [
# not help. Unmaintained-only: no known vulnerability.
# Drop when: yrs replaces or vendors smallstr.
{ id = "RUSTSEC-2026-0215", reason = "smallstr unmaintained; pinned at ^0.3 by yrs through 0.27.3; no vulnerability, no successor crate" },
+
+ # unsafe-libyaml is unmaintained (repository archived; it is a c2rust
+ # translation of libyaml). Path: knot-config -> figment 0.10.19 (feature
+ # "yaml") -> serde_yaml 0.9.34 (itself deprecated) -> unsafe-libyaml
+ # 0.2.11. figment 0.10.19 is the current latest and its Yaml provider is
+ # built on serde_yaml, so no bump reaches a safe-Rust parser.
+ # Unmaintained-only: no known vulnerability. Reachability: parses only the
+ # operator-supplied config file named by KNOT_CONFIG, never user input.
+ # Drop when: figment moves its Yaml provider off serde_yaml, or knot drops
+ # YAML config files.
+ { id = "RUSTSEC-2024-0449", reason = "unsafe-libyaml unmaintained; via figment's Yaml provider (serde_yaml); parses only the operator's KNOT_CONFIG file; no vulnerability" },
]
[licenses]
diff --git a/docs/superpowers/plans/2026-10-12-table-column-resize.md b/docs/superpowers/plans/2026-10-12-table-column-resize.md
new file mode 100644
index 0000000..3a6067a
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-12-table-column-resize.md
@@ -0,0 +1,90 @@
+# Resizable Table Columns Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Editors can drag table column borders in any doc, widths are stored as proportions and fill the available width on every screen, and long text wraps inside its cell.
+
+**Architecture:** A pure width model (`columnWidths.ts`) converts the `colwidth` cell attribute between stored integer weights and rendered shares. A table node view (`TableColumnView.ts`), always installed regardless of editability, renders a percentage `
` plus a handle layer and commits a drag as one transaction. `KnotTable` swaps prosemirror-tables' pixel resizing for this view.
+
+**Tech Stack:** Tiptap 3 / ProseMirror (`@tiptap/pm/tables` `TableMap`), Yjs via `@tiptap/extension-collaboration`, Vitest + jsdom, Playwright (e2e, not runnable locally).
+
+**Spec:** `docs/superpowers/specs/2026-10-12-table-column-resize-design.md`
+
+## Global Constraints
+
+- `colwidth` stays the only storage; no schema change; stays in `EDITOR_ONLY_ATTRS`.
+- Committed weights sum to exactly `WEIGHT_TOTAL = 1000`, every entry ≥ 1.
+- Minimum dragged column width: `MIN_COLUMN_PX = 40` rendered pixels.
+- One drag = one transaction; no transaction for a press without movement, `Escape`, `pointercancel`, or a non-editable view.
+- Web tests run with `NODE_OPTIONS=--no-experimental-webstorage` (Node 26 + jsdom localStorage clash); pnpm via `npx pnpm`.
+- Only the user commits and pushes: "Commit" steps below mean *stage*.
+
+## Review Focus
+
+1. A table whose first row has a colspan cell — the column count and per-column weights must come from `TableMap`, not the cell count.
+2. A column already narrower than 40px (phone, many columns) — a drag must never make it jump wider or shrink further.
+3. Legacy pixel `colwidth` values and partially set arrays (`[200, 0, null]`) — ratios kept, unset columns take the mean.
+4. Doc opened read-only then switched to edit — handles must work (the original bug).
+5. A remote change arriving mid-drag that adds or removes a column — the drag must be cancelled, not commit a mis-sized weight array.
+
+---
+
+### Task 1: Width model (`columnWidths.ts`)
+
+**Files:**
+- Create: `web/src/features/editor/columnWidths.ts`
+- Test: `web/src/features/editor/columnWidths.test.ts`
+
+**Interfaces — produces:**
+```ts
+export const WEIGHT_TOTAL = 1000;
+export const MIN_COLUMN_PX = 40;
+export function resolveShares(weights: readonly (number | null)[]): number[];
+export function moveBorder(shares: readonly number[], border: number, delta: number, minShare: number): number[];
+export function toWeights(shares: readonly number[]): number[];
+export function sharesEqual(a: readonly number[], b: readonly number[]): boolean;
+export function readColumnWeights(table: PMNode): (number | null)[];
+export function setColumnWeights(tr: Transaction, tableStart: number, table: PMNode, weights: readonly number[]): Transaction;
+export function sharePercent(share: number): string; // "33.333%"
+```
+
+- [ ] Write failing tests: `resolveShares` (all null → equal; missing/zero/negative → mean of known; legacy px keep ratio; empty → []); `moveBorder` (moves only the pair, pair sum constant, clamps at `minShare` both directions, a column already below the floor neither jumps wider nor shrinks, out-of-range border is a no-op); `toWeights` (sums to exactly 1000, largest-remainder, tiny share ≥ 1); `readColumnWeights` / `setColumnWeights` through a mounted editor, including a colspan cell in the first row.
+- [ ] Run, see them fail (module missing).
+- [ ] Implement.
+- [ ] Run, see them pass.
+- [ ] Stage.
+
+### Task 2: Table node view + extension wiring
+
+**Files:**
+- Create: `web/src/features/editor/TableColumnView.ts`
+- Modify: `web/src/features/editor/TableExtensions.ts` (`resizable: false`, `addNodeView`, `renderHTML`)
+- Modify: `web/src/test/boundEditor.ts` (`editable` option)
+- Test: `web/src/features/editor/TableColumnView.test.ts`
+
+**Interfaces — consumes** Task 1. **Produces:**
+```ts
+export class TableColumnView implements NodeView {
+ constructor(node: PMNode, view: EditorView, getPos: () => number | undefined, htmlAttributes: Record);
+}
+```
+DOM: `div.tableWrapper > table.knot-table > colgroup + tbody`, plus `div.knot-col-handles > div.knot-col-handle[data-border=i]` (n-1).
+
+- [ ] Write failing tests (jsdom; stub the table's `getBoundingClientRect().width = 800`): percentage colgroup from stored weights; `n-1` handles at cumulative `left`; drag border 0 by +80px → exactly one doc-changing transaction, every cell in the column carries `[350]`, `[150]`, `[250]`, `[250]`; colspan header cell gets a two-entry array; drag past the floor clamps to 40px → weight 50; press without move → no transaction; `Escape` mid-drag → no transaction and colgroup restored; non-editable view → no transaction; editor created non-editable then `setEditable(true)` → drag commits (regression); remote column added mid-drag → drag cancelled; `getHTML()` emits percentage ``s.
+- [ ] Run, see them fail.
+- [ ] Implement view and wiring.
+- [ ] Run, see them pass; run the whole editor suite (schema parity must still pass).
+- [ ] Stage.
+
+### Task 3: Styles, cleanup, changelog, e2e
+
+**Files:**
+- Modify: `web/src/styles/prose.css` (wrap, handle layer, wrapper margin; drop `.column-resize-handle`, `.resize-cursor`)
+- Modify: `web/src/features/editor/schema.test.ts` (comment on `colwidth`)
+- Modify: `CHANGELOG.md` (Unreleased → Added / Fixed)
+- Create: `e2e/flows/table-column-resize.spec.ts`
+
+- [ ] CSS: `th, td { overflow-wrap: anywhere }`; `.tableWrapper { position: relative; margin-block: 0.8em }`, table margin 0 inside it; handle layer `inset: 0; pointer-events: none`; handle 9px, `cursor: col-resize`, `touch-action: none`, accent line on hover / `.is-dragging`; hidden under `.ProseMirror[contenteditable="false"]`.
+- [ ] e2e: new doc → table → switch to view → reload (editor now created read-only) → no visible handle → Edit → drag first border +120px → first column wider → reload → still wider.
+- [ ] Full verification: `npx pnpm test`, `npx pnpm tsc`, `npx pnpm lint`, `npx pnpm build`.
+- [ ] Stage.
diff --git a/docs/superpowers/specs/2026-10-12-table-column-resize-design.md b/docs/superpowers/specs/2026-10-12-table-column-resize-design.md
new file mode 100644
index 0000000..eb1423b
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-12-table-column-resize-design.md
@@ -0,0 +1,180 @@
+# Resizable table columns — design
+
+**Date:** 2026-10-12
+**Status:** Approved (brainstorm)
+**Issue:** [#36](https://github.com/opendefensecloud/knot/issues/36)
+
+## Context
+
+The issue asks that editors can resize table columns by dragging the column
+lines, that long text wraps, that viewers see the result, that widths are
+relations rather than fixed pixels (screens differ), and that the change is
+saved with the page.
+
+Column resizing is nominally on already — `KnotTable` is configured with
+`resizable: true` (`web/src/features/editor/TableExtensions.ts`) — but in
+practice it does not work, and where it does, it produces pixel widths:
+
+1. **The drag handles are missing for almost every doc.** Tiptap's `Table`
+ decides in `addProseMirrorPlugins()` whether to install prosemirror-tables'
+ `columnResizing` plugin, using `this.editor.isEditable` *at editor creation*.
+ `KnotEditor` creates the editor with `editable: canEdit`, where `canEdit`
+ folds in edit mode, and toggles later with `setEditable()` without
+ re-creating it. Existing docs open in view mode, so the plugin is never
+ installed, and switching to Edit does not add it. Verified with a probe: an
+ editor created non-editable and then `setEditable(true)` has no
+ `tableColumnResizing` plugin; one created editable has it. Only freshly
+ created docs (seeded into edit mode by `markDocEditMode`) can drag columns.
+2. **Widths are pixels.** prosemirror-tables stores the dragged width of a
+ column in the cells' `colwidth` attribute as pixels. Once every column is
+ pinned, `TableView` writes an inline `width: px` on the table, which
+ beats the stylesheet's `width: 100%`; the `.tableWrapper { overflow-x: auto }`
+ rule (0.4.0) exists only to stop such a table blowing out the page.
+3. **Unbreakable text spills.** `table-layout: fixed` wraps ordinary words, but
+ a long URL or identifier in a narrow column overflows into its neighbour.
+
+`colwidth` already rides the Yjs document, so viewers already see whatever is
+stored and it is already persisted with the page.
+
+## Decisions
+
+- **Proportional widths.** A table always fills the available width (as an
+ un-resized table already does); each column keeps its share. Dragging a
+ border moves width between the two neighbouring columns only.
+- **Markdown round-trip is out of scope.** Export → import and "create from
+ template" go through markdown, which has no column-width concept, so tables
+ arriving that way start equal-width. Candidate follow-up issue.
+- **Own node view, not prosemirror-tables' resizing.** Its live preview writes
+ pixel widths straight into the DOM, so correcting it after the fact would
+ make the handle drift from the pointer. A pointer-near-cell-edge plugin (its
+ approach) would also work but needs hit-testing and colspan edge cases that a
+ node-owned handle layer avoids.
+
+## Data model
+
+- The existing `colwidth` cell attribute (array of numbers, one per spanned
+ column) is kept. No schema change; `colwidth` stays in
+ `EDITOR_ONLY_ATTRS` in `schema.test.ts` because markdown has no use for it.
+- Its values are now read as **relative weights**, not pixels.
+- Every committed drag rewrites the weights of **all** columns, normalised so
+ they sum to **1000**, and writes them to every cell of every row (colspan
+ mapped through `TableMap`). Normalising to a fixed total keeps two editors on
+ different screen widths from mixing scales. A happy side effect: a stale
+ client still reading the values as pixels renders a sensible 1000px table.
+- **Resolving shares for rendering** (first row, as prosemirror-tables and
+ Tiptap do):
+ - a column with a positive weight keeps that weight;
+ - columns with no weight (`null`, `0`, missing) share what the set ones
+ leave of 1000. When nothing is left — a column added to a normalised
+ table — each gets the mean of the known weights; with no weights at all,
+ columns are equal;
+ - each column then renders at `weight / sum of weights`.
+- Legacy pixel values read as weights against the same 1000 total. A fully
+ pinned table keeps its ratios exactly. prosemirror-tables only ever wrote
+ the dragged column, so the common legacy shape is `[300, null, null, null]`;
+ it renders 30/23/23/23%, keeping the dragged column the widest instead of
+ dropping it to an equal share. Either way the table now fits the screen
+ instead of scrolling.
+- prosemirror-tables' own commands keep working on `colwidth` unchanged:
+ `addColumn` inserts cells without a width (→ mean share), `deleteColumn`
+ removes the entry (→ others re-share; a column added afterwards takes the
+ freed share), merge/split carry the arrays, `fixTables` repairs per-column
+ mismatches.
+
+## Rendering
+
+- `KnotTable` is configured `resizable: false`, so prosemirror-tables'
+ `columnResizing` plugin and its `TableView` are never installed.
+- `KnotTable` overrides `addNodeView()` to always return a `TableColumnView`,
+ in view and edit mode alike, so a later `setEditable()` cannot lose anything.
+ It keeps the current DOM contract: `div.tableWrapper > table.knot-table >
+ colgroup + tbody`; the `renderHTML` override always emits the wrapper, so
+ `renderWrapper` is no longer configured.
+- `` widths are percentages from the resolved shares; the table is
+ `width: 100%` with no inline pixel width or min-width.
+- `KnotTable` also overrides `renderHTML()` (used for `getHTML()` and the
+ clipboard) to emit the same percentage colgroup, so pasting elsewhere keeps
+ the proportions.
+- `TableColumnView.update()` re-renders the colgroup and handles from the new
+ node; `ignoreMutation()` ignores everything outside `contentDOM`.
+
+## Drag interaction
+
+- **Handles.** The node view keeps a handle layer inside the wrapper,
+ absolutely positioned over the table: one strip per *inner* column border
+ (none on the outer right edge — the table is full width), placed at the
+ running total of shares (`left: `), about 8px to grab, full
+ table height, `cursor: col-resize`, showing the accent line on hover (where
+ the device has hover) and while dragging. The table never overflows the
+ wrapper (no min-width), so the percentages line up with the real borders
+ without measuring. The wrapper is `display: flow-root`, so the table keeps
+ its `0.8em` margin inside it and spacing around tables is unchanged; the
+ layer is inset by that same margin.
+- **Viewers.** Handles are hidden by CSS when the editor is not editable, and a
+ `pointerdown` is ignored unless `view.editable`.
+- **Dragging.** Pointer events (mouse, touch, pen), with pointer capture where
+ available. Movement under 3px is not a drag, so a tap or click jitter writes
+ nothing. Handles use `touch-action: pan-y pinch-zoom`: a vertical swipe that
+ starts on a strip still scrolls (the browser cancels the pointer, which
+ abandons the drag). Dragging border *i* moves width between columns *i* and *i+1*;
+ every other column is untouched and the border follows the pointer. Neither
+ of the two may shrink below 40px, computed against the table's current
+ rendered width. `stopEvent()` claims events on the handle layer so
+ ProseMirror never starts a selection from a handle.
+- **Live preview.** During a drag only the `` widths and the handle
+ positions change in the DOM; no transactions. If a remote update to the same
+ table arrives mid-drag, `update()` takes the new node but keeps rendering
+ the in-progress shares, so the column does not snap back; the commit then
+ rewrites every column, so the last drag wins. If the remote update changed
+ the number of columns, the drag is cancelled.
+- **Commit.** On `pointerup`, one transaction (one undo step) sets the
+ normalised weights on every cell, located via `getPos()`. A press without
+ movement writes nothing. `Escape` or `pointercancel` discards the drag and
+ restores the stored widths.
+- **Not included:** keyboard resizing, double-click to equalise, a "reset
+ widths" control.
+
+## Wrapping
+
+`th, td` get `overflow-wrap: anywhere`. Column widths come from the colgroup
+(`table-layout: fixed`), so this only affects how content breaks inside a
+cell: long URLs and identifiers wrap instead of spilling into the next column.
+
+## Code layout
+
+- `web/src/features/editor/columnWidths.ts` (new) — pure functions:
+ resolve stored weights to shares, move one border with the minimum-width
+ clamp, normalise shares to integer weights summing exactly to 1000
+ (largest-remainder rounding).
+- `web/src/features/editor/TableColumnView.ts` (new) — the node view:
+ wrapper, table, colgroup, handle layer, drag state, commit.
+- `web/src/features/editor/TableExtensions.ts` — `resizable: false`,
+ `addNodeView()` and `renderHTML()` overrides.
+- `web/src/styles/prose.css` — handle styles, `overflow-wrap`, remove the
+ now-unused `.column-resize-handle` / `.resize-cursor` rules. The
+ `.tableWrapper` overflow rule stays as a safety net.
+- `web/src/features/editor/schema.test.ts` — update the `colwidth` comment.
+- `CHANGELOG.md` — Unreleased entry.
+
+## Testing
+
+Vitest/jsdom, written test-first:
+
+- `columnWidths` unit tests: missing and zero weights, legacy pixel values,
+ all-unknown, border move within bounds, clamping at the 40px floor on both
+ sides, normalisation summing to exactly 1000.
+- `TableColumnView` tests (jsdom has no layout, so the table width is
+ stubbed): colgroup percentages from the node; `n-1` handles at cumulative
+ positions; a simulated drag dispatches exactly one transaction whose cells
+ all carry the normalised weights, including a colspan cell; no transaction
+ for a press without movement, on `Escape`, or when not editable;
+ `renderHTML` output carries percentages.
+- Regression for the original bug: an editor created non-editable, switched
+ with `setEditable(true)`, can drag a column.
+
+e2e: a Playwright flow (a doc opened in view mode → Edit → drag a border →
+reload → the share persists). Run locally against a throwaway Postgres; it
+fails against the previous table extension.
+
+Known limitation: on a narrow screen, a column dragged to the 40px floor on a
+wide screen can render narrower than its cell padding.
diff --git a/e2e/flows/table-column-resize.spec.ts b/e2e/flows/table-column-resize.spec.ts
new file mode 100644
index 0000000..2047253
--- /dev/null
+++ b/e2e/flows/table-column-resize.spec.ts
@@ -0,0 +1,101 @@
+import { expect, test, type Locator, type Page } from "@playwright/test";
+
+import { reset } from "../support/reset";
+
+// Reset once: the owner is created in the beforeAll below, and a per-test
+// reset would delete it out from under the later tests.
+test.beforeAll(reset);
+
+// This flow needs the production default — existing docs open in view mode,
+// so the editor is created read-only — which the config's edit-mode-by-default
+// flag would hide. That default is exactly what kept the old resize handles
+// from ever appearing.
+test.use({ storageState: { cookies: [], origins: [] } });
+
+const EMAIL = "owner@tables.test";
+const PASSWORD = "hunter22!tables";
+
+test.beforeAll(async () => {
+ const r = await fetch("http://localhost:3000/auth/setup", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ email: EMAIL, password: PASSWORD, display_name: "Owner" }),
+ });
+ if (!r.ok) throw new Error(`setup failed: ${r.status} ${await r.text()}`);
+});
+
+async function signIn(page: Page) {
+ await page.goto("/login");
+ await page.getByTestId("login-email").fill(EMAIL);
+ await page.getByTestId("login-password").fill(PASSWORD);
+ await page.getByTestId("login-submit").click();
+ await page.waitForURL(/\/(?:doc\/.+)?$/);
+}
+
+async function waitConnected(page: Page) {
+ await expect(page.getByTestId("status-dot")).toHaveAttribute("data-status", "connected", {
+ timeout: 10_000,
+ });
+}
+
+async function newDoc(page: Page) {
+ await page.getByTestId("new-doc").click();
+ await page.waitForSelector("[data-testid='new-doc-modal']", { state: "visible", timeout: 5_000 });
+ await page.getByTestId("new-doc-blank").click();
+ await page.waitForURL(/\/doc\/.+/);
+ await waitConnected(page);
+}
+
+async function width(locator: Locator) {
+ return (await locator.boundingBox())!.width;
+}
+
+test("a column border dragged after switching to Edit resizes the column and survives a reload", async ({
+ page,
+}) => {
+ await signIn(page);
+ await newDoc(page); // a new doc opens in edit mode
+
+ const prose = page.locator("[data-testid='editor-host'] .ProseMirror");
+ await prose.click();
+ await page.getByTestId("toolbar-table").click();
+ await expect(prose.locator("table")).toBeVisible();
+
+ // Leave edit mode and reload: the editor is now created read-only, the way
+ // every existing doc opens.
+ const toggle = page.getByTestId("toggle-edit-mode");
+ await toggle.click();
+ await expect(toggle).toHaveAttribute("aria-pressed", "false");
+ await page.waitForTimeout(800);
+ await page.reload();
+ await waitConnected(page);
+
+ const border = prose.locator('.knot-col-handle[data-border="0"]');
+ const firstHeader = prose.locator("tr").first().locator("th").first();
+ await expect(firstHeader).toBeVisible();
+ await expect(border).toBeHidden();
+
+ await toggle.click();
+ await expect(toggle).toHaveAttribute("aria-pressed", "true");
+ await expect(border).toBeVisible();
+
+ const before = await width(firstHeader);
+ const handle = (await border.boundingBox())!;
+ const x = handle.x + handle.width / 2;
+ const y = handle.y + handle.height / 2;
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ await page.mouse.move(x + 120, y, { steps: 5 });
+ await page.mouse.up();
+
+ await expect.poll(() => width(firstHeader)).toBeGreaterThan(before + 100);
+
+ // The table still fills its wrapper: the other columns gave up the width.
+ const overflow = await prose.locator(".tableWrapper").evaluate((el) => el.scrollWidth - el.clientWidth);
+ expect(overflow).toBeLessThanOrEqual(1);
+
+ await page.waitForTimeout(800);
+ await page.reload();
+ await waitConnected(page);
+ await expect.poll(() => width(firstHeader)).toBeGreaterThan(before + 100);
+});
diff --git a/web/src/features/editor/TableColumnView.test.ts b/web/src/features/editor/TableColumnView.test.ts
new file mode 100644
index 0000000..85c7b1e
--- /dev/null
+++ b/web/src/features/editor/TableColumnView.test.ts
@@ -0,0 +1,302 @@
+/**
+ * Column resizing through the table node view, against a real Yjs-bound
+ * editor. jsdom has no layout, so the table's rendered width is stubbed;
+ * everything else — the node view, the transactions, the stored `colwidth`
+ * — is the shipped code path.
+ */
+
+import type { Node as PMNode } from "@tiptap/pm/model";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { mountBoundEditor, type BoundEditor } from "../../test/boundEditor";
+import { setColumnWeights } from "./columnWidths";
+
+const TABLE_WIDTH = 800;
+const START_X = 100;
+
+let bound: BoundEditor | null = null;
+
+afterEach(() => {
+ bound?.destroy();
+ bound = null;
+});
+
+const row = (tag: "th" | "td", count: number) =>
+ `${Array.from({ length: count }, (_, i) => `<${tag}>${tag}${i}
${tag}>`).join("")}
`;
+
+/** Four equal columns: a header row and one body row. */
+const FOUR_COLUMNS = `${row("th", 4)}${row("td", 4)}
`;
+
+function mount(html: string, opts: { editable?: boolean } = {}): BoundEditor {
+ bound = mountBoundEditor(opts);
+ bound.editor.commands.setContent(html);
+ stubTableWidth(bound);
+ return bound;
+}
+
+function tableElement(b: BoundEditor): HTMLTableElement {
+ return b.editor.view.dom.querySelector("table")!;
+}
+
+function stubTableWidth(b: BoundEditor) {
+ const table = tableElement(b);
+ if (!table) return;
+ table.getBoundingClientRect = () =>
+ ({ width: TABLE_WIDTH, height: 100, top: 0, left: 0, right: TABLE_WIDTH, bottom: 100, x: 0, y: 0 }) as DOMRect;
+}
+
+function tableNode(b: BoundEditor): { node: PMNode; pos: number } {
+ let found: { node: PMNode; pos: number } | null = null;
+ b.editor.state.doc.descendants((node, pos) => {
+ if (!found && node.type.name === "table") found = { node, pos };
+ return !found;
+ });
+ return found!;
+}
+
+/** colwidth of every cell, row by row. */
+function storedColwidths(b: BoundEditor): (number[] | null)[][] {
+ const rows: (number[] | null)[][] = [];
+ tableNode(b).node.forEach((tableRow) => {
+ const cells: (number[] | null)[] = [];
+ tableRow.forEach((cell) => cells.push(cell.attrs.colwidth as number[] | null));
+ rows.push(cells);
+ });
+ return rows;
+}
+
+const colWidths = (b: BoundEditor) =>
+ Array.from(tableElement(b).querySelectorAll("col")).map((col) => col.style.width);
+
+const handle = (b: BoundEditor, border: number) =>
+ b.editor.view.dom.querySelector(`.knot-col-handle[data-border="${border}"]`);
+
+function countDocChanges(b: BoundEditor): () => number {
+ let count = 0;
+ b.editor.on("transaction", ({ transaction }) => {
+ if (transaction.docChanged) count++;
+ });
+ return () => count;
+}
+
+function pointerDown(b: BoundEditor, border: number) {
+ handle(b, border)!.dispatchEvent(
+ new PointerEvent("pointerdown", { bubbles: true, clientX: START_X, button: 0, pointerId: 1 }),
+ );
+}
+
+function pointerMove(dx: number) {
+ document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: START_X + dx, pointerId: 1 }));
+}
+
+function pointerUp(dx: number) {
+ document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: START_X + dx, pointerId: 1 }));
+}
+
+function drag(b: BoundEditor, border: number, dx: number) {
+ pointerDown(b, border);
+ pointerMove(dx);
+ pointerUp(dx);
+}
+
+describe("table column view: rendering", () => {
+ it("renders stored weights as a percentage colgroup", () => {
+ const b = mount(
+ ``,
+ );
+ expect(colWidths(b)).toEqual(["25%", "75%"]);
+ });
+
+ it("gives an unresized table equal columns", () => {
+ const b = mount(FOUR_COLUMNS);
+ expect(colWidths(b)).toEqual(["25%", "25%", "25%", "25%"]);
+ });
+
+ it("puts one handle on each inner border, at the running total", () => {
+ const b = mount(FOUR_COLUMNS);
+ const handles = Array.from(b.editor.view.dom.querySelectorAll(".knot-col-handle"));
+ expect(handles.map((h) => h.style.left)).toEqual(["25%", "50%", "75%"]);
+ });
+
+ it("keeps the table inside its wrapper", () => {
+ const b = mount(FOUR_COLUMNS);
+ const table = tableElement(b);
+ expect(table.parentElement?.classList.contains("tableWrapper")).toBe(true);
+ expect(table.classList.contains("knot-table")).toBe(true);
+ expect(table.style.width).toBe("");
+ expect(table.style.minWidth).toBe("");
+ });
+
+ it("serialises percentage columns to HTML, so pasting elsewhere keeps the proportions", () => {
+ const b = mount(
+ ``,
+ );
+ const html = b.editor.getHTML();
+ expect(html).toContain('');
+ expect(html).toContain('');
+ });
+
+ it("never installs prosemirror-tables' pixel resizing", () => {
+ const b = mount(FOUR_COLUMNS);
+ const keys = b.editor.state.plugins.map((plugin) => (plugin as unknown as { key: string }).key);
+ expect(keys.some((key) => key.startsWith("tableColumnResizing"))).toBe(false);
+ });
+});
+
+describe("table column view: dragging", () => {
+ it("commits a drag once, writing the new proportions to every cell", () => {
+ const b = mount(FOUR_COLUMNS);
+ const docChanges = countDocChanges(b);
+
+ drag(b, 0, 80);
+
+ expect(docChanges()).toBe(1);
+ expect(storedColwidths(b)).toEqual([
+ [[350], [150], [250], [250]],
+ [[350], [150], [250], [250]],
+ ]);
+ expect(colWidths(b)).toEqual(["35%", "15%", "25%", "25%"]);
+ });
+
+ it("follows the pointer live and writes nothing until release", () => {
+ const b = mount(FOUR_COLUMNS);
+ const docChanges = countDocChanges(b);
+
+ pointerDown(b, 0);
+ pointerMove(80);
+
+ expect(colWidths(b)).toEqual(["35%", "15%", "25%", "25%"]);
+ expect(handle(b, 0)!.style.left).toBe("35%");
+ expect(docChanges()).toBe(0);
+
+ pointerUp(80);
+ expect(docChanges()).toBe(1);
+ });
+
+ it("gives a colspan cell one width per column it spans", () => {
+ const b = mount(
+ ``,
+ );
+
+ drag(b, 1, 80);
+
+ expect(storedColwidths(b)).toEqual([
+ [[250, 350], [150], [250]],
+ [[250], [350], [150], [250]],
+ ]);
+ });
+
+ it("stops a column at 40px", () => {
+ const b = mount(FOUR_COLUMNS);
+
+ drag(b, 0, -400);
+
+ // 40px of an 800px table is 5%.
+ expect(storedColwidths(b)[0]).toEqual([[50], [450], [250], [250]]);
+ });
+
+ it("ignores jitter of a few pixels, so a tap or a scroll swipe changes nothing", () => {
+ const b = mount(FOUR_COLUMNS);
+ const docChanges = countDocChanges(b);
+
+ pointerDown(b, 0);
+ pointerMove(2);
+ expect(colWidths(b)).toEqual(["25%", "25%", "25%", "25%"]);
+ pointerUp(-2);
+
+ expect(docChanges()).toBe(0);
+ });
+
+ it("writes nothing for a press without movement", () => {
+ const b = mount(FOUR_COLUMNS);
+ const docChanges = countDocChanges(b);
+
+ drag(b, 0, 0);
+
+ expect(docChanges()).toBe(0);
+ expect(storedColwidths(b)[0]).toEqual([null, null, null, null]);
+ });
+
+ it("abandons the drag on Escape and restores the stored widths", () => {
+ const b = mount(FOUR_COLUMNS);
+ const docChanges = countDocChanges(b);
+
+ pointerDown(b, 0);
+ pointerMove(80);
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ pointerUp(80);
+
+ expect(docChanges()).toBe(0);
+ expect(colWidths(b)).toEqual(["25%", "25%", "25%", "25%"]);
+ });
+
+ it("abandons the drag when the pointer is cancelled", () => {
+ const b = mount(FOUR_COLUMNS);
+ const docChanges = countDocChanges(b);
+
+ pointerDown(b, 0);
+ pointerMove(80);
+ document.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerId: 1 }));
+ pointerUp(80);
+
+ expect(docChanges()).toBe(0);
+ expect(colWidths(b)).toEqual(["25%", "25%", "25%", "25%"]);
+ });
+
+ it("does not let a viewer drag", () => {
+ const b = mount(FOUR_COLUMNS);
+ b.editor.setEditable(false);
+ const docChanges = countDocChanges(b);
+
+ drag(b, 0, 80);
+
+ expect(docChanges()).toBe(0);
+ expect(colWidths(b)).toEqual(["25%", "25%", "25%", "25%"]);
+ });
+
+ it("lets an editor created read-only drag once it is switched to editing", () => {
+ // Docs open in view mode; the editor is created read-only and switched
+ // with setEditable(). prosemirror-tables' resizing was decided at
+ // creation, so the handles never appeared for existing docs.
+ bound = mountBoundEditor({ editable: false });
+ bound.editor.commands.setContent(FOUR_COLUMNS);
+ bound.editor.setEditable(true);
+ stubTableWidth(bound);
+ const docChanges = countDocChanges(bound);
+
+ drag(bound, 0, 80);
+
+ expect(docChanges()).toBe(1);
+ expect(storedColwidths(bound)[0]).toEqual([[350], [150], [250], [250]]);
+ });
+
+ it("keeps the dragged column under the pointer when someone else resizes mid-drag", () => {
+ const b = mount(FOUR_COLUMNS);
+
+ pointerDown(b, 0);
+ pointerMove(80);
+ const { node, pos } = tableNode(b);
+ b.editor.view.dispatch(setColumnWeights(b.editor.state.tr, pos + 1, node, [100, 100, 100, 700]));
+
+ expect(colWidths(b)).toEqual(["35%", "15%", "25%", "25%"]);
+
+ pointerUp(80);
+ expect(storedColwidths(b)[0]).toEqual([[350], [150], [250], [250]]);
+ });
+
+ it("cancels the drag when a column is added mid-drag", () => {
+ const b = mount(FOUR_COLUMNS);
+ const docChanges = countDocChanges(b);
+
+ pointerDown(b, 0);
+ pointerMove(80);
+ const { pos } = tableNode(b);
+ // Inside the first header cell's paragraph: table > row > cell > paragraph.
+ b.editor.commands.setTextSelection(pos + 4);
+ b.editor.commands.addColumnAfter();
+ pointerUp(80);
+
+ expect(docChanges()).toBe(1); // the added column; the drag wrote nothing
+ expect(colWidths(b)).toEqual(["20%", "20%", "20%", "20%", "20%"]);
+ });
+});
diff --git a/web/src/features/editor/TableColumnView.ts b/web/src/features/editor/TableColumnView.ts
new file mode 100644
index 0000000..2a03819
--- /dev/null
+++ b/web/src/features/editor/TableColumnView.ts
@@ -0,0 +1,246 @@
+/**
+ * TableColumnView — the node view for every table, editable or not.
+ *
+ * Columns render as percentages of a full-width table (see columnWidths.ts),
+ * and a handle layer over the table carries one drag strip per inner column
+ * border. Because the columns are percentages, a border sits at the running
+ * total of the shares before it, so the strips line up with the real borders
+ * without measuring anything.
+ *
+ * prosemirror-tables' own resizing is not used. Tiptap installs it only when
+ * the editor is editable at creation, and docs open in view mode, so its
+ * handles never appeared after switching to Edit. This view is installed
+ * regardless and checks `view.editable` when a drag starts.
+ *
+ * A drag only touches the DOM while it runs and commits one transaction on
+ * release, so it is one undo step and one Yjs update.
+ */
+
+import type { Node as PMNode } from "@tiptap/pm/model";
+import type { EditorView, NodeView, ViewMutationRecord } from "@tiptap/pm/view";
+
+import {
+ MIN_COLUMN_PX,
+ moveBorder,
+ readColumnWeights,
+ resolveShares,
+ setColumnWeights,
+ sharePercent,
+ sharesEqual,
+ toWeights,
+} from "./columnWidths";
+
+/**
+ * Movement below this is not a drag: a tap, a click's jitter, or the first
+ * pixels of a scroll swipe that the browser is about to cancel.
+ */
+const DRAG_THRESHOLD_PX = 3;
+
+type Drag = {
+ border: number;
+ /** Set once the pointer has moved DRAG_THRESHOLD_PX from where it started. */
+ active: boolean;
+ pointerId: number;
+ handle: HTMLElement;
+ startX: number;
+ tableWidth: number;
+ startShares: number[];
+ shares: number[];
+};
+
+function syncChildCount(parent: HTMLElement, count: number, create: () => HTMLElement) {
+ while (parent.children.length < count) parent.appendChild(create());
+ while (parent.children.length > count) parent.lastElementChild?.remove();
+}
+
+function setStyle(element: Element | undefined, property: "width" | "left", value: string) {
+ if (!(element instanceof HTMLElement) || element.style[property] === value) return;
+ element.style[property] = value;
+}
+
+export class TableColumnView implements NodeView {
+ dom: HTMLDivElement;
+ contentDOM: HTMLTableSectionElement;
+
+ private node: PMNode;
+ private readonly view: EditorView;
+ private readonly getPos: () => number | undefined;
+ private readonly table: HTMLTableElement;
+ private readonly colgroup: HTMLTableColElement;
+ private readonly handleLayer: HTMLDivElement;
+ private drag: Drag | null = null;
+
+ constructor(
+ node: PMNode,
+ view: EditorView,
+ getPos: () => number | undefined,
+ htmlAttributes: Record = {},
+ ) {
+ this.node = node;
+ this.view = view;
+ this.getPos = getPos;
+
+ const doc = view.dom.ownerDocument;
+ this.dom = doc.createElement("div");
+ this.dom.className = "tableWrapper";
+ this.table = this.dom.appendChild(doc.createElement("table"));
+ for (const [name, value] of Object.entries(htmlAttributes)) {
+ if (value !== undefined && value !== null) this.table.setAttribute(name, String(value));
+ }
+ this.colgroup = this.table.appendChild(doc.createElement("colgroup"));
+ this.contentDOM = this.table.appendChild(doc.createElement("tbody"));
+
+ this.handleLayer = this.dom.appendChild(doc.createElement("div"));
+ this.handleLayer.className = "knot-col-handles";
+ this.handleLayer.contentEditable = "false";
+ this.handleLayer.addEventListener("pointerdown", this.onPointerDown);
+
+ this.render(this.storedShares());
+ }
+
+ update(node: PMNode): boolean {
+ if (node.type !== this.node.type) return false;
+ this.node = node;
+ // A remote change mid-drag: keep showing the drag (the commit rewrites
+ // every column, so the last drag wins) unless the column count changed,
+ // which would make the in-progress shares meaningless.
+ if (this.drag && readColumnWeights(node).length !== this.drag.shares.length) this.endDrag();
+ this.render(this.drag ? this.drag.shares : this.storedShares());
+ return true;
+ }
+
+ /** Keeps ProseMirror from starting a selection on a handle. */
+ stopEvent(event: Event): boolean {
+ return event.target instanceof Node && this.handleLayer.contains(event.target);
+ }
+
+ /** Only the cell content is ProseMirror's; colgroup and handles are ours. */
+ ignoreMutation(mutation: ViewMutationRecord): boolean {
+ if (mutation.type === "selection") return false;
+ return !this.contentDOM.contains(mutation.target);
+ }
+
+ destroy(): void {
+ this.endDrag();
+ this.handleLayer.removeEventListener("pointerdown", this.onPointerDown);
+ }
+
+ private storedShares(): number[] {
+ return resolveShares(readColumnWeights(this.node));
+ }
+
+ private render(shares: readonly number[]): void {
+ const doc = this.dom.ownerDocument;
+ syncChildCount(this.colgroup, shares.length, () => doc.createElement("col"));
+ shares.forEach((share, column) => setStyle(this.colgroup.children[column], "width", sharePercent(share)));
+
+ syncChildCount(this.handleLayer, Math.max(shares.length - 1, 0), () => {
+ const handle = doc.createElement("div");
+ handle.className = "knot-col-handle";
+ return handle;
+ });
+ let left = 0;
+ for (let border = 0; border < shares.length - 1; border++) {
+ left += shares[border] ?? 0;
+ const handle = this.handleLayer.children[border];
+ if (handle instanceof HTMLElement) handle.dataset.border = String(border);
+ setStyle(handle, "left", sharePercent(left));
+ }
+ }
+
+ private onPointerDown = (event: PointerEvent): void => {
+ const handle = event.target instanceof Element ? event.target.closest(".knot-col-handle") : null;
+ if (!handle || this.drag || !this.view.editable || event.button !== 0) return;
+ const tableWidth = this.table.getBoundingClientRect().width;
+ if (tableWidth <= 0) return;
+
+ event.preventDefault();
+ const startShares = this.storedShares();
+ this.drag = {
+ border: Number(handle.dataset.border),
+ active: false,
+ pointerId: event.pointerId,
+ handle,
+ startX: event.clientX,
+ tableWidth,
+ startShares,
+ shares: startShares,
+ };
+ handle.classList.add("is-dragging");
+ // Absent in jsdom; in browsers it keeps the drag alive outside the window.
+ if (typeof handle.setPointerCapture === "function") handle.setPointerCapture(event.pointerId);
+
+ const doc = this.dom.ownerDocument;
+ doc.addEventListener("pointermove", this.onPointerMove);
+ doc.addEventListener("pointerup", this.onPointerUp);
+ doc.addEventListener("pointercancel", this.onPointerCancel);
+ doc.addEventListener("keydown", this.onKeyDown);
+ };
+
+ private onPointerMove = (event: PointerEvent): void => {
+ const drag = this.drag;
+ if (!drag || event.pointerId !== drag.pointerId) return;
+ const offset = event.clientX - drag.startX;
+ if (!drag.active && Math.abs(offset) < DRAG_THRESHOLD_PX) return;
+ drag.active = true;
+ const delta = offset / drag.tableWidth;
+ drag.shares = moveBorder(drag.startShares, drag.border, delta, MIN_COLUMN_PX / drag.tableWidth);
+ this.render(drag.shares);
+ };
+
+ private onPointerUp = (event: PointerEvent): void => {
+ const drag = this.drag;
+ if (!drag || event.pointerId !== drag.pointerId) return;
+ this.onPointerMove(event);
+ this.endDrag();
+ if (!drag.active || sharesEqual(drag.shares, drag.startShares)) {
+ this.render(this.storedShares());
+ return;
+ }
+ this.commit(drag.shares);
+ };
+
+ private onPointerCancel = (event: PointerEvent): void => {
+ if (this.drag && event.pointerId === this.drag.pointerId) this.cancelDrag();
+ };
+
+ private onKeyDown = (event: KeyboardEvent): void => {
+ if (event.key !== "Escape" || !this.drag) return;
+ event.preventDefault();
+ this.cancelDrag();
+ };
+
+ /** Writes the drag; anything that stops it falls back to the stored widths. */
+ private commit(shares: readonly number[]): void {
+ const pos = this.getPos();
+ const table = pos === undefined ? null : this.view.state.doc.nodeAt(pos);
+ if (pos !== undefined && table?.type === this.node.type && readColumnWeights(table).length === shares.length) {
+ const tr = setColumnWeights(this.view.state.tr, pos + 1, table, toWeights(shares));
+ if (tr.docChanged) {
+ this.view.dispatch(tr);
+ return;
+ }
+ }
+ this.render(this.storedShares());
+ }
+
+ private cancelDrag(): void {
+ this.endDrag();
+ this.render(this.storedShares());
+ }
+
+ private endDrag(): void {
+ const drag = this.drag;
+ if (!drag) return;
+ this.drag = null;
+ drag.handle.classList.remove("is-dragging");
+ if (typeof drag.handle.hasPointerCapture === "function" && drag.handle.hasPointerCapture(drag.pointerId)) {
+ drag.handle.releasePointerCapture(drag.pointerId);
+ }
+ const doc = this.dom.ownerDocument;
+ doc.removeEventListener("pointermove", this.onPointerMove);
+ doc.removeEventListener("pointerup", this.onPointerUp);
+ doc.removeEventListener("pointercancel", this.onPointerCancel);
+ doc.removeEventListener("keydown", this.onKeyDown);
+ }
+}
diff --git a/web/src/features/editor/TableExtensions.ts b/web/src/features/editor/TableExtensions.ts
index f725016..613328c 100644
--- a/web/src/features/editor/TableExtensions.ts
+++ b/web/src/features/editor/TableExtensions.ts
@@ -5,12 +5,20 @@
*
* Each extension adds an `align` attribute on cells/headers that mirrors
* GFM column alignment and round-trips through to_markdown.
+ *
+ * Column widths are proportions, not pixels: see columnWidths.ts for the
+ * model and TableColumnView.ts for rendering and drag-resizing.
*/
+import { mergeAttributes } from "@tiptap/core";
// The four table nodes now ship from one package as named exports; the
// aggregate has no default export, so `import Table from …` fails to compile.
// TableKit is deliberately not used: it registers camelCase node names.
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
+import type { DOMOutputSpec } from "@tiptap/pm/model";
+
+import { readColumnWeights, resolveShares, sharePercent } from "./columnWidths";
+import { TableColumnView } from "./TableColumnView";
const alignAttr = {
align: {
@@ -36,13 +44,40 @@ export const KnotTable = Table.extend({
// the camelCase name. Renaming the node alone doesn't update that string —
// it has to be overridden to match our snake_case schema.
content: "table_row+",
+
+ // Every table renders through TableColumnView, in view and edit mode
+ // alike. Upstream picks its view (and whether to install prosemirror-tables'
+ // pixel resizing) once, from the editability at creation — which is view
+ // mode for every existing doc — so a later setEditable() could not add the
+ // resize handles.
+ addNodeView() {
+ return ({ node, view, getPos, HTMLAttributes }) =>
+ new TableColumnView(node, view, getPos, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes));
+ },
+
+ // getHTML() and the clipboard: the same percentage columns as the view, so
+ // a table pasted elsewhere keeps its proportions. Cells still carry their
+ // `colwidth` weights, which is what a paste back into knot reads.
+ renderHTML({ node, HTMLAttributes }) {
+ const columns: DOMOutputSpec[] = resolveShares(readColumnWeights(node)).map((share) => [
+ "col",
+ { style: `width: ${sharePercent(share)}` },
+ ]);
+ return [
+ "div",
+ { class: "tableWrapper" },
+ [
+ "table",
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { style: "width: 100%" }),
+ ["colgroup", {}, ...columns],
+ ["tbody", 0],
+ ],
+ ];
+ },
}).configure({
- resizable: true,
- // Tiptap defaults this to false, and TableView (which emits its own
- // wrapper) only mounts when the editor is editable — so view mode, the
- // default here, had no `.tableWrapper` at all and nothing to scroll a
- // colwidth-pinned table inside. prose.css gives the wrapper overflow-x.
- renderWrapper: true,
+ // prosemirror-tables' resizing stores pixels and pins the table to their
+ // sum; TableColumnView replaces it.
+ resizable: false,
HTMLAttributes: { class: "knot-table" },
});
diff --git a/web/src/features/editor/columnWidths.test.ts b/web/src/features/editor/columnWidths.test.ts
new file mode 100644
index 0000000..04781f9
--- /dev/null
+++ b/web/src/features/editor/columnWidths.test.ts
@@ -0,0 +1,227 @@
+import { getSchema, type JSONContent } from "@tiptap/core";
+import type { Node as PMNode } from "@tiptap/pm/model";
+import { EditorState } from "@tiptap/pm/state";
+import StarterKit from "@tiptap/starter-kit";
+import { describe, expect, it } from "vitest";
+
+import {
+ moveBorder,
+ readColumnWeights,
+ resolveShares,
+ setColumnWeights,
+ sharePercent,
+ sharesEqual,
+ toWeights,
+} from "./columnWidths";
+import { KnotTable, KnotTableCell, KnotTableHeader, KnotTableRow } from "./TableExtensions";
+
+const schema = getSchema([StarterKit, KnotTable, KnotTableRow, KnotTableCell, KnotTableHeader]);
+
+type CellSpec = { header?: boolean; colspan?: number; rowspan?: number; colwidth?: number[] | null };
+
+function tableJson(rows: CellSpec[][]): JSONContent {
+ return {
+ type: "table",
+ content: rows.map((cells) => ({
+ type: "table_row",
+ content: cells.map((cell) => ({
+ type: cell.header ? "table_header" : "table_cell",
+ attrs: {
+ colspan: cell.colspan ?? 1,
+ rowspan: cell.rowspan ?? 1,
+ colwidth: cell.colwidth ?? null,
+ },
+ content: [{ type: "paragraph" }],
+ })),
+ })),
+ };
+}
+
+function tableNode(rows: CellSpec[][]): PMNode {
+ return schema.nodeFromJSON(tableJson(rows));
+}
+
+/** colwidth of every cell, row by row, as stored. */
+function storedColwidths(table: PMNode): (number[] | null)[][] {
+ const rows: (number[] | null)[][] = [];
+ table.forEach((row) => {
+ const cells: (number[] | null)[] = [];
+ row.forEach((cell) => cells.push(cell.attrs.colwidth as number[] | null));
+ rows.push(cells);
+ });
+ return rows;
+}
+
+const sum = (values: readonly number[]) => values.reduce((total, value) => total + value, 0);
+
+describe("resolveShares", () => {
+ it("gives every column an equal share when none is stored", () => {
+ expect(resolveShares([null, null, null, null])).toEqual([0.25, 0.25, 0.25, 0.25]);
+ });
+
+ it("keeps the ratio of stored weights whatever their scale, so legacy pixels survive", () => {
+ expect(resolveShares([200, 600])).toEqual([0.25, 0.75]);
+ expect(resolveShares([250, 750])).toEqual([0.25, 0.75]);
+ });
+
+ it("gives a column added to a resized table the mean share, since nothing is left of the total", () => {
+ const shares = resolveShares([400, null, 600]);
+ expect(shares[0]).toBeCloseTo(400 / 1500);
+ expect(shares[1]).toBeCloseTo(500 / 1500);
+ expect(shares[2]).toBeCloseTo(600 / 1500);
+ });
+
+ it("lets unset columns share what is left of the total", () => {
+ // prosemirror-tables only ever wrote the dragged column, so a table
+ // resized before this existed typically looks like this. The dragged
+ // column has to stay the widest, not drop back to an equal share.
+ const shares = resolveShares([300, null, null, null]);
+ expect(shares[0]).toBeCloseTo(0.3);
+ for (const share of shares.slice(1)) expect(share).toBeCloseTo(0.7 / 3);
+ });
+
+ it("hands a column re-added after a delete the share the deleted one left", () => {
+ expect(resolveShares([500, 250, null])).toEqual([0.5, 0.25, 0.25]);
+ });
+
+ it("treats zero, negative and non-finite weights as unset", () => {
+ expect(resolveShares([0, 300, -5, Number.NaN])).toEqual(resolveShares([null, 300, null, null]));
+ });
+
+ it("returns no shares for no columns", () => {
+ expect(resolveShares([])).toEqual([]);
+ });
+});
+
+describe("moveBorder", () => {
+ const quarters = [0.25, 0.25, 0.25, 0.25];
+
+ it("moves width between the two neighbours only", () => {
+ const moved = moveBorder(quarters, 0, 0.1, 0.05);
+ expect(moved[0]).toBeCloseTo(0.35);
+ expect(moved[1]).toBeCloseTo(0.15);
+ expect(moved.slice(2)).toEqual([0.25, 0.25]);
+ expect(sum(moved)).toBeCloseTo(1);
+ });
+
+ it("stops the left neighbour at the minimum", () => {
+ const moved = moveBorder(quarters, 1, -0.5, 0.05);
+ expect(moved[1]).toBeCloseTo(0.05);
+ expect(moved[2]).toBeCloseTo(0.45);
+ });
+
+ it("stops the right neighbour at the minimum", () => {
+ const moved = moveBorder(quarters, 0, 0.5, 0.05);
+ expect(moved[0]).toBeCloseTo(0.45);
+ expect(moved[1]).toBeCloseTo(0.05);
+ });
+
+ it("never makes a column already under the minimum jump wider or shrink further", () => {
+ const narrow = [0.02, 0.48, 0.5];
+ expect(moveBorder(narrow, 0, -0.01, 0.05)).toEqual(narrow);
+ const nudged = moveBorder(narrow, 0, 0.01, 0.05);
+ expect(nudged[0]).toBeCloseTo(0.03);
+ expect(nudged[1]).toBeCloseTo(0.47);
+ });
+
+ it("ignores a border that does not exist", () => {
+ expect(moveBorder(quarters, 3, 0.1, 0.05)).toEqual(quarters);
+ expect(moveBorder(quarters, -1, 0.1, 0.05)).toEqual(quarters);
+ });
+});
+
+describe("toWeights", () => {
+ it("scales shares to integers that sum to exactly 1000", () => {
+ expect(toWeights([0.35, 0.15, 0.25, 0.25])).toEqual([350, 150, 250, 250]);
+ });
+
+ it("hands rounding leftovers to the largest remainders", () => {
+ const weights = toWeights([1 / 3, 1 / 3, 1 / 3]);
+ expect(sum(weights)).toBe(1000);
+ expect(weights).toEqual([334, 333, 333]);
+ });
+
+ it("never stores a zero, which would read back as unset", () => {
+ const weights = toWeights([0.0001, 0.9999]);
+ expect(weights).toEqual([1, 999]);
+ });
+
+ it("returns no weights for no columns", () => {
+ expect(toWeights([])).toEqual([]);
+ });
+});
+
+describe("sharesEqual", () => {
+ it("ignores floating-point noise", () => {
+ expect(sharesEqual([0.25, 0.75], [0.25, 0.7500000001])).toBe(true);
+ });
+
+ it("detects a moved border and a changed column count", () => {
+ expect(sharesEqual([0.25, 0.75], [0.3, 0.7])).toBe(false);
+ expect(sharesEqual([0.5, 0.5], [0.5, 0.25, 0.25])).toBe(false);
+ });
+});
+
+describe("sharePercent", () => {
+ it("formats a share as a short CSS percentage", () => {
+ expect(sharePercent(0.25)).toBe("25%");
+ expect(sharePercent(1 / 3)).toBe("33.333%");
+ });
+});
+
+describe("readColumnWeights", () => {
+ it("reads one weight per grid column, through a colspan in the first row", () => {
+ const table = tableNode([
+ [{ header: true, colspan: 2, colwidth: [100, 200] }, { header: true, colwidth: [300] }],
+ [{}, {}, {}],
+ ]);
+ expect(readColumnWeights(table)).toEqual([100, 200, 300]);
+ });
+
+ it("reports unset and zero widths as null", () => {
+ const table = tableNode([[{ colwidth: null }, { colwidth: [0] }, { colwidth: [120] }]]);
+ expect(readColumnWeights(table)).toEqual([null, null, 120]);
+ });
+});
+
+describe("setColumnWeights", () => {
+ function apply(rows: CellSpec[][], weights: number[]) {
+ const doc = schema.nodeFromJSON({ type: "doc", content: [tableJson(rows)] });
+ const state = EditorState.create({ doc });
+ const tr = setColumnWeights(state.tr, 1, doc.firstChild!, weights);
+ return { tr, table: tr.doc.firstChild! };
+ }
+
+ it("writes the weights onto every cell of every row, sliced across a colspan", () => {
+ const { table } = apply(
+ [
+ [{ header: true, colspan: 2 }, { header: true }],
+ [{}, {}, {}],
+ ],
+ [500, 200, 300],
+ );
+ expect(storedColwidths(table)).toEqual([
+ [[500, 200], [300]],
+ [[500], [200], [300]],
+ ]);
+ });
+
+ it("writes a rowspan cell once, and the shorter row below it by its real columns", () => {
+ const { table } = apply(
+ [
+ [{ rowspan: 2 }, {}, {}],
+ [{}, {}],
+ ],
+ [100, 300, 600],
+ );
+ expect(storedColwidths(table)).toEqual([
+ [[100], [300], [600]],
+ [[300], [600]],
+ ]);
+ });
+
+ it("changes nothing when the weights are already stored", () => {
+ const { tr } = apply([[{ colwidth: [400] }, { colwidth: [600] }]], [400, 600]);
+ expect(tr.docChanged).toBe(false);
+ });
+});
diff --git a/web/src/features/editor/columnWidths.ts b/web/src/features/editor/columnWidths.ts
new file mode 100644
index 0000000..b288c22
--- /dev/null
+++ b/web/src/features/editor/columnWidths.ts
@@ -0,0 +1,175 @@
+/**
+ * Table column widths as proportions.
+ *
+ * Widths live in prosemirror-tables' `colwidth` cell attribute — an array
+ * with one number per column the cell spans — because that is what its
+ * table commands (add/delete column, merge/split, fixTables) already carry
+ * along. knot reads those numbers as relative weights, not pixels: a column
+ * renders at `weight / total` of the table, and the table always fills the
+ * available width, so a layout made on one screen keeps its proportions on
+ * every other one.
+ *
+ * Every drag rewrites all columns normalised to WEIGHT_TOTAL. A fixed total
+ * keeps two editors on different screen widths from mixing scales. Pixel
+ * widths stored before this existed read as weights against that same
+ * total: a fully pinned table keeps its ratios exactly.
+ */
+
+import type { Node as PMNode } from "@tiptap/pm/model";
+import type { Transaction } from "@tiptap/pm/state";
+import { TableMap } from "@tiptap/pm/tables";
+
+/** Committed weights add up to exactly this. */
+export const WEIGHT_TOTAL = 1000;
+
+/** The narrowest a drag may make a column, in rendered pixels. */
+export const MIN_COLUMN_PX = 40;
+
+const SHARE_EPSILON = 1e-6;
+
+const isWeight = (value: number | null | undefined): value is number =>
+ typeof value === "number" && Number.isFinite(value) && value > 0;
+
+/**
+ * Fractions of the table width (summing to 1) for stored weights. Columns
+ * with no usable weight share what the set ones leave of WEIGHT_TOTAL — so a
+ * column re-added after a delete takes the freed share, and a table resized
+ * by prosemirror-tables (which only ever wrote the dragged column) keeps
+ * that column the widest. When nothing is left, as for a column added to a
+ * normalised table, each gets the mean; with no weights at all, columns are
+ * equal.
+ */
+export function resolveShares(weights: readonly (number | null)[]): number[] {
+ const known = weights.filter(isWeight);
+ const knownTotal = known.reduce((total, weight) => total + weight, 0);
+ const unsetCount = weights.length - known.length;
+ const leftover = WEIGHT_TOTAL - knownTotal;
+ let fallback = 1;
+ if (known.length > 0) {
+ fallback = leftover > 0 && unsetCount > 0 ? leftover / unsetCount : knownTotal / known.length;
+ }
+ const filled = weights.map((weight) => (isWeight(weight) ? weight : fallback));
+ const total = filled.reduce((sum, weight) => sum + weight, 0);
+ return filled.map((weight) => weight / total);
+}
+
+/**
+ * Moves the border after column `border` by `delta` (a fraction of the table
+ * width, positive to the right). Only the two neighbours change, so the
+ * total stays put and the border tracks the pointer. Neither neighbour goes
+ * below `minShare` — or below its current share, when it already is
+ * narrower, so a small drag never makes it jump.
+ */
+export function moveBorder(
+ shares: readonly number[],
+ border: number,
+ delta: number,
+ minShare: number,
+): number[] {
+ const next = [...shares];
+ const left = shares[border];
+ const right = shares[border + 1];
+ if (left === undefined || right === undefined) return next;
+
+ const pair = left + right;
+ const leftFloor = Math.min(minShare, left);
+ const rightFloor = Math.min(minShare, right);
+ const newLeft = Math.min(Math.max(left + delta, leftFloor), pair - rightFloor);
+ next[border] = newLeft;
+ next[border + 1] = pair - newLeft;
+ return next;
+}
+
+/**
+ * Integer weights summing to exactly WEIGHT_TOTAL (largest-remainder
+ * rounding). None is zero: a stored zero reads back as "unset" and would
+ * hand that column the mean share.
+ */
+export function toWeights(shares: readonly number[]): number[] {
+ if (shares.length === 0) return [];
+ const total = shares.reduce((sum, share) => sum + share, 0);
+ const exact = shares.map((share) => (share / total) * WEIGHT_TOTAL);
+ const remainder = WEIGHT_TOTAL - exact.reduce((sum, value) => sum + Math.floor(value), 0);
+ const roundsUp = new Set(
+ exact
+ .map((value, index) => ({ index, fraction: value - Math.floor(value) }))
+ .sort((a, b) => b.fraction - a.fraction || a.index - b.index)
+ .slice(0, remainder)
+ .map(({ index }) => index),
+ );
+ const weights = exact.map((value, index) => Math.floor(value) + (roundsUp.has(index) ? 1 : 0));
+
+ const zeroCount = weights.filter((weight) => weight === 0).length;
+ if (zeroCount === 0) return weights;
+ const widest = weights.indexOf(Math.max(...weights));
+ return weights.map((weight, index) => {
+ if (weight === 0) return 1;
+ return index === widest ? weight - zeroCount : weight;
+ });
+}
+
+export function sharesEqual(a: readonly number[], b: readonly number[]): boolean {
+ return (
+ a.length === b.length &&
+ a.every((share, index) => Math.abs(share - (b[index] ?? Number.NaN)) < SHARE_EPSILON)
+ );
+}
+
+/** A share as a CSS percentage, trimmed to three decimals ("33.333%"). */
+export function sharePercent(share: number): string {
+ return `${Number((share * 100).toFixed(3))}%`;
+}
+
+/**
+ * One stored weight per grid column, `null` where none is set. Read from the
+ * first row — nothing spans into it, so its cells cover every column — via
+ * TableMap, so a colspan there still yields one entry per column.
+ */
+export function readColumnWeights(table: PMNode): (number | null)[] {
+ const map = TableMap.get(table);
+ const weights: (number | null)[] = [];
+ for (let column = 0; column < map.width; column++) {
+ const cellPos = map.map[column];
+ if (cellPos === undefined) {
+ weights.push(null);
+ continue;
+ }
+ const cell = table.nodeAt(cellPos);
+ const colwidth = cell?.attrs.colwidth as number[] | null | undefined;
+ const weight = colwidth?.[column - map.colCount(cellPos)];
+ weights.push(isWeight(weight) ? weight : null);
+ }
+ return weights;
+}
+
+/**
+ * Writes `weights` (one per grid column) onto every cell of `table`, which
+ * starts its content at `tableStart`. Each cell gets the slice for the
+ * columns it spans. Cells that already match are left alone, so an
+ * unchanged layout produces no doc change.
+ */
+export function setColumnWeights(
+ tr: Transaction,
+ tableStart: number,
+ table: PMNode,
+ weights: readonly number[],
+): Transaction {
+ const map = TableMap.get(table);
+ const visited = new Set();
+ for (const cellPos of map.map) {
+ if (visited.has(cellPos)) continue;
+ visited.add(cellPos);
+ const cell = table.nodeAt(cellPos);
+ if (!cell) continue;
+
+ const firstColumn = map.colCount(cellPos);
+ const colspan = (cell.attrs.colspan as number | undefined) ?? 1;
+ const colwidth = weights.slice(firstColumn, firstColumn + colspan);
+ const current = cell.attrs.colwidth as number[] | null;
+ if (current && current.length === colwidth.length && current.every((w, i) => w === colwidth[i])) {
+ continue;
+ }
+ tr.setNodeMarkup(tableStart + cellPos, null, { ...cell.attrs, colwidth });
+ }
+ return tr;
+}
diff --git a/web/src/features/editor/schema.test.ts b/web/src/features/editor/schema.test.ts
index f3eb594..15deca6 100644
--- a/web/src/features/editor/schema.test.ts
+++ b/web/src/features/editor/schema.test.ts
@@ -107,9 +107,9 @@ const REGISTERED_EXTENSIONS = [
* one that arrived with a dependency bump.
*/
const EDITOR_ONLY_ATTRS: Record = {
- // prosemirror-tables records a dragged column width here. It does ride in
- // the CRDT (one collaborator's drag is visible to everyone), but markdown
- // has no column-width concept, so the canonical schema omits it.
+ // A column's relative width (see columnWidths.ts). It does ride in the
+ // CRDT (one collaborator's drag is visible to everyone), but markdown has
+ // no column-width concept, so the canonical schema omits it.
table_cell: ["colwidth"],
table_header: ["colwidth"],
// Tiptap's OrderedList ships the HTML `type` attribute (1/a/A/i/I). Nothing
diff --git a/web/src/styles/prose.css b/web/src/styles/prose.css
index ae58270..792540c 100644
--- a/web/src/styles/prose.css
+++ b/web/src/styles/prose.css
@@ -85,20 +85,32 @@
color: var(--color-fg-muted);
}
-/* Tables */
+/* Tables. Every table renders inside TableColumnView's wrapper. flow-root
+ keeps the table's margin inside the wrapper (the top-level one already did
+ so through overflow-x), so spacing around tables is unchanged and the
+ handle layer can be inset by exactly that margin to span the table.
+ Column widths are percentages on the s. */
+.ProseMirror .tableWrapper {
+ --knot-table-margin: 0.8em;
+ position: relative;
+ display: flow-root;
+}
.ProseMirror table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
- margin: 0.8em 0;
+ margin: var(--knot-table-margin, 0.8em) 0;
overflow: hidden;
}
+/* Widths come from the colgroup, so long URLs and identifiers have to break
+ inside their cell instead of spilling into the next column. */
.ProseMirror th,
.ProseMirror td {
border: 1px solid var(--color-border);
padding: 6px 8px;
vertical-align: top;
position: relative;
+ overflow-wrap: anywhere;
}
.ProseMirror th {
background: var(--color-muted, rgb(0 0 0 / 0.03));
@@ -116,17 +128,53 @@
background: rgb(99 102 241 / 0.10);
pointer-events: none;
}
-.ProseMirror .column-resize-handle {
+/* Column resize handles: one strip per inner border, positioned by
+ TableColumnView at the running total of the column shares. */
+.ProseMirror .knot-col-handles {
position: absolute;
- right: -2px;
- top: 0;
- bottom: -2px;
- width: 4px;
- background: var(--color-accent);
+ inset: var(--knot-table-margin) 0;
pointer-events: none;
+ user-select: none;
+ -webkit-touch-callout: none;
}
-.ProseMirror.resize-cursor {
+.ProseMirror .knot-col-handle {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 9px;
+ transform: translateX(-50%);
cursor: col-resize;
+ pointer-events: auto;
+ /* A vertical swipe that starts on a strip still scrolls the page — the
+ browser then cancels the pointer, which abandons the drag — while a
+ horizontal one resizes. */
+ touch-action: pan-y pinch-zoom;
+ z-index: 1;
+}
+.ProseMirror .knot-col-handle::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 50%;
+ width: 3px;
+ transform: translateX(-50%);
+ background: var(--color-accent);
+ opacity: 0;
+ transition: opacity 120ms;
+}
+.ProseMirror .knot-col-handle.is-dragging::after {
+ opacity: 1;
+}
+/* Hover only where there is one; on touch it would stay lit after a tap. */
+@media (hover: hover) {
+ .ProseMirror .knot-col-handle:hover::after {
+ opacity: 1;
+ }
+}
+/* Readers see the proportions but get nothing to drag. */
+.ProseMirror[contenteditable="false"] .knot-col-handles {
+ display: none;
}
/* Task list items: a list_item with a `data-checked` attribute renders
@@ -387,11 +435,9 @@
max-width: 100%;
}
-/* The wrapper has never had this rule, and without it a table carrying
- persisted `colwidth` attributes overflows: the inline width TableView
- writes beats the `width: 100%` above. `colwidth` rides Yjs, so one
- collaborator's column drag would otherwise blow out the page for every
- reader. KnotTable sets renderWrapper so this exists in view mode too. */
+/* Column widths are proportions of a full-width table, so a table no longer
+ overflows on its own; this stays as a safety net so that nothing inside
+ one can ever give the page a horizontal scrollbar. */
.ProseMirror > .tableWrapper {
overflow-x: auto;
}
@@ -437,8 +483,8 @@
}
/* Clip illegitimate horizontal bleed (peer cursor labels are absolutely
- positioned, white-space:nowrap and unclamped) while keeping the 2px
- table column-resize handle grabbable. `clip` is not a scroll container
+ positioned, white-space:nowrap and unclamped) with a few pixels of slack
+ for edge decorations. `clip` is not a scroll container
and creates no containing block for fixed elements, so the sticky
toolbar — a sibling before this host, not a descendant — and the z-40
rails are unaffected. */
diff --git a/web/src/test/boundEditor.ts b/web/src/test/boundEditor.ts
index 5e2290f..0ac6fca 100644
--- a/web/src/test/boundEditor.ts
+++ b/web/src/test/boundEditor.ts
@@ -56,7 +56,7 @@ export function fragmentXml(bound: BoundEditor): string {
}
export function mountBoundEditor(
- opts: { user?: { name: string; color: string } } = {},
+ opts: { user?: { name: string; color: string }; editable?: boolean } = {},
): BoundEditor {
const ydoc = new Y.Doc();
const awareness = new Awareness(ydoc);
@@ -65,6 +65,7 @@ export function mountBoundEditor(
const editor = new Editor({
element,
+ editable: opts.editable ?? true,
extensions: createExtensions({
doc: ydoc,
awareness,