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}

`).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,