From f8985eeb072cfcaa61ec6789189ecadbb15498b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20H=C3=BCning?= Date: Mon, 12 Oct 2026 22:23:42 +0200 Subject: [PATCH 1/2] feat(editor): resizable table columns with proportional widths (#36) 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 --- CHANGELOG.md | 21 ++ .../plans/2026-10-12-table-column-resize.md | 90 ++++++ .../2026-10-12-table-column-resize-design.md | 180 +++++++++++ e2e/flows/table-column-resize.spec.ts | 101 ++++++ .../features/editor/TableColumnView.test.ts | 302 ++++++++++++++++++ web/src/features/editor/TableColumnView.ts | 246 ++++++++++++++ web/src/features/editor/TableExtensions.ts | 47 ++- web/src/features/editor/columnWidths.test.ts | 227 +++++++++++++ web/src/features/editor/columnWidths.ts | 175 ++++++++++ web/src/features/editor/schema.test.ts | 6 +- web/src/styles/prose.css | 78 ++++- web/src/test/boundEditor.ts | 3 +- 12 files changed, 1450 insertions(+), 26 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-12-table-column-resize.md create mode 100644 docs/superpowers/specs/2026-10-12-table-column-resize-design.md create mode 100644 e2e/flows/table-column-resize.spec.ts create mode 100644 web/src/features/editor/TableColumnView.test.ts create mode 100644 web/src/features/editor/TableColumnView.ts create mode 100644 web/src/features/editor/columnWidths.test.ts create mode 100644 web/src/features/editor/columnWidths.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c078023..85eda2b 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 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}

`).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( + `

a

b

`, + ); + 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( + `

a

b

`, + ); + 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( + `${row("td", 4)}

wide

c

d

`, + ); + + 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, From 40c08755bc4a63831abea3c57b195cd19106923b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20H=C3=BCning?= Date: Mon, 12 Oct 2026 22:34:53 +0200 Subject: [PATCH 2/2] fix(deps): clear new cargo-deny advisories 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 --- CHANGELOG.md | 10 ++++++++++ Cargo.lock | 4 ++-- deny.toml | 11 +++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85eda2b..92eb8f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,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]