From d00f7b72f04db7871656b8e4e2038c2a31dd58b3 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 02:30:51 -0800 Subject: [PATCH 01/85] Store a table's choice of keys in data.sheet.table.keys A table uses spreadsheet keys or document keys. The choice is a new key of the sheet's table object (spec R-SHT-10), so the sheet model and the reference reader accept it. A table that stores nothing keeps document keys when it holds only text, and uses spreadsheet keys when a cell holds a number or a formula (PLAN.md decision 4). Co-Authored-By: Claude Opus 5.5 --- app/src/features/tables/keys/mode.test.ts | 37 ++++++++++++++++++ app/src/features/tables/keys/mode.ts | 39 +++++++++++++++++++ app/src/features/tables/sheet/model/schema.ts | 1 + docs/format/README.md | 1 + docs/format/tools/schema_sheet.py | 1 + 5 files changed, 79 insertions(+) create mode 100644 app/src/features/tables/keys/mode.test.ts create mode 100644 app/src/features/tables/keys/mode.ts diff --git a/app/src/features/tables/keys/mode.test.ts b/app/src/features/tables/keys/mode.test.ts new file mode 100644 index 000000000..8f4bfba63 --- /dev/null +++ b/app/src/features/tables/keys/mode.test.ts @@ -0,0 +1,37 @@ +// Which keys a table uses (PLAN.md decision 4): its stored choice, else spreadsheet keys when it holds a number or a +// formula. The choice is a key of data.sheet.table that the sheet model accepts (R-SHT-10). +import { describe, expect, it } from 'vitest'; +import { sheetProblems } from '../sheet/model'; +import { isSheetValue, keysOf, keysPatch, storedKeys } from './mode'; + +describe('table keys', () => { + it('uses the stored choice first', () => { + expect(keysOf({ sheet: { table: { keys: 'document' } } }, [['=1+2']])).toBe('document'); + expect(keysOf({ sheet: { table: { keys: 'spreadsheet' } } }, [['words']])).toBe('spreadsheet'); + expect(storedKeys({ sheet: { table: { keys: 'later' } } })).toBeNull(); + }); + + it('gives an older table spreadsheet keys only when it holds a number or a formula', () => { + expect( + keysOf({}, [ + ['Stage', 'Where'], + ['Prophase', 'Nucleus'], + ]), + ).toBe('document'); + expect( + keysOf({}, [ + ['Item', 'Cost'], + ['Pens', '$1,250.50'], + ]), + ).toBe('spreadsheet'); + expect(keysOf({}, [['Total'], ['=SUM(A1:A2)']])).toBe('spreadsheet'); + expect(['12', '-3.5', '45%', '1e3', '€ 9'].every(isSheetValue)).toBe(true); + expect(['', '=', 'Room 12', '12 apples', '2026-11-07'].some(isSheetValue)).toBe(false); + }); + + it('stores the choice as a merge patch that the sheet model accepts', () => { + expect(keysPatch('document')).toEqual({ sheet: { table: { keys: 'document' } } }); + expect(sheetProblems({ table: { keys: 'document' } }, 'sheet')).toEqual([]); + expect(sheetProblems({ table: { keys: 'grid' } }, 'sheet')).not.toEqual([]); + }); +}); diff --git a/app/src/features/tables/keys/mode.ts b/app/src/features/tables/keys/mode.ts new file mode 100644 index 000000000..5b34bc19b --- /dev/null +++ b/app/src/features/tables/keys/mode.ts @@ -0,0 +1,39 @@ +// Which keys a table uses (ADR 0063, PLAN.md decision 4). A table stores its choice in `data.sheet.table.keys`. +// New tables store the setting "New tables use spreadsheet keys" when they are made. A table that stores nothing +// is an older one: it keeps document keys when it holds only text, and uses spreadsheet keys when it holds a +// number or a formula. +import type { Texts } from './grid'; + +export type TableKeys = 'spreadsheet' | 'document'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** The choice the table stores, or null when it stores none (or one this version doesn't know). */ +export function storedKeys(data: Record): TableKeys | null { + const sheet = data.sheet; + const table = isRecord(sheet) ? sheet.table : undefined; + const keys = isRecord(table) ? table.keys : undefined; + return keys === 'spreadsheet' || keys === 'document' ? keys : null; +} + +// A plain number as people type one. It may start with a sign or a currency sign and end with a percent sign. +const NUMBER = /^[-+]?[$€£¥]?\s*(?:\d[\d,.\u00a0 ]*|[.,]\d+)(?:[eE][-+]?\d+)?\s*%?$/; + +/** Whether a cell's text is a number or a formula, which makes an older table a spreadsheet. */ +export function isSheetValue(text: string): boolean { + const trimmed = text.trim(); + return trimmed.startsWith('=') ? trimmed.length > 1 : NUMBER.test(trimmed); +} + +/** The keys the table uses: its stored choice, else spreadsheet keys when it holds a number or a formula. */ +export function keysOf(data: Record, texts: Texts): TableKeys { + const stored = storedKeys(data); + if (stored) return stored; + return texts.some((row) => row.some(isSheetValue)) ? 'spreadsheet' : 'document'; +} + +/** The merge patch of the block's data that stores a choice. Other keys of `sheet` stay as they are. */ +export function keysPatch(keys: TableKeys): Record { + return { sheet: { table: { keys } } }; +} diff --git a/app/src/features/tables/sheet/model/schema.ts b/app/src/features/tables/sheet/model/schema.ts index 639383046..4c57a9ee6 100644 --- a/app/src/features/tables/sheet/model/schema.ts +++ b/app/src/features/tables/sheet/model/schema.ts @@ -179,6 +179,7 @@ const TABLE = obj({ pad: PAD, grid: BOOL, name: ok(isName, 'a name (R-SHT-13)'), + keys: oneOf('spreadsheet', 'document'), }); const COL = obj({ style: STYLE_REF, hidden: BOOL, level: integer(0, 7), collapsed: BOOL }); const ROW = obj({ diff --git a/docs/format/README.md b/docs/format/README.md index 85726a9b3..eb9739db6 100644 --- a/docs/format/README.md +++ b/docs/format/README.md @@ -1035,6 +1035,7 @@ A cell of a choice column stores the name of its choice as plain Markdown text, | `pad` | As the style key `pad` | `R-SHT-8` | Default padding of every cell | | `grid` | boolean | `true` | Gridlines where a cell has no border | | `name` | A name (`R-SHT-13`), unique among the page's tables without regard to case | `Table1`, `Table2`, and so on, by the table's place in the page's reading order | The name formulas use for this table | +| `keys` | `spreadsheet` or `document` | `spreadsheet` when a cell holds a number or a formula, else `document` | The table's keys: a spreadsheet's Grid and Edit modes, or a text editor's, where Enter starts a new line in the cell. Writers store the setting "New tables use spreadsheet keys" in each new table. A reader that has no spreadsheet keys ignores it | `R-SHT-11`. A column's settings are `style` (style ID), `hidden` (boolean, default `false`), `level` (outline depth, an integer from 0 to 7, default 0), and `collapsed` (boolean, default `false`). A row's settings add `height` and `exact`. `height` is in page units, from 1 to 10,000, rounded as a geometry value; absent means the row fits its content. `exact` is `true` when the height is exact, and `false` (the default) when it is a minimum. Column widths stay in the `width` of `data.columns`, in page units (`R-NUM-2`). diff --git a/docs/format/tools/schema_sheet.py b/docs/format/tools/schema_sheet.py index 751c77ce4..1e875e242 100644 --- a/docs/format/tools/schema_sheet.py +++ b/docs/format/tools/schema_sheet.py @@ -89,6 +89,7 @@ def is_name(key): "pad": PAD, "grid": BOOL, "name": ok(is_name, "a name (R-SHT-13)"), + "keys": enum("spreadsheet", "document"), } ) COL = obj({"style": STYLE_REF, "hidden": BOOL, "level": integer(0, 7), "collapsed": BOOL}) From 20963b7b329a933b46c0172bd3da79f58a3e68a7 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 02:30:53 -0800 Subject: [PATCH 02/85] Add the spreadsheet grid moves and cell announcements Pure functions for Ctrl+arrow jumps to the data's edges, the last used cell, the current region, Enter and Tab cycling inside a range, and cell addresses. Screen readers hear the address, the column header, the shown value, and the formula read as words. The sheetKeys strings hold every sentence. Co-Authored-By: Claude Opus 5.5 --- app/src/features/tables/keys/announce.test.ts | 35 ++++ app/src/features/tables/keys/announce.ts | 75 +++++++++ app/src/features/tables/keys/grid.test.ts | 83 ++++++++++ app/src/features/tables/keys/grid.ts | 154 ++++++++++++++++++ app/src/strings/en/sheetKeys.ts | 94 ++++++++++- 5 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 app/src/features/tables/keys/announce.test.ts create mode 100644 app/src/features/tables/keys/announce.ts create mode 100644 app/src/features/tables/keys/grid.test.ts create mode 100644 app/src/features/tables/keys/grid.ts diff --git a/app/src/features/tables/keys/announce.test.ts b/app/src/features/tables/keys/announce.test.ts new file mode 100644 index 000000000..0c1b2a2bb --- /dev/null +++ b/app/src/features/tables/keys/announce.test.ts @@ -0,0 +1,35 @@ +// What a screen reader hears in Grid mode (DESIGN.md section 17). The expected sentences are the plan's. +import { describe, expect, it } from 'vitest'; +import { cellAnnouncement, formulaWords, rangeAnnouncement } from './announce'; + +describe('cell announcements', () => { + it('reads the address, header, value, and formula', () => { + const said = cellAnnouncement({ address: 'C4', header: 'Total', shown: '10', formula: '=B4*2' }); + expect(said).toBe('C4, Total, 10, formula equals B4 times 2'); + }); + + it('leaves out what a cell does not have', () => { + expect(cellAnnouncement({ address: 'C4', header: 'Total', shown: '10', formula: null })).toBe('C4, Total, 10'); + expect(cellAnnouncement({ address: 'C4', header: null, shown: '', formula: null })).toBe('C4, blank'); + expect(cellAnnouncement({ address: 'C4', header: ' ', shown: '3', formula: '=1+2' })).toBe( + 'C4, 3, formula equals 1 plus 2', + ); + expect(cellAnnouncement({ address: 'C', header: 'Total', shown: 'Total', formula: null, isHeader: true })).toBe( + 'Column C header, Total', + ); + }); + + it('reads a range', () => { + expect(rangeAnnouncement('B2', 'C3', 4)).toBe('B2 through C3 selected, 4 cells'); + }); +}); + +describe('formulas as words', () => { + it('reads every operator, the longest first, and leaves quoted text alone', () => { + expect(formulaWords('=SUM(B2:B4)/2')).toBe('equals SUM(B2 through B4) divided by 2'); + expect(formulaWords('=A1<=3')).toBe('equals A1 is less than or equal to 3'); + expect(formulaWords('=A1<>B1')).toBe('equals A1 is not equal to B1'); + expect(formulaWords('=A1&" + "&B1')).toBe('equals A1 joined with " + " joined with B1'); + expect(formulaWords('=-A1^2%')).toBe('equals minus A1 to the power of 2 percent'); + }); +}); diff --git a/app/src/features/tables/keys/announce.ts b/app/src/features/tables/keys/announce.ts new file mode 100644 index 000000000..88915c187 --- /dev/null +++ b/app/src/features/tables/keys/announce.ts @@ -0,0 +1,75 @@ +// What a screen reader hears in a table's Grid mode (DESIGN.md section 17): the address, the column header, the +// shown value, and "formula" with the formula read as words. "C4, Total, 10, formula equals B4 times 2". +import { t } from '../../../strings/t'; +import type { MessageKey } from '../../../strings/t'; + +const QUOTED = /("(?:[^"]|"")*")/; +// Longest first, so "<=" is read before "<". +const OPERATORS: readonly (readonly [RegExp, MessageKey])[] = [ + [/<>/g, 'sheetKeys.words.notEqual'], + [/<=/g, 'sheetKeys.words.lessOrEqual'], + [/>=/g, 'sheetKeys.words.greaterOrEqual'], + [//g, 'sheetKeys.words.greater'], + [/=/g, 'sheetKeys.words.equals'], + [/\+/g, 'sheetKeys.words.plus'], + [/-/g, 'sheetKeys.words.minus'], + [/\*/g, 'sheetKeys.words.times'], + [/\//g, 'sheetKeys.words.dividedBy'], + [/\^/g, 'sheetKeys.words.power'], + [/&/g, 'sheetKeys.words.join'], + [/%/g, 'sheetKeys.words.percent'], + [/:/g, 'sheetKeys.words.through'], +]; + +/** + * A formula read as words: "=B4*2" is "equals B4 times 2" and "=SUM(B2:B4)" is "equals SUM(B2 through B4)". Text + * in quotes is read as it is. + */ +export function formulaWords(formula: string): string { + return formula + .split(QUOTED) + .map((part, i) => { + if (i % 2 === 1) return part; + // Each operator becomes a private-use mark first, so a word never holds another operator's sign. + const words: string[] = []; + let marked = part; + for (const [sign, key] of OPERATORS) { + marked = marked.replace(sign, () => ` \u{E000}${words.push(t(key)) - 1}\u{E001} `); + } + return marked.replace(/\u{E000}(\d+)\u{E001}/gu, (_, n: string) => words[Number(n)]); + }) + .join('') + .replace(/\s+/g, ' ') + .trim(); +} + +export interface CellSpeech { + /** "C4", or the column letter alone in the header row. */ + address: string; + /** The column's header text, or null when the table has no header row. */ + header: string | null; + /** What the cell shows: a formula's result, or its text. */ + shown: string; + /** The formula, with its "=", when the cell holds one. */ + formula: string | null; + /** Whether the cell is in the header row. */ + isHeader?: boolean; +} + +/** The announcement for a cell that became the active cell. */ +export function cellAnnouncement(cell: CellSpeech): string { + const value = cell.shown.trim() === '' ? t('sheetKeys.announce.blank') : cell.shown.trim(); + if (cell.isHeader) return t('sheetKeys.announce.headerCell', { address: cell.address, value }); + const header = cell.header?.trim() || null; + const formula = cell.formula ? formulaWords(cell.formula) : null; + if (header && formula) return t('sheetKeys.announce.cellFormula', { address: cell.address, header, value, formula }); + if (header) return t('sheetKeys.announce.cell', { address: cell.address, header, value }); + if (formula) return t('sheetKeys.announce.cellNoHeaderFormula', { address: cell.address, value, formula }); + return t('sheetKeys.announce.cellNoHeader', { address: cell.address, value }); +} + +/** The announcement for a range: "B2 through C3 selected, 4 cells". */ +export function rangeAnnouncement(from: string, to: string, count: number): string { + return t('sheetKeys.announce.range', { from, to, count }); +} diff --git a/app/src/features/tables/keys/grid.test.ts b/app/src/features/tables/keys/grid.test.ts new file mode 100644 index 000000000..6cc1fcdd7 --- /dev/null +++ b/app/src/features/tables/keys/grid.test.ts @@ -0,0 +1,83 @@ +// The grid's spreadsheet moves, against hand-worked grids. Expected cells follow the documented behavior of +// Ctrl+arrow, Ctrl+End, Ctrl+A, and Enter in a selection (DESIGN.md section 11.2), worked out by hand. +import { describe, expect, it } from 'vitest'; +import { address, boxOf, cycle, jump, lastUsed, region, step } from './grid'; + +// A 5 by 5 grid with data in A1:C3. +const DATA = [ + ['1', '2', '3', '', ''], + ['4', '5', '6', '', ''], + ['7', '8', '9', '', ''], + ['', '', '', '', ''], + ['', '', '', '', ''], +]; + +const A1 = { row: 0, column: 0 }; + +describe('the data edges', () => { + it('jumps to the end of a run of data, then to the grid edge past it', () => { + expect(jump(DATA, A1, 1, 0)).toEqual({ row: 2, column: 0 }); + expect(jump(DATA, A1, 0, 1)).toEqual({ row: 0, column: 2 }); + expect(jump(DATA, { row: 2, column: 0 }, 1, 0)).toEqual({ row: 4, column: 0 }); + expect(jump(DATA, { row: 4, column: 4 }, -1, 0)).toEqual({ row: 0, column: 4 }); + }); + + it('jumps from an empty cell to the next filled one', () => { + const gaps = [['a', '', '', 'b', 'c']]; + expect(jump(gaps, { row: 0, column: 0 }, 0, 1)).toEqual({ row: 0, column: 3 }); + expect(jump(gaps, { row: 0, column: 1 }, 0, 1)).toEqual({ row: 0, column: 3 }); + expect(jump(gaps, { row: 0, column: 3 }, 0, 1)).toEqual({ row: 0, column: 4 }); + expect(jump(gaps, { row: 0, column: 4 }, 0, 1)).toEqual({ row: 0, column: 4 }); + }); + + it('finds the last used cell and the region around a cell', () => { + expect(lastUsed(DATA)).toEqual({ row: 2, column: 2 }); + expect( + lastUsed([ + ['', ''], + ['', ''], + ]), + ).toEqual(A1); + expect(region(DATA, { row: 1, column: 1 })).toEqual({ top: 0, left: 0, bottom: 2, right: 2 }); + expect(region(DATA, { row: 4, column: 4 })).toEqual({ top: 4, left: 4, bottom: 4, right: 4 }); + // A diagonal neighbor joins the region. + expect( + region( + [ + ['a', ''], + ['', 'b'], + ], + A1, + ), + ).toEqual({ top: 0, left: 0, bottom: 1, right: 1 }); + }); + + it('keeps a step inside the grid', () => { + expect(step(DATA, A1, -1, 0)).toEqual(A1); + expect(step(DATA, { row: 4, column: 4 }, 3, 3)).toEqual({ row: 4, column: 4 }); + }); +}); + +describe('a range', () => { + const box = boxOf({ row: 1, column: 1 }, { row: 2, column: 2 }); + + it('goes down each column with Enter, then on to the next column, and wraps', () => { + const order = [{ row: 1, column: 1 }]; + for (let i = 0; i < 4; i++) order.push(cycle(box, order[order.length - 1], 'column')); + expect(order.map((at) => address(at, false))).toEqual(['B2', 'B3', 'C2', 'C3', 'B2']); + expect(address(cycle(box, { row: 1, column: 1 }, 'column', true), false)).toBe('C3'); + }); + + it('goes along each row with Tab', () => { + expect(address(cycle(box, { row: 1, column: 1 }, 'row'), false)).toBe('C2'); + expect(address(cycle(box, { row: 1, column: 2 }, 'row'), false)).toBe('B3'); + }); +}); + +describe('addresses', () => { + it('numbers rows from 1, under the header row when there is one', () => { + expect(address({ row: 3, column: 2 }, false)).toBe('C4'); + expect(address({ row: 4, column: 2 }, true)).toBe('C4'); + expect(address({ row: 0, column: 27 }, true)).toBe('AB'); + }); +}); diff --git a/app/src/features/tables/keys/grid.ts b/app/src/features/tables/keys/grid.ts new file mode 100644 index 000000000..212655afb --- /dev/null +++ b/app/src/features/tables/keys/grid.ts @@ -0,0 +1,154 @@ +// The spreadsheet moves of a table's grid (ADR 0063), as pure functions of the cells' text. Ctrl+arrows jump to +// the edge of the data, Ctrl+End finds the last used cell, Ctrl+A finds the current region, and Enter and Tab +// cycle inside a range. A cell is filled when its text has anything but spaces. +import { lettersFromColumn } from '../../../core/expr'; + +/** A cell of the grid, counted from 0. Row 0 is the header row when the table has one. */ +export interface GridCell { + row: number; + column: number; +} + +/** A block of cells, inclusive on every side. */ +export interface Box { + top: number; + left: number; + bottom: number; + right: number; +} + +/** The text of every cell, row by row. Rows may be short; a missing cell is empty. */ +export type Texts = readonly (readonly string[])[]; + +export const heightOf = (texts: Texts): number => texts.length; +export const widthOf = (texts: Texts): number => Math.max(0, ...texts.map((row) => row.length)); + +const filled = (texts: Texts, row: number, column: number): boolean => (texts[row]?.[column] ?? '').trim() !== ''; + +const inside = (texts: Texts, row: number, column: number): boolean => + row >= 0 && column >= 0 && row < heightOf(texts) && column < widthOf(texts); + +/** The box with `a` and `b` at opposite corners. */ +export function boxOf(a: GridCell, b: GridCell): Box { + return { + top: Math.min(a.row, b.row), + left: Math.min(a.column, b.column), + bottom: Math.max(a.row, b.row), + right: Math.max(a.column, b.column), + }; +} + +export const boxSize = (box: Box): number => (box.bottom - box.top + 1) * (box.right - box.left + 1); + +export const inBox = (box: Box, at: GridCell): boolean => + at.row >= box.top && at.row <= box.bottom && at.column >= box.left && at.column <= box.right; + +/** One step in a direction, kept inside the grid. */ +export function step(texts: Texts, from: GridCell, rows: number, columns: number): GridCell { + const row = Math.min(Math.max(from.row + rows, 0), Math.max(heightOf(texts) - 1, 0)); + const column = Math.min(Math.max(from.column + columns, 0), Math.max(widthOf(texts) - 1, 0)); + return { row, column }; +} + +/** + * Ctrl+arrow: the edge of the data in a direction. Inside a run of filled cells it stops at the run's last cell. + * From an empty cell, or from the last cell of a run, it stops at the next filled cell, or at the grid's edge. + */ +export function jump(texts: Texts, from: GridCell, rows: -1 | 0 | 1, columns: -1 | 0 | 1): GridCell { + let { row, column } = from; + if (!inside(texts, row + rows, column + columns)) return { row, column }; + if (filled(texts, row, column) && filled(texts, row + rows, column + columns)) { + while (inside(texts, row + rows, column + columns) && filled(texts, row + rows, column + columns)) { + row += rows; + column += columns; + } + return { row, column }; + } + row += rows; + column += columns; + while (!filled(texts, row, column) && inside(texts, row + rows, column + columns)) { + row += rows; + column += columns; + } + return { row, column }; +} + +/** Ctrl+End: the last row that has data, in the last column that has data. A1 when the grid is empty. */ +export function lastUsed(texts: Texts): GridCell { + let row = 0; + let column = 0; + texts.forEach((line, r) => + line.forEach((_, c) => { + if (!filled(texts, r, c)) return; + row = Math.max(row, r); + column = Math.max(column, c); + }), + ); + return { row, column }; +} + +/** Whether any cell of the box is filled. */ +function anyFilled(texts: Texts, box: Box): boolean { + for (let r = Math.max(box.top, 0); r <= box.bottom; r++) + for (let c = Math.max(box.left, 0); c <= box.right; c++) if (filled(texts, r, c)) return true; + return false; +} + +/** + * Ctrl+A: the current region, the block of data around a cell up to empty rows and columns, diagonals included. + * An empty cell with no data next to it is a region of one cell. + */ +export function region(texts: Texts, at: GridCell): Box { + const box: Box = { top: at.row, left: at.column, bottom: at.row, right: at.column }; + const last = { row: heightOf(texts) - 1, column: widthOf(texts) - 1 }; + for (let grew = true; grew;) { + grew = false; + const wide = { left: Math.max(box.left - 1, 0), right: Math.min(box.right + 1, last.column) }; + const tall = { top: Math.max(box.top - 1, 0), bottom: Math.min(box.bottom + 1, last.row) }; + if (box.top > 0 && anyFilled(texts, { ...wide, top: box.top - 1, bottom: box.top - 1 })) { + box.top--; + grew = true; + } + if (box.bottom < last.row && anyFilled(texts, { ...wide, top: box.bottom + 1, bottom: box.bottom + 1 })) { + box.bottom++; + grew = true; + } + if (box.left > 0 && anyFilled(texts, { ...tall, left: box.left - 1, right: box.left - 1 })) { + box.left--; + grew = true; + } + if (box.right < last.column && anyFilled(texts, { ...tall, left: box.right + 1, right: box.right + 1 })) { + box.right++; + grew = true; + } + } + return box; +} + +/** + * Enter and Tab inside a range: the next cell of the box, wrapping. Enter goes down each column, then on to the + * next column; Tab goes along each row, then on to the next row. `back` is Shift+Enter and Shift+Tab. + */ +export function cycle(box: Box, from: GridCell, by: 'column' | 'row', back = false): GridCell { + const rows = box.bottom - box.top + 1; + const columns = box.right - box.left + 1; + const total = rows * columns; + const index = + by === 'column' + ? (from.column - box.left) * rows + (from.row - box.top) + : (from.row - box.top) * columns + (from.column - box.left); + const next = (index + (back ? -1 : 1) + total) % total; + return by === 'column' + ? { row: box.top + (next % rows), column: box.left + Math.floor(next / rows) } + : { row: box.top + Math.floor(next / columns), column: box.left + (next % columns) }; +} + +/** + * The cell's address, as formulas name it: "C4". With a header row, the header row has no number and the first + * row under it is row 1, so formulas see the data as a sheet does. The header row's address is the column alone. + */ +export function address(at: GridCell, header: boolean): string { + const letters = lettersFromColumn(at.column); + if (header && at.row === 0) return letters; + return `${letters}${header ? at.row : at.row + 1}`; +} diff --git a/app/src/strings/en/sheetKeys.ts b/app/src/strings/en/sheetKeys.ts index 712d9affe..00bcd6f0b 100644 --- a/app/src/strings/en/sheetKeys.ts +++ b/app/src/strings/en/sheetKeys.ts @@ -1,4 +1,96 @@ // The spreadsheet keys of a table: Grid mode, Edit mode, and their announcements. // Owner: tables:keys (B10). shared:x-shared registered it in the string index, and the owner fills it in. +// Every interface string is a full sentence here, never joined from pieces (ARCHITECTURE.md section 19). -export const sheetKeys = {} as const; +export const sheetKeys = { + commands: { + extendUp: 'Extend the selection up', + extendDown: 'Extend the selection down', + extendLeft: 'Extend the selection left', + extendRight: 'Extend the selection right', + jumpUp: 'Go to the top edge of the data', + jumpDown: 'Go to the bottom edge of the data', + jumpLeft: 'Go to the left edge of the data', + jumpRight: 'Go to the right edge of the data', + extendJumpUp: 'Extend the selection to the top edge of the data', + extendJumpDown: 'Extend the selection to the bottom edge of the data', + extendJumpLeft: 'Extend the selection to the left edge of the data', + extendJumpRight: 'Extend the selection to the right edge of the data', + firstCell: 'Go to the first cell', + lastCell: 'Go to the last used cell', + extendFirstCell: 'Extend the selection to the first cell', + extendLastCell: 'Extend the selection to the last used cell', + extendRowStart: 'Extend the selection to the start of the row', + screenLeft: 'Move one screen left', + screenRight: 'Move one screen right', + moveUp: 'Move up a cell', + fillEntry: 'Fill the selected cells with the active cell', + edit: 'Edit the active cell', + clear: 'Clear the selected cells', + clearAndEdit: 'Clear the active cell and edit it', + selectColumn: 'Select the column', + selectRegion: 'Select the data around the cell, then the table', + fillDown: 'Fill down', + fillRight: 'Fill right', + insertDate: 'Enter today’s date', + insertTime: 'Enter the current time', + copyFormulaAbove: 'Copy the formula from the cell above', + copyValueAbove: 'Copy the value from the cell above', + commitUp: 'Finish the entry and move up', + commitStay: 'Finish the entry and stay in the cell', + lineBreak: 'Start a new line in the cell', + toggleEnterMode: 'Switch between Enter and Edit modes', + documentKeys: 'Use document keys in this table', + }, + keywords: 'spreadsheet keys grid cell edit enter mode excel sheets', + announce: { + cell: '{address}, {header}, {value}', + cellFormula: '{address}, {header}, {value}, formula {formula}', + cellNoHeader: '{address}, {value}', + cellNoHeaderFormula: '{address}, {value}, formula {formula}', + headerCell: 'Column {address} header, {value}', + blank: 'blank', + range: '{from} through {to} selected, {count} cells', + editing: 'Editing {address}.', + enterMode: 'Enter mode. Arrow keys finish the entry and move.', + editMode: 'Edit mode. Arrow keys move in the text.', + canceled: 'Entry canceled.', + filled: 'Filled {count} cells.', + cleared: 'Cleared {count} cells.', + documentKeysOn: 'This table now uses document keys. Enter starts a new line.', + documentKeysOff: 'This table now uses spreadsheet keys. Enter finishes the entry and moves down.', + }, + // How a formula is read aloud: "=B4*2" is "equals B4 times 2". + words: { + equals: 'equals', + plus: 'plus', + minus: 'minus', + times: 'times', + dividedBy: 'divided by', + power: 'to the power of', + join: 'joined with', + percent: 'percent', + through: 'through', + notEqual: 'is not equal to', + lessOrEqual: 'is less than or equal to', + greaterOrEqual: 'is greater than or equal to', + less: 'is less than', + greater: 'is greater than', + }, + fixed: { + title: 'Table keys', + description: 'In a table that uses spreadsheet keys. These keys can’t be changed.', + gridArrows: 'Grid mode: move the active cell', + gridEnter: 'Grid mode: move down or up, and inside a selection, go through its cells', + gridTab: 'Grid mode and Edit mode: move right or left, finishing an entry', + gridHome: 'Grid mode: go to the start of the row', + gridPage: 'Grid mode: move one screen up or down', + gridEscape: 'Grid mode: select only the active cell, then select the table as an object', + gridSpace: 'Grid mode: select the row', + gridType: 'Grid mode: replace the cell with what you type', + typeKey: 'Any letter, digit, or sign', + editEnter: 'Edit mode: finish the entry and move down', + editEscape: 'Edit mode: cancel the entry and go back to Grid mode', + editArrows: 'Enter mode: finish the entry and move; Edit mode: move in the text', + }, +} as const; From 5dfff1985ca69d174c8d1d2166728cbab9ff9fe4 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 02:30:53 -0800 Subject: [PATCH 03/85] Give tables Grid and Edit modes with spreadsheet keys A plugin, added first through the table extra, makes a cell selection Grid mode and the caret in a cell Edit mode. Typing replaces the active cell, Esc puts it back, F2 edits, Enter finishes and moves down, and Enter and Tab go through a range. Changeable chords are commands in the tableGrid, tableEdit, and editor.table scopes, so rebinding works and Ctrl+K stays the palette. Insert table stores the new-table setting. The shortcut list gains a Table keys section. Behind tables.sheetWrites, so Stable keeps today's table keys. Co-Authored-By: Claude Opus 5.5 --- app/src/app/flagUsage.test.ts | 1 - app/src/editor/commands/state.ts | 2 + app/src/features/page/index.ts | 2 + app/src/features/page/tables/insert.ts | 19 +- app/src/features/tables/keys/KeysTable.tsx | 50 +++ app/src/features/tables/keys/actions.ts | 274 +++++++++++++ app/src/features/tables/keys/active.ts | 25 ++ app/src/features/tables/keys/attach.ts | 29 ++ app/src/features/tables/keys/bindings.ts | 110 ++++++ app/src/features/tables/keys/commands.test.ts | 80 ++++ app/src/features/tables/keys/commands.ts | 70 ++++ app/src/features/tables/keys/keys.module.css | 67 ++++ app/src/features/tables/keys/keys.ts | 146 +++++++ app/src/features/tables/keys/moves.ts | 131 ++++++ app/src/features/tables/keys/plugin.test.ts | 373 ++++++++++++++++++ app/src/features/tables/keys/plugin.ts | 193 +++++++++ app/src/features/tables/keys/register.ts | 22 ++ app/src/features/tables/keys/state.ts | 174 ++++++++ app/src/features/tables/register.ts | 3 + 19 files changed, 1768 insertions(+), 3 deletions(-) create mode 100644 app/src/features/tables/keys/KeysTable.tsx create mode 100644 app/src/features/tables/keys/actions.ts create mode 100644 app/src/features/tables/keys/active.ts create mode 100644 app/src/features/tables/keys/attach.ts create mode 100644 app/src/features/tables/keys/bindings.ts create mode 100644 app/src/features/tables/keys/commands.test.ts create mode 100644 app/src/features/tables/keys/commands.ts create mode 100644 app/src/features/tables/keys/keys.module.css create mode 100644 app/src/features/tables/keys/keys.ts create mode 100644 app/src/features/tables/keys/moves.ts create mode 100644 app/src/features/tables/keys/plugin.test.ts create mode 100644 app/src/features/tables/keys/plugin.ts create mode 100644 app/src/features/tables/keys/register.ts create mode 100644 app/src/features/tables/keys/state.ts create mode 100644 app/src/features/tables/register.ts diff --git a/app/src/app/flagUsage.test.ts b/app/src/app/flagUsage.test.ts index bd9eb1631..4218785c9 100644 --- a/app/src/app/flagUsage.test.ts +++ b/app/src/app/flagUsage.test.ts @@ -24,7 +24,6 @@ const WAITING: Readonly> = { 'text.footnotes': 'Beta 10 to 12: the lane that builds it reads it.', 'text.pasteFidelity': 'Beta 10 to 12: the lane that builds it reads it.', 'text.findFormat': 'Beta 10 to 12: the lane that builds it reads it.', - 'tables.sheetWrites': 'Beta 10: the table bar and the formula bar read it.', 'code.tier2': 'Beta 14: code:L16 and code:L17 run installed toolchains behind it.', 'code.models': 'Beta 13: code:L13a and code:L13b read it for the code models.', }; diff --git a/app/src/editor/commands/state.ts b/app/src/editor/commands/state.ts index 4ece2fde1..d0ca8fe3b 100644 --- a/app/src/editor/commands/state.ts +++ b/app/src/editor/commands/state.ts @@ -54,6 +54,8 @@ export function caretInLink(state: EditorState): boolean { /** Whether the selection has text in it, or the caret is in a link: when Ctrl+K edits a link. */ export function linkApplies(state: EditorState): boolean { + // Selected cells are not selected text: in a table's Grid mode Ctrl+K stays the palette (ADR 0063). + if ('$anchorCell' in state.selection) return caretInLink(state); return !state.selection.empty || caretInLink(state); } diff --git a/app/src/features/page/index.ts b/app/src/features/page/index.ts index 4b0cae8c6..4cf042123 100644 --- a/app/src/features/page/index.ts +++ b/app/src/features/page/index.ts @@ -20,6 +20,8 @@ export type { TranscriptAction, TranscriptContext } from './audio/transcripts/ac export { usePrefs as usePageExtrasPrefs } from './qol/prefs'; export { readingLock } from './qol/stores'; export type { TableExtraDef, TableExtraHandle, TableExtraHost } from './tables/extras'; +/** What features add to a table block, such as the spreadsheet keys (a light module). */ +export { tableExtras } from './tables/extras'; export { assetTable } from './images/assets'; /** The exporter's helpers load when a page is exported, not at start-up. */ export const expandEmbedText = async (...args: Parameters) => diff --git a/app/src/features/page/tables/insert.ts b/app/src/features/page/tables/insert.ts index 1f95797cb..e97dd5e73 100644 --- a/app/src/features/page/tables/insert.ts +++ b/app/src/features/page/tables/insert.ts @@ -2,12 +2,14 @@ // paragraph with the caret. Text after that paragraph moves into a new text block below the table, so the page // reads in the same order. The text edit and both new blocks go to the core as one step, which one undo removes. import type { Editor } from '@tiptap/core'; +import { isEnabled } from '../../../app/flags'; import { newId } from '../../../editor/ids'; import { createMarkdownCache, serializeTextBlock } from '../../../editor/markdown'; import { META_REMOTE } from '../../../editor/meta'; import type { TableData } from '../../../editor/schema/specs'; import { newTableData, tableOf } from '../../../editor/table/mapping'; import type { BlockId, BlockJson, Edit, Frame } from '../../../services/pages/types'; +import { getSettings } from '../../../state/settings'; import { t } from '../../../strings/t'; import { announce } from '../../../ui'; import { syncOf } from '../blocks/textBlock'; @@ -34,13 +36,26 @@ function splitAtCaret(editor: Editor): { head: string; tail: string; headEnd: nu }; } +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** A new table stores the setting "New tables use spreadsheet keys" as data.sheet.table.keys (ADR 0063). */ +function withKeys(data: TableData): Record { + const own = data as unknown as Record; + if (!isEnabled('tables.sheetWrites')) return own; + const sheet = isRecord(own.sheet) ? own.sheet : {}; + const table = isRecord(sheet.table) ? sheet.table : {}; + if (table.keys !== undefined) return own; + return { ...own, sheet: { ...sheet, table: { ...table, keys: getSettings().tables.newTableKeys } } }; +} + export async function insertTable(data: TableData = newTableData(3, 3, true)): Promise { - return insertBlocks([{ type: 'table', data: data as unknown as Record }]); + return insertBlocks([{ type: 'table', data: withKeys(data) }]); } /** Several tables, one below the other after the block with focus, as one step: one undo removes them all. */ export async function insertTables(list: readonly TableData[]): Promise { - return insertBlocks(list.map((data) => ({ type: 'table', data: data as unknown as Record }))); + return insertBlocks(list.map((data) => ({ type: 'table', data: withKeys(data) }))); } /** A display equation, as a text block of its own after the block with focus. */ diff --git a/app/src/features/tables/keys/KeysTable.tsx b/app/src/features/tables/keys/KeysTable.tsx new file mode 100644 index 000000000..0fcbb3bdd --- /dev/null +++ b/app/src/features/tables/keys/KeysTable.tsx @@ -0,0 +1,50 @@ +// The Table keys section of the shortcut list (ADR 0063): the fixed keys of Grid mode and Edit mode. The keys a +// person may change are commands, so they are in the list's Table group with the others. +import { formatChord } from '../../../commands/keymap'; +import type { Chord } from '../../../commands/types'; +import { t } from '../../../strings/t'; +import type { MessageKey } from '../../../strings/t'; +import { SHEET_FIXED_KEYS } from './bindings'; +import styles from './keys.module.css'; + +function Keys({ keys }: { keys: readonly string[] | MessageKey }) { + if (typeof keys === 'string') return {t(keys)}; + return ( + + {keys.map((key, i) => ( + + {i > 0 && {t('shortcuts.or')}} + {formatChord(key as Chord)} + + ))} + + ); +} + +export function SheetKeysTable() { + return ( + <> +

{t('sheetKeys.fixed.description')}

+ + + + + + + + + {SHEET_FIXED_KEYS.map((row) => ( + + + + + ))} + +
+ {t(row.action)} + + +
+ + ); +} diff --git a/app/src/features/tables/keys/actions.ts b/app/src/features/tables/keys/actions.ts new file mode 100644 index 000000000..d48cfe2bd --- /dev/null +++ b/app/src/features/tables/keys/actions.ts @@ -0,0 +1,274 @@ +// The actions of the sheet commands (ADR 0063): entries, fills, and clearing. Also the date and the time, the +// cell above, and the switch to document keys. Each runs on a view, in Grid mode or Edit mode. +import { Fragment } from '@tiptap/pm/model'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { TextSelection } from '@tiptap/pm/state'; +import { CellSelection } from '@tiptap/pm/tables'; +import type { EditorView } from '@tiptap/pm/view'; +import type { CommandId } from '../../../commands/types'; +import { t } from '../../../strings/t'; +import { daysToYmd, todayDays } from '../engine/dates'; +import type { DateOrder } from '../engine/locale'; +import { filledText } from '../smart/fill'; +import { address, lastUsed } from './grid'; +import type { Box, GridCell } from './grid'; +import { advance, commitBy, extend, extendBy, jumpBy, move, moveBy, pageOf, selectBox, selectRegion } from './moves'; +import type { Ctx } from './moves'; +import { + activeCell, + caretAtEnd, + contentOf, + gridOf, + hasHeader, + modeOf, + placedAt, + setCellContent, + sheetKeysKey, + textRange, + textsOf, +} from './state'; +import type { Entry, SheetKeysOptions, SheetMeta, SheetMode } from './state'; + +/** Starts an entry: the cell's content replaced (or kept), the caret at its end. */ +export function startEntry(ctx: Ctx, at: GridCell, content: Fragment | string | null, mode: Entry['mode']): boolean { + const before = contentOf(ctx.state.doc, at); + const tr = ctx.state.tr; + if (content !== null) setCellContent(tr, at, content); + caretAtEnd(tr, at); + ctx.dispatch(tr.setMeta(sheetKeysKey, { entry: { ...at, before, mode }, active: null } satisfies SheetMeta)); + return true; +} + +export function typeOver(ctx: Ctx, text: string): boolean { + const at = activeCell(ctx.state); + return at ? startEntry(ctx, at, text, 'enter') : false; +} + +export function cancel(ctx: Ctx): boolean { + const at = activeCell(ctx.state); + if (!at) return false; + const entry = sheetKeysKey.getState(ctx.state)?.entry; + const tr = ctx.state.tr; + const restore = entry && entry.before && entry.row === at.row && entry.column === at.column; + if (restore) setCellContent(tr, at, entry.before!); + const cell = placedAt(tr.doc, at); + if (cell) tr.setSelection(CellSelection.create(tr.doc, cell.pos)); + ctx.dispatch(tr.setMeta(sheetKeysKey, { entry: null, active: null } satisfies SheetMeta)); + if (restore) ctx.options.announce(t('sheetKeys.announce.canceled')); + return true; +} + +/** Each cell of the box, top to bottom and left to right. */ +export function* cellsIn(box: Box): Generator { + for (let row = box.top; row <= box.bottom; row++) + for (let column = box.left; column <= box.right; column++) yield { row, column }; +} + +/** What a cell becomes when filled from another: a formula moves its references, other content is copied. */ +function filledFrom(doc: PMNode, source: GridCell, to: GridCell): Fragment | string { + const text = placedAt(doc, source)?.node.textContent ?? ''; + if (text.trim().startsWith('=')) return filledText(text.trim(), to.row - source.row, to.column - source.column); + return contentOf(doc, source); +} + +/** Fills cells of a box, each from the source `source(cell)` names. One undo step. */ +function fill(ctx: Ctx, targets: Iterable, source: (at: GridCell) => GridCell | null): number { + const { state } = ctx; + const tr = state.tr; + let count = 0; + for (const at of targets) { + const from = source(at); + if (!from || (from.row === at.row && from.column === at.column)) continue; + setCellContent(tr, at, filledFrom(state.doc, from, at)); + count++; + } + if (count > 0) { + ctx.dispatch(tr); + if (count > 1) ctx.options.announce(t('sheetKeys.announce.filled', { count })); + } + return count; +} + +/** Ctrl+D and Ctrl+R: the range's first row (or column) into the rest; one cell takes the cell above (or left). */ +function fillAlong(ctx: Ctx, down: boolean): boolean { + const grid = gridOf(ctx.state); + if (!grid) return false; + const { box } = grid; + const single = down ? box.top === box.bottom : box.left === box.right; + if (single && (down ? box.top === 0 : box.left === 0)) return true; + fill(ctx, cellsIn(box), (at) => + down + ? { row: single ? at.row - 1 : box.top, column: at.column } + : { row: at.row, column: single ? at.column - 1 : box.left }, + ); + return true; +} + +function clearCells(ctx: Ctx, box: Box): boolean { + const tr = ctx.state.tr; + let count = 0; + for (const at of cellsIn(box)) { + if (contentOf(ctx.state.doc, at).size === 0) continue; + setCellContent(tr, at, ''); + count++; + } + if (count === 0) return true; + ctx.dispatch(tr); + if (count > 1) ctx.options.announce(t('sheetKeys.announce.cleared', { count })); + return true; +} + +const pad = (n: number) => String(n).padStart(2, '0'); + +/** Today's date as a person types it in the table's locale, so the engine reads it as a date. */ +export function dateText(days: number, order: DateOrder): string { + const { y, m, d } = daysToYmd(days); + if (order === 'ymd') return `${y}-${pad(m)}-${pad(d)}`; + return order === 'dmy' ? `${d}/${m}/${y}` : `${m}/${d}/${y}`; +} + +/** The time as a person types it: "1:05 PM" in American English. */ +export function timeText(now: Date, locale: string): string { + return now.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' }).replace(/[\u202f\u00a0]/g, ' '); +} + +function insertStamp(ctx: Ctx, mode: SheetMode, text: string): boolean { + if (mode === 'edit') { + ctx.dispatch(ctx.state.tr.insertText(text).scrollIntoView()); + return true; + } + return typeOver(ctx, text); +} + +/** Ctrl+' and Ctrl+Shift+": the formula, or the shown value, of the cell above, in Edit mode. */ +function copyAbove(ctx: Ctx, value: boolean): boolean { + const at = activeCell(ctx.state); + if (!at || at.row === 0) return true; + const above = { row: at.row - 1, column: at.column }; + const text = value + ? shownText(ctx.view, ctx.options, above) + : (placedAt(ctx.state.doc, above)?.node.textContent ?? ''); + return startEntry(ctx, at, text, 'edit'); +} + +export function shownText(view: EditorView, options: SheetKeysOptions, at: GridCell): string { + const shown = (options.shown ?? domShown)(view, at); + return shown ?? placedAt(view.state.doc, at)?.node.textContent ?? ''; +} + +/** A smart table draws a formula's result from the cell's data-shown attribute (smart/display.ts). */ +function domShown(view: EditorView, at: GridCell): string | null { + const cell = placedAt(view.state.doc, at); + const dom = cell ? view.nodeDOM(cell.pos) : null; + return dom instanceof HTMLElement ? dom.getAttribute('data-shown') || null : null; +} + +function setSpreadsheet(ctx: Ctx, spreadsheet: boolean): void { + const tr = ctx.state.tr.setMeta(sheetKeysKey, { spreadsheet, entry: null, active: null } satisfies SheetMeta); + const at = activeCell(ctx.state); + const cell = at && placedAt(ctx.state.doc, at); + // Document keys have no Grid mode, so the cells become a caret in the active cell. + if (!spreadsheet && cell && ctx.state.selection instanceof CellSelection) + tr.setSelection(TextSelection.create(tr.doc, textRange(cell).to)); + ctx.dispatch(tr); +} + +type Action = (ctx: Ctx, mode: SheetMode) => boolean; + +const grid = + (action: (ctx: Ctx) => boolean): Action => + (ctx, mode) => + mode === 'grid' ? action(ctx) : false; +const edit = + (action: (ctx: Ctx) => boolean): Action => + (ctx, mode) => + mode === 'edit' ? action(ctx) : false; + +/** The registered commands' actions, by command ID. */ +const ACTIONS: Readonly> = { + 'table.sheet.extendUp': grid((ctx) => extendBy(ctx, -1, 0)), + 'table.sheet.extendDown': grid((ctx) => extendBy(ctx, 1, 0)), + 'table.sheet.extendLeft': grid((ctx) => extendBy(ctx, 0, -1)), + 'table.sheet.extendRight': grid((ctx) => extendBy(ctx, 0, 1)), + 'table.sheet.jumpUp': grid((ctx) => jumpBy(ctx, -1, 0, false)), + 'table.sheet.jumpDown': grid((ctx) => jumpBy(ctx, 1, 0, false)), + 'table.sheet.jumpLeft': grid((ctx) => jumpBy(ctx, 0, -1, false)), + 'table.sheet.jumpRight': grid((ctx) => jumpBy(ctx, 0, 1, false)), + 'table.sheet.extendJumpUp': grid((ctx) => jumpBy(ctx, -1, 0, true)), + 'table.sheet.extendJumpDown': grid((ctx) => jumpBy(ctx, 1, 0, true)), + 'table.sheet.extendJumpLeft': grid((ctx) => jumpBy(ctx, 0, -1, true)), + 'table.sheet.extendJumpRight': grid((ctx) => jumpBy(ctx, 0, 1, true)), + 'table.sheet.firstCell': grid((ctx) => move(ctx, { row: 0, column: 0 })), + 'table.sheet.lastCell': grid((ctx) => move(ctx, lastUsed(ctx.texts))), + 'table.sheet.extendFirstCell': grid((ctx) => extend(ctx, { row: 0, column: 0 })), + 'table.sheet.extendLastCell': grid((ctx) => extend(ctx, lastUsed(ctx.texts))), + 'table.sheet.extendRowStart': grid((ctx) => extend(ctx, { row: gridOf(ctx.state)!.head.row, column: 0 })), + 'table.sheet.screenLeft': grid((ctx) => moveBy(ctx, 0, -pageOf(ctx.view, false))), + 'table.sheet.screenRight': grid((ctx) => moveBy(ctx, 0, pageOf(ctx.view, false))), + 'table.sheet.moveUp': grid((ctx) => advance(ctx, 'column', true)), + 'table.sheet.fillEntry': grid((ctx) => { + const g = gridOf(ctx.state)!; + fill(ctx, cellsIn(g.box), () => g.active); + return true; + }), + 'table.sheet.edit': grid((ctx) => { + const at = activeCell(ctx.state)!; + ctx.options.announce(t('sheetKeys.announce.editing', { address: address(at, hasHeader(ctx.state.doc)) })); + return startEntry(ctx, at, null, 'edit'); + }), + 'table.sheet.clear': grid((ctx) => clearCells(ctx, gridOf(ctx.state)!.box)), + 'table.sheet.clearAndEdit': grid((ctx) => startEntry(ctx, activeCell(ctx.state)!, '', 'enter')), + 'table.sheet.selectColumn': grid((ctx) => { + const g = gridOf(ctx.state)!; + return selectBox(ctx, { ...g.box, top: 0, bottom: ctx.texts.length - 1 }, g.active); + }), + 'table.sheet.selectRegion': grid(selectRegion), + 'table.sheet.fillDown': grid((ctx) => fillAlong(ctx, true)), + 'table.sheet.fillRight': grid((ctx) => fillAlong(ctx, false)), + 'table.sheet.copyFormulaAbove': grid((ctx) => copyAbove(ctx, false)), + 'table.sheet.copyValueAbove': grid((ctx) => copyAbove(ctx, true)), + 'table.sheet.insertDate': (ctx, mode) => + insertStamp(ctx, mode, dateText(todayDays(ctx.options.now?.()), ctx.options.dateOrder?.() ?? 'mdy')), + 'table.sheet.insertTime': (ctx, mode) => + insertStamp(ctx, mode, timeText(ctx.options.now?.() ?? new Date(), ctx.options.locale?.() ?? 'en-US')), + 'table.sheet.commitUp': edit((ctx) => commitBy(ctx, -1, 0)), + 'table.sheet.commitStay': edit((ctx) => commitBy(ctx, 0, 0)), + 'table.sheet.lineBreak': edit((ctx) => { + const hardBreak = ctx.state.schema.nodes.hardBreak; + if (!hardBreak) return false; + ctx.dispatch(ctx.state.tr.replaceSelectionWith(hardBreak.create()).scrollIntoView()); + return true; + }), + 'table.sheet.toggleEnterMode': edit((ctx) => { + const entry = sheetKeysKey.getState(ctx.state)?.entry; + const at = activeCell(ctx.state)!; + const mode = entry?.mode === 'enter' ? 'edit' : 'enter'; + const next: Entry = entry ? { ...entry, mode } : { ...at, before: null, mode }; + ctx.dispatch(ctx.state.tr.setMeta(sheetKeysKey, { entry: next } satisfies SheetMeta)); + ctx.options.announce(t(mode === 'enter' ? 'sheetKeys.announce.enterMode' : 'sheetKeys.announce.editMode')); + return true; + }), +}; + +/** Runs a sheet command on the view. Document keys work in every table; the rest need spreadsheet keys. */ +export function runSheetCommand(view: EditorView, options: SheetKeysOptions, id: CommandId): boolean { + const state = view.state; + const ctx: Ctx = { view, state, dispatch: (tr) => view.dispatch(tr), options, texts: textsOf(state.doc) }; + if (id === 'table.sheet.documentKeys') { + const spreadsheet = !sheetKeysKey.getState(state)?.spreadsheet; + setSpreadsheet(ctx, spreadsheet); + options.setKeys?.(spreadsheet ? 'spreadsheet' : 'document'); + options.announce(t(spreadsheet ? 'sheetKeys.announce.documentKeysOff' : 'sheetKeys.announce.documentKeysOn')); + return true; + } + const mode = modeOf(state); + const action = ACTIONS[id]; + return mode !== null && action !== undefined && action(ctx, mode); +} + +/** Turns spreadsheet keys on or off, such as when another window changed the table's choice. */ +export function setSheetKeys(view: EditorView, spreadsheet: boolean): void { + if (sheetKeysKey.getState(view.state)?.spreadsheet === spreadsheet) return; + const options: SheetKeysOptions = { spreadsheet, announce: () => {} }; + setSpreadsheet({ view, state: view.state, dispatch: (tr) => view.dispatch(tr), options, texts: [] }, spreadsheet); +} diff --git a/app/src/features/tables/keys/active.ts b/app/src/features/tables/keys/active.ts new file mode 100644 index 000000000..44d72408e --- /dev/null +++ b/app/src/features/tables/keys/active.ts @@ -0,0 +1,25 @@ +// The spreadsheet table that keys and the palette act on: the one that had focus last. A light module, so the +// commands can ask it at start-up without loading the editor code. +import type { CommandId } from '../../../commands/types'; + +export interface SheetRunner { + /** Runs a sheet command on this table. False when it does nothing here. */ + run(id: CommandId): boolean; + /** Whether this table uses document keys. */ + documentKeys(): boolean; +} + +let current: SheetRunner | null = null; + +export function activeSheet(): SheetRunner | null { + return current; +} + +export function setActiveSheet(runner: SheetRunner): void { + current = runner; +} + +/** Forgets the runner when it is the active one, such as when its table goes. */ +export function clearActiveSheet(runner: SheetRunner): void { + if (current === runner) current = null; +} diff --git a/app/src/features/tables/keys/attach.ts b/app/src/features/tables/keys/attach.ts new file mode 100644 index 000000000..772b82fe5 --- /dev/null +++ b/app/src/features/tables/keys/attach.ts @@ -0,0 +1,29 @@ +// Gives a table block's editor its spreadsheet keys (ADR 0063). The table extra loads this when the block's editor +// mounts. The table's choice of keys comes from its data, and the Document keys command stores a new one. +import type { TableExtraHandle, TableExtraHost } from '../../page'; +import { appLocale } from '../smart/locale'; +import { keysOf, keysPatch, storedKeys } from './mode'; +import { setSheetKeys, sheetKeysKey, sheetKeysPlugin, textsOf } from './plugin'; + +export function attachSheetKeys(host: TableExtraHost): TableExtraHandle { + const { editor } = host; + const keys = keysOf(host.data(), textsOf(editor.state.doc)); + const plugin = sheetKeysPlugin({ + spreadsheet: keys === 'spreadsheet', + announce: (text) => host.announce(text), + setKeys: (next) => void host.patch(keysPatch(next)), + dateOrder: () => appLocale().dateOrder, + locale: () => appLocale().tag, + }); + // First, so Grid mode's keys come before the table's text keys and the editor's own. + editor.registerPlugin(plugin, (added, plugins) => [added, ...plugins]); + return { + update(block) { + const stored = storedKeys(block.data); + if (stored && !editor.isDestroyed) setSheetKeys(editor.view, stored === 'spreadsheet'); + }, + destroy() { + if (!editor.isDestroyed) editor.unregisterPlugin(sheetKeysKey); + }, + }; +} diff --git a/app/src/features/tables/keys/bindings.ts b/app/src/features/tables/keys/bindings.ts new file mode 100644 index 000000000..7d07f5b1f --- /dev/null +++ b/app/src/features/tables/keys/bindings.ts @@ -0,0 +1,110 @@ +// The spreadsheet keys of a table (DESIGN.md section 11.2, ADR 0063), as data. Chords that a person may change +// are commands in the one command registry, scoped to Grid mode (tableGrid), Edit mode (tableEdit), or both +// (editor.table). The navigation keys that reserved.ts keeps free are fixed, as in every other editor. +// +// Keys that feature lanes own are not here. They register their own commands in the same scopes: +// - Ctrl+1: format-dialog. F4, Alt+=, Shift+F3, and F9: formula-bar and the engine. +// - Number format and border chords: toolbar. Ctrl+9, Ctrl+0, and the insert and delete chords: structure. +// - Ctrl+Shift+L: data-tools. Shift+F2: notes. The paste chords: clipboard. +// Ctrl+5 is not here: Ctrl+1 to Ctrl+9 stay free for tags, and +// Ctrl+B, Ctrl+I, Ctrl+U, and the strikethrough command already format the text of selected cells. +import type { CommandId, KeyScope } from '../../../commands/types'; +import type { MessageKey } from '../../../strings/t'; + +export type SheetScope = Extract; + +export interface SheetKeyDef { + id: CommandId; + title: MessageKey; + keys: readonly string[]; + scope: SheetScope; + /** Shown in the command palette. Moves are not; fills, dates, and the mode switch are. */ + palette?: boolean; + /** The less specific command that shares the chord outside this mode (AMENDMENTS.md P2-2). */ + refines?: CommandId; +} + +const def = ( + name: string, + keys: readonly string[], + scope: SheetScope, + palette = false, + refines?: CommandId, +): SheetKeyDef => ({ + id: `table.sheet.${name}`, + title: `sheetKeys.commands.${name}` as MessageKey, + keys, + scope, + palette, + ...(refines ? { refines } : {}), +}); + +const GRID = 'tableGrid'; +const EDIT = 'tableEdit'; +const BOTH = 'editor.table'; + +export const SHEET_KEYS: readonly SheetKeyDef[] = [ + def('extendUp', ['Shift+Up'], GRID), + def('extendDown', ['Shift+Down'], GRID), + def('extendLeft', ['Shift+Left'], GRID), + def('extendRight', ['Shift+Right'], GRID), + def('jumpUp', ['Ctrl+Up'], GRID), + def('jumpDown', ['Ctrl+Down'], GRID), + def('jumpLeft', ['Ctrl+Left'], GRID), + def('jumpRight', ['Ctrl+Right'], GRID), + def('extendJumpUp', ['Ctrl+Shift+Up'], GRID), + def('extendJumpDown', ['Ctrl+Shift+Down'], GRID), + def('extendJumpLeft', ['Ctrl+Shift+Left'], GRID), + def('extendJumpRight', ['Ctrl+Shift+Right'], GRID), + def('firstCell', ['Ctrl+Home'], GRID), + def('lastCell', ['Ctrl+End'], GRID), + def('extendFirstCell', ['Ctrl+Shift+Home'], GRID), + def('extendLastCell', ['Ctrl+Shift+End'], GRID), + def('extendRowStart', ['Shift+Home'], GRID), + def('screenLeft', ['Alt+PageUp'], GRID), + def('screenRight', ['Alt+PageDown'], GRID), + def('moveUp', ['Shift+Enter'], GRID), + def('fillEntry', ['Ctrl+Enter'], GRID, false, 'table.rowBelow'), + def('edit', ['F2'], GRID), + def('clear', ['Delete'], GRID), + def('clearAndEdit', ['Backspace'], GRID), + def('selectColumn', ['Ctrl+Space'], GRID), + def('selectRegion', ['Ctrl+A'], GRID), + def('fillDown', ['Ctrl+D'], GRID, true, 'table.fillDown'), + def('fillRight', ['Ctrl+R'], GRID, true, 'table.fillRight'), + def('copyFormulaAbove', ["Ctrl+'"], GRID), + def('copyValueAbove', ['Ctrl+"'], GRID), + def('insertDate', ['Ctrl+;'], BOTH, true), + // Ctrl+Shift+; types ":" on a US keyboard, so the chord names the typed character. + def('insertTime', ['Ctrl+:'], BOTH, true), + def('commitUp', ['Shift+Enter'], EDIT), + def('commitStay', ['Ctrl+Enter'], EDIT, false, 'table.rowBelow'), + def('lineBreak', ['Alt+Enter'], EDIT), + def('toggleEnterMode', ['F2'], EDIT), + def('documentKeys', [], BOTH, true), +]; + +/** The command that turns document keys on and off for the table with focus. */ +export const DOCUMENT_KEYS_COMMAND: CommandId = 'table.sheet.documentKeys'; + +export interface SheetFixedRow { + readonly id: string; + /** Chords in the canonical form, or a message for keys that aren't one. */ + readonly keys: readonly string[] | MessageKey; + readonly action: MessageKey; +} + +/** The fixed keys of a spreadsheet table, for the shortcut list. The plugin handles them itself. */ +export const SHEET_FIXED_KEYS: readonly SheetFixedRow[] = [ + { id: 'gridArrows', keys: ['Up', 'Down', 'Left', 'Right'], action: 'sheetKeys.fixed.gridArrows' }, + { id: 'gridEnter', keys: ['Enter'], action: 'sheetKeys.fixed.gridEnter' }, + { id: 'gridTab', keys: ['Tab', 'Shift+Tab'], action: 'sheetKeys.fixed.gridTab' }, + { id: 'gridHome', keys: ['Home'], action: 'sheetKeys.fixed.gridHome' }, + { id: 'gridPage', keys: ['PageUp', 'PageDown'], action: 'sheetKeys.fixed.gridPage' }, + { id: 'gridEscape', keys: ['Escape'], action: 'sheetKeys.fixed.gridEscape' }, + { id: 'gridSpace', keys: ['Shift+Space'], action: 'sheetKeys.fixed.gridSpace' }, + { id: 'gridType', keys: 'sheetKeys.fixed.typeKey', action: 'sheetKeys.fixed.gridType' }, + { id: 'editEnter', keys: ['Enter'], action: 'sheetKeys.fixed.editEnter' }, + { id: 'editEscape', keys: ['Escape'], action: 'sheetKeys.fixed.editEscape' }, + { id: 'editArrows', keys: ['Up', 'Down', 'Left', 'Right'], action: 'sheetKeys.fixed.editArrows' }, +]; diff --git a/app/src/features/tables/keys/commands.test.ts b/app/src/features/tables/keys/commands.test.ts new file mode 100644 index 000000000..b9ca63b2f --- /dev/null +++ b/app/src/features/tables/keys/commands.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +// The sheet commands in the registry (ADR 0063): Grid mode's Ctrl+D comes before the page's, applies only to a +// table with spreadsheet keys that had focus, leaves Ctrl+K to the palette, and follows the person's own chord. +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { initFlags } from '../../../app/flags'; +import { commandForKey } from '../../../commands/dispatcher'; +import { chord, configureCommands, defineCommand } from '../../../commands/registry'; +import type { CommandDef } from '../../../commands/types'; +import { commands } from '../../../registries'; +import { createMemoryNotesService } from '../../../services/notes/memory'; +import { setShortcut } from '../../../state/keymap'; +import { resetStores } from '../../../state/store'; +import { createTestPlatform } from '../../../test/platform'; +import { activeSheet, clearActiveSheet, setActiveSheet } from './active'; +import type { SheetRunner } from './active'; +import { registerSheetKeyCommands } from './commands'; + +const stops: (() => void)[] = []; +const ran: string[] = []; +let documentKeys = false; +const sheet: SheetRunner = { run: (id) => (ran.push(id), true), documentKeys: () => documentKeys }; + +function add(id: string, rest: Partial) { + stops.push( + commands.register( + defineCommand({ id: `test.${id}`, title: 'theme.commands.toggle', category: 'general', run() {}, ...rest }), + ), + ); +} + +beforeAll(() => { + initFlags('dev'); + configureCommands({ platform: createTestPlatform(), notes: createMemoryNotesService({ seed: 'sample' }) }); +}); + +afterEach(() => { + stops.splice(0).forEach((stop) => stop()); + clearActiveSheet(sheet); + documentKeys = false; + resetStores(); +}); + +function press(scope: string, key: string, code: string, mods: string) { + document.body.innerHTML = `
`; + const target = document.querySelector('[contenteditable]'); + const has = (name: string) => mods.includes(name); + const event = { key, code, ctrlKey: has('ctrl'), altKey: has('alt'), shiftKey: false, metaKey: false }; + return commandForKey({ ...event, isComposing: false, repeat: false, target })?.id ?? null; +} + +const GRID = 'editor editor.table tableGrid'; +const EDIT = 'editor editor.table tableEdit'; + +describe('the sheet commands', () => { + it('run in Grid mode before the page, only for a spreadsheet table with focus', () => { + stops.push(registerSheetKeyCommands()); + add('pageFill', { keys: [chord('Ctrl+D')], scope: 'page', allowInTextInput: true }); + add('palette', { keys: [chord('Ctrl+K')], allowInTextInput: true }); + expect(press(GRID, 'd', 'KeyD', 'ctrl')).toBe('test.pageFill'); + setActiveSheet(sheet); + expect(activeSheet()).toBe(sheet); + expect(press(GRID, 'd', 'KeyD', 'ctrl')).toBe('table.sheet.fillDown'); + expect(press(EDIT, 'd', 'KeyD', 'ctrl')).toBe('test.pageFill'); + expect(press(GRID, 'k', 'KeyK', 'ctrl')).toBe('test.palette'); + expect(press(EDIT, ';', 'Semicolon', 'ctrl')).toBe('table.sheet.insertDate'); + documentKeys = true; + expect(press(GRID, 'd', 'KeyD', 'ctrl')).toBe('test.pageFill'); + }); + + it('follow the chord the person gives them', async () => { + stops.push(registerSheetKeyCommands()); + add('pageFill', { keys: [chord('Ctrl+D')], scope: 'page', allowInTextInput: true }); + setActiveSheet(sheet); + await setShortcut('table.sheet.fillDown', [chord('Ctrl+Alt+D')]); + expect(press(GRID, 'd', 'KeyD', 'ctrl')).toBe('test.pageFill'); + expect(press(GRID, 'd', 'KeyD', 'ctrl alt')).toBe('table.sheet.fillDown'); + void commands.get('table.sheet.fillDown')?.run({} as never, undefined); + expect(ran).toEqual(['table.sheet.fillDown']); + }); +}); diff --git a/app/src/features/tables/keys/commands.ts b/app/src/features/tables/keys/commands.ts new file mode 100644 index 000000000..333ecf529 --- /dev/null +++ b/app/src/features/tables/keys/commands.ts @@ -0,0 +1,70 @@ +// The spreadsheet keys' commands (ADR 0063): one per changeable chord, and the table menu's Document keys item. +// Each runs on the table that had focus last (active.ts). Behind the flag tables.sheetWrites, so Stable keeps +// today's table keys. +import { chord } from '../../../commands/chords'; +import { commands } from '../../../registries'; +import type { CommandDef } from '../../../commands/types'; +import { contribute } from '../sheet/registry'; +import type { SheetContribution } from '../sheet/registry'; +import { activeSheet } from './active'; +import { DOCUMENT_KEYS_COMMAND, SHEET_KEYS } from './bindings'; +import type { SheetKeyDef } from './bindings'; + +export const SHEET_KEYS_FLAG = 'tables.sheetWrites' as const; +const FLAG = SHEET_KEYS_FLAG; + +/** A sheet command applies to the table that had focus last, and only while it uses spreadsheet keys. */ +function applies(id: string): boolean { + const sheet = activeSheet(); + return sheet !== null && (id === DOCUMENT_KEYS_COMMAND || !sheet.documentKeys()); +} + +/** + * Add row below (Ctrl+Enter) registers when a table first loads, and it refines Check or uncheck. Until it exists, + * the Ctrl+Enter commands refine Check or uncheck themselves, so the chord is shared in every state of the registry. + */ +function withRefines(def: CommandDef, refines: SheetKeyDef['refines']): CommandDef { + if (refines !== 'table.rowBelow') return refines ? { ...def, refines } : def; + return Object.defineProperty(def, 'refines', { + enumerable: true, + get: () => (commands.get('table.rowBelow') ? 'table.rowBelow' : 'block.toggleCheck'), + }); +} + +function commandOf(key: SheetKeyDef): CommandDef { + return withRefines( + { + id: key.id, + title: key.title, + keywords: 'sheetKeys.keywords', + category: 'table', + keys: key.keys.map(chord), + scope: key.scope, + allowInTextInput: true, + palette: key.palette, + flag: FLAG, + when: () => applies(key.id), + ...(key.id === DOCUMENT_KEYS_COMMAND ? { checked: () => activeSheet()?.documentKeys() ?? false } : {}), + run: () => { + activeSheet()?.run(key.id); + }, + }, + key.refines, + ); +} + +const items: SheetContribution[] = SHEET_KEYS.map((key) => ({ + kind: 'command', + lane: 'keys', + command: commandOf(key), +})); +items.push({ + kind: 'contextMenu', + lane: 'keys', + item: { id: DOCUMENT_KEYS_COMMAND, command: DOCUMENT_KEYS_COMMAND, group: 'table', order: 50, flag: FLAG }, +}); + +/** Registers the commands. Returns a function that takes them back. */ +export function registerSheetKeyCommands(): () => void { + return contribute(...items); +} diff --git a/app/src/features/tables/keys/keys.module.css b/app/src/features/tables/keys/keys.module.css new file mode 100644 index 000000000..9cc869f23 --- /dev/null +++ b/app/src/features/tables/keys/keys.module.css @@ -0,0 +1,67 @@ +/* + * The active cell of a table with spreadsheet keys (ADR 0063). Grid mode draws a thick outline inside the cell, Edit + * mode a thin one, so the mode shows without color alone. Outlines stay visible in forced colors. + */ +@layer components { + .active { + outline: calc(var(--size-focus-ring-width) * 1.5) solid var(--color-focus-ring); + outline-offset: calc(var(--size-focus-ring-width) * -1.5); + } + + .editing { + outline: 1px solid var(--color-focus-ring); + outline-offset: -1px; + } + + @media (forced-colors: active) { + .active, + .editing { + outline-color: Highlight; + } + } +} + +/* The Table keys section of the shortcut list, drawn like the list's own tables (ShortcutList.module.css). */ +@layer components { + .note { + margin-block: 0 var(--space-2); + color: var(--color-text-secondary); + } + + .table { + inline-size: 100%; + border-collapse: collapse; + } + + .table th, + .table td { + padding: var(--space-2) var(--space-3); + border-block-end: 1px solid var(--color-border-subtle); + text-align: start; + vertical-align: middle; + } + + .table th:first-child, + .table td:first-child { + padding-inline-start: 0; + } + + .name { + font-weight: var(--font-weight-regular); + } + + .or { + margin-inline: var(--space-2); + color: var(--color-text-secondary); + } + + .kbd { + padding: var(--space-1) var(--space-3); + border: 1px solid var(--color-border-control); + border-radius: var(--radius-sm); + background: var(--color-surface-sunken); + color: var(--color-text-primary); + font-family: var(--font-ui); + white-space: nowrap; + } +} diff --git a/app/src/features/tables/keys/keys.ts b/app/src/features/tables/keys/keys.ts new file mode 100644 index 000000000..09e6044bb --- /dev/null +++ b/app/src/features/tables/keys/keys.ts @@ -0,0 +1,146 @@ +// The keys of Grid mode and Edit mode (ADR 0063). Chords a person may change come from the command registry, so +// rebinding works; the navigation keys that reserved.ts keeps free are fixed here. Typing in Grid mode replaces the +// active cell. +import type { EditorState } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import { isEnabled } from '../../../app/flags'; +import { chordMatches } from '../../../commands/chords'; +import { commandsForChord } from '../../../commands/keymap'; +import type { CommandId } from '../../../commands/types'; +import { cancel, runSheetCommand, typeOver } from './actions'; +import { SHEET_KEYS } from './bindings'; +import type { SheetScope } from './bindings'; +import { boxSize } from './grid'; +import { advance, commitBy, move, moveBy, nextInRow, pageOf, selectBox } from './moves'; +import type { Ctx } from './moves'; +import { activeCell, gridOf, modeOf, sheetKeysKey, textsOf } from './state'; +import type { SheetKeysOptions, SheetMode } from './state'; + +const SCOPES: Readonly> = { + grid: ['tableGrid', 'editor.table'], + edit: ['tableEdit', 'editor.table'], +}; +const SHEET_IDS = new Set(SHEET_KEYS.map((key) => key.id)); + +/** The sheet command a key press names in this mode, with the person's own chords. */ +function commandFor(event: KeyboardEvent, mode: SheetMode): CommandId | null { + for (const { chord } of chordMatches(event)) { + const def = commandsForChord(chord).find( + (one) => + SHEET_IDS.has(one.id) && SCOPES[mode].includes(one.scope as SheetScope) && (!one.flag || isEnabled(one.flag)), + ); + if (def) return def.id; + } + return null; +} + +/** A key press that types a character: no Ctrl, Alt, or Meta, unless AltGr makes it. */ +function typedText(event: KeyboardEvent): string | null { + if (event.isComposing || [...event.key].length !== 1) return null; + const altGraph = event.getModifierState?.('AltGraph') ?? false; + if (event.metaKey || ((event.ctrlKey || event.altKey) && !altGraph)) return null; + return event.key; +} + +const ARROWS: Readonly> = { + ArrowUp: [-1, 0], + ArrowDown: [1, 0], + ArrowLeft: [0, -1], + ArrowRight: [0, 1], +}; + +const plain = (event: KeyboardEvent) => !event.ctrlKey && !event.altKey && !event.metaKey; + +/** The fixed keys of Grid mode. */ +function gridKey(ctx: Ctx, event: KeyboardEvent): boolean { + const arrow = ARROWS[event.key]; + if (arrow && plain(event) && !event.shiftKey) return moveBy(ctx, arrow[0], arrow[1]); + if (!plain(event)) return false; + switch (event.key) { + case 'Enter': + return event.shiftKey ? false : advance(ctx, 'column', false); + case 'Tab': + return advance(ctx, 'row', event.shiftKey); + case 'Home': + return event.shiftKey ? false : moveBy(ctx, 0, -(activeCell(ctx.state)?.column ?? 0)); + case 'PageUp': + case 'PageDown': + return event.shiftKey ? false : moveBy(ctx, (event.key === 'PageUp' ? -1 : 1) * pageOf(ctx.view, true), 0); + case 'Escape': { + const g = gridOf(ctx.state); + // One cell: Esc goes on to the editor, which selects the table as an object, so the keyboard can leave. + return g !== null && boxSize(g.box) > 1 && move(ctx, g.active); + } + case ' ': + if (!event.shiftKey) return false; + return selectBox(ctx, { + ...gridOf(ctx.state)!.box, + left: 0, + right: Math.max(0, ...ctx.texts.map((r) => r.length)) - 1, + }); + default: + return false; + } +} + +/** Whether the caret is at the start or end of its cell's text. */ +function atTextEdge(state: EditorState, end: boolean): boolean { + const { $head, empty } = state.selection; + return empty && (end ? $head.parentOffset === $head.parent.content.size : $head.parentOffset === 0); +} + +function atLineEdge(view: EditorView, down: boolean): boolean { + try { + return view.endOfTextblock(down ? 'down' : 'up'); + } catch { + return true; + } +} + +/** The fixed keys of Edit mode. */ +function editKey(ctx: Ctx, event: KeyboardEvent): boolean { + if (!plain(event)) return false; + const entry = sheetKeysKey.getState(ctx.state)?.entry; + const arrow = ARROWS[event.key]; + if (arrow && !event.shiftKey) { + if (entry?.mode === 'enter') return commitBy(ctx, arrow[0], arrow[1]); + // Edit mode keeps the caret in the cell, as a spreadsheet's does. + if (arrow[1] !== 0) return atTextEdge(ctx.state, arrow[1] > 0); + return atLineEdge(ctx.view, arrow[0] > 0); + } + switch (event.key) { + case 'Enter': + return event.shiftKey ? false : commitBy(ctx, 1, 0); + case 'Tab': { + // Finish the entry and move as Tab does in Grid mode. + const at = activeCell(ctx.state); + return at !== null && nextInRow(ctx, at, event.shiftKey); + } + case 'Escape': + return cancel(ctx); + default: + return false; + } +} + +export function handleKey(view: EditorView, event: KeyboardEvent, options: SheetKeysOptions): boolean { + const mode = modeOf(view.state); + if (!mode || event.isComposing) return false; + const id = commandFor(event, mode); + if (id) { + // The app's key handler ran it already when the chord reaches commands from a text field. + if (event.defaultPrevented) return true; + if (runSheetCommand(view, options, id)) return true; + } + const ctx: Ctx = { + view, + state: view.state, + dispatch: (tr) => view.dispatch(tr), + options, + texts: textsOf(view.state.doc), + }; + if (mode === 'edit') return editKey(ctx, event); + if (gridKey(ctx, event)) return true; + const text = typedText(event); + return text !== null && typeOver(ctx, text); +} diff --git a/app/src/features/tables/keys/moves.ts b/app/src/features/tables/keys/moves.ts new file mode 100644 index 000000000..7105e712b --- /dev/null +++ b/app/src/features/tables/keys/moves.ts @@ -0,0 +1,131 @@ +// Grid mode's moves (ADR 0063). Arrows move one cell, and Ctrl+arrows jump to the data's edges. Enter and Tab +// go through a range, and Tab in the last cell adds a row. Ctrl+Space, Shift+Space, and Ctrl+A select. +import type { Node as PMNode } from '@tiptap/pm/model'; +import type { EditorState } from '@tiptap/pm/state'; +import { CellSelection } from '@tiptap/pm/tables'; +import type { EditorView } from '@tiptap/pm/view'; +import { boxSize, cycle, jump, region, step } from './grid'; +import type { Box, GridCell, Texts } from './grid'; +import { activeCell, cellsOf, gridOf, placedAt, selectCells, sheetKeysKey } from './state'; +import type { Dispatch, SheetKeysOptions, SheetMeta } from './state'; + +export interface Ctx { + view: EditorView; + state: EditorState; + dispatch: Dispatch; + options: SheetKeysOptions; + texts: Texts; +} + +export function move(ctx: Ctx, to: GridCell): boolean { + const tr = selectCells(ctx.state, to); + if (tr) ctx.dispatch(tr); + return true; +} + +export function extend(ctx: Ctx, head: GridCell): boolean { + const grid = gridOf(ctx.state); + if (!grid) return false; + const tr = selectCells(ctx.state, grid.active, head, grid.active); + if (tr) ctx.dispatch(tr); + return true; +} + +/** Arrow: one cell from the active cell, the range collapsed. */ +export function moveBy(ctx: Ctx, rows: number, columns: number): boolean { + const from = activeCell(ctx.state); + return from ? move(ctx, step(ctx.texts, from, rows, columns)) : false; +} + +export function extendBy(ctx: Ctx, rows: number, columns: number): boolean { + const grid = gridOf(ctx.state); + return grid ? extend(ctx, step(ctx.texts, grid.head, rows, columns)) : false; +} + +export function jumpBy(ctx: Ctx, rows: -1 | 0 | 1, columns: -1 | 0 | 1, extending: boolean): boolean { + const grid = gridOf(ctx.state); + if (!grid) return false; + if (extending) return extend(ctx, jump(ctx.texts, grid.head, rows, columns)); + return move(ctx, jump(ctx.texts, grid.active, rows, columns)); +} + +/** Enter, Shift+Enter, Tab, and Shift+Tab in Grid mode: through a range's cells, or on to the next cell. */ +export function advance(ctx: Ctx, by: 'column' | 'row', back: boolean): boolean { + const grid = gridOf(ctx.state); + if (!grid) return false; + if (boxSize(grid.box) > 1) { + const next = cycle(grid.box, grid.active, by, back); + const tr = selectCells(ctx.state, grid.anchor, grid.head, next); + if (tr) ctx.dispatch(tr); + return true; + } + return by === 'column' ? moveBy(ctx, back ? -1 : 1, 0) : nextInRow(ctx, grid.active, back); +} + +/** Tab: the next cell along the row, then the next row's first cell. Tab in the last cell adds a row, as today. */ +export function nextInRow(ctx: Ctx, from: GridCell, back: boolean): boolean { + const cells = cellsOf(ctx.state.doc); + const width = cells[from.row]?.length ?? 0; + const index = from.row * width + from.column + (back ? -1 : 1); + if (index < 0) return move(ctx, from); + if (index < cells.length * width) return move(ctx, { row: Math.floor(index / width), column: index % width }); + return addRow(ctx); +} + +/** A new empty row under the table, with its first cell active. The normalizer gives it its IDs. */ +export function addRow(ctx: Ctx): boolean { + const { state } = ctx; + const table = state.doc.firstChild; + const last = table?.lastChild; + if (!table || !last) return false; + const { nodes } = state.schema; + const cells: PMNode[] = []; + last.forEach((cell) => { + const attrs = { colwidth: cell.attrs.colwidth as unknown, colId: cell.attrs.colId as unknown }; + cells.push(nodes.tableCell.create(attrs, nodes.paragraph.create())); + }); + const tr = state.tr.insert(1 + table.content.size, nodes.tableRow.create(null, cells)); + const first = placedAt(tr.doc, { row: table.childCount, column: 0 }); + if (first) tr.setSelection(CellSelection.create(tr.doc, first.pos)); + ctx.dispatch(tr.setMeta(sheetKeysKey, { active: null, entry: null } satisfies SheetMeta).scrollIntoView()); + return true; +} + +/** Leaves Edit mode keeping the text, and goes to a cell in Grid mode. */ +export function commitTo(ctx: Ctx, to: GridCell): boolean { + return move(ctx, to); +} + +export function commitBy(ctx: Ctx, rows: number, columns: number): boolean { + const from = activeCell(ctx.state); + return from ? commitTo(ctx, step(ctx.texts, from, rows, columns)) : false; +} + +export function selectBox(ctx: Ctx, box: Box, active?: GridCell): boolean { + const tr = selectCells(ctx.state, { row: box.top, column: box.left }, { row: box.bottom, column: box.right }, active); + if (tr) ctx.dispatch(tr); + return true; +} + +export function wholeBox(texts: Texts): Box { + return { top: 0, left: 0, bottom: texts.length - 1, right: Math.max(0, ...texts.map((row) => row.length)) - 1 }; +} + +/** Ctrl+A: the data around the active cell, then the whole table. */ +export function selectRegion(ctx: Ctx): boolean { + const grid = gridOf(ctx.state); + if (!grid) return false; + const around = region(ctx.texts, grid.active); + const same = (a: Box, b: Box) => a.top === b.top && a.left === b.left && a.bottom === b.bottom && a.right === b.right; + const next = same(around, grid.box) || boxSize(around) === 1 ? wholeBox(ctx.texts) : around; + return selectBox(ctx, next, grid.active); +} + +/** About a screen of rows, or of columns, from the size the table shows at. */ +export function pageOf(view: EditorView, rows: boolean): number { + const cell = view.dom.querySelector('td, th'); + const rect = cell?.getBoundingClientRect(); + const size = rows ? rect?.height : rect?.width; + const room = rows ? window.innerHeight : (view.dom.parentElement?.clientWidth ?? 0); + return size && room ? Math.max(1, Math.floor(room / size) - 1) : rows ? 10 : 5; +} diff --git a/app/src/features/tables/keys/plugin.test.ts b/app/src/features/tables/keys/plugin.test.ts new file mode 100644 index 000000000..dca5b0b7d --- /dev/null +++ b/app/src/features/tables/keys/plugin.test.ts @@ -0,0 +1,373 @@ +// @vitest-environment jsdom +// Grid mode and Edit mode in a real table editor (ADR 0063). Keys go to the editor as key presses, so the plugin, +// the table's own keys, and the registered sheet commands all take part. +import type { Editor } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { CellSelection } from '@tiptap/pm/tables'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { initFlags } from '../../../app/flags'; +import { linkApplies } from '../../../editor/commands/state'; +import { createBlockEditor } from '../../../editor/extensions/kit'; +import { DEFAULT_EDITING_VIEW } from '../../../editor/host'; +import type { EditorHost } from '../../../editor/host'; +import { tableSchema } from '../../../editor/schema/schema'; +import { parseDate, todayDays } from '../engine/dates'; +import { registerSheetKeyCommands } from './commands'; +import { activeCell, gridOf, modeOf, sheetKeysPlugin, textsOf } from './plugin'; +import type { SheetKeysOptions } from './plugin'; +import { address } from './grid'; + +const host: EditorHost = { + settings: () => DEFAULT_EDITING_VIEW, + flag: () => true, + announce: () => {}, + openMenu: () => Promise.resolve(null), + screenReader: () => false, + spelling: () => null, + selectBlocks: () => {}, +}; + +const { nodes } = tableSchema; + +function tableDoc(rows: string[][], header: boolean): PMNode { + const made = rows.map((line, r) => + nodes.tableRow.create( + null, + line.map((text) => + (header && r === 0 ? nodes.tableHeader : nodes.tableCell).create( + null, + nodes.paragraph.create(null, text ? tableSchema.text(text) : null), + ), + ), + ), + ); + return nodes.doc.create(null, nodes.table.create(null, made)); +} + +let editors: Editor[] = []; +let stopCommands = () => {}; +beforeAll(() => { + initFlags('dev'); + stopCommands = registerSheetKeyCommands(); +}); +afterAll(() => stopCommands()); +afterEach(() => { + editors.forEach((editor) => { + editor.view.dom.remove(); + editor.destroy(); + }); + editors = []; +}); + +interface Harness { + editor: Editor; + said: string[]; + press(key: string, mods?: string): void; + type(text: string): void; + select(from: string, to?: string): void; + caretIn(cell: string): void; + texts(): string[][]; + at(): string | null; + range(): string | null; +} + +/** "B2" as a cell of a table without a header row, or with one. */ +function cellOf(name: string, header: boolean) { + const column = name.charCodeAt(0) - 65; + const number = Number(name.slice(1)); + return { row: header ? number : number - 1, column }; +} + +function mount(rows: string[][], options: Partial & { header?: boolean } = {}): Harness { + const header = options.header ?? false; + const root = document.body.appendChild(document.createElement('div')); + const editor = createBlockEditor(root, tableDoc(rows, header), { kind: 'table', block: 'b1', host }); + editors.push(editor); + const said: string[] = []; + const plugin = sheetKeysPlugin({ spreadsheet: true, announce: (text) => said.push(text), ...options }); + editor.registerPlugin(plugin, (added, plugins) => [added, ...plugins]); + const { view } = editor; + const cellPos = (name: string) => { + const at = cellOf(name, header); + let pos = 1; + const table = view.state.doc.firstChild!; + for (let r = 0; r < at.row; r++) pos += table.child(r).nodeSize; + pos += 1; + for (let c = 0; c < at.column; c++) pos += table.child(at.row).child(c).nodeSize; + return pos; + }; + const press = (key: string, mods = '') => { + const has = (name: string) => mods.includes(name); + const code = key.length === 1 ? (/\d/.test(key) ? `Digit${key}` : `Key${key.toUpperCase()}`) : key; + const event = new KeyboardEvent('keydown', { + key, + code: key === ';' ? 'Semicolon' : code, + ctrlKey: has('ctrl'), + shiftKey: has('shift'), + altKey: has('alt'), + bubbles: true, + cancelable: true, + }); + view.dom.dispatchEvent(event); + }; + return { + editor, + said, + press, + type: (text) => [...text].forEach((key) => press(key)), + select(from, to = from) { + view.dispatch(view.state.tr.setSelection(CellSelection.create(view.state.doc, cellPos(from), cellPos(to)))); + }, + caretIn(name) { + editor.commands.setTextSelection(cellPos(name) + 2); + }, + texts: () => textsOf(view.state.doc), + at: () => { + const at = activeCell(view.state); + return at && address(at, header); + }, + range: () => { + const grid = gridOf(view.state); + if (!grid) return null; + const { box } = grid; + return `${address({ row: box.top, column: box.left }, header)}:${address({ row: box.bottom, column: box.right }, header)}`; + }, + }; +} + +const FIVE = [ + ['1', '2', '3', '', ''], + ['4', '5', '6', '', ''], + ['7', '8', '9', '', ''], + ['', '', '', '', ''], + ['', '', '', '', ''], +]; + +describe('Grid mode moves', () => { + it('jumps to the data edges and selects to the last used cell', () => { + const sheet = mount(FIVE); + sheet.select('A1'); + sheet.press('ArrowDown', 'ctrl'); + expect(sheet.at()).toBe('A3'); + sheet.select('A1'); + sheet.press('ArrowRight', 'ctrl'); + expect(sheet.at()).toBe('C1'); + sheet.select('A1'); + sheet.press('End', 'ctrl shift'); + expect(sheet.range()).toBe('A1:C3'); + expect(sheet.at()).toBe('A1'); + }); + + it('moves with arrows, extends with Shift, and collapses with Esc', () => { + const sheet = mount(FIVE); + sheet.select('B2'); + sheet.press('ArrowRight'); + expect(sheet.at()).toBe('C2'); + sheet.press('ArrowDown', 'shift'); + sheet.press('ArrowLeft', 'shift'); + expect(sheet.range()).toBe('B2:C3'); + sheet.press('Escape'); + expect(sheet.range()).toBe('C2:C2'); + sheet.press('Home'); + expect(sheet.at()).toBe('A2'); + sheet.press(' ', 'shift'); + expect(sheet.range()).toBe('A2:E2'); + }); + + it('selects the column, the region, then the table', () => { + const sheet = mount(FIVE); + sheet.select('B2'); + sheet.press(' ', 'ctrl'); + expect(sheet.range()).toBe('B1:B5'); + sheet.select('B2'); + sheet.press('a', 'ctrl'); + expect(sheet.range()).toBe('A1:C3'); + sheet.press('a', 'ctrl'); + expect(sheet.range()).toBe('A1:E5'); + }); +}); + +describe('entries', () => { + it('types over a cell, cancels with Esc, edits with F2, and commits with Enter', () => { + const sheet = mount(FIVE); + sheet.select('B2'); + sheet.type('7'); + expect(sheet.texts()[1][1]).toBe('7'); + expect(modeOf(sheet.editor.state)).toBe('edit'); + sheet.press('Escape'); + expect(sheet.texts()[1][1]).toBe('5'); + expect(modeOf(sheet.editor.state)).toBe('grid'); + expect(sheet.at()).toBe('B2'); + sheet.press('F2'); + const { selection } = sheet.editor.state; + expect(modeOf(sheet.editor.state)).toBe('edit'); + expect(selection.empty && selection.$head.parentOffset === selection.$head.parent.content.size).toBe(true); + // In Edit mode the browser types the text; jsdom has no text input, so the test inserts it. + sheet.editor.view.dispatch(sheet.editor.state.tr.insertText('9')); + sheet.press('Enter'); + expect(sheet.texts()[1][1]).toBe('59'); + expect(modeOf(sheet.editor.state)).toBe('grid'); + expect(sheet.at()).toBe('B3'); + }); + + it('goes through a range with Enter: B2, B3, C2, C3', () => { + const sheet = mount(FIVE); + sheet.select('B2', 'C3'); + const seen = [sheet.at()]; + for (let i = 0; i < 4; i++) { + sheet.press('Enter'); + seen.push(sheet.at()); + } + expect(seen).toEqual(['B2', 'B3', 'C2', 'C3', 'B2']); + expect(sheet.range()).toBe('B2:C3'); + }); + + it('finishes an Enter mode entry with an arrow, and keeps the caret in the cell in Edit mode', () => { + const sheet = mount(FIVE); + sheet.select('A1'); + sheet.type('x'); + sheet.press('ArrowRight'); + expect(sheet.texts()[0][0]).toBe('x'); + expect(sheet.at()).toBe('B1'); + sheet.caretIn('C1'); + sheet.press('ArrowLeft'); + expect(modeOf(sheet.editor.state)).toBe('edit'); + expect(sheet.at()).toBe('C1'); + sheet.press('Tab'); + expect(sheet.at()).toBe('D1'); + expect(modeOf(sheet.editor.state)).toBe('grid'); + }); + + it('clears the range with Delete and the active cell with Backspace', () => { + const sheet = mount(FIVE); + sheet.select('A1', 'B2'); + sheet.press('Delete'); + expect(sheet.texts().slice(0, 2)).toEqual([ + ['', '', '3', '', ''], + ['', '', '6', '', ''], + ]); + sheet.select('C3'); + sheet.press('Backspace'); + expect(sheet.texts()[2][2]).toBe(''); + expect(modeOf(sheet.editor.state)).toBe('edit'); + }); + + it('adds a row with Tab in the last cell', () => { + const sheet = mount([['a', 'b']]); + sheet.select('B1'); + sheet.press('Tab'); + expect(sheet.texts()).toEqual([ + ['a', 'b'], + ['', ''], + ]); + expect(sheet.at()).toBe('A2'); + }); +}); + +describe('line breaks', () => { + const breaks = (editor: Editor) => { + let count = 0; + editor.state.doc.descendants((node) => { + if (node.type.name === 'hardBreak') count++; + }); + return count; + }; + + it('adds a line break with Alt+Enter in Edit mode', () => { + const sheet = mount(FIVE); + sheet.caretIn('A1'); + sheet.press('Enter', 'alt'); + expect(breaks(sheet.editor)).toBe(1); + expect(sheet.at()).toBe('A1'); + }); + + it('keeps today’s Enter in a table set to document keys', () => { + const sheet = mount(FIVE, { spreadsheet: false }); + sheet.caretIn('A1'); + sheet.press('Enter'); + expect(breaks(sheet.editor)).toBe(1); + expect(sheet.editor.state.selection instanceof CellSelection).toBe(false); + sheet.press('ArrowDown', 'ctrl'); + expect(modeOf(sheet.editor.state)).toBeNull(); + }); +}); + +describe('fills and stamps', () => { + it('fills B2 down into B3:B4 and moves its references', () => { + const sheet = mount([ + ['1', '', ''], + ['2', '=A2', ''], + ['3', '', ''], + ['4', 'old', ''], + ]); + sheet.select('B2', 'B4'); + sheet.press('d', 'ctrl'); + expect(sheet.texts().map((row) => row[1])).toEqual(['', '=A2', '=A3', '=A4']); + sheet.select('B2', 'C2'); + sheet.press('r', 'ctrl'); + expect(sheet.texts()[1][2]).toBe('=B2'); + }); + + it('enters today’s date as a date the engine reads', () => { + const now = new Date(2026, 10, 7, 13, 5); + const sheet = mount(FIVE, { now: () => now }); + sheet.select('D4'); + sheet.press(';', 'ctrl'); + sheet.press('Enter'); + const text = sheet.texts()[3][3]; + expect(text).toBe('11/7/2026'); + expect(parseDate(text, 'mdy')).toBe(todayDays(now)); + }); + + it('copies the formula and the value from the cell above', () => { + const sheet = mount( + [ + ['=1+1', ''], + ['', ''], + ], + { shown: (_view, at) => (at.row === 0 && at.column === 0 ? '2' : null) }, + ); + sheet.select('A2'); + sheet.press("'", 'ctrl'); + sheet.press('Enter'); + expect(sheet.texts()[1][0]).toBe('=1+1'); + }); +}); + +describe('screen readers', () => { + it('announces the address, header, value, and formula of the new active cell', () => { + const sheet = mount( + [ + ['Item', 'Qty', 'Total'], + ['a', '1', '=B1*2'], + ['b', '2', '=B2*2'], + ['c', '3', '=B3*2'], + ['d', '5', '=B4*2'], + ], + { header: true, shown: (_view, at) => (at.column === 2 && at.row > 0 ? '10' : null) }, + ); + sheet.select('C3'); + sheet.press('ArrowDown'); + expect(sheet.said.at(-1)).toBe('C4, Total, 10, formula equals B4 times 2'); + }); + + it('numbers every cell and marks the range and the active cell', () => { + const sheet = mount(FIVE); + sheet.select('B2', 'C3'); + const dom = sheet.editor.view.dom; + const b2 = dom.querySelectorAll('tr')[1].querySelectorAll('td')[1]; + expect(b2.getAttribute('aria-rowindex')).toBe('2'); + expect(b2.getAttribute('aria-colindex')).toBe('2'); + expect(dom.querySelectorAll('[aria-selected="true"]')).toHaveLength(4); + expect(b2.className).toMatch(/active/); + expect(dom.getAttribute('data-scope')).toBe('editor editor.table tableGrid'); + sheet.caretIn('A1'); + expect(dom.getAttribute('data-scope')).toBe('editor editor.table tableEdit'); + }); + + it('leaves Ctrl+K to the palette when cells are selected', () => { + const sheet = mount(FIVE); + sheet.select('A1', 'B2'); + expect(linkApplies(sheet.editor.state)).toBe(false); + }); +}); diff --git a/app/src/features/tables/keys/plugin.ts b/app/src/features/tables/keys/plugin.ts new file mode 100644 index 000000000..012dd705b --- /dev/null +++ b/app/src/features/tables/keys/plugin.ts @@ -0,0 +1,193 @@ +// Grid mode and Edit mode in a table block's editor (ADR 0063, DESIGN.md section 11). Grid mode is a cell +// selection: one active cell, or a range with the active cell inside it. Edit mode is the caret in a cell's text, +// as in a text editor. An entry started by typing over a cell, or by Backspace, is Enter mode: arrows finish it +// and move. Esc cancels an entry that F2, typing, or Backspace started, and puts the old text back. +// +// The plugin runs first among the editor's plugins. Chords a person may change come from the command registry +// (bindings.ts), so rebinding works; the navigation keys are fixed. A table set to document keys keeps today's +// keys: the plugin then only adds the cells' row and column numbers for screen readers. +import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin } from '@tiptap/pm/state'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import { CellSelection } from '@tiptap/pm/tables'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import type { EditorView } from '@tiptap/pm/view'; +import { clearActiveSheet, setActiveSheet } from './active'; +import type { SheetRunner } from './active'; +import { cellsIn, runSheetCommand, shownText, typeOver } from './actions'; +import { cellAnnouncement, rangeAnnouncement } from './announce'; +import { address, boxSize } from './grid'; +import type { GridCell } from './grid'; +import { handleKey } from './keys'; +import type { Ctx } from './moves'; +import { + activeCell, + cellOfPos, + cellsOf, + gridOf, + hasHeader, + modeOf, + placedAt, + selectCells, + sheetKeysKey, +} from './state'; +import type { SheetKeysOptions, SheetMeta, SheetState } from './state'; +import styles from './keys.module.css'; + +export { activeCell, gridOf, modeOf, sheetKeysKey, textsOf } from './state'; +export type { SheetKeysOptions, SheetMode } from './state'; +export { dateText, runSheetCommand, setSheetKeys, timeText } from './actions'; + +// ---- Decorations and announcements + +/** aria-rowindex and aria-colindex on every cell, counted from 1. */ +function indexesOf(doc: PMNode): DecorationSet { + const decorations: Decoration[] = []; + cellsOf(doc).forEach((row, r) => + row.forEach((cell, c) => + decorations.push( + Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, { + 'aria-rowindex': String(r + 1), + 'aria-colindex': String(c + 1), + }), + ), + ), + ); + return DecorationSet.create(doc, decorations); +} + +/** What a screen reader hears when the active cell or the range changes. */ +function speech(view: EditorView, options: SheetKeysOptions): string | null { + const g = gridOf(view.state); + if (!g) return null; + const header = hasHeader(view.state.doc); + if (boxSize(g.box) > 1 && g.active.row === g.anchor.row && g.active.column === g.anchor.column) + return rangeAnnouncement( + address({ row: g.box.top, column: g.box.left }, header), + address({ row: g.box.bottom, column: g.box.right }, header), + boxSize(g.box), + ); + const text = placedAt(view.state.doc, g.active)?.node.textContent ?? ''; + const isFormula = text.trim().startsWith('='); + return cellAnnouncement({ + address: address(g.active, header), + header: header ? (placedAt(view.state.doc, { row: 0, column: g.active.column })?.node.textContent ?? null) : null, + shown: isFormula ? shownText(view, options, g.active) : text, + formula: isFormula ? text.trim() : null, + isHeader: header && g.active.row === 0, + }); +} + +/** The key of what was announced: the range and the active cell. */ +function spoken(state: EditorState): string | null { + const g = gridOf(state); + return g ? `${g.box.top},${g.box.left},${g.box.bottom},${g.box.right}:${g.active.row},${g.active.column}` : null; +} + +/** The plugin state after a transaction: meta from the actions, and an entry that ends when the caret leaves. */ +function applyMeta(tr: Transaction, value: SheetState, state: EditorState): SheetState { + const meta = tr.getMeta(sheetKeysKey) as SheetMeta | undefined; + const next: SheetState = { ...value }; + if (tr.docChanged) next.indexes = indexesOf(state.doc); + if (meta?.spreadsheet !== undefined) next.spreadsheet = meta.spreadsheet; + if (meta && 'active' in meta) next.active = meta.active ?? null; + else if (tr.selectionSet) next.active = null; + if (meta && 'entry' in meta) next.entry = meta.entry ?? null; + else if (next.entry) { + const at = state.selection instanceof CellSelection ? null : cellOfPos(state.doc, state.selection.head); + if (!at || at.row !== next.entry.row || at.column !== next.entry.column) next.entry = null; + } + return next; +} + +/** The cell numbers on every cell; in Grid mode the range's aria-selected; and the active cell's outline. */ +function decorationsOf(state: EditorState): DecorationSet | null { + const sheet = sheetKeysKey.getState(state); + if (!sheet) return null; + const mode = modeOf(state); + if (!mode) return sheet.indexes; + const extra: Decoration[] = []; + const mark = (at: GridCell, attrs: Record) => { + const cell = placedAt(state.doc, at); + if (cell) extra.push(Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, attrs)); + }; + const g = gridOf(state); + if (g) for (const at of cellsIn(g.box)) mark(at, { 'aria-selected': 'true' }); + const active = activeCell(state); + if (active) mark(active, { class: mode === 'grid' ? styles.active : styles.editing }); + return sheet.indexes.add(state.doc, extra); +} + +/** A click on a cell's padding, not its text, selects the cell in Grid mode. */ +function clickPadding(view: EditorView, event: MouseEvent): boolean { + const target = event.target; + if (!sheetKeysKey.getState(view.state)?.spreadsheet || event.button !== 0 || event.shiftKey) return false; + if (!(target instanceof HTMLTableCellElement) || !view.dom.contains(target)) return false; + const at = cellOfPos(view.state.doc, view.posAtDOM(target, 0)); + const tr = at && selectCells(view.state, at); + if (!tr) return false; + event.preventDefault(); + view.focus(); + view.dispatch(tr); + return true; +} + +/** The mode's key scope on the editor, so the app's key handler finds Grid mode's and Edit mode's commands. */ +function scopeOf(state: EditorState): Record { + const mode = modeOf(state); + const scope = mode === 'grid' ? ' tableGrid' : mode === 'edit' ? ' tableEdit' : ''; + return { 'data-scope': `editor editor.table${scope}` }; +} + +/** Makes this table the one commands act on while it has focus, and says each move. */ +function sheetView(view: EditorView, options: SheetKeysOptions) { + const runner: SheetRunner = { + run: (command) => runSheetCommand(view, options, command), + documentKeys: () => !sheetKeysKey.getState(view.state)?.spreadsheet, + }; + const focus = () => setActiveSheet(runner); + view.dom.addEventListener('focusin', focus); + if (view.hasFocus()) focus(); + let said = spoken(view.state); + return { + update(next: EditorView) { + const now = spoken(next.state); + if (now === said) return; + said = now; + const text = now === null ? null : speech(next, options); + if (text) options.announce(text); + }, + destroy() { + view.dom.removeEventListener('focusin', focus); + clearActiveSheet(runner); + }, + }; +} + +export function sheetKeysPlugin(options: SheetKeysOptions): Plugin { + return new Plugin({ + key: sheetKeysKey, + state: { + init: (_config, state) => ({ + spreadsheet: options.spreadsheet, + entry: null, + active: null, + indexes: indexesOf(state.doc), + }), + apply: (tr, value, _old, state) => applyMeta(tr, value, state), + }, + props: { + handleKeyDown: (view, event) => handleKey(view, event, options), + handleTextInput(view, _from, _to, text) { + // Text that arrives without a key press, such as from a phone keyboard, types over the cell too. + if (modeOf(view.state) !== 'grid') return false; + const ctx: Ctx = { view, state: view.state, dispatch: (tr) => view.dispatch(tr), options, texts: [] }; + return typeOver(ctx, text); + }, + handleDOMEvents: { mousedown: clickPadding }, + attributes: scopeOf, + decorations: decorationsOf, + }, + view: (view) => sheetView(view, options), + }); +} diff --git a/app/src/features/tables/keys/register.ts b/app/src/features/tables/keys/register.ts new file mode 100644 index 000000000..a421ffb2a --- /dev/null +++ b/app/src/features/tables/keys/register.ts @@ -0,0 +1,22 @@ +// Registers the spreadsheet keys at start-up (ADR 0063): their commands, their section of the shortcut list, and +// the table extra that gives each table block its Grid and Edit modes. The editor code loads when a table first shows. +import { shortcutListSections } from '../../../registries'; +import { tableExtras } from '../../page'; +import { registerSheetKeyCommands, SHEET_KEYS_FLAG } from './commands'; +import { SheetKeysTable } from './KeysTable'; + +registerSheetKeyCommands(); + +tableExtras.register({ + id: 'sheetKeys', + flag: SHEET_KEYS_FLAG, + attach: async (host) => (await import('./attach')).attachSheetKeys(host), +}); + +shortcutListSections.register({ + id: 'sheetKeys', + title: 'sheetKeys.fixed.title', + order: 20, + flag: SHEET_KEYS_FLAG, + Component: SheetKeysTable, +}); diff --git a/app/src/features/tables/keys/state.ts b/app/src/features/tables/keys/state.ts new file mode 100644 index 000000000..85aae0948 --- /dev/null +++ b/app/src/features/tables/keys/state.ts @@ -0,0 +1,174 @@ +// The state of a table's spreadsheet keys (ADR 0063): the plugin key, the entry in progress, the active cell inside +// a range, and the cells of the document. Grid mode is a cell selection; Edit mode is the caret in a cell's text. +import { Fragment } from '@tiptap/pm/model'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { PluginKey, TextSelection } from '@tiptap/pm/state'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import { CellSelection } from '@tiptap/pm/tables'; +import type { DecorationSet, EditorView } from '@tiptap/pm/view'; +import type { DateOrder } from '../engine/locale'; +import { boxOf, inBox } from './grid'; +import type { Box, GridCell } from './grid'; +import type { TableKeys } from './mode'; + +/** An entry in a cell: where, the cell's content before it, and whether arrows finish it (Enter mode). */ +export interface Entry { + row: number; + column: number; + before: Fragment | null; + mode: 'enter' | 'edit'; +} + +export interface SheetState { + spreadsheet: boolean; + entry: Entry | null; + /** The active cell inside a range, once Enter or Tab moved it; null means the range's anchor. */ + active: GridCell | null; + /** aria-rowindex and aria-colindex on every cell, rebuilt when the document changes. */ + indexes: DecorationSet; +} + +export interface SheetMeta { + spreadsheet?: boolean; + entry?: Entry | null; + active?: GridCell | null; +} + +export interface SheetKeysOptions { + spreadsheet: boolean; + announce(text: string): void; + /** Stores the table's choice of keys in its data. */ + setKeys?(keys: TableKeys): void; + /** What a cell shows: a formula's result. Null means its text. The default reads the cell's data-shown. */ + shown?(view: EditorView, at: GridCell): string | null; + /** The order of today's date. Default: month, day, year. */ + dateOrder?(): DateOrder; + /** The language tag for the time. Default: en-US. */ + locale?(): string; + /** Now, for tests. */ + now?(): Date; +} + +export const sheetKeysKey = new PluginKey('opennote.sheetKeys'); + +// ---- The grid of the document + +export interface Placed { + pos: number; + node: PMNode; +} + +const layouts = new WeakMap(); + +/** Every cell's position (before the cell) and node, row by row. A table block's document is one table. */ +export function cellsOf(doc: PMNode): Placed[][] { + const known = layouts.get(doc); + if (known) return known; + const table = doc.firstChild; + const rows: Placed[][] = []; + layouts.set(doc, rows); + if (!table) return rows; + let rowPos = 1; + table.forEach((row) => { + const cells: Placed[] = []; + let pos = rowPos + 1; + row.forEach((cell) => { + cells.push({ pos, node: cell }); + pos += cell.nodeSize; + }); + rows.push(cells); + rowPos += row.nodeSize; + }); + return rows; +} + +export function textsOf(doc: PMNode): string[][] { + return cellsOf(doc).map((row) => row.map((cell) => cell.node.textContent)); +} + +export const hasHeader = (doc: PMNode) => doc.firstChild?.firstChild?.firstChild?.type.name === 'tableHeader'; + +/** The grid cell of a position inside or before a cell. */ +export function cellOfPos(doc: PMNode, pos: number): GridCell | null { + const cells = cellsOf(doc); + for (let row = 0; row < cells.length; row++) + for (let column = 0; column < cells[row].length; column++) { + const { pos: start, node } = cells[row][column]; + if (pos >= start && pos < start + node.nodeSize) return { row, column }; + } + return null; +} + +export const placedAt = (doc: PMNode, at: GridCell): Placed | undefined => cellsOf(doc)[at.row]?.[at.column]; + +/** The range of the paragraph's content inside a cell: cells hold one paragraph. */ +export function textRange(cell: Placed): { from: number; to: number } { + const from = cell.pos + 2; + return { from, to: from + (cell.node.firstChild?.content.size ?? 0) }; +} + +// ---- Modes + +export type SheetMode = 'grid' | 'edit'; + +export function modeOf(state: EditorState): SheetMode | null { + const sheet = sheetKeysKey.getState(state); + if (!sheet?.spreadsheet) return null; + if (state.selection instanceof CellSelection) return 'grid'; + return cellOfPos(state.doc, state.selection.head) ? 'edit' : null; +} + +/** Grid mode's range and active cell, or null outside Grid mode. */ +export function gridOf(state: EditorState): { box: Box; anchor: GridCell; head: GridCell; active: GridCell } | null { + const selection = state.selection; + if (!(selection instanceof CellSelection)) return null; + const anchor = cellOfPos(state.doc, selection.$anchorCell.pos); + const head = cellOfPos(state.doc, selection.$headCell.pos); + if (!anchor || !head) return null; + const box = boxOf(anchor, head); + const kept = sheetKeysKey.getState(state)?.active; + const active = kept && inBox(box, kept) ? kept : anchor; + return { box, anchor, head, active }; +} + +/** The cell keys act on: Grid mode's active cell, or the cell with the caret. */ +export function activeCell(state: EditorState): GridCell | null { + return gridOf(state)?.active ?? cellOfPos(state.doc, state.selection.head); +} + +// ---- Transactions + +export function selectCells( + state: EditorState, + anchor: GridCell, + head: GridCell = anchor, + active?: GridCell, +): Transaction | null { + const a = placedAt(state.doc, anchor); + const h = placedAt(state.doc, head); + if (!a || !h) return null; + const tr = state.tr.setSelection(CellSelection.create(state.doc, a.pos, h.pos)); + return tr.setMeta(sheetKeysKey, { active: active ?? null, entry: null } satisfies SheetMeta).scrollIntoView(); +} + +/** The caret at the end of a cell's text, in Edit mode. */ +export function caretAtEnd(tr: Transaction, at: GridCell): Transaction { + const cell = placedAt(tr.doc, at); + if (cell) tr.setSelection(TextSelection.create(tr.doc, textRange(cell).to)); + return tr.scrollIntoView(); +} + +/** Replaces a cell's content with text, or with the given content. */ +export function setCellContent(tr: Transaction, at: GridCell, content: Fragment | string): Transaction { + const cell = placedAt(tr.doc, at); + if (!cell) return tr; + const { from, to } = textRange(cell); + const nodes = typeof content === 'string' ? (content ? tr.doc.type.schema.text(content) : Fragment.empty) : content; + return tr.replaceWith(from, to, nodes); +} + +/** The content of a cell's paragraph. */ +export const contentOf = (doc: PMNode, at: GridCell): Fragment => + placedAt(doc, at)?.node.firstChild?.content ?? Fragment.empty; + +export type Dispatch = (tr: Transaction) => void; diff --git a/app/src/features/tables/register.ts b/app/src/features/tables/register.ts new file mode 100644 index 000000000..3ce498275 --- /dev/null +++ b/app/src/features/tables/register.ts @@ -0,0 +1,3 @@ +// The spreadsheet tables' start-up registrations (ARCHITECTURE.md section 7.1). Each lane that registers at start-up +// adds one import here. The rest of the feature loads when a table first shows. +import './keys/register'; From b2ff822a664ff760b428359ef656cd60b121880b Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 02:30:54 -0800 Subject: [PATCH 04/85] Test the table-grid key context in the keymap Grid mode's commands come before the page's, the page's chord applies in Edit mode, Ctrl+K stays the palette, and a changed chord moves with the command. Co-Authored-By: Claude Opus 5.5 --- app/src/state/keymap.test.ts | 46 ++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/app/src/state/keymap.test.ts b/app/src/state/keymap.test.ts index aa5f05447..a15b99415 100644 --- a/app/src/state/keymap.test.ts +++ b/app/src/state/keymap.test.ts @@ -1,8 +1,13 @@ -import { afterEach, describe, expect, it } from 'vitest'; +// @vitest-environment jsdom +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { initFlags } from '../app/flags'; +import { commandForKey } from '../commands/dispatcher'; import { keysFor } from '../commands/keymap'; -import { chord, defineCommand } from '../commands/registry'; +import { chord, configureCommands, defineCommand } from '../commands/registry'; import type { CommandDef } from '../commands/types'; import { commands } from '../registries'; +import { createMemoryNotesService } from '../services/notes/memory'; +import { createTestPlatform } from '../test/platform'; import { assignShortcut, resetAllShortcuts, resetShortcut, setShortcut, setShortcutSet } from './keymap'; import { DEFAULT_SETTINGS, getSettings, settingsStore } from './settings'; import { resetStores } from './store'; @@ -63,3 +68,40 @@ describe('changing shortcuts', () => { expect(keysFor('test.theme')).toEqual(['Ctrl+Alt+D']); }); }); + +// The table-grid context (ADR 0063): in a table's Grid mode its commands come before the page's and the editor's, +// Ctrl+K stays the palette, and a chord the person changes moves with the command. These are stand-ins for the +// sheet commands; features/tables/keys/commands.test.ts checks the real ones. +describe('the table-grid context', () => { + beforeAll(() => { + initFlags('dev'); + configureCommands({ platform: createTestPlatform(), notes: createMemoryNotesService({ seed: 'sample' }) }); + }); + + function press(scope: string, key: string, code: string, mods: string) { + document.body.innerHTML = `
`; + const target = document.querySelector('[contenteditable]'); + const has = (name: string) => mods.includes(name); + const event = { key, code, ctrlKey: has('ctrl'), altKey: has('alt'), shiftKey: false, metaKey: false }; + return commandForKey({ ...event, isComposing: false, repeat: false, target })?.id ?? null; + } + + const GRID = 'editor editor.table tableGrid'; + + it('runs Grid mode’s chord before the page’s, and the page’s in Edit mode', () => { + add('pageFill', { keys: [chord('Ctrl+D')], scope: 'page', allowInTextInput: true }); + add('gridFill', { keys: [chord('Ctrl+D')], scope: 'tableGrid', allowInTextInput: true }); + add('palette', { keys: [chord('Ctrl+K')], allowInTextInput: true }); + expect(press(GRID, 'd', 'KeyD', 'ctrl')).toBe('test.gridFill'); + expect(press('editor editor.table tableEdit', 'd', 'KeyD', 'ctrl')).toBe('test.pageFill'); + expect(press(GRID, 'k', 'KeyK', 'ctrl')).toBe('test.palette'); + }); + + it('moves a grid command to the chord the person gives it', async () => { + add('pageFill', { keys: [chord('Ctrl+D')], scope: 'page', allowInTextInput: true }); + add('gridFill', { keys: [chord('Ctrl+D')], scope: 'tableGrid', allowInTextInput: true }); + await setShortcut('test.gridFill', [chord('Ctrl+Alt+D')]); + expect(press(GRID, 'd', 'KeyD', 'ctrl')).toBe('test.pageFill'); + expect(press(GRID, 'd', 'KeyD', 'ctrl alt')).toBe('test.gridFill'); + }); +}); From 7d5b99819bd471264ddd55f8afe380e3a3c0ce17 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 02:30:54 -0800 Subject: [PATCH 05/85] Add the sheet keys interface spec Drives a smart table in the browser: data edge jumps, typing over and Esc, F2 and Enter, Alt+Enter and document keys, Ctrl+D and Ctrl+;, the screen reader announcement, and Ctrl+K in Grid mode. Co-Authored-By: Claude Opus 5.5 --- tests/ui/behavior/sheet-keys.spec.ts | 101 +++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/ui/behavior/sheet-keys.spec.ts diff --git a/tests/ui/behavior/sheet-keys.spec.ts b/tests/ui/behavior/sheet-keys.spec.ts new file mode 100644 index 000000000..9fc4802c0 --- /dev/null +++ b/tests/ui/behavior/sheet-keys.spec.ts @@ -0,0 +1,101 @@ +// Spreadsheet keys in a table (ADR 0063). Grid mode moves an active cell. Typing replaces a cell, and Esc puts it +// back. Enter finishes and moves down. Ctrl+D fills with references moved, and Ctrl+; enters today's date. A screen +// reader hears each cell, and Ctrl+K stays the palette. A table set to document keys keeps today's Enter. + +import type { Locator, Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; +import { fillSales, openSmart, setCell } from './smart'; + +const cell = (page: Page, row: number, column: number): Locator => + page.locator('table tr').nth(row).locator('td, th').nth(column); + +/** Grid mode on a cell: a click into its text, then Esc. */ +async function gridAt(page: Page, row: number, column: number): Promise { + await cell(page, row, column).click(); + await page.keyboard.press('Escape'); + await expect(cell(page, row, column)).toHaveAttribute('aria-selected', 'true'); +} + +const selected = (page: Page) => page.locator('table [aria-selected="true"]'); +/** The polite live region that says `text`. The announcer keeps two, and fills them in turn. */ +const said = (page: Page, text: string) => page.locator('[aria-live="polite"]').filter({ hasText: text }); + +test.beforeEach(async ({ page }) => { + await openSmart(page); + // The keys load with the table's editor; every cell has its row number once they are in. + await expect(cell(page, 0, 0)).toHaveAttribute('aria-rowindex', '1'); + await fillSales(page); +}); + +test('jumps to the edges of the data and selects to the first cell', async ({ page }) => { + await gridAt(page, 1, 0); + await page.keyboard.press('Control+ArrowDown'); + await expect(selected(page)).toHaveCount(1); + await expect(cell(page, 3, 0)).toHaveAttribute('aria-selected', 'true'); + await page.keyboard.press('Control+ArrowRight'); + await expect(cell(page, 3, 2)).toHaveAttribute('aria-selected', 'true'); + await page.keyboard.press('Control+Shift+Home'); + await expect(selected(page)).toHaveCount(12); +}); + +test('types over a cell, puts it back with Esc, and finishes an F2 entry with Enter', async ({ page }) => { + await gridAt(page, 1, 1); + await page.keyboard.type('7'); + await expect(cell(page, 1, 1)).toHaveText('7'); + await page.keyboard.press('Escape'); + await expect(cell(page, 1, 1)).toHaveText('120'); + await page.keyboard.press('F2'); + await page.keyboard.type('5'); + await page.keyboard.press('Enter'); + await expect(cell(page, 1, 1)).toHaveText('1205'); + await expect(cell(page, 2, 1)).toHaveAttribute('aria-selected', 'true'); +}); + +test('starts a new line with Alt+Enter, and with Enter once the table uses document keys', async ({ page }) => { + const breaks = (at: Locator) => at.locator('br:not(.ProseMirror-trailingBreak)'); + await cell(page, 1, 0).click(); + await page.keyboard.press('End'); + await page.keyboard.press('Alt+Enter'); + await expect(breaks(cell(page, 1, 0))).toHaveCount(1); + // The smart table's View list is a combobox too, so open the palette by its key and wait for its field. + await page.keyboard.press('Control+k'); + await expect(page.getByRole('combobox').first()).toBeFocused(); + await page.keyboard.type('Use document keys'); + await page.getByRole('option', { name: 'Use document keys in this table' }).first().click(); + await cell(page, 3, 0).click(); + await page.keyboard.press('End'); + await page.keyboard.press('Enter'); + await expect(breaks(cell(page, 3, 0))).toHaveCount(1); + await expect(selected(page)).toHaveCount(0); +}); + +test('fills a formula down with Ctrl+D and enters today’s date with Ctrl+;', async ({ page }) => { + await gridAt(page, 2, 2); + await page.keyboard.press('Shift+ArrowDown'); + await page.keyboard.press('Control+d'); + await expect(cell(page, 3, 2)).toContainText('=B3*0.6'); + await gridAt(page, 3, 1); + await page.keyboard.press('Control+;'); + await page.keyboard.press('Enter'); + const today = await page.evaluate(() => { + const now = new Date(); + return `${now.getMonth() + 1}/${now.getDate()}/${now.getFullYear()}`; + }); + await expect(cell(page, 3, 1)).toHaveText(today); +}); + +test('tells a screen reader the address, header, value, and formula', async ({ page }) => { + await setCell(page, 0, 2, 'Total'); + await setCell(page, 3, 1, '5'); + await setCell(page, 3, 2, '=B3*2'); + await gridAt(page, 2, 2); + await page.keyboard.press('ArrowDown'); + await expect(said(page, 'C3, Total, 10, formula equals B3 times 2')).toHaveCount(1); +}); + +test('keeps Ctrl+K as the command palette in Grid mode', async ({ page }) => { + await gridAt(page, 1, 0); + await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.press('Control+k'); + await expect(page.getByRole('combobox').first()).toBeFocused(); +}); From 7300a26d41547390fe914413a4c5232f996ccb92 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 02:30:55 -0800 Subject: [PATCH 06/85] Record the spreadsheet keys in ADR 0063 and the feature list ADR 0063 gains implementation notes. The generated feature registry and list gain the sheet commands, each with a sentence. The README's tables line names the new keys. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/FEATURE-LIST.md | 39 ++- docs/adr/0063-spreadsheet-keyboard-model.md | 12 + docs/tools/featureCommands.ts | 39 +++ docs/tools/registry.json | 363 ++++++++++++++++++++ 5 files changed, 453 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 36674a22e..ca33a8d4f 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ You keep notebooks, sections, and pages in a notes folder you choose, as plain f - [Ink and palm rejection](docs/help/ink-and-palm-rejection.md): pens, a highlighter, erasers, the lasso, shapes, and gestures, with your palm resting on the screen. - [My handwriting](docs/help/my-handwriting.md): typed text and math written in your own hand, learned from a calibration page. - [Paper and page views](docs/help/paper-and-page-views.md): flow, paginated, and canvas pages, with lined, grid, and dot paper; slides, print, and PDF. -- [Tables and charts](docs/help/tables-and-charts.md): smart tables with views and formulas, and charts that update with them. +- [Tables and charts](docs/help/tables-and-charts.md): smart tables with views and formulas, spreadsheet keys with Grid and Edit modes, and charts that update with them. - [Language and spelling](docs/help/language-and-spelling.md): American English by default, and one choice for every part that reads or writes words. ### Organize and find diff --git a/docs/FEATURE-LIST.md b/docs/FEATURE-LIST.md index 019b3f8c6..1a656a283 100644 --- a/docs/FEATURE-LIST.md +++ b/docs/FEATURE-LIST.md @@ -2,7 +2,7 @@ Every tool, action, and feature in OpenNote, grouped by category, with one sentence each. `npm run docs:features` makes this page. The commands, tool windows, title bar items, settings sections, and setup steps come from what the app registers, so a new one shows up here or fails the tests. The feature sentences come from [FEATURES.md](FEATURES.md), which also says what has been tested by hand and which flag turns each one on. The [help pages](help/README.md) explain how to use them. [docs/screens](screens/README.md) has every picture in light and dark. -The app registers 886 commands and 48 tool windows. 24 of the windows are subject and engineering kits. The count includes the ink commands, which register when a page first shows its ink, and the commands that register once start-up is done or with the first page: on-device intelligence, the second pass of page commands, and the code block and table commands. The command palette (Ctrl+K) runs every command not marked as left out of it. Some commands need a feature flag, which FEATURES.md names. The keys shown are the defaults of the standard shortcut set. +The app registers 923 commands and 48 tool windows. 24 of the windows are subject and engineering kits. The count includes the ink commands, which register when a page first shows its ink, and the commands that register once start-up is done or with the first page: on-device intelligence, the second pass of page commands, and the code block and table commands. The command palette (Ctrl+K) runs every command not marked as left out of it. Some commands need a feature flag, which FEATURES.md names. The keys shown are the defaults of the standard shortcut set. ## Contents @@ -300,6 +300,43 @@ Open it with the command "Open the code studio". | Table: Eigenvalues of the selection as an equation | Inserts the eigenvalues of the selected matrix as an equation. | | | Table: Solution of Ax = b as an equation | Inserts the solution of Ax = b from the selected cells as an equation. | | | Insert table | Inserts a table. | | +| Extend the selection up (not in the palette) | In Grid mode, extends the selected cells one row up. | Shift+Up | +| Extend the selection down (not in the palette) | In Grid mode, extends the selected cells one row down. | Shift+Down | +| Extend the selection left (not in the palette) | In Grid mode, extends the selected cells one column left. | Shift+Left | +| Extend the selection right (not in the palette) | In Grid mode, extends the selected cells one column right. | Shift+Right | +| Go to the top edge of the data (not in the palette) | In Grid mode, goes to the top edge of the data in the column. | Ctrl+Up | +| Go to the bottom edge of the data (not in the palette) | In Grid mode, goes to the bottom edge of the data in the column. | Ctrl+Down | +| Go to the left edge of the data (not in the palette) | In Grid mode, goes to the left edge of the data in the row. | Ctrl+Left | +| Go to the right edge of the data (not in the palette) | In Grid mode, goes to the right edge of the data in the row. | Ctrl+Right | +| Extend the selection to the top edge of the data (not in the palette) | In Grid mode, extends the selected cells to the top edge of the data. | Ctrl+Shift+Up | +| Extend the selection to the bottom edge of the data (not in the palette) | In Grid mode, extends the selected cells to the bottom edge of the data. | Ctrl+Shift+Down | +| Extend the selection to the left edge of the data (not in the palette) | In Grid mode, extends the selected cells to the left edge of the data. | Ctrl+Shift+Left | +| Extend the selection to the right edge of the data (not in the palette) | In Grid mode, extends the selected cells to the right edge of the data. | Ctrl+Shift+Right | +| Go to the first cell (not in the palette) | In Grid mode, goes to the table's first cell. | Ctrl+Home | +| Go to the last used cell (not in the palette) | In Grid mode, goes to the last cell that holds data. | Ctrl+End | +| Extend the selection to the first cell (not in the palette) | In Grid mode, extends the selected cells to the table's first cell. | Ctrl+Shift+Home | +| Extend the selection to the last used cell (not in the palette) | In Grid mode, extends the selected cells to the last cell that holds data. | Ctrl+Shift+End | +| Extend the selection to the start of the row (not in the palette) | In Grid mode, extends the selected cells to the start of the row. | Shift+Home | +| Move one screen left (not in the palette) | In Grid mode, moves one screen of columns left. | Alt+PageUp | +| Move one screen right (not in the palette) | In Grid mode, moves one screen of columns right. | Alt+PageDown | +| Move up a cell (not in the palette) | In Grid mode, moves up a cell, or back through the selected cells. | Shift+Enter | +| Fill the selected cells with the active cell (not in the palette) | In Grid mode, fills every selected cell with the active cell, moving formula references. | Ctrl+Enter | +| Edit the active cell (not in the palette) | In Grid mode, starts editing the active cell with the caret at the end. | F2 | +| Clear the selected cells (not in the palette) | In Grid mode, clears the text of every selected cell. | Delete | +| Clear the active cell and edit it (not in the palette) | In Grid mode, clears the active cell and starts editing it. | Backspace | +| Select the column (not in the palette) | In Grid mode, selects the whole column. | Ctrl+Space | +| Select the data around the cell, then the table (not in the palette) | In Grid mode, selects the data around the active cell, then the whole table. | Ctrl+A | +| Fill down | In Grid mode, copies the top cell of the selection down, moving formula references. | Ctrl+D | +| Fill right | In Grid mode, copies the left cell of the selection right, moving formula references. | Ctrl+R | +| Copy the formula from the cell above (not in the palette) | In Grid mode, copies the formula of the cell above into the active cell. | Ctrl+' | +| Copy the value from the cell above (not in the palette) | In Grid mode, copies the value of the cell above into the active cell. | Ctrl+" | +| Enter today’s date | Enters today's date in the cell, as a date the formulas read. | Ctrl+; | +| Enter the current time | Enters the current time in the cell. | Ctrl+: | +| Finish the entry and move up (not in the palette) | In Edit mode, finishes the cell's entry and moves up. | Shift+Enter | +| Finish the entry and stay in the cell (not in the palette) | In Edit mode, finishes the cell's entry and stays in the cell. | Ctrl+Enter | +| Start a new line in the cell (not in the palette) | In Edit mode, starts a new line in the cell. | Alt+Enter | +| Switch between Enter and Edit modes (not in the palette) | In Edit mode, switches between Enter mode, where arrows move to the next cell, and Edit mode. | F2 | +| Use document keys in this table | Switches a table between spreadsheet keys and document keys, where Enter starts a new line. | | | Add row above | Adds a row above the current one. | | | Add row below | Adds a row below the current one. | Ctrl+Enter | | Move row up | Moves the current row up one place. | Alt+Shift+Up | diff --git a/docs/adr/0063-spreadsheet-keyboard-model.md b/docs/adr/0063-spreadsheet-keyboard-model.md index 9b3fbe261..23c0e0cc8 100644 --- a/docs/adr/0063-spreadsheet-keyboard-model.md +++ b/docs/adr/0063-spreadsheet-keyboard-model.md @@ -24,3 +24,15 @@ We will give tables two modes. Grid mode (Excel's Ready mode) has an active cell - Screen readers get roving focus on the active cell and an announcement per move (DESIGN.md section 17). - The shortcuts page and help gain a table section for both modes. - Changing a chord that collides with an app chord needs a scoped keymap context in `state/keymap.ts`. + +## Implementation notes + +The keys live in `app/src/features/tables/keys/` (tables:keys, B10), behind the flag `tables.sheetWrites`: + +- Grid mode is a cell selection. Its anchor is the active cell; once Enter or Tab moves through a range, the plugin keeps the active cell beside the range. Edit mode is the caret in a cell's text. An entry begun by typing, Backspace, F2, or Ctrl+' remembers the cell's content, so Esc puts it back. An entry begun by typing or Backspace is Enter mode, where arrows finish it and move. +- A ProseMirror plugin, registered first through the table extra (`features/page/tables/extras.ts`), handles the keys. Chords a person may change are commands in the registry, in the scopes `tableGrid`, `tableEdit`, and `editor.table` that `commands/keymap.ts` already nests under the editor. The plugin looks up the person's own chords, so rebinding works. The app's key handler runs the chords that reach commands from a text field (Ctrl, Alt, function keys); the plugin runs the rest. Ctrl+D, Ctrl+R, and Ctrl+Enter refine the table's existing commands, so the registry has no conflicts. +- The navigation keys that `commands/reserved.ts` keeps free (arrows, Enter, Tab, Home, Page Up, Page Down, and Esc) and Shift+Space are fixed. The shortcut list shows them in a Table keys section; the changeable chords are in its Table group. +- Esc on a single cell goes on to the editor, which selects the table as an object, so the keyboard can always leave the table. +- `data.sheet.table.keys` is `spreadsheet` or `document` (spec R-SHT-10). Insert table stores the setting "New tables use spreadsheet keys". A table that stores nothing keeps document keys when it holds only text, and uses spreadsheet keys when a cell holds a number or a formula (PLAN.md decision 4). The table menu's "Use document keys in this table" switches it. +- Screen readers: every cell has `aria-rowindex` and `aria-colindex`, and in Grid mode the range has `aria-selected`. DOM focus stays on the editor, because a cell inside an editable region cannot take focus without breaking the editor's selection, and the editor's root has no role that allows `aria-activedescendant`. So instead of roving `tabindex`, each move says the address, header, shown value, and formula in words through the polite live region. The active cell has a thick outline in Grid mode and a thin one in Edit mode. +- Ctrl+5 is not bound: Ctrl+1 to Ctrl+9 stay free for tags. Bold, italic, underline, and the strikethrough command already format the text of selected cells. Keys that other lanes own (Ctrl+1, F4, Alt+=, the number format, border, structure, filter, note, function, and paste chords) register in the same scopes. diff --git a/docs/tools/featureCommands.ts b/docs/tools/featureCommands.ts index 6d24fed73..6cb9edfb3 100644 --- a/docs/tools/featureCommands.ts +++ b/docs/tools/featureCommands.ts @@ -325,6 +325,45 @@ export const COMMAND_TEXT: Readonly> = { 'table.editChoices': 'Edits the list of choices of the column.', 'table.totalSum': 'Adds a total row with the sum of the column.', 'table.totalNone': "Removes the column's total.", + 'table.sheet.extendUp': 'In Grid mode, extends the selected cells one row up.', + 'table.sheet.extendDown': 'In Grid mode, extends the selected cells one row down.', + 'table.sheet.extendLeft': 'In Grid mode, extends the selected cells one column left.', + 'table.sheet.extendRight': 'In Grid mode, extends the selected cells one column right.', + 'table.sheet.jumpUp': 'In Grid mode, goes to the top edge of the data in the column.', + 'table.sheet.jumpDown': 'In Grid mode, goes to the bottom edge of the data in the column.', + 'table.sheet.jumpLeft': 'In Grid mode, goes to the left edge of the data in the row.', + 'table.sheet.jumpRight': 'In Grid mode, goes to the right edge of the data in the row.', + 'table.sheet.extendJumpUp': 'In Grid mode, extends the selected cells to the top edge of the data.', + 'table.sheet.extendJumpDown': 'In Grid mode, extends the selected cells to the bottom edge of the data.', + 'table.sheet.extendJumpLeft': 'In Grid mode, extends the selected cells to the left edge of the data.', + 'table.sheet.extendJumpRight': 'In Grid mode, extends the selected cells to the right edge of the data.', + 'table.sheet.firstCell': "In Grid mode, goes to the table's first cell.", + 'table.sheet.lastCell': 'In Grid mode, goes to the last cell that holds data.', + 'table.sheet.extendFirstCell': "In Grid mode, extends the selected cells to the table's first cell.", + 'table.sheet.extendLastCell': 'In Grid mode, extends the selected cells to the last cell that holds data.', + 'table.sheet.extendRowStart': 'In Grid mode, extends the selected cells to the start of the row.', + 'table.sheet.screenLeft': 'In Grid mode, moves one screen of columns left.', + 'table.sheet.screenRight': 'In Grid mode, moves one screen of columns right.', + 'table.sheet.moveUp': 'In Grid mode, moves up a cell, or back through the selected cells.', + 'table.sheet.fillEntry': 'In Grid mode, fills every selected cell with the active cell, moving formula references.', + 'table.sheet.edit': 'In Grid mode, starts editing the active cell with the caret at the end.', + 'table.sheet.clear': 'In Grid mode, clears the text of every selected cell.', + 'table.sheet.clearAndEdit': 'In Grid mode, clears the active cell and starts editing it.', + 'table.sheet.selectColumn': 'In Grid mode, selects the whole column.', + 'table.sheet.selectRegion': 'In Grid mode, selects the data around the active cell, then the whole table.', + 'table.sheet.fillDown': 'In Grid mode, copies the top cell of the selection down, moving formula references.', + 'table.sheet.fillRight': 'In Grid mode, copies the left cell of the selection right, moving formula references.', + 'table.sheet.copyFormulaAbove': 'In Grid mode, copies the formula of the cell above into the active cell.', + 'table.sheet.copyValueAbove': 'In Grid mode, copies the value of the cell above into the active cell.', + 'table.sheet.insertDate': "Enters today's date in the cell, as a date the formulas read.", + 'table.sheet.insertTime': 'Enters the current time in the cell.', + 'table.sheet.commitUp': "In Edit mode, finishes the cell's entry and moves up.", + 'table.sheet.commitStay': "In Edit mode, finishes the cell's entry and stays in the cell.", + 'table.sheet.lineBreak': 'In Edit mode, starts a new line in the cell.', + 'table.sheet.toggleEnterMode': + 'In Edit mode, switches between Enter mode, where arrows move to the next cell, and Edit mode.', + 'table.sheet.documentKeys': + 'Switches a table between spreadsheet keys and document keys, where Enter starts a new line.', 'insert.chartBar': 'Inserts a bar chart of the table.', 'insert.chartLine': 'Inserts a line chart of the table.', 'insert.chartArea': 'Inserts an area chart of the table.', diff --git a/docs/tools/registry.json b/docs/tools/registry.json index 00264d981..6c2510aff 100644 --- a/docs/tools/registry.json +++ b/docs/tools/registry.json @@ -5505,6 +5505,369 @@ "keys": [], "flag": "sync.e2ee" }, + { + "id": "table.sheet.extendUp", + "title": "Extend the selection up", + "category": "table", + "keys": [ + "Shift+Up" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendDown", + "title": "Extend the selection down", + "category": "table", + "keys": [ + "Shift+Down" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendLeft", + "title": "Extend the selection left", + "category": "table", + "keys": [ + "Shift+Left" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendRight", + "title": "Extend the selection right", + "category": "table", + "keys": [ + "Shift+Right" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.jumpUp", + "title": "Go to the top edge of the data", + "category": "table", + "keys": [ + "Ctrl+Up" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.jumpDown", + "title": "Go to the bottom edge of the data", + "category": "table", + "keys": [ + "Ctrl+Down" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.jumpLeft", + "title": "Go to the left edge of the data", + "category": "table", + "keys": [ + "Ctrl+Left" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.jumpRight", + "title": "Go to the right edge of the data", + "category": "table", + "keys": [ + "Ctrl+Right" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendJumpUp", + "title": "Extend the selection to the top edge of the data", + "category": "table", + "keys": [ + "Ctrl+Shift+Up" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendJumpDown", + "title": "Extend the selection to the bottom edge of the data", + "category": "table", + "keys": [ + "Ctrl+Shift+Down" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendJumpLeft", + "title": "Extend the selection to the left edge of the data", + "category": "table", + "keys": [ + "Ctrl+Shift+Left" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendJumpRight", + "title": "Extend the selection to the right edge of the data", + "category": "table", + "keys": [ + "Ctrl+Shift+Right" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.firstCell", + "title": "Go to the first cell", + "category": "table", + "keys": [ + "Ctrl+Home" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.lastCell", + "title": "Go to the last used cell", + "category": "table", + "keys": [ + "Ctrl+End" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendFirstCell", + "title": "Extend the selection to the first cell", + "category": "table", + "keys": [ + "Ctrl+Shift+Home" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendLastCell", + "title": "Extend the selection to the last used cell", + "category": "table", + "keys": [ + "Ctrl+Shift+End" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.extendRowStart", + "title": "Extend the selection to the start of the row", + "category": "table", + "keys": [ + "Shift+Home" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.screenLeft", + "title": "Move one screen left", + "category": "table", + "keys": [ + "Alt+PageUp" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.screenRight", + "title": "Move one screen right", + "category": "table", + "keys": [ + "Alt+PageDown" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.moveUp", + "title": "Move up a cell", + "category": "table", + "keys": [ + "Shift+Enter" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.fillEntry", + "title": "Fill the selected cells with the active cell", + "category": "table", + "keys": [ + "Ctrl+Enter" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.edit", + "title": "Edit the active cell", + "category": "table", + "keys": [ + "F2" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.clear", + "title": "Clear the selected cells", + "category": "table", + "keys": [ + "Delete" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.clearAndEdit", + "title": "Clear the active cell and edit it", + "category": "table", + "keys": [ + "Backspace" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.selectColumn", + "title": "Select the column", + "category": "table", + "keys": [ + "Ctrl+Space" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.selectRegion", + "title": "Select the data around the cell, then the table", + "category": "table", + "keys": [ + "Ctrl+A" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.fillDown", + "title": "Fill down", + "category": "table", + "keys": [ + "Ctrl+D" + ], + "flag": "tables.sheetWrites" + }, + { + "id": "table.sheet.fillRight", + "title": "Fill right", + "category": "table", + "keys": [ + "Ctrl+R" + ], + "flag": "tables.sheetWrites" + }, + { + "id": "table.sheet.copyFormulaAbove", + "title": "Copy the formula from the cell above", + "category": "table", + "keys": [ + "Ctrl+'" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.copyValueAbove", + "title": "Copy the value from the cell above", + "category": "table", + "keys": [ + "Ctrl+\"" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.insertDate", + "title": "Enter today’s date", + "category": "table", + "keys": [ + "Ctrl+;" + ], + "flag": "tables.sheetWrites" + }, + { + "id": "table.sheet.insertTime", + "title": "Enter the current time", + "category": "table", + "keys": [ + "Ctrl+:" + ], + "flag": "tables.sheetWrites" + }, + { + "id": "table.sheet.commitUp", + "title": "Finish the entry and move up", + "category": "table", + "keys": [ + "Shift+Enter" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.commitStay", + "title": "Finish the entry and stay in the cell", + "category": "table", + "keys": [ + "Ctrl+Enter" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.lineBreak", + "title": "Start a new line in the cell", + "category": "table", + "keys": [ + "Alt+Enter" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.toggleEnterMode", + "title": "Switch between Enter and Edit modes", + "category": "table", + "keys": [ + "F2" + ], + "flag": "tables.sheetWrites", + "palette": false + }, + { + "id": "table.sheet.documentKeys", + "title": "Use document keys in this table", + "category": "table", + "keys": [], + "flag": "tables.sheetWrites" + }, { "id": "theme.toggle", "title": "Toggle dark mode", From 7dc536badb49b02c8a9a2a1ef95709b14876df35 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 04:09:32 -0800 Subject: [PATCH 07/85] Close the tables keys verification gaps - Settings, then Tables: choose spreadsheet or document keys for new tables. - A table with spreadsheet keys is an ARIA grid, so aria-selected is allowed. No cell stays selected while focus is outside the editor. - Typing maps the cell numbers instead of rebuilding them for every cell. - The key scope fixture lists the real sheet commands and chords. - ADR 0063 is accepted: announcements replace roving focus. - A long run of spaces in a cell no longer slows the number check. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- app/src/commands/fixtures/scopes/tables.ts | 96 ++++++++++++++++--- app/src/commands/scopes.test.ts | 6 ++ .../features/settings/SettingsView.test.tsx | 1 + .../features/tables/keys/Settings.test.tsx | 27 ++++++ app/src/features/tables/keys/Settings.tsx | 33 +++++++ app/src/features/tables/keys/bindings.test.ts | 18 ++++ app/src/features/tables/keys/keys.module.css | 20 ++++ app/src/features/tables/keys/mode.test.ts | 6 ++ app/src/features/tables/keys/mode.ts | 4 +- app/src/features/tables/keys/plugin.test.ts | 87 ++++++++++++++++- app/src/features/tables/keys/plugin.ts | 83 ++++++++++++---- app/src/features/tables/keys/register.ts | 12 ++- app/src/features/tables/keys/state.ts | 6 +- app/src/strings/en/sheetKeys.ts | 10 ++ docs/FEATURE-LIST.md | 1 + docs/adr/0063-spreadsheet-keyboard-model.md | 6 +- docs/adr/README.md | 2 +- docs/tools/featureCommands.ts | 1 + docs/tools/registry.json | 4 + 20 files changed, 385 insertions(+), 40 deletions(-) create mode 100644 app/src/features/tables/keys/Settings.test.tsx create mode 100644 app/src/features/tables/keys/Settings.tsx create mode 100644 app/src/features/tables/keys/bindings.test.ts diff --git a/README.md b/README.md index ca33a8d4f..8e18924ed 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ You keep notebooks, sections, and pages in a notes folder you choose, as plain f - [Ink and palm rejection](docs/help/ink-and-palm-rejection.md): pens, a highlighter, erasers, the lasso, shapes, and gestures, with your palm resting on the screen. - [My handwriting](docs/help/my-handwriting.md): typed text and math written in your own hand, learned from a calibration page. - [Paper and page views](docs/help/paper-and-page-views.md): flow, paginated, and canvas pages, with lined, grid, and dot paper; slides, print, and PDF. -- [Tables and charts](docs/help/tables-and-charts.md): smart tables with views and formulas, spreadsheet keys with Grid and Edit modes, and charts that update with them. +- [Tables and charts](docs/help/tables-and-charts.md): smart tables with views and formulas, spreadsheet keys with Grid and Edit modes (a setting chooses them for new tables), and charts that update with them. - [Language and spelling](docs/help/language-and-spelling.md): American English by default, and one choice for every part that reads or writes words. ### Organize and find diff --git a/app/src/commands/fixtures/scopes/tables.ts b/app/src/commands/fixtures/scopes/tables.ts index add13190d..a3ed70d68 100644 --- a/app/src/commands/fixtures/scopes/tables.ts +++ b/app/src/commands/fixtures/scopes/tables.ts @@ -1,37 +1,105 @@ // The spreadsheet keys of a table in Grid mode and Edit mode. Owner: tables:keys. shared:x-shared wrote the first -// cases from the plan's key table. The IDs are stand-ins until the table commands exist. A chord that Edit mode -// gives its text meaning has no table command, so it falls through to the text program. -import type { ScopeFixture } from './types'; +// cases from the plan's key table. The sheet commands are the registered ones in bindings.ts, so a chord +// that collides with the text or code program fails here. The table.* IDs below are stand-ins for chords that +// other lanes build: F9 and Shift+F3 (formula-bar and the engine), Ctrl+` and Ctrl+5. A chord that Edit mode gives +// its text meaning has no table command, so it falls through to the text program. +import type { ScopeCommand, ScopeFixture } from './types'; + +// The registered sheet commands, as bindings.ts lists them. commands.test.ts in tables/keys checks that they match. +const sheetCommands: readonly ScopeCommand[] = [ + { id: 'table.sheet.extendUp', scope: 'tableGrid', keys: ['Shift+Up'] }, + { id: 'table.sheet.extendDown', scope: 'tableGrid', keys: ['Shift+Down'] }, + { id: 'table.sheet.extendLeft', scope: 'tableGrid', keys: ['Shift+Left'] }, + { id: 'table.sheet.extendRight', scope: 'tableGrid', keys: ['Shift+Right'] }, + { id: 'table.sheet.jumpUp', scope: 'tableGrid', keys: ['Ctrl+Up'] }, + { id: 'table.sheet.jumpDown', scope: 'tableGrid', keys: ['Ctrl+Down'] }, + { id: 'table.sheet.jumpLeft', scope: 'tableGrid', keys: ['Ctrl+Left'] }, + { id: 'table.sheet.jumpRight', scope: 'tableGrid', keys: ['Ctrl+Right'] }, + { id: 'table.sheet.extendJumpUp', scope: 'tableGrid', keys: ['Ctrl+Shift+Up'] }, + { id: 'table.sheet.extendJumpDown', scope: 'tableGrid', keys: ['Ctrl+Shift+Down'] }, + { id: 'table.sheet.extendJumpLeft', scope: 'tableGrid', keys: ['Ctrl+Shift+Left'] }, + { id: 'table.sheet.extendJumpRight', scope: 'tableGrid', keys: ['Ctrl+Shift+Right'] }, + { id: 'table.sheet.firstCell', scope: 'tableGrid', keys: ['Ctrl+Home'] }, + { id: 'table.sheet.lastCell', scope: 'tableGrid', keys: ['Ctrl+End'] }, + { id: 'table.sheet.extendFirstCell', scope: 'tableGrid', keys: ['Ctrl+Shift+Home'] }, + { id: 'table.sheet.extendLastCell', scope: 'tableGrid', keys: ['Ctrl+Shift+End'] }, + { id: 'table.sheet.extendRowStart', scope: 'tableGrid', keys: ['Shift+Home'] }, + { id: 'table.sheet.screenLeft', scope: 'tableGrid', keys: ['Alt+PageUp'] }, + { id: 'table.sheet.screenRight', scope: 'tableGrid', keys: ['Alt+PageDown'] }, + { id: 'table.sheet.moveUp', scope: 'tableGrid', keys: ['Shift+Enter'] }, + { id: 'table.sheet.fillEntry', scope: 'tableGrid', keys: ['Ctrl+Enter'], refines: 'table.rowBelow' }, + { id: 'table.sheet.edit', scope: 'tableGrid', keys: ['F2'] }, + { id: 'table.sheet.clear', scope: 'tableGrid', keys: ['Delete'] }, + { id: 'table.sheet.clearAndEdit', scope: 'tableGrid', keys: ['Backspace'] }, + { id: 'table.sheet.selectColumn', scope: 'tableGrid', keys: ['Ctrl+Space'] }, + { id: 'table.sheet.selectRegion', scope: 'tableGrid', keys: ['Ctrl+A'] }, + { id: 'table.sheet.fillDown', scope: 'tableGrid', keys: ['Ctrl+D'], refines: 'table.fillDown' }, + { id: 'table.sheet.fillRight', scope: 'tableGrid', keys: ['Ctrl+R'], refines: 'table.fillRight' }, + { id: 'table.sheet.copyFormulaAbove', scope: 'tableGrid', keys: ["Ctrl+'"] }, + { id: 'table.sheet.copyValueAbove', scope: 'tableGrid', keys: ['Ctrl+"'] }, + { id: 'table.sheet.insertDate', scope: 'editor.table', keys: ['Ctrl+;'] }, + { id: 'table.sheet.insertTime', scope: 'editor.table', keys: ['Ctrl+:'] }, + { id: 'table.sheet.commitUp', scope: 'tableEdit', keys: ['Shift+Enter'] }, + { id: 'table.sheet.commitStay', scope: 'tableEdit', keys: ['Ctrl+Enter'], refines: 'table.rowBelow' }, + { id: 'table.sheet.lineBreak', scope: 'tableEdit', keys: ['Alt+Enter'] }, + { id: 'table.sheet.toggleEnterMode', scope: 'tableEdit', keys: ['F2'] }, +]; export const tablesScopes: ScopeFixture = { program: 'tables', owner: 'tables:keys', commands: [ - { id: 'table.fillDown', scope: 'tableGrid', keys: ['Ctrl+D'] }, - { id: 'table.fillRight', scope: 'tableGrid', keys: ['Ctrl+R'] }, - { id: 'table.fillEntry', scope: 'tableGrid', keys: ['Ctrl+Enter'] }, - { id: 'table.selectColumn', scope: 'tableGrid', keys: ['Ctrl+Space'] }, + ...sheetCommands, { id: 'table.calculateNow', scope: 'tableGrid', keys: ['F9'] }, { id: 'table.showFormulas', scope: 'tableGrid', keys: ['Ctrl+`'] }, - { id: 'table.commitStay', scope: 'tableEdit', keys: ['Ctrl+Enter'] }, { id: 'table.evaluatePart', scope: 'tableEdit', keys: ['F9'] }, // Table-only chords in both modes. { id: 'table.strikethrough', scope: 'editor.table', keys: ['Ctrl+5'] }, { id: 'table.insertFunction', scope: 'editor.table', keys: ['Shift+F3'] }, ], cases: [ - { chord: 'Ctrl+D', at: 'tableGrid', expect: 'table.fillDown', why: 'Grid: fill down' }, + { chord: 'Ctrl+D', at: 'tableGrid', expect: 'table.sheet.fillDown', why: 'Grid: fill down' }, { chord: 'Ctrl+D', at: 'tableEdit', expect: 'format.fontDialog', why: 'Edit: the text meaning, Font' }, - { chord: 'Ctrl+R', at: 'tableGrid', expect: 'table.fillRight', why: 'Grid: fill right' }, + { chord: 'Ctrl+R', at: 'tableGrid', expect: 'table.sheet.fillRight', why: 'Grid: fill right' }, { chord: 'Ctrl+R', at: 'tableEdit', expect: 'format.alignRight', why: 'Edit: align right, as the cell alignment' }, - { chord: 'Ctrl+Enter', at: 'tableGrid', expect: 'table.fillEntry', why: 'Grid: fill the range with the entry' }, - { chord: 'Ctrl+Enter', at: 'tableEdit', expect: 'table.commitStay', why: 'Edit: commit and stay' }, + { chord: 'Ctrl+Enter', at: 'tableGrid', expect: 'table.sheet.fillEntry', why: 'Grid: fill the range' }, + { chord: 'Ctrl+Enter', at: 'tableEdit', expect: 'table.sheet.commitStay', why: 'Edit: commit and stay' }, + { chord: 'Ctrl+Enter', at: 'editor', expect: 'insert.pageBreak', why: 'Outside a table: a page break' }, + { chord: 'Shift+Enter', at: 'tableGrid', expect: 'table.sheet.moveUp', why: 'Grid: move up a cell' }, + { chord: 'Shift+Enter', at: 'tableEdit', expect: 'table.sheet.commitUp', why: 'Edit: commit and move up' }, + { chord: 'Ctrl+Space', at: 'tableGrid', expect: 'table.sheet.selectColumn', why: 'Grid: select the column' }, + { chord: 'Ctrl+Space', at: 'tableEdit', expect: 'format.clearCharacter', why: 'Edit: clear formatting' }, + { chord: 'Ctrl+;', at: 'tableGrid', expect: 'table.sheet.insertDate', why: 'Grid: today’s date' }, + { chord: 'Ctrl+;', at: 'tableEdit', expect: 'table.sheet.insertDate', why: 'Edit: today’s date' }, + { chord: 'Ctrl+:', at: 'tableGrid', expect: 'table.sheet.insertTime', why: 'Grid: the time' }, + { chord: 'Ctrl+:', at: 'tableEdit', expect: 'table.sheet.insertTime', why: 'Edit: the time' }, + { chord: 'F2', at: 'tableGrid', expect: 'table.sheet.edit', why: 'Grid: edit the active cell' }, + { chord: 'F2', at: 'tableEdit', expect: 'table.sheet.toggleEnterMode', why: 'Edit: switch Enter and Edit mode' }, + { chord: 'Alt+Enter', at: 'tableEdit', expect: 'table.sheet.lineBreak', why: 'Edit: a line break in the cell' }, + { chord: 'Ctrl+A', at: 'tableGrid', expect: 'table.sheet.selectRegion', why: 'Grid: select the region' }, + { chord: "Ctrl+'", at: 'tableGrid', expect: 'table.sheet.copyFormulaAbove', why: 'Grid: formula from above' }, + { chord: 'Ctrl+"', at: 'tableGrid', expect: 'table.sheet.copyValueAbove', why: 'Grid: value from above' }, + { chord: 'Delete', at: 'tableGrid', expect: 'table.sheet.clear', why: 'Grid: clear the cells' }, + { chord: 'Backspace', at: 'tableGrid', expect: 'table.sheet.clearAndEdit', why: 'Grid: clear and edit' }, + { + chord: 'Shift+Home', + at: 'tableGrid', + expect: 'table.sheet.extendRowStart', + why: 'Grid: extend to the row start', + }, + { + chord: 'Ctrl+Shift+Home', + at: 'tableGrid', + expect: 'table.sheet.extendFirstCell', + why: 'Grid: to the first cell', + }, + { chord: 'Ctrl+Shift+End', at: 'tableGrid', expect: 'table.sheet.extendLastCell', why: 'Grid: to the last cell' }, + { chord: 'Alt+PageUp', at: 'tableGrid', expect: 'table.sheet.screenLeft', why: 'Grid: one screen left' }, + { chord: 'Alt+PageDown', at: 'tableGrid', expect: 'table.sheet.screenRight', why: 'Grid: one screen right' }, { chord: 'Ctrl+5', at: 'tableGrid', expect: 'table.strikethrough', why: 'Grid: strikethrough on the range' }, { chord: 'Ctrl+5', at: 'tableEdit', expect: 'table.strikethrough', why: 'Edit: strikethrough on the text' }, { chord: 'Shift+F3', at: 'tableGrid', expect: 'table.insertFunction', why: 'Grid: insert function' }, { chord: 'Shift+F3', at: 'tableEdit', expect: 'table.insertFunction', why: 'Edit: insert function' }, - { chord: 'Ctrl+Space', at: 'tableGrid', expect: 'table.selectColumn', why: 'Grid: select the column' }, - { chord: 'Ctrl+Space', at: 'tableEdit', expect: 'format.clearCharacter', why: 'Edit: clear formatting' }, { chord: 'F9', at: 'tableGrid', expect: 'table.calculateNow', why: 'Grid: calculate now' }, { chord: 'F9', at: 'tableEdit', expect: 'table.evaluatePart', why: 'Edit: evaluate the selected part' }, { chord: 'Ctrl+`', at: 'tableGrid', expect: 'table.showFormulas', why: 'Grid: show formulas' }, diff --git a/app/src/commands/scopes.test.ts b/app/src/commands/scopes.test.ts index cba0edf62..97ac83f81 100644 --- a/app/src/commands/scopes.test.ts +++ b/app/src/commands/scopes.test.ts @@ -54,6 +54,12 @@ const KEYS: Readonly> = { Space: { key: ' ', code: 'Space' }, Tab: { key: 'Tab', code: 'Tab' }, '`': { key: '`', code: 'Backquote' }, + Delete: { key: 'Delete', code: 'Delete' }, + Backspace: { key: 'Backspace', code: 'Backspace' }, + Home: { key: 'Home', code: 'Home' }, + End: { key: 'End', code: 'End' }, + PageUp: { key: 'PageUp', code: 'PageUp' }, + PageDown: { key: 'PageDown', code: 'PageDown' }, }; /** A key press as the browser reports it for a chord such as 'Ctrl+Shift+Tab', on a US layout. */ diff --git a/app/src/features/settings/SettingsView.test.tsx b/app/src/features/settings/SettingsView.test.tsx index 91d9d52a6..d4f4495e9 100644 --- a/app/src/features/settings/SettingsView.test.tsx +++ b/app/src/features/settings/SettingsView.test.tsx @@ -28,6 +28,7 @@ describe('the Settings page', () => { 'Appearance', 'Language', 'Editing', + 'Tables', 'Recording', 'Pen and touch', 'My handwriting', diff --git a/app/src/features/tables/keys/Settings.test.tsx b/app/src/features/tables/keys/Settings.test.tsx new file mode 100644 index 000000000..ab638af15 --- /dev/null +++ b/app/src/features/tables/keys/Settings.test.tsx @@ -0,0 +1,27 @@ +// Settings, then Tables: a person chooses the keys new tables use (DESIGN.md section 11.1, PLAN.md decision 4). +import { screen, waitFor, within } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { navigate } from '../../../app/location'; +import { getSettings } from '../../../state/settings'; +import { disposeApp, expectNoAxeViolations, renderApp } from '../../../test'; + +afterEach(() => disposeApp()); + +describe('the Tables settings section', () => { + it('lists Tables in the sections and sets the keys new tables use', async () => { + await renderApp(); + navigate({ view: 'settings', section: 'tables' }); + const group = await screen.findByRole('radiogroup', { name: 'New tables use spreadsheet keys' }); + const spreadsheet = within(group).getByRole('radio', { name: /Spreadsheet keys/ }); + const document_ = within(group).getByRole('radio', { name: /Document keys/ }); + expect(spreadsheet.getAttribute('aria-checked')).toBe('true'); + expect(getSettings().tables.newTableKeys).toBe('spreadsheet'); + await userEvent.click(document_); + await waitFor(() => expect(getSettings().tables.newTableKeys).toBe('document')); + expect(document_.getAttribute('aria-checked')).toBe('true'); + await userEvent.click(spreadsheet); + await waitFor(() => expect(getSettings().tables.newTableKeys).toBe('spreadsheet')); + await expectNoAxeViolations(document.body); + }); +}); diff --git a/app/src/features/tables/keys/Settings.tsx b/app/src/features/tables/keys/Settings.tsx new file mode 100644 index 000000000..3ee606ed6 --- /dev/null +++ b/app/src/features/tables/keys/Settings.tsx @@ -0,0 +1,33 @@ +// Settings, then Tables: which keys a new table uses (ADR 0063, DESIGN.md section 11.1, PLAN.md decision 4). +// A table stores its choice when it is made, so this setting changes only tables made after it. +import type { TableKeys } from '../../../platform/bindings/TableKeys'; +import { updateSettings, useSettings } from '../../../state/settings'; +import { t } from '../../../strings/t'; +import { RadioCard, RadioGroup } from '../../../ui'; +import styles from './keys.module.css'; + +export default function TablesSettings() { + const keys = useSettings((settings) => settings.tables.newTableKeys); + return ( +
+

{t('sheetKeys.settings.title')}

+ + label={t('sheetKeys.settings.newTableKeys')} + value={keys} + onChange={(next) => void updateSettings({ tables: { newTableKeys: next } })} + > + + + +

{t('sheetKeys.settings.help')}

+
+ ); +} diff --git a/app/src/features/tables/keys/bindings.test.ts b/app/src/features/tables/keys/bindings.test.ts new file mode 100644 index 000000000..73c55172b --- /dev/null +++ b/app/src/features/tables/keys/bindings.test.ts @@ -0,0 +1,18 @@ +// The key scope fixture lists the registered sheet commands. So commands/scopes.test.ts checks the real chords +// against the text and code programs. This test keeps the fixture in step with bindings.ts. +import { describe, expect, it } from 'vitest'; +import { tablesScopes } from '../../../commands/fixtures/scopes/tables'; +import { SHEET_KEYS } from './bindings'; + +describe('the tables key scope fixture', () => { + it('lists every sheet command that has a chord, with its scope, chords, and refined command', () => { + const listed = tablesScopes.commands.filter((command) => command.id.startsWith('table.sheet.')); + const registered = SHEET_KEYS.filter((def) => def.keys.length > 0).map((def) => ({ + id: def.id, + scope: def.scope, + keys: def.keys, + ...(def.refines ? { refines: def.refines } : {}), + })); + expect(listed).toEqual(registered); + }); +}); diff --git a/app/src/features/tables/keys/keys.module.css b/app/src/features/tables/keys/keys.module.css index 9cc869f23..f360e6ec2 100644 --- a/app/src/features/tables/keys/keys.module.css +++ b/app/src/features/tables/keys/keys.module.css @@ -3,6 +3,26 @@ * mode a thin one, so the mode shows without color alone. Outlines stay visible in forced colors. */ @layer components { + /* Settings, then Tables. */ + .settings { + display: grid; + gap: var(--space-4); + max-inline-size: var(--size-reading-measure); + } + + .settings h2 { + margin: 0; + font-size: var(--type-subtitle-size); + line-height: var(--type-subtitle-line); + } + + .help { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--type-small-size); + line-height: var(--type-small-line); + } + .active { outline: calc(var(--size-focus-ring-width) * 1.5) solid var(--color-focus-ring); outline-offset: calc(var(--size-focus-ring-width) * -1.5); diff --git a/app/src/features/tables/keys/mode.test.ts b/app/src/features/tables/keys/mode.test.ts index 8f4bfba63..e72011ed7 100644 --- a/app/src/features/tables/keys/mode.test.ts +++ b/app/src/features/tables/keys/mode.test.ts @@ -5,6 +5,12 @@ import { sheetProblems } from '../sheet/model'; import { isSheetValue, keysOf, keysPatch, storedKeys } from './mode'; describe('table keys', () => { + it('reads a cell of a digit, a long run of spaces, and a letter quickly, as text', () => { + const start = performance.now(); + expect(isSheetValue(`1${' '.repeat(100_000)}a`)).toBe(false); + expect(performance.now() - start).toBeLessThan(200); + }); + it('uses the stored choice first', () => { expect(keysOf({ sheet: { table: { keys: 'document' } } }, [['=1+2']])).toBe('document'); expect(keysOf({ sheet: { table: { keys: 'spreadsheet' } } }, [['words']])).toBe('spreadsheet'); diff --git a/app/src/features/tables/keys/mode.ts b/app/src/features/tables/keys/mode.ts index 5b34bc19b..df1513a96 100644 --- a/app/src/features/tables/keys/mode.ts +++ b/app/src/features/tables/keys/mode.ts @@ -19,11 +19,13 @@ export function storedKeys(data: Record): TableKeys | null { // A plain number as people type one. It may start with a sign or a currency sign and end with a percent sign. const NUMBER = /^[-+]?[$€£¥]?\s*(?:\d[\d,.\u00a0 ]*|[.,]\d+)(?:[eE][-+]?\d+)?\s*%?$/; +// No number people type is longer. The cap also keeps the pattern's backtracking short on a long run of spaces. +const NUMBER_MAX = 64; /** Whether a cell's text is a number or a formula, which makes an older table a spreadsheet. */ export function isSheetValue(text: string): boolean { const trimmed = text.trim(); - return trimmed.startsWith('=') ? trimmed.length > 1 : NUMBER.test(trimmed); + return trimmed.startsWith('=') ? trimmed.length > 1 : trimmed.length <= NUMBER_MAX && NUMBER.test(trimmed); } /** The keys the table uses: its stored choice, else spreadsheet keys when it holds a number or a formula. */ diff --git a/app/src/features/tables/keys/plugin.test.ts b/app/src/features/tables/keys/plugin.test.ts index dca5b0b7d..5a777579d 100644 --- a/app/src/features/tables/keys/plugin.test.ts +++ b/app/src/features/tables/keys/plugin.test.ts @@ -4,7 +4,9 @@ import type { Editor } from '@tiptap/core'; import type { Node as PMNode } from '@tiptap/pm/model'; import { CellSelection } from '@tiptap/pm/tables'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { Decoration } from '@tiptap/pm/view'; +import axe from 'axe-core'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { initFlags } from '../../../app/flags'; import { linkApplies } from '../../../editor/commands/state'; import { createBlockEditor } from '../../../editor/extensions/kit'; @@ -13,9 +15,10 @@ import type { EditorHost } from '../../../editor/host'; import { tableSchema } from '../../../editor/schema/schema'; import { parseDate, todayDays } from '../engine/dates'; import { registerSheetKeyCommands } from './commands'; -import { activeCell, gridOf, modeOf, sheetKeysPlugin, textsOf } from './plugin'; +import { activeCell, gridOf, insideOneCell, modeOf, sheetKeysPlugin, textsOf } from './plugin'; import type { SheetKeysOptions } from './plugin'; import { address } from './grid'; +import { sheetKeysKey } from './state'; const host: EditorHost = { settings: () => DEFAULT_EDITING_VIEW, @@ -364,6 +367,86 @@ describe('screen readers', () => { sheet.caretIn('A1'); expect(dom.getAttribute('data-scope')).toBe('editor editor.table tableEdit'); }); +}); + +describe('the grid that screen readers read', () => { + it('makes a table with spreadsheet keys a grid, so aria-selected is allowed on its cells', async () => { + const sheet = mount( + [ + ['Item', 'Qty'], + ['a', '1'], + ['b', '2'], + ], + { header: true }, + ); + const dom = sheet.editor.view.dom; + expect(dom.querySelector('table')?.getAttribute('role')).toBe('grid'); + expect(dom.querySelector('th')?.getAttribute('role')).toBe('columnheader'); + expect(dom.querySelector('td')?.getAttribute('role')).toBe('gridcell'); + sheet.select('A1', 'B2'); + expect(dom.querySelectorAll('td[aria-selected="true"]')).toHaveLength(4); + const rules = ['aria-allowed-attr', 'aria-required-children', 'aria-required-parent', 'aria-allowed-role']; + const results = await axe.run(dom, { runOnly: { type: 'rule', values: rules } }); + expect(results.violations.map((violation) => violation.id)).toEqual([]); + sheet.editor.view.dispatch(sheet.editor.state.tr.setMeta(sheetKeysKey, { spreadsheet: false })); + expect(dom.querySelector('table')?.getAttribute('role')).toBeNull(); + expect(dom.querySelector('td')?.getAttribute('role')).toBeNull(); + expect(dom.querySelector('td')?.getAttribute('aria-rowindex')).toBe('2'); + }); + + it('marks no cell selected while focus is outside the editor, as when the table is selected as an object', () => { + const sheet = mount(FIVE); + sheet.select('B2', 'C3'); + const dom = sheet.editor.view.dom; + expect(dom.querySelectorAll('[aria-selected="true"]')).toHaveLength(4); + dom.dispatchEvent(new FocusEvent('blur')); + expect(dom.querySelectorAll('[aria-selected]')).toHaveLength(0); + dom.dispatchEvent(new FocusEvent('focus')); + expect(dom.querySelectorAll('[aria-selected="true"]')).toHaveLength(4); + }); + + it('maps the cell numbers through typing instead of rebuilding them for every cell', () => { + const rows = Array.from({ length: 30 }, () => Array.from({ length: 10 }, () => 'x')); + const sheet = mount(rows); + sheet.caretIn('B2'); + // The first change gives the rows their IDs, which moves no cell but changes the rows; later ones don't. + sheet.editor.commands.insertContent('-'); + const node = vi.spyOn(Decoration, 'node'); + sheet.editor.commands.insertContent('typed'); + // Only the active cell's outline is drawn again; the 300 cells' numbers are mapped. + expect(node.mock.calls.length).toBeLessThan(10); + node.mockRestore(); + const b2 = sheet.editor.view.dom.querySelectorAll('tr')[1].querySelectorAll('td')[1]; + expect(b2.textContent).toBe('-typedx'); + expect(b2.getAttribute('aria-colindex')).toBe('2'); + }); + + it('numbers the cells again when a row is added', () => { + const sheet = mount([ + ['1', '2'], + ['3', '4'], + ]); + sheet.caretIn('A1'); + sheet.editor.commands.addRowBefore(); + const rows = sheet.editor.view.dom.querySelectorAll('tr'); + expect(rows).toHaveLength(3); + expect(rows[2].querySelectorAll('td')[1].getAttribute('aria-rowindex')).toBe('3'); + expect(rows[0].querySelectorAll('td')[1].getAttribute('aria-colindex')).toBe('2'); + expect(rows[0].querySelectorAll('td')[1].getAttribute('role')).toBe('gridcell'); + }); + + it('tells a change inside one cell from one that moves cells', () => { + const sheet = mount([ + ['1', '2'], + ['3', '4'], + ]); + const { state } = sheet.editor; + expect(insideOneCell(state.tr.insertText('a', 4))).toBe(true); + expect(insideOneCell(state.tr.delete(4, 5))).toBe(true); + // From A1's text to B1's text crosses a cell's edge. + expect(insideOneCell(state.tr.delete(4, 10))).toBe(false); + expect(insideOneCell(state.tr.delete(1, 1 + state.doc.firstChild!.firstChild!.nodeSize))).toBe(false); + }); it('leaves Ctrl+K to the palette when cells are selected', () => { const sheet = mount(FIVE); diff --git a/app/src/features/tables/keys/plugin.ts b/app/src/features/tables/keys/plugin.ts index 012dd705b..1623287c3 100644 --- a/app/src/features/tables/keys/plugin.ts +++ b/app/src/features/tables/keys/plugin.ts @@ -40,22 +40,38 @@ export { dateText, runSheetCommand, setSheetKeys, timeText } from './actions'; // ---- Decorations and announcements -/** aria-rowindex and aria-colindex on every cell, counted from 1. */ -function indexesOf(doc: PMNode): DecorationSet { +/** + * aria-rowindex and aria-colindex on every cell, counted from 1. With spreadsheet keys each cell is a "gridcell" + * or a "columnheader" of the grid that markGrid makes the table, so Grid mode's aria-selected is allowed. + */ +function indexesOf(doc: PMNode, spreadsheet: boolean): DecorationSet { const decorations: Decoration[] = []; cellsOf(doc).forEach((row, r) => - row.forEach((cell, c) => - decorations.push( - Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, { - 'aria-rowindex': String(r + 1), - 'aria-colindex': String(c + 1), - }), - ), - ), + row.forEach((cell, c) => { + const attrs: Record = { 'aria-rowindex': String(r + 1), 'aria-colindex': String(c + 1) }; + if (spreadsheet) attrs.role = cell.node.type.name === 'tableHeader' ? 'columnheader' : 'gridcell'; + decorations.push(Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, attrs)); + }), ); return DecorationSet.create(doc, decorations); } +/** Whether each step changed only the inside of one cell, so every cell keeps its row and column. */ +export function insideOneCell(tr: Transaction): boolean { + return tr.steps.every((step, i) => { + const doc = tr.docs[i]; + let inside = true; + step.getMap().forEach((from, to) => { + if (!inside) return; + const $from = doc.resolve(from); + const $to = doc.resolve(to); + // A cell's content sits at depth 3: the document, the table, the row, then the cell. + inside = $from.depth >= 3 && $to.depth >= 3 && $from.start(3) === $to.start(3); + }); + return inside; + }); +} + /** What a screen reader hears when the active cell or the range changes. */ function speech(view: EditorView, options: SheetKeysOptions): string | null { const g = gridOf(view.state); @@ -88,8 +104,12 @@ function spoken(state: EditorState): string | null { function applyMeta(tr: Transaction, value: SheetState, state: EditorState): SheetState { const meta = tr.getMeta(sheetKeysKey) as SheetMeta | undefined; const next: SheetState = { ...value }; - if (tr.docChanged) next.indexes = indexesOf(state.doc); if (meta?.spreadsheet !== undefined) next.spreadsheet = meta.spreadsheet; + if (meta?.focused !== undefined) next.focused = meta.focused; + // Typing maps the cells' numbers through the change, so a keystroke costs the same in a big table as in a small one. + if (next.spreadsheet !== value.spreadsheet || (tr.docChanged && !insideOneCell(tr))) + next.indexes = indexesOf(state.doc, next.spreadsheet); + else if (tr.docChanged) next.indexes = value.indexes.map(tr.mapping, tr.doc); if (meta && 'active' in meta) next.active = meta.active ?? null; else if (tr.selectionSet) next.active = null; if (meta && 'entry' in meta) next.entry = meta.entry ?? null; @@ -100,7 +120,10 @@ function applyMeta(tr: Transaction, value: SheetState, state: EditorState): Shee return next; } -/** The cell numbers on every cell; in Grid mode the range's aria-selected; and the active cell's outline. */ +/** + * The cell numbers on every cell; in Grid mode the range's aria-selected while the editor has focus; and the active + * cell's outline. + */ function decorationsOf(state: EditorState): DecorationSet | null { const sheet = sheetKeysKey.getState(state); if (!sheet) return null; @@ -112,12 +135,20 @@ function decorationsOf(state: EditorState): DecorationSet | null { if (cell) extra.push(Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, attrs)); }; const g = gridOf(state); - if (g) for (const at of cellsIn(g.box)) mark(at, { 'aria-selected': 'true' }); + if (g && sheet.focused) for (const at of cellsIn(g.box)) mark(at, { 'aria-selected': 'true' }); const active = activeCell(state); if (active) mark(active, { class: mode === 'grid' ? styles.active : styles.editing }); return sheet.indexes.add(state.doc, extra); } +/** Notes when the editor gains or loses focus, so a cell isn't announced as selected while focus is elsewhere. */ +function noteFocus(view: EditorView, focused: boolean): boolean { + const sheet = sheetKeysKey.getState(view.state); + if (!view.isDestroyed && sheet && sheet.focused !== focused) + view.dispatch(view.state.tr.setMeta(sheetKeysKey, { focused } satisfies SheetMeta)); + return false; +} + /** A click on a cell's padding, not its text, selects the cell in Grid mode. */ function clickPadding(view: EditorView, event: MouseEvent): boolean { const target = event.target; @@ -139,7 +170,18 @@ function scopeOf(state: EditorState): Record { return { 'data-scope': `editor editor.table${scope}` }; } -/** Makes this table the one commands act on while it has focus, and says each move. */ +/** + * role="grid" on the table element with spreadsheet keys. The table's node view draws the element, so a node + * decoration would land on its wrapper; the view ignores attribute changes on the element. + */ +function markGrid(view: EditorView, on: boolean): void { + const table = view.dom.querySelector('table'); + if (!table || (table.getAttribute('role') === 'grid') === on) return; + if (on) table.setAttribute('role', 'grid'); + else table.removeAttribute('role'); +} + +/** Makes this table the one commands act on while it has focus, marks it a grid, and says each move. */ function sheetView(view: EditorView, options: SheetKeysOptions) { const runner: SheetRunner = { run: (command) => runSheetCommand(view, options, command), @@ -148,9 +190,12 @@ function sheetView(view: EditorView, options: SheetKeysOptions) { const focus = () => setActiveSheet(runner); view.dom.addEventListener('focusin', focus); if (view.hasFocus()) focus(); + const spreadsheet = (state: EditorState) => sheetKeysKey.getState(state)?.spreadsheet ?? false; + markGrid(view, spreadsheet(view.state)); let said = spoken(view.state); return { update(next: EditorView) { + markGrid(next, spreadsheet(next.state)); const now = spoken(next.state); if (now === said) return; said = now; @@ -158,6 +203,7 @@ function sheetView(view: EditorView, options: SheetKeysOptions) { if (text) options.announce(text); }, destroy() { + markGrid(view, false); view.dom.removeEventListener('focusin', focus); clearActiveSheet(runner); }, @@ -172,7 +218,8 @@ export function sheetKeysPlugin(options: SheetKeysOptions): Plugin { spreadsheet: options.spreadsheet, entry: null, active: null, - indexes: indexesOf(state.doc), + indexes: indexesOf(state.doc, options.spreadsheet), + focused: true, }), apply: (tr, value, _old, state) => applyMeta(tr, value, state), }, @@ -184,7 +231,11 @@ export function sheetKeysPlugin(options: SheetKeysOptions): Plugin { const ctx: Ctx = { view, state: view.state, dispatch: (tr) => view.dispatch(tr), options, texts: [] }; return typeOver(ctx, text); }, - handleDOMEvents: { mousedown: clickPadding }, + handleDOMEvents: { + mousedown: clickPadding, + focus: (view) => noteFocus(view, true), + blur: (view) => noteFocus(view, false), + }, attributes: scopeOf, decorations: decorationsOf, }, diff --git a/app/src/features/tables/keys/register.ts b/app/src/features/tables/keys/register.ts index a421ffb2a..12c85d3eb 100644 --- a/app/src/features/tables/keys/register.ts +++ b/app/src/features/tables/keys/register.ts @@ -1,6 +1,6 @@ // Registers the spreadsheet keys at start-up (ADR 0063): their commands, their section of the shortcut list, and // the table extra that gives each table block its Grid and Edit modes. The editor code loads when a table first shows. -import { shortcutListSections } from '../../../registries'; +import { settingsSections, shortcutListSections } from '../../../registries'; import { tableExtras } from '../../page'; import { registerSheetKeyCommands, SHEET_KEYS_FLAG } from './commands'; import { SheetKeysTable } from './KeysTable'; @@ -20,3 +20,13 @@ shortcutListSections.register({ flag: SHEET_KEYS_FLAG, Component: SheetKeysTable, }); + +// Settings, then Tables: the keys new tables use. tables:formula-bar adds its switch to this section. +settingsSections.register({ + id: 'tables', + title: 'sheetKeys.settings.title', + icon: 'Table', + order: 25.5, + flag: SHEET_KEYS_FLAG, + load: () => import('./Settings'), +}); diff --git a/app/src/features/tables/keys/state.ts b/app/src/features/tables/keys/state.ts index 85aae0948..d6fa8da97 100644 --- a/app/src/features/tables/keys/state.ts +++ b/app/src/features/tables/keys/state.ts @@ -24,14 +24,18 @@ export interface SheetState { entry: Entry | null; /** The active cell inside a range, once Enter or Tab moved it; null means the range's anchor. */ active: GridCell | null; - /** aria-rowindex and aria-colindex on every cell, rebuilt when the document changes. */ + /** aria-rowindex and aria-colindex on every cell, and the grid roles with spreadsheet keys. Mapped through a + * change inside one cell; rebuilt when cells move or the keys change. */ indexes: DecorationSet; + /** Whether the editor has focus. A table selected as an object, or focus elsewhere, marks no cell selected. */ + focused: boolean; } export interface SheetMeta { spreadsheet?: boolean; entry?: Entry | null; active?: GridCell | null; + focused?: boolean; } export interface SheetKeysOptions { diff --git a/app/src/strings/en/sheetKeys.ts b/app/src/strings/en/sheetKeys.ts index 00bcd6f0b..1ee79b6c3 100644 --- a/app/src/strings/en/sheetKeys.ts +++ b/app/src/strings/en/sheetKeys.ts @@ -93,4 +93,14 @@ export const sheetKeys = { editEscape: 'Edit mode: cancel the entry and go back to Grid mode', editArrows: 'Enter mode: finish the entry and move; Edit mode: move in the text', }, + // Settings, then Tables. + settings: { + title: 'Tables', + newTableKeys: 'New tables use spreadsheet keys', + spreadsheet: 'Spreadsheet keys', + spreadsheetHelp: 'Enter finishes the entry and moves down. Arrows move between cells.', + document: 'Document keys', + documentHelp: 'Enter starts a new line. Arrows move in the text.', + help: 'Each table keeps the keys it was made with. The command “Use document keys in this table” switches one table.', + }, } as const; diff --git a/docs/FEATURE-LIST.md b/docs/FEATURE-LIST.md index 1a656a283..4037d2969 100644 --- a/docs/FEATURE-LIST.md +++ b/docs/FEATURE-LIST.md @@ -2326,6 +2326,7 @@ Open it with the command "Open upcoming". - **Appearance.** Sets the theme, the page color in dark mode, the text and interface size, and reduced motion. - **Language.** Sets the app language and the spelling dialect, with American English as the default. - **Editing.** Turns Markdown shortcuts, the slash menu, and AutoCorrect on or off. +- **Tables.** Chooses whether new tables use spreadsheet keys or document keys. - **Recording.** Sets live captions, transcripts of recordings, audio formats, and the consent reminder. - **Pen and touch.** Sets the writing hand, finger drawing, the steady pen, and the pressure curve. - **My handwriting.** Calibrates the app to your handwriting and the symbols you write by hand. diff --git a/docs/adr/0063-spreadsheet-keyboard-model.md b/docs/adr/0063-spreadsheet-keyboard-model.md index 23c0e0cc8..7221458e5 100644 --- a/docs/adr/0063-spreadsheet-keyboard-model.md +++ b/docs/adr/0063-spreadsheet-keyboard-model.md @@ -1,6 +1,6 @@ # ADR 0063: Tables use a spreadsheet keyboard model with grid and edit modes -- Status: Proposed +- Status: Accepted - Date: 2026-11-01 ## Context @@ -21,7 +21,7 @@ We will give tables two modes. Grid mode (Excel's Ready mode) has an active cell ## Consequences -- Screen readers get roving focus on the active cell and an announcement per move (DESIGN.md section 17). +- Screen readers hear an announcement per move in place of roving focus on the active cell (DESIGN.md section 17). DOM focus stays on the editor, because a focused cell would break the editor's selection. Roving focus can come later through `aria-activedescendant` on the grid. - The shortcuts page and help gain a table section for both modes. - Changing a chord that collides with an app chord needs a scoped keymap context in `state/keymap.ts`. @@ -34,5 +34,5 @@ The keys live in `app/src/features/tables/keys/` (tables:keys, B10), behind the - The navigation keys that `commands/reserved.ts` keeps free (arrows, Enter, Tab, Home, Page Up, Page Down, and Esc) and Shift+Space are fixed. The shortcut list shows them in a Table keys section; the changeable chords are in its Table group. - Esc on a single cell goes on to the editor, which selects the table as an object, so the keyboard can always leave the table. - `data.sheet.table.keys` is `spreadsheet` or `document` (spec R-SHT-10). Insert table stores the setting "New tables use spreadsheet keys". A table that stores nothing keeps document keys when it holds only text, and uses spreadsheet keys when a cell holds a number or a formula (PLAN.md decision 4). The table menu's "Use document keys in this table" switches it. -- Screen readers: every cell has `aria-rowindex` and `aria-colindex`, and in Grid mode the range has `aria-selected`. DOM focus stays on the editor, because a cell inside an editable region cannot take focus without breaking the editor's selection, and the editor's root has no role that allows `aria-activedescendant`. So instead of roving `tabindex`, each move says the address, header, shown value, and formula in words through the polite live region. The active cell has a thick outline in Grid mode and a thin one in Edit mode. +- Screen readers: every cell has `aria-rowindex` and `aria-colindex`. With spreadsheet keys the table has `role="grid"` and each cell `role="gridcell"` or `role="columnheader"`, so in Grid mode the range can have `aria-selected`. The range drops `aria-selected` while focus is outside the editor, as when the table is selected as an object. DOM focus stays on the editor, because a cell inside an editable region cannot take focus without breaking the editor's selection, and the editor's root has no role that allows `aria-activedescendant`. The grid role on the table is the first step toward it. So instead of roving `tabindex`, each move says the address, header, shown value, and formula in words through the polite live region. The active cell has a thick outline in Grid mode and a thin one in Edit mode. - Ctrl+5 is not bound: Ctrl+1 to Ctrl+9 stay free for tags. Bold, italic, underline, and the strikethrough command already format the text of selected cells. Keys that other lanes own (Ctrl+1, F4, Alt+=, the number format, border, structure, filter, note, function, and paste chords) register in the same scopes. diff --git a/docs/adr/README.md b/docs/adr/README.md index 24b6994f7..0b5644591 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -93,7 +93,7 @@ Accepted records aren't rewritten when plans change. Instead, write a new ADR fo | [0060](0060-own-function-library-and-oracle-fixtures.md) | An in-repository function library tested against reference values | Proposed | 2026-11-01 | | [0061](0061-cached-formula-results.md) | Tables cache formula results in the file | Proposed | 2026-11-01 | | [0062](0062-names-and-cross-table-references.md) | Names, structured references, and references to other tables and pages | Proposed | 2026-11-01 | -| [0063](0063-spreadsheet-keyboard-model.md) | Tables use a spreadsheet keyboard model with grid and edit modes | Proposed | 2026-11-01 | +| [0063](0063-spreadsheet-keyboard-model.md) | Tables use a spreadsheet keyboard model with grid and edit modes | Accepted | 2026-11-01 | | [0064](0064-merged-cells.md) | Merged cells are ranges by corner IDs over a whole grid | Proposed | 2026-11-01 | | [0065](0065-incremental-recalculation.md) | Incremental recalculation, iterative calculation, and the worker | Proposed | 2026-11-01 | | [0066](0066-table-merge-on-sync.md) | Three-way merge of table blocks when two devices edit one page | Proposed | 2026-11-01 | diff --git a/docs/tools/featureCommands.ts b/docs/tools/featureCommands.ts index 6cb9edfb3..53a20190f 100644 --- a/docs/tools/featureCommands.ts +++ b/docs/tools/featureCommands.ts @@ -8,6 +8,7 @@ export const SETTINGS_TEXT: Readonly> = { appearance: 'Sets the theme, the page color in dark mode, the text and interface size, and reduced motion.', language: 'Sets the app language and the spelling dialect, with American English as the default.', editing: 'Turns Markdown shortcuts, the slash menu, and AutoCorrect on or off.', + tables: 'Chooses whether new tables use spreadsheet keys or document keys.', recording: 'Sets live captions, transcripts of recordings, audio formats, and the consent reminder.', penAndTouch: 'Sets the writing hand, finger drawing, the steady pen, and the pressure curve.', myHandwriting: 'Calibrates the app to your handwriting and the symbols you write by hand.', diff --git a/docs/tools/registry.json b/docs/tools/registry.json index 6c2510aff..412b64736 100644 --- a/docs/tools/registry.json +++ b/docs/tools/registry.json @@ -7176,6 +7176,10 @@ "id": "editing", "title": "Editing" }, + { + "id": "tables", + "title": "Tables" + }, { "id": "recording", "title": "Recording" From 2e0ad8d679c3e9bd005c77bfb9e3f48d830ee35d Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 05:00:41 -0800 Subject: [PATCH 08/85] Fix the second round of tables keys verification gaps - Cells are placed by the table's map, so a merged cell no longer shifts the column numbers and addresses of the cells after it. A table with a merged cell uses document keys until the merge goes. Tab in the last cell adds a row as wide as the table. - An older table that switches to spreadsheet keys shows a one-time tip that names the Document keys command. - In Edit mode an arrow at the table's edge goes on to the block beside the table. The help says how Esc leaves. - Esc puts the text back after a click into a cell and typing. - No cell is marked selected before the editor has focus. - The UI spec waits up to 20 seconds for the keys to load. Co-Authored-By: Claude Opus 5.5 --- app/src/features/tables/keys/actions.ts | 6 +- app/src/features/tables/keys/attach.ts | 18 +++- app/src/features/tables/keys/keys.ts | 19 +++- app/src/features/tables/keys/moves.ts | 17 ++-- app/src/features/tables/keys/plugin.test.ts | 99 ++++++++++++++++++++- app/src/features/tables/keys/plugin.ts | 45 +++++++--- app/src/features/tables/keys/state.ts | 52 +++++++---- app/src/features/tables/keys/tip.test.ts | 48 ++++++++++ app/src/features/tables/keys/tip.ts | 37 ++++++++ app/src/strings/en/sheetKeys.ts | 14 ++- tests/ui/behavior/sheet-keys.spec.ts | 5 +- 11 files changed, 311 insertions(+), 49 deletions(-) create mode 100644 app/src/features/tables/keys/tip.test.ts create mode 100644 app/src/features/tables/keys/tip.ts diff --git a/app/src/features/tables/keys/actions.ts b/app/src/features/tables/keys/actions.ts index d48cfe2bd..3a015d785 100644 --- a/app/src/features/tables/keys/actions.ts +++ b/app/src/features/tables/keys/actions.ts @@ -49,8 +49,10 @@ export function cancel(ctx: Ctx): boolean { if (!at) return false; const entry = sheetKeysKey.getState(ctx.state)?.entry; const tr = ctx.state.tr; - const restore = entry && entry.before && entry.row === at.row && entry.column === at.column; - if (restore) setCellContent(tr, at, entry.before!); + const before = entry && entry.row === at.row && entry.column === at.column ? entry.before : null; + // Nothing to put back when the text is as it was, such as after a click into the cell and no typing. + const restore = before !== null && !before.eq(contentOf(ctx.state.doc, at)); + if (restore) setCellContent(tr, at, before); const cell = placedAt(tr.doc, at); if (cell) tr.setSelection(CellSelection.create(tr.doc, cell.pos)); ctx.dispatch(tr.setMeta(sheetKeysKey, { entry: null, active: null } satisfies SheetMeta)); diff --git a/app/src/features/tables/keys/attach.ts b/app/src/features/tables/keys/attach.ts index 772b82fe5..597a09148 100644 --- a/app/src/features/tables/keys/attach.ts +++ b/app/src/features/tables/keys/attach.ts @@ -2,21 +2,31 @@ // mounts. The table's choice of keys comes from its data, and the Document keys command stores a new one. import type { TableExtraHandle, TableExtraHost } from '../../page'; import { appLocale } from '../smart/locale'; +import { DOCUMENT_KEYS_COMMAND } from './bindings'; import { keysOf, keysPatch, storedKeys } from './mode'; -import { setSheetKeys, sheetKeysKey, sheetKeysPlugin, textsOf } from './plugin'; +import { runSheetCommand, setSheetKeys, sheetKeysKey, sheetKeysPlugin, textsOf } from './plugin'; +import type { SheetKeysOptions } from './plugin'; +import { keysTip } from './tip'; export function attachSheetKeys(host: TableExtraHost): TableExtraHandle { const { editor } = host; - const keys = keysOf(host.data(), textsOf(editor.state.doc)); - const plugin = sheetKeysPlugin({ + const texts = textsOf(editor.state.doc); + const keys = keysOf(host.data(), texts); + const options: SheetKeysOptions = { spreadsheet: keys === 'spreadsheet', announce: (text) => host.announce(text), setKeys: (next) => void host.patch(keysPatch(next)), dateOrder: () => appLocale().dateOrder, locale: () => appLocale().tag, - }); + }; + const plugin = sheetKeysPlugin(options); // First, so Grid mode's keys come before the table's text keys and the editor's own. editor.registerPlugin(plugin, (added, plugins) => [added, ...plugins]); + keysTip(host.data(), texts, () => { + // The command switches the keys, so it runs only while the table still has spreadsheet keys. + if (!editor.isDestroyed && sheetKeysKey.getState(editor.state)?.spreadsheet) + runSheetCommand(editor.view, options, DOCUMENT_KEYS_COMMAND); + }); return { update(block) { const stored = storedKeys(block.data); diff --git a/app/src/features/tables/keys/keys.ts b/app/src/features/tables/keys/keys.ts index 09e6044bb..a52f54eab 100644 --- a/app/src/features/tables/keys/keys.ts +++ b/app/src/features/tables/keys/keys.ts @@ -97,6 +97,19 @@ function atLineEdge(view: EditorView, down: boolean): boolean { } } +/** + * Whether an arrow leaves the table from this cell: up from the first row, down from the last, left from the first + * cell, or right from the last. The table block's edge keys then move to the block beside the table. + */ +function leavesTable(ctx: Ctx, rows: number, columns: number): boolean { + const at = activeCell(ctx.state); + const last = ctx.texts.length - 1; + if (!at) return false; + if (rows !== 0) return at.row === (rows < 0 ? 0 : last); + const width = ctx.texts[last]?.length ?? 0; + return columns < 0 ? at.row === 0 && at.column === 0 : at.row === last && at.column === width - 1; +} + /** The fixed keys of Edit mode. */ function editKey(ctx: Ctx, event: KeyboardEvent): boolean { if (!plain(event)) return false; @@ -104,9 +117,9 @@ function editKey(ctx: Ctx, event: KeyboardEvent): boolean { const arrow = ARROWS[event.key]; if (arrow && !event.shiftKey) { if (entry?.mode === 'enter') return commitBy(ctx, arrow[0], arrow[1]); - // Edit mode keeps the caret in the cell, as a spreadsheet's does. - if (arrow[1] !== 0) return atTextEdge(ctx.state, arrow[1] > 0); - return atLineEdge(ctx.view, arrow[0] > 0); + // Edit mode keeps the caret in the cell, as a spreadsheet's does, except where the arrow leaves the table. + const edge = arrow[1] !== 0 ? atTextEdge(ctx.state, arrow[1] > 0) : atLineEdge(ctx.view, arrow[0] > 0); + return edge && !leavesTable(ctx, arrow[0], arrow[1]); } switch (event.key) { case 'Enter': diff --git a/app/src/features/tables/keys/moves.ts b/app/src/features/tables/keys/moves.ts index 7105e712b..e60daa48d 100644 --- a/app/src/features/tables/keys/moves.ts +++ b/app/src/features/tables/keys/moves.ts @@ -76,16 +76,21 @@ export function nextInRow(ctx: Ctx, from: GridCell, back: boolean): boolean { export function addRow(ctx: Ctx): boolean { const { state } = ctx; const table = state.doc.firstChild; - const last = table?.lastChild; + const grid = cellsOf(state.doc); + const last = grid[grid.length - 1]; if (!table || !last) return false; const { nodes } = state.schema; - const cells: PMNode[] = []; - last.forEach((cell) => { - const attrs = { colwidth: cell.attrs.colwidth as unknown, colId: cell.attrs.colId as unknown }; - cells.push(nodes.tableCell.create(attrs, nodes.paragraph.create())); + // One cell for each column of the table's map, so a merged cell above doesn't make the row short. + const cells: PMNode[] = last.map(({ node }) => { + const single = Number(node.attrs.colspan ?? 1) === 1; + const attrs = { + colwidth: single ? (node.attrs.colwidth as unknown) : null, + colId: single ? (node.attrs.colId as unknown) : null, + }; + return nodes.tableCell.create(attrs, nodes.paragraph.create()); }); const tr = state.tr.insert(1 + table.content.size, nodes.tableRow.create(null, cells)); - const first = placedAt(tr.doc, { row: table.childCount, column: 0 }); + const first = placedAt(tr.doc, { row: grid.length, column: 0 }); if (first) tr.setSelection(CellSelection.create(tr.doc, first.pos)); ctx.dispatch(tr.setMeta(sheetKeysKey, { active: null, entry: null } satisfies SheetMeta).scrollIntoView()); return true; diff --git a/app/src/features/tables/keys/plugin.test.ts b/app/src/features/tables/keys/plugin.test.ts index 5a777579d..a97e67e26 100644 --- a/app/src/features/tables/keys/plugin.test.ts +++ b/app/src/features/tables/keys/plugin.test.ts @@ -3,6 +3,7 @@ // the table's own keys, and the registered sheet commands all take part. import type { Editor } from '@tiptap/core'; import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin } from '@tiptap/pm/state'; import { CellSelection } from '@tiptap/pm/tables'; import { Decoration } from '@tiptap/pm/view'; import axe from 'axe-core'; @@ -81,15 +82,24 @@ function cellOf(name: string, header: boolean) { return { row: header ? number : number - 1, column }; } -function mount(rows: string[][], options: Partial & { header?: boolean } = {}): Harness { +interface MountOptions extends Partial { + header?: boolean; + /** The document instead of one made from the rows. */ + doc?: PMNode; + /** Whether the editor gets focus, as when a person works in the table. Default: true. */ + focus?: boolean; +} + +function mount(rows: string[][], options: MountOptions = {}): Harness { const header = options.header ?? false; const root = document.body.appendChild(document.createElement('div')); - const editor = createBlockEditor(root, tableDoc(rows, header), { kind: 'table', block: 'b1', host }); + const editor = createBlockEditor(root, options.doc ?? tableDoc(rows, header), { kind: 'table', block: 'b1', host }); editors.push(editor); const said: string[] = []; const plugin = sheetKeysPlugin({ spreadsheet: true, announce: (text) => said.push(text), ...options }); editor.registerPlugin(plugin, (added, plugins) => [added, ...plugins]); const { view } = editor; + if (options.focus !== false) view.dom.dispatchEvent(new FocusEvent('focus')); const cellPos = (name: string) => { const at = cellOf(name, header); let pos = 1; @@ -454,3 +464,88 @@ describe('the grid that screen readers read', () => { expect(linkApplies(sheet.editor.state)).toBe(false); }); }); + +describe('merged cells', () => { + // A1 spans two columns: A1 (A1:B1), C1 / A2, B2, C2. Pasted HTML can make one. + function mergedDoc(): PMNode { + const cell = (text: string, attrs: Record | null = null) => + nodes.tableCell.create(attrs, nodes.paragraph.create(null, tableSchema.text(text))); + return nodes.doc.create( + null, + nodes.table.create(null, [ + nodes.tableRow.create(null, [cell('wide', { colspan: 2 }), cell('c1')]), + nodes.tableRow.create(null, [cell('a2'), cell('b2'), cell('c2')]), + ]), + ); + } + + it('numbers the cells by the table’s columns, so the cell after a merged one is column 3', () => { + const sheet = mount([], { doc: mergedDoc() }); + const first = sheet.editor.view.dom.querySelectorAll('tr')[0].querySelectorAll('td'); + expect(first[1].textContent).toBe('c1'); + expect(first[1].getAttribute('aria-colindex')).toBe('3'); + expect(sheet.texts()[0]).toEqual(['wide', 'wide', 'c1']); + }); + + it('uses document keys while a cell is merged, so keys never land on the wrong cell', () => { + const sheet = mount([], { doc: mergedDoc() }); + const { view } = sheet.editor; + sheet.caretIn('A2'); + expect(modeOf(view.state)).toBeNull(); + expect(view.dom.getAttribute('data-scope')).toBe('editor editor.table'); + expect(view.dom.querySelector('table')?.getAttribute('role')).toBeNull(); + expect(view.dom.querySelector('td')?.getAttribute('role')).toBeNull(); + }); +}); + +describe('leaving and canceling', () => { + it('lets an arrow at the table’s edge in Edit mode go on, so the keyboard can leave the table', () => { + const sheet = mount([ + ['1', '2'], + ['3', '4'], + ]); + const seen: string[] = []; + // Stands in for the table block's edge keys: the next plugin after the sheet keys. + sheet.editor.registerPlugin( + new Plugin({ props: { handleKeyDown: (_view, event) => (seen.push(event.key), true) } }), + (added, plugins) => [plugins[0], added, ...plugins.slice(1)], + ); + sheet.caretIn('B2'); + sheet.editor.commands.setTextSelection(sheet.editor.state.selection.head + 1); + sheet.press('ArrowRight'); + sheet.press('ArrowDown'); + expect(seen).toEqual(['ArrowRight', 'ArrowDown']); + // Inside the table the caret stays in its cell. + sheet.caretIn('A2'); + sheet.editor.commands.setTextSelection(sheet.editor.state.selection.head + 1); + sheet.press('ArrowRight'); + expect(seen).toHaveLength(2); + }); + + it('puts the text back with Esc after a click into the cell and typing', () => { + const sheet = mount(FIVE); + sheet.caretIn('B2'); + sheet.editor.view.dispatch(sheet.editor.state.tr.insertText('9')); + expect(sheet.texts()[1][1]).toBe('95'); + sheet.press('Escape'); + expect(sheet.texts()[1][1]).toBe('5'); + expect(modeOf(sheet.editor.state)).toBe('grid'); + expect(sheet.said).toContain('Entry canceled.'); + }); + + it('says nothing was canceled when Esc follows a click with no typing', () => { + const sheet = mount(FIVE); + sheet.caretIn('B2'); + sheet.press('Escape'); + expect(sheet.texts()[1][1]).toBe('5'); + expect(sheet.said).not.toContain('Entry canceled.'); + }); +}); + +describe('focus', () => { + it('marks no cell selected before the editor ever had focus', () => { + const sheet = mount(FIVE, { focus: false }); + sheet.select('B2', 'C3'); + expect(sheet.editor.view.dom.querySelectorAll('[aria-selected]')).toHaveLength(0); + }); +}); diff --git a/app/src/features/tables/keys/plugin.ts b/app/src/features/tables/keys/plugin.ts index 1623287c3..e2f60b928 100644 --- a/app/src/features/tables/keys/plugin.ts +++ b/app/src/features/tables/keys/plugin.ts @@ -7,7 +7,7 @@ // (bindings.ts), so rebinding works; the navigation keys are fixed. A table set to document keys keeps today's // keys: the plugin then only adds the cells' row and column numbers for screen readers. import type { Node as PMNode } from '@tiptap/pm/model'; -import { Plugin } from '@tiptap/pm/state'; +import { Plugin, TextSelection } from '@tiptap/pm/state'; import type { EditorState, Transaction } from '@tiptap/pm/state'; import { CellSelection } from '@tiptap/pm/tables'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; @@ -24,8 +24,11 @@ import { activeCell, cellOfPos, cellsOf, + contentOf, gridOf, hasHeader, + hasMerged, + keysOn, modeOf, placedAt, selectCells, @@ -41,13 +44,17 @@ export { dateText, runSheetCommand, setSheetKeys, timeText } from './actions'; // ---- Decorations and announcements /** - * aria-rowindex and aria-colindex on every cell, counted from 1. With spreadsheet keys each cell is a "gridcell" - * or a "columnheader" of the grid that markGrid makes the table, so Grid mode's aria-selected is allowed. + * aria-rowindex and aria-colindex on every cell, counted from 1. A merged cell takes its top left slot's. With + * spreadsheet keys each cell is a "gridcell" or a "columnheader" of the grid that markGrid makes the table, so Grid + * mode's aria-selected is allowed. */ function indexesOf(doc: PMNode, spreadsheet: boolean): DecorationSet { const decorations: Decoration[] = []; + const done = new Set(); cellsOf(doc).forEach((row, r) => row.forEach((cell, c) => { + if (done.has(cell.pos)) return; + done.add(cell.pos); const attrs: Record = { 'aria-rowindex': String(r + 1), 'aria-colindex': String(c + 1) }; if (spreadsheet) attrs.role = cell.node.type.name === 'tableHeader' ? 'columnheader' : 'gridcell'; decorations.push(Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, attrs)); @@ -108,7 +115,7 @@ function applyMeta(tr: Transaction, value: SheetState, state: EditorState): Shee if (meta?.focused !== undefined) next.focused = meta.focused; // Typing maps the cells' numbers through the change, so a keystroke costs the same in a big table as in a small one. if (next.spreadsheet !== value.spreadsheet || (tr.docChanged && !insideOneCell(tr))) - next.indexes = indexesOf(state.doc, next.spreadsheet); + next.indexes = indexesOf(state.doc, next.spreadsheet && !hasMerged(state.doc)); else if (tr.docChanged) next.indexes = value.indexes.map(tr.mapping, tr.doc); if (meta && 'active' in meta) next.active = meta.active ?? null; else if (tr.selectionSet) next.active = null; @@ -117,6 +124,17 @@ function applyMeta(tr: Transaction, value: SheetState, state: EditorState): Shee const at = state.selection instanceof CellSelection ? null : cellOfPos(state.doc, state.selection.head); if (!at || at.row !== next.entry.row || at.column !== next.entry.column) next.entry = null; } + // A click or Tab that puts the caret in a cell starts an Edit mode entry, so Esc puts the cell's text back. + if ( + !next.entry && + tr.selectionSet && + !tr.docChanged && + next.spreadsheet && + state.selection instanceof TextSelection + ) { + const at = cellOfPos(state.doc, state.selection.head); + if (at && !hasMerged(state.doc)) next.entry = { ...at, before: contentOf(state.doc, at), mode: 'edit' }; + } return next; } @@ -152,7 +170,7 @@ function noteFocus(view: EditorView, focused: boolean): boolean { /** A click on a cell's padding, not its text, selects the cell in Grid mode. */ function clickPadding(view: EditorView, event: MouseEvent): boolean { const target = event.target; - if (!sheetKeysKey.getState(view.state)?.spreadsheet || event.button !== 0 || event.shiftKey) return false; + if (!keysOn(view.state) || event.button !== 0 || event.shiftKey) return false; if (!(target instanceof HTMLTableCellElement) || !view.dom.contains(target)) return false; const at = cellOfPos(view.state.doc, view.posAtDOM(target, 0)); const tr = at && selectCells(view.state, at); @@ -185,17 +203,20 @@ function markGrid(view: EditorView, on: boolean): void { function sheetView(view: EditorView, options: SheetKeysOptions) { const runner: SheetRunner = { run: (command) => runSheetCommand(view, options, command), - documentKeys: () => !sheetKeysKey.getState(view.state)?.spreadsheet, + documentKeys: () => !keysOn(view.state), }; const focus = () => setActiveSheet(runner); view.dom.addEventListener('focusin', focus); - if (view.hasFocus()) focus(); - const spreadsheet = (state: EditorState) => sheetKeysKey.getState(state)?.spreadsheet ?? false; - markGrid(view, spreadsheet(view.state)); + if (view.hasFocus()) { + focus(); + // The state starts unfocused. Note focus after this update, since a view can't dispatch while it updates. + queueMicrotask(() => noteFocus(view, view.hasFocus())); + } + markGrid(view, keysOn(view.state)); let said = spoken(view.state); return { update(next: EditorView) { - markGrid(next, spreadsheet(next.state)); + markGrid(next, keysOn(next.state)); const now = spoken(next.state); if (now === said) return; said = now; @@ -218,8 +239,8 @@ export function sheetKeysPlugin(options: SheetKeysOptions): Plugin { spreadsheet: options.spreadsheet, entry: null, active: null, - indexes: indexesOf(state.doc, options.spreadsheet), - focused: true, + indexes: indexesOf(state.doc, options.spreadsheet && !hasMerged(state.doc)), + focused: false, }), apply: (tr, value, _old, state) => applyMeta(tr, value, state), }, diff --git a/app/src/features/tables/keys/state.ts b/app/src/features/tables/keys/state.ts index d6fa8da97..0377433ae 100644 --- a/app/src/features/tables/keys/state.ts +++ b/app/src/features/tables/keys/state.ts @@ -4,7 +4,7 @@ import { Fragment } from '@tiptap/pm/model'; import type { Node as PMNode } from '@tiptap/pm/model'; import { PluginKey, TextSelection } from '@tiptap/pm/state'; import type { EditorState, Transaction } from '@tiptap/pm/state'; -import { CellSelection } from '@tiptap/pm/tables'; +import { CellSelection, TableMap } from '@tiptap/pm/tables'; import type { DecorationSet, EditorView } from '@tiptap/pm/view'; import type { DateOrder } from '../engine/locale'; import { boxOf, inBox } from './grid'; @@ -64,35 +64,58 @@ export interface Placed { const layouts = new WeakMap(); -/** Every cell's position (before the cell) and node, row by row. A table block's document is one table. */ +/** + * Every grid slot's cell, row by row: its position (before the cell) and node. The slots come from the table's map, + * so a merged cell fills each slot it covers. A table block's document is one table. + */ export function cellsOf(doc: PMNode): Placed[][] { const known = layouts.get(doc); if (known) return known; const table = doc.firstChild; const rows: Placed[][] = []; layouts.set(doc, rows); - if (!table) return rows; - let rowPos = 1; - table.forEach((row) => { + if (!table || table.childCount === 0) return rows; + const map = TableMap.get(table); + for (let row = 0; row < map.height; row++) { const cells: Placed[] = []; - let pos = rowPos + 1; - row.forEach((cell) => { - cells.push({ pos, node: cell }); - pos += cell.nodeSize; - }); + for (let column = 0; column < map.width; column++) { + const offset = map.map[row * map.width + column]; + const node = table.nodeAt(offset); + if (node) cells.push({ pos: offset + 1, node }); + } rows.push(cells); - rowPos += row.nodeSize; - }); + } return rows; } +const merges = new WeakMap(); + +/** Whether a cell spans more than one row or column. Spreadsheet keys step one slot at a time, so they wait. */ +export function hasMerged(doc: PMNode): boolean { + const known = merges.get(doc); + if (known !== undefined) return known; + let merged = false; + doc.firstChild?.forEach((row) => + row.forEach((cell) => { + if (Number(cell.attrs.colspan ?? 1) > 1 || Number(cell.attrs.rowspan ?? 1) > 1) merged = true; + }), + ); + merges.set(doc, merged); + return merged; +} + +/** Whether the table's keys are spreadsheet keys now: chosen, and no merged cells. Otherwise document keys work. */ +export function keysOn(state: EditorState): boolean { + return (sheetKeysKey.getState(state)?.spreadsheet ?? false) && !hasMerged(state.doc); +} + export function textsOf(doc: PMNode): string[][] { return cellsOf(doc).map((row) => row.map((cell) => cell.node.textContent)); } export const hasHeader = (doc: PMNode) => doc.firstChild?.firstChild?.firstChild?.type.name === 'tableHeader'; -/** The grid cell of a position inside or before a cell. */ +/** The grid cell of a position inside or before a cell. A merged cell's is its top left slot. */ export function cellOfPos(doc: PMNode, pos: number): GridCell | null { const cells = cellsOf(doc); for (let row = 0; row < cells.length; row++) @@ -116,8 +139,7 @@ export function textRange(cell: Placed): { from: number; to: number } { export type SheetMode = 'grid' | 'edit'; export function modeOf(state: EditorState): SheetMode | null { - const sheet = sheetKeysKey.getState(state); - if (!sheet?.spreadsheet) return null; + if (!keysOn(state)) return null; if (state.selection instanceof CellSelection) return 'grid'; return cellOfPos(state.doc, state.selection.head) ? 'edit' : null; } diff --git a/app/src/features/tables/keys/tip.test.ts b/app/src/features/tables/keys/tip.test.ts new file mode 100644 index 000000000..28df23288 --- /dev/null +++ b/app/src/features/tables/keys/tip.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +// The one-time tip when an older table with numbers switches to spreadsheet keys (PLAN.md decision 4). +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const shown = vi.hoisted(() => [] as { message: string; action?: { label: string; run(): void } }[]); +vi.mock('../../../ui/toast', () => ({ + showToast: (spec: (typeof shown)[number]) => { + shown.push(spec); + return { dismiss: () => {} }; + }, +})); + +import { KEYS_TIP_KEY, keysTip } from './tip'; + +const NUMBERS = [ + ['Item', 'Price'], + ['Tea', '3.50'], +]; +const WORDS = [ + ['Item', 'Note'], + ['Tea', 'green'], +]; + +beforeEach(() => { + localStorage.clear(); + shown.length = 0; +}); +afterEach(() => localStorage.clear()); + +describe('the spreadsheet keys tip', () => { + it('shows once, names the Document keys command, and offers to switch back', () => { + const use = vi.fn(); + expect(keysTip({}, NUMBERS, use)).toBe(true); + expect(shown).toHaveLength(1); + expect(shown[0].message).toContain('Use document keys in this table'); + shown[0].action?.run(); + expect(use).toHaveBeenCalledOnce(); + expect(localStorage.getItem(KEYS_TIP_KEY)).toBe('1'); + expect(keysTip({}, NUMBERS, use)).toBe(false); + expect(shown).toHaveLength(1); + }); + + it('stays quiet for a table that stores its keys or holds only text', () => { + expect(keysTip({ sheet: { table: { keys: 'spreadsheet' } } }, NUMBERS, () => {})).toBe(false); + expect(keysTip({}, WORDS, () => {})).toBe(false); + expect(shown).toHaveLength(0); + }); +}); diff --git a/app/src/features/tables/keys/tip.ts b/app/src/features/tables/keys/tip.ts new file mode 100644 index 000000000..e0df323f3 --- /dev/null +++ b/app/src/features/tables/keys/tip.ts @@ -0,0 +1,37 @@ +// The one-time tip of PLAN.md decision 4. An older table stores no choice of keys. When it holds a number or a +// formula, it switches to spreadsheet keys. The first time that happens, a toast says so. It names the command that +// gives the table document keys again. The device remembers that it showed the tip. +import { t } from '../../../strings/t'; +import { showToast } from '../../../ui/toast'; +import type { Texts } from './grid'; +import { keysOf, storedKeys } from './mode'; + +export const KEYS_TIP_KEY = 'opennote.sheetKeys.tipShown'; + +function shownBefore(): boolean { + try { + return localStorage.getItem(KEYS_TIP_KEY) === '1'; + } catch { + return false; + } +} + +function remember(): void { + try { + localStorage.setItem(KEYS_TIP_KEY, '1'); + } catch { + // The tip may show again in the next session. + } +} + +/** Shows the tip when this table switched to spreadsheet keys on its own and the tip never showed. */ +export function keysTip(data: Record, texts: Texts, useDocumentKeys: () => void): boolean { + if (storedKeys(data) !== null || keysOf(data, texts) !== 'spreadsheet' || shownBefore()) return false; + remember(); + showToast({ + id: 'sheetKeysTip', + message: t('sheetKeys.tip.switched'), + action: { label: t('sheetKeys.tip.useDocument'), run: useDocumentKeys }, + }); + return true; +} diff --git a/app/src/strings/en/sheetKeys.ts b/app/src/strings/en/sheetKeys.ts index 1ee79b6c3..1233bc9a5 100644 --- a/app/src/strings/en/sheetKeys.ts +++ b/app/src/strings/en/sheetKeys.ts @@ -85,13 +85,21 @@ export const sheetKeys = { gridTab: 'Grid mode and Edit mode: move right or left, finishing an entry', gridHome: 'Grid mode: go to the start of the row', gridPage: 'Grid mode: move one screen up or down', - gridEscape: 'Grid mode: select only the active cell, then select the table as an object', + gridEscape: + 'Grid mode: select only the active cell, then select the table as an object. Arrows then leave the table.', gridSpace: 'Grid mode: select the row', gridType: 'Grid mode: replace the cell with what you type', typeKey: 'Any letter, digit, or sign', editEnter: 'Edit mode: finish the entry and move down', - editEscape: 'Edit mode: cancel the entry and go back to Grid mode', - editArrows: 'Enter mode: finish the entry and move; Edit mode: move in the text', + editEscape: 'Edit mode: cancel the entry, put the cell’s text back, and go back to Grid mode', + editArrows: 'Enter mode: finish the entry and move. Edit mode: move in the text, or leave the table at its edge.', + }, + // Shown once, when an older table with numbers or formulas first uses spreadsheet keys. + tip: { + switched: + 'This table holds numbers, so it uses spreadsheet keys. Enter moves down, and typing replaces the cell. ' + + 'To get the old keys back, use the command “Use document keys in this table.”', + useDocument: 'Use document keys', }, // Settings, then Tables. settings: { diff --git a/tests/ui/behavior/sheet-keys.spec.ts b/tests/ui/behavior/sheet-keys.spec.ts index 9fc4802c0..3d5bea9bf 100644 --- a/tests/ui/behavior/sheet-keys.spec.ts +++ b/tests/ui/behavior/sheet-keys.spec.ts @@ -22,8 +22,9 @@ const said = (page: Page, text: string) => page.locator('[aria-live="polite"]'). test.beforeEach(async ({ page }) => { await openSmart(page); - // The keys load with the table's editor; every cell has its row number once they are in. - await expect(cell(page, 0, 0)).toHaveAttribute('aria-rowindex', '1'); + // The keys load with the table's editor; every cell has its row number once they are in. Start-up defers that + // import, so a loaded machine can take longer than the default 5 seconds. + await expect(cell(page, 0, 0)).toHaveAttribute('aria-rowindex', '1', { timeout: 20_000 }); await fillSales(page); }); From 63ebe8e25ef862da3ba1ba9237863daafe676626 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 05:22:17 -0800 Subject: [PATCH 09/85] Find the summary statistics cell by its grid role A new table uses spreadsheet keys, so its cells are grid cells. Co-Authored-By: Claude Opus 5.5 --- tests/ui/behavior/table-analyze.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ui/behavior/table-analyze.spec.ts b/tests/ui/behavior/table-analyze.spec.ts index 5c283ec86..259c540b3 100644 --- a/tests/ui/behavior/table-analyze.spec.ts +++ b/tests/ui/behavior/table-analyze.spec.ts @@ -25,7 +25,8 @@ test('adds summary statistics of the selection as a table', async ({ page }) => await fillSales(page); await selectSales(page); await analyze(page, 'Statistics', 'Summary statistics'); - await expect(page.getByRole('cell', { name: 'Median' })).toBeVisible(); + // A new table uses spreadsheet keys, so its cells are grid cells (ADR 0063). + await expect(page.getByRole('gridcell', { name: 'Median' })).toBeVisible(); }); test('adds a box plot chart', async ({ page }) => { From 92747d54eb43dacdcc2ec97b8ab8e2672193b66d Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 13:58:51 -0800 Subject: [PATCH 10/85] Update the audit known list for table roles and raise the start-up budget The table cells now carry grid roles, so the listed holder overlaps use the new selectors. The keys code adds about 4 KB to start-up. Co-Authored-By: Claude Sonnet 5.5 --- app/bundle-budget.json | 2 +- tests/ui/audit/known.json | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/bundle-budget.json b/app/bundle-budget.json index 0474c84c9..fce71b1bb 100644 --- a/app/bundle-budget.json +++ b/app/bundle-budget.json @@ -1,6 +1,6 @@ { "startup": { - "jsKb": 542, + "jsKb": 549, "cssKb": 27.5 }, "after": [ diff --git a/tests/ui/audit/known.json b/tests/ui/audit/known.json index d1619677e..e0cabc630 100644 --- a/tests/ui/audit/known.json +++ b/tests/ui/audit/known.json @@ -3,10 +3,11 @@ "block | box-overlap | span.mathInline > div.holderInline > button.toggle x p > strong": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", "block | target-small | div.trace > label.slider > input": "The slider is the browser's own range input, 16 px tall with a 573 px wide hit line; its thumb is the target and it sits within the 24 px minimum spacing.", "block | target-small | li.task > span.taskBox > input": "A native checkbox drawn at the size of the text; making it 24 px would push the item's text and the ruled-paper alignment (a larger hit area needs a custom control).", - "block | text-overlap | div.holderInline > div[role=group].actions > button x th > p": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", - "block | text-overlap | span.mathInline > div.holderInline > button.toggle x th > p": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", - "block | text-overlap | td > p > strong x div.holderInline > button.toggle": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", - "block | text-overlap | tr > th > p x div.holderInline > button.toggle": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", + "block | text-overlap | div.holderInline > div[role=group].actions > button x th[role=columnheader] > p": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", + "block | text-overlap | span.mathInline > div.holderInline > button.toggle x th[role=columnheader] > p": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", + "block | text-overlap | td[role=gridcell] > p > strong x div.holderInline > button.toggle": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", + "block | text-overlap | tr > th[role=columnheader] > p x div.holderInline > button.toggle": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", + "block | text-overlap | tr > th[role=columnheader].editing > p x div.holderInline > button.toggle": "The Edit/Simplify/Solve holder opens as a popover laid over the text after the equation, by design; it is closed again by Escape or a click away.", "command | box-overlap | div.panel > div[role=group].filters > button[role=switch].labeled x div[role=group].filters > button[role=switch].labeled": "A command in the walk now opens a second filter panel whose switches sit in one row; their focus outlines touch at the narrowest width and the labels are not covered. The palette entry above has the same cause.", "command | target-small | div.panel > div[role=group].filters > button[role=switch].labeled": "A compact filter switch with its label inside the hit area; touch density raises it to 44 px. The palette entry for the same switch has the same cause.", "command | dead-space | details > label.field > textarea.mono (nested)": "The box is a text area, a table region, or a typeset equation that grows with its content, so it is larger than its content until the person types more; the room is its own.", From f1762703ddc51da475c04fddcdbdcca7530c7789 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 14:44:00 -0800 Subject: [PATCH 11/85] Let functions ask about cells and read dotted names CELL, ISFORMULA, ISREF, SHEET and SHEETS need the reference itself, not its values. A function with refArgs gets references as references and a probe of the page; the engine answers from the table as it is now, so an edit never leaves a stale answer. The sheet lexer keeps a dot between letters inside a name (ERROR.TYPE, STDEV.S). Co-Authored-By: Claude Sonnet 5.5 --- app/src/core/expr/lexer.ts | 3 +- .../features/tables/engine/formula/calls.ts | 24 +++++++++++- .../features/tables/engine/formula/context.ts | 11 ++++++ .../tables/engine/formula/fn/index.ts | 2 +- .../tables/engine/formula/fn/types.ts | 38 ++++++++++++++++++- .../features/tables/engine/graph/engine.ts | 12 +++++- 6 files changed, 84 insertions(+), 6 deletions(-) diff --git a/app/src/core/expr/lexer.ts b/app/src/core/expr/lexer.ts index 0ad3b5095..0fd24c82e 100644 --- a/app/src/core/expr/lexer.ts +++ b/app/src/core/expr/lexer.ts @@ -78,7 +78,8 @@ const SUPERSCRIPT_DIGITS = '⁰¹²³⁴⁵⁶⁷⁸⁹'; const SUPERSCRIPT_MINUS = '⁻'; const NAME_PATTERNS = { ascii: /[A-Za-z_][A-Za-z0-9_]*/y, - cell: /[A-Za-z_$][A-Za-z0-9_$]*/y, + // A dot between letters stays inside the name, as in ERROR.TYPE and STDEV.S. + cell: /[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*/y, letters: /\p{L}+\d*/uy, } as const; const WHITESPACE = /\s/; diff --git a/app/src/features/tables/engine/formula/calls.ts b/app/src/features/tables/engine/formula/calls.ts index 5bb290cf1..f823576d6 100644 --- a/app/src/features/tables/engine/formula/calls.ts +++ b/app/src/features/tables/engine/formula/calls.ts @@ -20,7 +20,7 @@ import { } from '../values'; import { asArray, mapN } from '../arrays'; import type { Box } from '../graph'; -import { isGroup, paramsOf, type FnSpec2, type Param } from './fn'; +import { isGroup, paramsOf, type FnSpec2, type Param, type Probe, type ProbeCell } from './fn'; import type { ArgSummary, ErrorOrigin } from './explain'; import { cellsOf, colKey, constant, isBad, readBox, type Ctx, type Fn, type Host, type RefPart } from './context'; @@ -160,8 +160,27 @@ function runLifted(spec: FnSpec2, values: Value[], scalar: boolean[], env: Host[ ); } +/** What a function with `refArgs` may ask about the page. */ +function probeOf(h: Host, ctx: Ctx): Probe { + const sheetOf = (ref: RefValue): number => (ref.table === undefined ? h.ownSheet : h.sheetNamed(ref.table)); + return { + sheet: h.ownSheet, + sheets: h.book.map((info) => info.name), + self: { kind: 'ref', r1: ctx.row, c1: ctx.col, r2: ctx.row, c2: ctx.col }, + calc: ctx.rt.calcMode?.(h.ownSheet) ?? 'auto', + sheetOf, + cell(ref): ProbeCell | undefined { + const s = sheetOf(ref); + const facts = ctx.rt.cellFacts?.(s, ref.c1, ref.r1); + if (facts === undefined) return undefined; + return { value: toScalar(ctx.sheets[s]?.[ref.c1]?.[ref.r1] ?? null), ...facts }; + }, + }; +} + function plain(h: Host, spec: FnSpec2, node: Call): Fn { const env = h.scope.env; + const refArgs = spec.refArgs === true; const params = paramsFor(spec, node.args.length); const returnsRef = spec.returnsRef === true; if (returnsRef) h.dynamic = true; @@ -169,6 +188,7 @@ function plain(h: Host, spec: FnSpec2, node: Call): Fn { const kind = params[i]?.kind ?? 'value'; if (kind !== 'range' && kind !== 'array') return h.value(arg); const ref = h.refOrNull(arg); + if (ref !== null && refArgs) return (ctx) => toRefValue(h, ref.fn(ctx)); if (ref !== null && returnsRef && kind === 'range') return (ctx) => toRefValue(h, ref.fn(ctx)); const v = ref !== null ? h.deref(ref) : h.value(arg); return (ctx) => { @@ -181,7 +201,7 @@ function plain(h: Host, spec: FnSpec2, node: Call): Fn { const origin = originOf(spec, node); return (ctx) => { const values = parts.map((p) => p(ctx)); - let v = runLifted(spec, values, scalar, env); + let v = runLifted(spec, values, scalar, refArgs ? { ...env, probe: probeOf(h, ctx) } : env); if (isRef(v)) v = readReturned(h, ctx, v); if (ctx.origin === null && isError(v) && !values.some((a) => a === v)) { ctx.origin = { ...origin, code: v.error, args: values.map(summarize) }; diff --git a/app/src/features/tables/engine/formula/context.ts b/app/src/features/tables/engine/formula/context.ts index febc5df06..83c143dc8 100644 --- a/app/src/features/tables/engine/formula/context.ts +++ b/app/src/features/tables/engine/formula/context.ts @@ -39,6 +39,17 @@ export interface Scope { export interface Runtime { /** The block a formula cell spills into, the cell itself when it does not spill, or its error. */ spillBox(s: number, col: number, row: number): Box | FormulaError; + /** What CELL and ISFORMULA ask about a cell, read from the table as it is now. Unset: nothing is known. */ + cellFacts?(s: number, col: number, row: number): CellFacts | undefined; + /** The table's calculation mode. */ + calcMode?(s: number): 'auto' | 'manual'; +} + +/** A cell's kind of content and its column's type, as functions about cells read them. */ +export interface CellFacts { + readonly formula: boolean; + readonly type: string; + readonly decimals?: number; } /** What a closure reads while it runs. One context serves a whole recalculation. */ diff --git a/app/src/features/tables/engine/formula/fn/index.ts b/app/src/features/tables/engine/formula/fn/index.ts index 26ab396e3..d12e0ee3c 100644 --- a/app/src/features/tables/engine/formula/fn/index.ts +++ b/app/src/features/tables/engine/formula/fn/index.ts @@ -17,7 +17,7 @@ import { functions as statsCompat } from './statsCompat'; import { functions as text } from './text'; import type { FnSpec2 } from './types'; -export type { Env, Family, FnMeta, FnSpec2, Param, ParamGroup, ParamKind } from './types'; +export type { Env, Family, FnMeta, FnSpec2, Param, ParamGroup, ParamKind, Probe, ProbeCell } from './types'; export { arity, isGroup, isSpecial, paramsOf } from './types'; const FAMILIES: readonly (readonly FnSpec2[])[] = [ diff --git a/app/src/features/tables/engine/formula/fn/types.ts b/app/src/features/tables/engine/formula/fn/types.ts index b16b405f1..ccfebd7b9 100644 --- a/app/src/features/tables/engine/formula/fn/types.ts +++ b/app/src/features/tables/engine/formula/fn/types.ts @@ -3,12 +3,43 @@ // never touch the same file. The descriptions people read (one line per function, one per argument) live in // app/src/strings/en/fn.ts under the function's name, written fresh in our own words. -import { isError, isNumber, type FormulaError, type Value } from '../../values'; +import { isError, isNumber, type FormulaError, type RefValue, type Scalar, type Value } from '../../values'; /** What the formula engine gives every function. */ export interface Env { /** Today as a serial, read when a formula is calculated. */ today: () => number; + /** The page's title, which CELL("filename") returns. Unset gives an empty text, as an unsaved workbook does. */ + title?: string; + /** What the engine knows about the formula's cell and page. Set only for a function with `refArgs`. */ + probe?: Probe; +} + +/** One cell as functions about cells see it (CELL, ISFORMULA). */ +export interface ProbeCell { + /** The cell's value. Empty cells are null. */ + readonly value: Scalar; + /** True when the cell holds a formula, alone, or through its calculated column. */ + readonly formula: boolean; + /** The column's type, such as "number" or "date". */ + readonly type: string; + /** The column's decimal places, when set. */ + readonly decimals?: number; +} + +/** The look at the page that the engine lends to a function with `refArgs`. */ +export interface Probe { + /** The formula's own table, zero-based. */ + readonly sheet: number; + /** The name of every table on the page, in order. */ + readonly sheets: readonly string[]; + /** The cell the formula is in. */ + readonly self: RefValue; + readonly calc: 'auto' | 'manual'; + /** The table a reference names, zero-based, or -1 when it names none. */ + sheetOf(ref: RefValue): number; + /** The cell at the top left of a reference, or undefined when it lies outside the table. */ + cell(ref: RefValue): ProbeCell | undefined; } /** @@ -78,6 +109,11 @@ export interface FnSpec2 { readonly volatile?: boolean; /** Returns a reference rather than a value, as OFFSET and INDEX do. */ readonly returnsRef?: boolean; + /** + * Array parameters get a reference as a reference (a RefValue) instead of its values, and `env.probe` is set. For + * functions that ask about a cell rather than its content, as ISREF and CELL do. + */ + readonly refArgs?: boolean; /** An error argument reaches the function instead of becoming its result. */ readonly handlesErrors?: boolean; /** Computes the result. A function with lazy parameters is evaluated by the compiler itself and never runs this. */ diff --git a/app/src/features/tables/engine/graph/engine.ts b/app/src/features/tables/engine/graph/engine.ts index 7469f5515..61f560682 100644 --- a/app/src/features/tables/engine/graph/engine.ts +++ b/app/src/features/tables/engine/graph/engine.ts @@ -71,7 +71,17 @@ export class Engine { } makeCtx(): Ctx { - const ctx = newCtx([], { spillBox: (s, c, r) => spillBox(this, s, c, r) }); + const ctx = newCtx([], { + spillBox: (s, c, r) => spillBox(this, s, c, r), + cellFacts: (s, c, r) => { + const column = this.tables[s]?.columns[c]; + const cell = this.tables[s]?.rows[r]?.cells[c]; + if (column === undefined || cell === undefined) return undefined; + const decimals = column.decimals === undefined ? {} : { decimals: column.decimals }; + return { formula: column.formula !== undefined || cell.formula !== undefined, type: column.type, ...decimals }; + }, + calcMode: (s) => this.tables[s]?.calc ?? 'auto', + }); ctx.sheets = this.sheets; return ctx; } From 9ac1201b6680c89a0090a0401cb705b187e161f8 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 14:44:01 -0800 Subject: [PATCH 12/85] Add the logical and information function families IFS, SWITCH, XOR, IFNA, TRUE, FALSE, LET and LAMBDA metadata, AND and OR over arrays; CELL, INFO (offline-safe types only), ERROR.TYPE, the IS functions, N, NA, SHEET, SHEETS, TYPE, ISEMAIL, ISURL, ISDATE. Cases come from a fixture of documented and hand-worked values. The registry and differential tests no longer pin the old function count. Co-Authored-By: Claude Sonnet 5.5 --- .../tables/engine/formula/fn/index.test.ts | 14 +- .../features/tables/engine/formula/fn/info.ts | 320 ++++++++++++- .../engine/formula/fn/logicInfo.test.ts | 175 +++++++ .../tables/engine/formula/fn/logical.ts | 159 ++++++- .../tables/engine/gridcraftDiff.test.ts | 11 +- .../tables/fixtures/fn/logicInfo.json | 441 ++++++++++++++++++ app/src/strings/en/fnLogicInfo.ts | 114 ++++- 7 files changed, 1211 insertions(+), 23 deletions(-) create mode 100644 app/src/features/tables/engine/formula/fn/logicInfo.test.ts create mode 100644 app/src/features/tables/fixtures/fn/logicInfo.json diff --git a/app/src/features/tables/engine/formula/fn/index.test.ts b/app/src/features/tables/engine/formula/fn/index.test.ts index c16e351ed..b480d68ac 100644 --- a/app/src/features/tables/engine/formula/fn/index.test.ts +++ b/app/src/features/tables/engine/formula/fn/index.test.ts @@ -38,18 +38,22 @@ const BY_FAMILY: Partial> = { datetime: ['DATE', 'DAY', 'DAYS', 'MONTH', 'TODAY', 'YEAR'], }; +/** The functions the compiler evaluates itself, because an argument is lazy. */ +const LAZY = new Set(['IF', 'IFERROR', 'LET', 'LAMBDA', 'ISOMITTED']); + describe('the function registry', () => { it('holds the 48 names of before, each in its family', () => { - expect(REGISTRY.size).toBe(48); + expect(REGISTRY.size).toBeGreaterThanOrEqual(48); for (const [family, names] of Object.entries(BY_FAMILY)) { const found = [...REGISTRY.values()].filter((spec) => spec.family === family).map((spec) => spec.name); - expect(found.sort(), family).toEqual(names); + expect(found, family).toEqual(expect.arrayContaining(names)); } }); it('keeps the argument counts and the kinds the compiler knew', () => { - expect(Object.keys(FUNCTIONS)).toHaveLength(46); - expect(SPECIAL_FORMS).toEqual({ IF: { min: 2, max: 3 }, IFERROR: { min: 2, max: 2 } }); + expect(Object.keys(FUNCTIONS).length).toBeGreaterThanOrEqual(46); + expect(SPECIAL_FORMS).toMatchObject({ IF: { min: 2, max: 3 }, IFERROR: { min: 2, max: 2 } }); + expect(Object.keys(SPECIAL_FORMS).sort()).toEqual([...LAZY].sort()); expect(FUNCTIONS.SUM).toMatchObject({ kind: 'aggregate', min: 1, max: Infinity }); expect(FUNCTIONS.COUNTA).toMatchObject({ kind: 'aggregate', raw: true }); expect(FUNCTIONS.ROUND).toMatchObject({ kind: 'scalar', min: 1, max: 2 }); @@ -63,7 +67,7 @@ describe('the function registry', () => { for (const spec of REGISTRY.values()) { expect(spec.meta.signature.startsWith(`${spec.name}(`), spec.name).toBe(true); expect(spec.meta.example, spec.name).toMatch(/^=/); - expect(isSpecial(spec)).toBe(spec.name === 'IF' || spec.name === 'IFERROR'); + expect(isSpecial(spec)).toBe(LAZY.has(spec.name)); } }); diff --git a/app/src/features/tables/engine/formula/fn/info.ts b/app/src/features/tables/engine/formula/fn/info.ts index 0dbac0d00..9123ebe58 100644 --- a/app/src/features/tables/engine/formula/fn/info.ts +++ b/app/src/features/tables/engine/formula/fn/info.ts @@ -1,7 +1,172 @@ -// Information functions. Owner: tables:fn-logic-info. ISBLANK is the function tables had before the library; its -// behavior is unchanged. +// Information functions. Owner: tables:fn-logic-info. Functions that ask about a cell (CELL, ISFORMULA, ISREF, +// SHEET, SHEETS) set `refArgs`: they get the reference itself and a probe of the page (fn/types.ts). INFO gives only +// answers that are safe offline: it never reveals a folder, the system's memory, or its version. ISOMITTED is an +// engine form (formula/special.ts); its spec here gives it a name and an example. -import type { FnSpec2 } from './types'; +import { lettersFromColumn } from '../../../../../core/expr'; +import { parseDate } from '../../dates'; +import { + err, + isArray, + isError, + isRef, + type ErrorCode, + type FormulaError, + type RefValue, + type Scalar, + type Value, +} from '../../values'; +import type { Env, FnSpec2, Probe } from './types'; + +type Test = (v: Scalar) => boolean; + +function is(name: string, test: Test, example: string): FnSpec2 { + return { + name, + family: 'info', + params: [{ name: 'value', kind: 'value' }], + handlesErrors: true, + run: ([v]) => test(v as Scalar), + meta: { signature: `${name}(value)`, example }, + }; +} + +/** The codes ERROR.TYPE gives. Errors with no code of their own read as #N/A's 7, the documented fallback. */ +const ERROR_TYPES: Record = { + NULL: 1, + DIV0: 2, + VALUE: 3, + REF: 4, + NAME: 5, + NUM: 6, + NA: 7, + BUSY: 8, + SPILL: 9, + CALC: 14, + FIELD: 7, + CYCLE: 4, +}; + +function parity(name: string, odd: boolean, example: string): FnSpec2 { + return { + name, + family: 'info', + params: [{ name: 'number', kind: 'value' }], + run: ([v]) => { + if (typeof v === 'boolean') return err('VALUE'); + const n = typeof v === 'string' ? Number(v.trim() === '' ? NaN : v) : v; + if (typeof n !== 'number' || !Number.isFinite(n)) return err('VALUE'); + return (Math.trunc(n) % 2 !== 0) === odd; + }, + meta: { signature: `${name}(number)`, example }, + }; +} + +const isText: Test = (v) => typeof v === 'string'; + +/** An email address has one at sign, a dotted domain, and no spaces. */ +const EMAIL = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/; +const URL_PATTERN = + /^(?:(?:https?|ftps?|sftp):\/\/)?(?:[^\s/?#@]+@)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:[/?#]\S*)?$/i; + +function address(ref: RefValue): string { + const cell = `$${lettersFromColumn(ref.c1)}$${ref.r1 + 1}`; + if (ref.table === undefined) return cell; + const name = ref.table; + return `${/^\w+$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`}!${cell}`; +} + +/** The format code CELL("format") gives for a column type: G general, F fixed, C currency, P percent, D date. */ +function formatCode(type: string, decimals: number | undefined): string { + const digits = Math.min(decimals ?? 2, 9); + if (type === 'currency') return `C${digits}`; + if (type === 'percent') return `P${digits}`; + if (type === 'date') return 'D1'; + return type === 'number' && decimals !== undefined ? `F${digits}` : 'G'; +} + +/** The default column width in characters, rounded as CELL("width") rounds it. */ +const COLUMN_WIDTH = 8; + +const first = (v: Value): Value => (isArray(v) ? (v.data[0] ?? null) : v); + +function cellInfo(args: Value[], env: Env): Value { + const probe = env.probe; + const kind = first(args[0]); + if (isError(kind)) return kind; + const given = args[1]; + if (given !== undefined && isError(given)) return given; + if (probe === undefined) return err('VALUE'); + const ref = given === undefined ? probe.self : isRef(given) ? given : undefined; + if (ref === undefined) return err('VALUE'); + const cell = probe.cell(ref); + if (cell === undefined) return err('REF'); + switch (typeof kind === 'string' ? kind.trim().toLowerCase() : '') { + case 'address': + return address(ref); + case 'col': + return ref.c1 + 1; + case 'row': + return ref.r1 + 1; + case 'contents': + return cell.value; + case 'type': + return cell.value === null ? 'b' : typeof cell.value === 'string' ? 'l' : 'v'; + case 'format': + return formatCode(cell.type, cell.decimals); + case 'width': + return COLUMN_WIDTH; + case 'filename': + return env.title ?? ''; + case 'prefix': + return typeof cell.value === 'string' ? "'" : ''; + case 'color': + case 'parentheses': + case 'protect': + return 0; + default: + return err('VALUE'); + } +} + +function infoText(args: Value[], env: Env): Value { + const key = first(args[0]); + if (isError(key)) return key; + const probe = env.probe; + switch (typeof key === 'string' ? key.trim().toLowerCase() : '') { + case 'numfile': + return probe?.sheets.length ?? 1; + case 'origin': + return '$A:$A$1'; + case 'recalc': + return probe?.calc === 'manual' ? 'Manual' : 'Automatic'; + case 'system': + return 'pcdos'; + case 'directory': + case 'memavail': + case 'memused': + case 'totmem': + case 'osversion': + case 'release': + return err('NA'); + default: + return err('VALUE'); + } +} + +/** The table that the argument of SHEET names: a reference, or the text of a table's name. */ +function sheetIndex(arg: Value, probe: Probe): number | FormulaError { + if (isError(arg)) return arg; + if (isRef(arg)) { + const s = probe.sheetOf(arg); + return s < 0 ? err('REF') : s; + } + const text = first(arg); + if (typeof text !== 'string') return err('NA'); + const want = text.trim().toLowerCase(); + const found = probe.sheets.findIndex((name) => name.toLowerCase() === want); + return found < 0 ? err('NA') : found; +} export const functions: readonly FnSpec2[] = [ { @@ -12,4 +177,153 @@ export const functions: readonly FnSpec2[] = [ run: ([v]) => v === null, meta: { signature: 'ISBLANK(value)', example: '=ISBLANK("") gives FALSE' }, }, + is('ISNUMBER', (v) => typeof v === 'number', '=ISNUMBER("1") gives FALSE'), + is('ISTEXT', isText, '=ISTEXT("a") gives TRUE'), + is('ISNONTEXT', (v) => !isText(v), '=ISNONTEXT(1) gives TRUE'), + is('ISLOGICAL', (v) => typeof v === 'boolean', '=ISLOGICAL(1>2) gives TRUE'), + is('ISERROR', (v) => isError(v), '=ISERROR(1/0) gives TRUE'), + is('ISERR', (v) => isError(v) && v.error !== 'NA', '=ISERR(NA()) gives FALSE'), + is('ISNA', (v) => isError(v) && v.error === 'NA', '=ISNA(NA()) gives TRUE'), + parity('ISEVEN', false, '=ISEVEN(-2) gives TRUE'), + parity('ISODD', true, '=ISODD(2.9) gives FALSE'), + { + name: 'N', + family: 'info', + params: [{ name: 'value', kind: 'value' }], + run: ([v]) => (typeof v === 'number' ? v : v === true ? 1 : 0), + meta: { signature: 'N(value)', example: '=N(TRUE) gives 1' }, + }, + { + name: 'NA', + family: 'info', + params: [], + run: () => err('NA'), + meta: { signature: 'NA()', example: '=NA() gives #N/A' }, + }, + { + name: 'TYPE', + family: 'info', + params: [{ name: 'value', kind: 'array' }], + handlesErrors: true, + run: ([v]) => { + if (isError(v)) return 16; + const items = isArray(v) ? v.data : [v as Scalar]; + if (items.length !== 1) return 64; + const one = items[0]; + if (isError(one)) return 16; + return typeof one === 'string' ? 2 : typeof one === 'boolean' ? 4 : 1; + }, + meta: { signature: 'TYPE(value)', example: '=TYPE("a") gives 2' }, + }, + { + name: 'ERROR.TYPE', + family: 'info', + params: [{ name: 'error', kind: 'value' }], + handlesErrors: true, + run: ([v]) => (isError(v) ? ERROR_TYPES[v.error] : err('NA')), + meta: { signature: 'ERROR.TYPE(error)', example: '=ERROR.TYPE(1/0) gives 2' }, + }, + { + name: 'ISREF', + family: 'info', + params: [{ name: 'value', kind: 'array' }], + refArgs: true, + handlesErrors: true, + run: ([v]) => isRef(v), + meta: { signature: 'ISREF(value)', example: '=ISREF(B2) gives TRUE' }, + }, + { + name: 'ISFORMULA', + family: 'info', + params: [{ name: 'reference', kind: 'array' }], + refArgs: true, + run: ([v], env) => { + if (!isRef(v)) return err('VALUE'); + const cell = env.probe?.cell(v); + return cell === undefined ? err('REF') : cell.formula; + }, + meta: { signature: 'ISFORMULA(reference)', example: '=ISFORMULA(B2) gives TRUE when B2 has a formula' }, + }, + { + name: 'ISOMITTED', + family: 'info', + params: [{ name: 'argument', kind: 'lazy' }], + run: () => err('VALUE'), + meta: { signature: 'ISOMITTED(argument)', example: '=LAMBDA(x, [y], ISOMITTED(y))(1) gives TRUE' }, + }, + { + name: 'SHEET', + family: 'info', + params: [{ name: 'value', kind: 'array', optional: true }], + refArgs: true, + handlesErrors: true, + run: (args, env) => { + const probe = env.probe; + if (probe === undefined) return err('VALUE'); + if (args.length === 0) return probe.sheet + 1; + const s = sheetIndex(args[0], probe); + return typeof s === 'number' ? s + 1 : s; + }, + meta: { signature: 'SHEET([value])', example: '=SHEET() gives 1 on the first table' }, + }, + { + name: 'SHEETS', + family: 'info', + params: [{ name: 'reference', kind: 'array', optional: true }], + refArgs: true, + run: (args, env) => { + const probe = env.probe; + if (probe === undefined) return err('VALUE'); + if (args.length === 0) return probe.sheets.length; + const ref = args[0]; + if (!isRef(ref)) return err('VALUE'); + return probe.sheetOf(ref) < 0 ? err('REF') : 1; + }, + meta: { signature: 'SHEETS([reference])', example: '=SHEETS() gives the number of tables on the page' }, + }, + { + name: 'CELL', + family: 'info', + params: [ + { name: 'info_type', kind: 'value' }, + { name: 'reference', kind: 'array', optional: true }, + ], + refArgs: true, + volatile: true, + run: cellInfo, + meta: { signature: 'CELL(info_type, [reference])', example: '=CELL("address", B2) gives $B$2' }, + }, + { + name: 'INFO', + family: 'info', + params: [{ name: 'type_text', kind: 'value' }], + refArgs: true, + volatile: true, + run: infoText, + meta: { signature: 'INFO(type_text)', example: '=INFO("recalc") gives Automatic' }, + }, + { + name: 'ISEMAIL', + family: 'info', + params: [{ name: 'value', kind: 'value' }], + handlesErrors: true, + run: ([v]) => typeof v === 'string' && EMAIL.test(v.trim()), + meta: { signature: 'ISEMAIL(value)', example: '=ISEMAIL("a@b.org") gives TRUE' }, + }, + { + name: 'ISURL', + family: 'info', + params: [{ name: 'value', kind: 'value' }], + handlesErrors: true, + run: ([v]) => typeof v === 'string' && URL_PATTERN.test(v.trim()), + meta: { signature: 'ISURL(value)', example: '=ISURL("https://example.org") gives TRUE' }, + }, + { + name: 'ISDATE', + family: 'info', + params: [{ name: 'value', kind: 'value' }], + handlesErrors: true, + run: ([v]) => typeof v === 'string' && parseDate(v, 'mdy') !== null, + meta: { signature: 'ISDATE(value)', example: '=ISDATE("2026-01-31") gives TRUE' }, + }, ]; diff --git a/app/src/features/tables/engine/formula/fn/logicInfo.test.ts b/app/src/features/tables/engine/formula/fn/logicInfo.test.ts new file mode 100644 index 000000000..6262d564f --- /dev/null +++ b/app/src/features/tables/engine/formula/fn/logicInfo.test.ts @@ -0,0 +1,175 @@ +// Logical and information functions. Expected values come from fixtures/fn/logicInfo.json: documented examples and +// hand-worked values, never the code's own output. The lane tests at the top repeat the contract's examples. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createTable, setCellInput } from '../../edit'; +import { EN_US } from '../../locale'; +import type { Table } from '../../model'; +import { DEFAULT_ENV, recalculate, type EngineEnv } from '../../recalc'; +import { ERROR_TOKENS, err, type ErrorCode, type Value } from '../../values'; +import { recalculateBook, setBookCellInput } from '../../workbook'; +import { fnLogicInfo } from '../../../../../strings/en/fnLogicInfo'; +import { REGISTRY, paramsOf } from '.'; + +interface Fixture { + data: string[][]; + cases: { formula: string; expect: Value | { error: string }; source: string }[]; +} +const FIXTURE = JSON.parse(readFileSync(join(__dirname, '../../../fixtures/fn/logicInfo.json'), 'utf8')) as Fixture; + +const LETTERS = ['A', 'B', 'C', 'D']; + +function sheet(data: string[][]): Table { + const columns = LETTERS.map((name) => ({ name, type: 'number' as const })); + return createTable(columns, data, EN_US, DEFAULT_ENV, 1); +} + +/** Types a formula in D3 of the fixture's sheet and reads the result. */ +function run(formula: string): Value { + const t = setCellInput(sheet(FIXTURE.data), { row: 2, col: 3, raw: formula }, EN_US, DEFAULT_ENV); + return t.rows[2].cells[3].value; +} + +function expected(value: unknown): Value { + if (typeof value !== 'object' || value === null || !('error' in value)) return value as Value; + const token = (value as { error: string }).error; + const code = (Object.keys(ERROR_TOKENS) as ErrorCode[]).find((c) => c !== 'CYCLE' && ERROR_TOKENS[c] === token); + if (code === undefined) throw new Error(`Unknown error token ${token}`); + return err(code); +} + +describe('the contract examples (lane test)', () => { + it('reads logical functions', () => { + expect(run('=IFS(FALSE,1,TRUE,2)')).toBe(2); + expect(run('=IFS(FALSE,1)')).toEqual(err('NA')); + expect(run('=SWITCH(2,1,"a",2,"b","z")')).toBe('b'); + expect(run('=XOR(TRUE,TRUE,TRUE)')).toBe(true); + expect(run('=IFNA(NA(),"x")')).toBe('x'); + }); + + it('reads information functions', () => { + expect(run('=ISNUMBER("1")')).toBe(false); + expect(run('=N(TRUE)')).toBe(1); + expect(run('=TYPE("a")')).toBe(2); + expect(run('=TYPE({1,2})')).toBe(64); + expect(run('=ERROR.TYPE(1/0)')).toBe(2); + expect(run('=ERROR.TYPE(NA())')).toBe(7); + expect(run('=ISEVEN(-2)')).toBe(true); + expect(run('=ISODD(2.9)')).toBe(false); + }); +}); + +describe('the fixture cases', () => { + it.each(FIXTURE.cases.map((c) => [c.formula, c] as const))('%s', (_formula, c) => { + expect(c.source).toMatch(/\S/); + expect(run(c.formula)).toEqual(expected(c.expect)); + }); +}); + +describe('functions that ask about cells', () => { + it('sees formulas typed in single cells', () => { + let t = sheet(FIXTURE.data); + t = setCellInput(t, { row: 0, col: 3, raw: '=A1+1' }, EN_US, DEFAULT_ENV); + t = setCellInput(t, { row: 2, col: 3, raw: '=ISFORMULA(D1)' }, EN_US, DEFAULT_ENV); + expect(t.rows[2].cells[3].value).toBe(true); + t = setCellInput(t, { row: 0, col: 3, raw: '5' }, EN_US, DEFAULT_ENV); + expect(t.rows[2].cells[3].value).toBe(false); + }); + + it('follows the cell it reads when that cell changes', () => { + let t = sheet(FIXTURE.data); + t = setCellInput(t, { row: 2, col: 3, raw: '=CELL("contents",A1)' }, EN_US, DEFAULT_ENV); + expect(t.rows[2].cells[3].value).toBe(1); + t = setCellInput(t, { row: 0, col: 0, raw: '9' }, EN_US, DEFAULT_ENV); + expect(t.rows[2].cells[3].value).toBe(9); + }); + + it('reads the format of a column and the page title', () => { + const columns = [ + { name: 'A', type: 'currency' as const, decimals: 2 }, + { name: 'B', type: 'percent' as const, decimals: 0 }, + { name: 'C', type: 'date' as const }, + { name: 'D', type: 'number' as const, decimals: 3 }, + ]; + const env: EngineEnv = { ...DEFAULT_ENV, title: 'Budget' }; + const blank = ['', '', '', '']; + let t = createTable(columns, [blank, blank, blank], EN_US, env, 1); + const ask = (f: string): Value => { + t = setCellInput(t, { row: 2, col: 3, raw: f }, EN_US, env); + return t.rows[2].cells[3].value; + }; + expect(ask('=CELL("format",A1)')).toBe('C2'); + expect(ask('=CELL("format",B1)')).toBe('P0'); + expect(ask('=CELL("format",C1)')).toBe('D1'); + expect(ask('=CELL("format",D1)')).toBe('F3'); + expect(ask('=CELL("filename")')).toBe('Budget'); + }); + + it('numbers the tables of a page and names another table in an address', () => { + const one = { ...sheet(FIXTURE.data), name: 'Sales' }; + const two = { ...sheet(FIXTURE.data), name: 'Costs' }; + let book = recalculateBook([one, two], DEFAULT_ENV); + const ask = (raw: string, col: number): Value => { + book = setBookCellInput(book, 1, { row: 2, col, raw }, EN_US, DEFAULT_ENV); + return book[1].rows[2].cells[col].value; + }; + expect(ask('=SHEET()', 3)).toBe(2); + expect(ask('=SHEETS()', 2)).toBe(2); + expect(ask('=SHEET("Sales")', 1)).toBe(1); + expect(ask('=CELL("address",Sales!B2)', 0)).toBe('Sales!$B$2'); + }); + + it('reports a manual page', () => { + const t = recalculate({ ...sheet(FIXTURE.data), calc: 'manual' }, DEFAULT_ENV); + const typed = setCellInput(t, { row: 2, col: 3, raw: '=INFO("recalc")' }, EN_US, DEFAULT_ENV); + expect(typed.rows[2].cells[3].value).toBe('Manual'); + }); +}); + +describe('the registry', () => { + it('holds every function the design lists for these two families', () => { + const logical = [ + 'AND', + 'FALSE', + 'IF', + 'IFERROR', + 'IFNA', + 'IFS', + 'LAMBDA', + 'LET', + 'NOT', + 'OR', + 'SWITCH', + 'TRUE', + 'XOR', + ]; + const info = [ + ...['CELL', 'ERROR.TYPE', 'INFO', 'ISBLANK', 'ISDATE', 'ISEMAIL', 'ISERR', 'ISERROR', 'ISEVEN', 'ISFORMULA'], + ...['ISLOGICAL', 'ISNA', 'ISNONTEXT', 'ISNUMBER', 'ISODD', 'ISOMITTED', 'ISREF', 'ISTEXT', 'ISURL', 'N', 'NA'], + ...['SHEET', 'SHEETS', 'TYPE'], + ]; + const of = (family: string): string[] => + [...REGISTRY.values()] + .filter((s) => s.family === family) + .map((s) => s.name) + .sort(); + expect(of('logical')).toEqual(logical); + expect(of('info')).toEqual(info); + }); + + it('describes every function and every argument in the strings', () => { + const strings = fnLogicInfo as unknown as Record }>; + for (const spec of REGISTRY.values()) { + if (spec.family !== 'logical' && spec.family !== 'info') continue; + const text = strings[spec.name]; + expect(text?.about, spec.name).toMatch(/\S/); + expect(Object.keys(text.args).sort(), spec.name).toEqual( + paramsOf(spec) + .map((p) => p.name) + .sort(), + ); + } + }); +}); diff --git a/app/src/features/tables/engine/formula/fn/logical.ts b/app/src/features/tables/engine/formula/fn/logical.ts index 2acd10f2a..c6cc13bc4 100644 --- a/app/src/features/tables/engine/formula/fn/logical.ts +++ b/app/src/features/tables/engine/formula/fn/logical.ts @@ -1,23 +1,46 @@ -// Logical functions and LAMBDA helpers. Owner: tables:fn-logic-info. IF and IFERROR take lazy arguments, so the -// compiler evaluates them itself and only the branch it needs runs. These are the functions tables had before the -// library; their behavior is unchanged. +// Logical functions and the metadata of LET and LAMBDA. Owner: tables:fn-logic-info. IF and IFERROR take lazy +// arguments, so the compiler evaluates them itself and only the branch it needs runs. LET, LAMBDA, and ISOMITTED +// (info.ts) are engine forms too (formula/special.ts): the specs here only give them a name, a signature, and an +// example. The other functions are written against the documented rules. -import { err, toBool, type Value } from '../../values'; +import { err, isArray, isError, toBool, type FormulaError, type Scalar, type Value } from '../../values'; import type { FnSpec2 } from './types'; +/** + * The logical values behind AND, OR, and XOR. Arrays and references give their numbers and booleans; text and + * empty cells inside them are skipped. A value typed alone is a one-by-one array, so a lone text is read as a + * boolean ("TRUE" works, other text is #VALUE!). The first error wins. No logical value at all is #VALUE!. + */ +function logicals(args: readonly Value[]): boolean[] | FormulaError { + const out: boolean[] = []; + for (const arg of args) { + if (isError(arg)) return arg; + const items: readonly Scalar[] = isArray(arg) ? arg.data : [arg as Scalar]; + const single = items.length === 1; + for (const v of items) { + if (isError(v)) return v; + if (v === null) continue; + if (typeof v === 'string') { + if (!single) continue; + const b = toBool(v); + if (typeof b !== 'boolean') return b; + out.push(b); + continue; + } + out.push(typeof v === 'boolean' ? v : v !== 0); + } + } + return out.length === 0 ? err('VALUE') : out; +} + function logic(name: string, combine: (bools: boolean[]) => boolean, example: string): FnSpec2 { return { name, family: 'logical', - params: [{ repeat: [{ name: 'logical', kind: 'bool' }], min: 1 }], + params: [{ repeat: [{ name: 'logical', kind: 'array' }], min: 1 }], run: (args) => { - const bools: boolean[] = []; - for (const v of args) { - const b = toBool(v); - if (typeof b !== 'boolean') return b; - bools.push(b); - } - return combine(bools); + const bools = logicals(args); + return Array.isArray(bools) ? combine(bools) : bools; }, meta: { signature: `${name}(logical, ...)`, example }, }; @@ -26,9 +49,21 @@ function logic(name: string, combine: (bools: boolean[]) => boolean, example: st /** The compiler evaluates functions with lazy arguments itself. */ const special = (): Value => err('VALUE'); +/** Equality as SWITCH reads it: text without regard to case, an empty cell equal to 0 and to "". */ +function same(a: Scalar, b: Scalar): boolean { + if (a === null) return b === null || b === 0 || b === ''; + if (b === null) return same(b, a); + if (typeof a === 'string' && typeof b === 'string') return a.toLowerCase() === b.toLowerCase(); + if (isError(a) || isError(b)) return isError(a) && isError(b) && a.error === b.error; + return a === b; +} + +const scalar = (v: Value): Scalar => (isArray(v) ? (v.data[0] ?? null) : (v as Scalar)); + export const functions: readonly FnSpec2[] = [ logic('AND', (b) => b.every(Boolean), '=AND(TRUE, 1>2) gives FALSE'), logic('OR', (b) => b.some(Boolean), '=OR(TRUE, 1>2) gives TRUE'), + logic('XOR', (b) => b.filter(Boolean).length % 2 === 1, '=XOR(TRUE, TRUE, TRUE) gives TRUE'), { name: 'NOT', family: 'logical', @@ -39,6 +74,20 @@ export const functions: readonly FnSpec2[] = [ }, meta: { signature: 'NOT(logical)', example: '=NOT(1>2) gives TRUE' }, }, + { + name: 'TRUE', + family: 'logical', + params: [], + run: () => true, + meta: { signature: 'TRUE()', example: '=TRUE() gives TRUE' }, + }, + { + name: 'FALSE', + family: 'logical', + params: [], + run: () => false, + meta: { signature: 'FALSE()', example: '=FALSE() gives FALSE' }, + }, { name: 'IF', family: 'logical', @@ -60,4 +109,90 @@ export const functions: readonly FnSpec2[] = [ run: special, meta: { signature: 'IFERROR(value, on_error)', example: '=IFERROR(1/0, 0) gives 0' }, }, + { + name: 'IFNA', + family: 'logical', + params: [ + { name: 'value', kind: 'value' }, + { name: 'on_na', kind: 'value' }, + ], + handlesErrors: true, + run: ([v, fallback]) => (isError(v) && v.error === 'NA' ? fallback : v), + meta: { signature: 'IFNA(value, on_na)', example: '=IFNA(NA(), "x") gives x' }, + }, + { + name: 'IFS', + family: 'logical', + params: [ + { + repeat: [ + { name: 'test', kind: 'value' }, + { name: 'result', kind: 'value' }, + ], + min: 1, + }, + ], + // A test that is never reached cannot spoil the answer, so errors come in and are read in order. + handlesErrors: true, + run: (args) => { + if (args.length % 2 !== 0) return err('VALUE'); + for (let i = 0; i < args.length; i += 2) { + const test = toBool(args[i]); + if (typeof test !== 'boolean') return test; + if (test) return args[i + 1]; + } + return err('NA'); + }, + meta: { signature: 'IFS(test, result, ...)', example: '=IFS(FALSE, 1, TRUE, 2) gives 2' }, + }, + { + name: 'SWITCH', + family: 'logical', + params: [ + { name: 'value', kind: 'value' }, + { + repeat: [ + { name: 'match', kind: 'value' }, + { name: 'result', kind: 'value' }, + ], + min: 1, + }, + { name: 'default', kind: 'value', optional: true }, + ], + handlesErrors: true, + run: (args) => { + const [value, ...rest] = args.map(scalar); + if (isError(value)) return value; + const pairs = Math.floor(rest.length / 2); + for (let i = 0; i < pairs; i++) if (same(value, rest[2 * i])) return rest[2 * i + 1]; + return rest.length % 2 === 1 ? rest[rest.length - 1] : err('NA'); + }, + meta: { + signature: 'SWITCH(value, match, result, ..., [default])', + example: '=SWITCH(2, 1, "a", 2, "b", "z") gives b', + }, + }, + { + name: 'LET', + family: 'logical', + params: [ + { + repeat: [ + { name: 'name', kind: 'lazy' }, + { name: 'value', kind: 'lazy' }, + ], + min: 1, + }, + { name: 'calculation', kind: 'lazy' }, + ], + run: special, + meta: { signature: 'LET(name, value, ..., calculation)', example: '=LET(x, 3, x*x) gives 9' }, + }, + { + name: 'LAMBDA', + family: 'logical', + params: [{ repeat: [{ name: 'parameter', kind: 'lazy' }] }, { name: 'calculation', kind: 'lazy' }], + run: special, + meta: { signature: 'LAMBDA(parameter, ..., calculation)', example: '=LAMBDA(x, x+1)(2) gives 3' }, + }, ]; diff --git a/app/src/features/tables/engine/gridcraftDiff.test.ts b/app/src/features/tables/engine/gridcraftDiff.test.ts index a00b83a85..fb9ac9eab 100644 --- a/app/src/features/tables/engine/gridcraftDiff.test.ts +++ b/app/src/features/tables/engine/gridcraftDiff.test.ts @@ -100,6 +100,13 @@ const KNOWN: Record = { /** Functions that cannot be compared with a fixed answer: their result depends on the day it runs. */ const NOT_COMPARABLE = new Set(['TODAY']); +/** Functions the other engine's shared set does not cover. Their cases are fixtures of the family's own test. */ +const OWN_FIXTURES = new Set([ + ...['IFNA', 'IFS', 'SWITCH', 'XOR', 'TRUE', 'FALSE', 'LET', 'LAMBDA', 'ISOMITTED', 'CELL', 'ERROR.TYPE', 'INFO'], + ...['ISDATE', 'ISEMAIL', 'ISERR', 'ISERROR', 'ISEVEN', 'ISFORMULA', 'ISLOGICAL', 'ISNA', 'ISNONTEXT', 'ISNUMBER'], + ...['ISODD', 'ISREF', 'ISTEXT', 'ISURL', 'N', 'NA', 'SHEET', 'SHEETS', 'TYPE'], +]); + const DATE_RESULTS = new Set(['DATE']); function opennote(formula: string): Value { @@ -139,7 +146,9 @@ const tool = findTool('gridcraft-cli'); describe('the formula function set', () => { it('has a differential case for every function', () => { const covered = new Set(Object.keys(CASES)); - const missing = Object.keys(FUNCTIONS).filter((name) => !covered.has(name) && !NOT_COMPARABLE.has(name)); + const missing = Object.keys(FUNCTIONS).filter( + (name) => !covered.has(name) && !NOT_COMPARABLE.has(name) && !OWN_FIXTURES.has(name), + ); expect(missing, 'add cases for these functions to CASES').toEqual([]); }); }); diff --git a/app/src/features/tables/fixtures/fn/logicInfo.json b/app/src/features/tables/fixtures/fn/logicInfo.json new file mode 100644 index 000000000..3eb4aeba0 --- /dev/null +++ b/app/src/features/tables/fixtures/fn/logicInfo.json @@ -0,0 +1,441 @@ +{ + "about": "Logical and information functions. The formula is typed in D3 of a sheet with columns A to D and these data rows. An expect of {error} is the error's token.", + "data": [ + ["1", "2", "x", ""], + ["3", "4", "y", ""], + ["", "", "", ""] + ], + "cases": [ + { + "formula": "=IFS(FALSE,1,TRUE,2)", + "expect": 2, + "source": "documented example" + }, + { + "formula": "=IFS(FALSE,1)", + "expect": { + "error": "#N/A" + }, + "source": "documented example" + }, + { + "formula": "=IFS(A1>5,\"big\",A1>0,\"small\")", + "expect": "small", + "source": "hand-worked" + }, + { + "formula": "=IFS(TRUE,1,1/0,2)", + "expect": 1, + "source": "hand-worked" + }, + { + "formula": "=IFS(FALSE,1,1/0,2)", + "expect": { + "error": "#DIV/0!" + }, + "source": "hand-worked" + }, + { + "formula": "=SWITCH(2,1,\"a\",2,\"b\",\"z\")", + "expect": "b", + "source": "documented example" + }, + { + "formula": "=SWITCH(9,1,\"a\",2,\"b\",\"z\")", + "expect": "z", + "source": "documented example" + }, + { + "formula": "=SWITCH(9,1,\"a\",2,\"b\")", + "expect": { + "error": "#N/A" + }, + "source": "documented example" + }, + { + "formula": "=SWITCH(\"B\",\"a\",1,\"b\",2)", + "expect": 2, + "source": "hand-worked" + }, + { + "formula": "=XOR(TRUE,TRUE,TRUE)", + "expect": true, + "source": "documented example" + }, + { + "formula": "=XOR(TRUE,TRUE)", + "expect": false, + "source": "documented example" + }, + { + "formula": "=XOR(A1:B1>1)", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=IFNA(NA(),\"x\")", + "expect": "x", + "source": "documented example" + }, + { + "formula": "=IFNA(1/0,\"x\")", + "expect": { + "error": "#DIV/0!" + }, + "source": "hand-worked" + }, + { + "formula": "=IFNA(5,\"x\")", + "expect": 5, + "source": "hand-worked" + }, + { + "formula": "=TRUE()", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=FALSE()", + "expect": false, + "source": "hand-worked" + }, + { + "formula": "=AND(A1:B1)", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=AND(A1:B1,FALSE)", + "expect": false, + "source": "hand-worked" + }, + { + "formula": "=OR(A1:B1>5,C1=\"x\")", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=AND(C1)", + "expect": { + "error": "#VALUE!" + }, + "source": "documented example" + }, + { + "formula": "=AND(A1:C1)", + "expect": true, + "source": "documented example" + }, + { + "formula": "=OR(D1:D2)", + "expect": { + "error": "#VALUE!" + }, + "source": "documented example" + }, + { + "formula": "=ISNUMBER(\"1\")", + "expect": false, + "source": "hand-worked" + }, + { + "formula": "=ISNUMBER(1)", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISTEXT(\"a\")", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISNONTEXT(D1)", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISLOGICAL(1>2)", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISERROR(1/0)", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISERR(NA())", + "expect": false, + "source": "hand-worked" + }, + { + "formula": "=ISERR(1/0)", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISNA(NA())", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISBLANK(D1)", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=N(TRUE)", + "expect": 1, + "source": "documented example" + }, + { + "formula": "=N(\"7\")", + "expect": 0, + "source": "documented example" + }, + { + "formula": "=N(A1+1)", + "expect": 2, + "source": "hand-worked" + }, + { + "formula": "=N(1/0)", + "expect": { + "error": "#DIV/0!" + }, + "source": "hand-worked" + }, + { + "formula": "=NA()", + "expect": { + "error": "#N/A" + }, + "source": "documented example" + }, + { + "formula": "=TYPE(\"a\")", + "expect": 2, + "source": "documented example" + }, + { + "formula": "=TYPE(1)", + "expect": 1, + "source": "documented example" + }, + { + "formula": "=TYPE(TRUE)", + "expect": 4, + "source": "documented example" + }, + { + "formula": "=TYPE(1/0)", + "expect": 16, + "source": "documented example" + }, + { + "formula": "=TYPE({1,2})", + "expect": 64, + "source": "documented example" + }, + { + "formula": "=ERROR.TYPE(1/0)", + "expect": 2, + "source": "documented example" + }, + { + "formula": "=ERROR.TYPE(NA())", + "expect": 7, + "source": "documented example" + }, + { + "formula": "=ERROR.TYPE(A1)", + "expect": { + "error": "#N/A" + }, + "source": "documented example" + }, + { + "formula": "=ISEVEN(-2)", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISODD(2.9)", + "expect": false, + "source": "hand-worked" + }, + { + "formula": "=ISODD(-3)", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISEVEN(\"x\")", + "expect": { + "error": "#VALUE!" + }, + "source": "hand-worked" + }, + { + "formula": "=ISREF(A1)", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISREF(5)", + "expect": false, + "source": "hand-worked" + }, + { + "formula": "=ISREF(A1:B2)", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISFORMULA(A1)", + "expect": false, + "source": "hand-worked" + }, + { + "formula": "=SHEET()", + "expect": 1, + "source": "hand-worked" + }, + { + "formula": "=SHEET(\"Table1\")", + "expect": 1, + "source": "hand-worked" + }, + { + "formula": "=SHEET(\"Nope\")", + "expect": { + "error": "#N/A" + }, + "source": "hand-worked" + }, + { + "formula": "=SHEETS()", + "expect": 1, + "source": "hand-worked" + }, + { + "formula": "=CELL(\"address\",B2)", + "expect": "$B$2", + "source": "documented example" + }, + { + "formula": "=CELL(\"col\",C1)", + "expect": 3, + "source": "hand-worked" + }, + { + "formula": "=CELL(\"row\",C2)", + "expect": 2, + "source": "hand-worked" + }, + { + "formula": "=CELL(\"contents\",B1)", + "expect": 2, + "source": "hand-worked" + }, + { + "formula": "=CELL(\"type\",C1)", + "expect": "l", + "source": "hand-worked" + }, + { + "formula": "=CELL(\"type\",A1)", + "expect": "v", + "source": "hand-worked" + }, + { + "formula": "=CELL(\"type\",D1)", + "expect": "b", + "source": "hand-worked" + }, + { + "formula": "=CELL(\"filename\",A1)", + "expect": "", + "source": "hand-worked" + }, + { + "formula": "=CELL(\"format\",A1)", + "expect": "G", + "source": "hand-worked" + }, + { + "formula": "=CELL(\"width\",A1)", + "expect": 8, + "source": "hand-worked" + }, + { + "formula": "=CELL(\"nonsense\",A1)", + "expect": { + "error": "#VALUE!" + }, + "source": "hand-worked" + }, + { + "formula": "=INFO(\"recalc\")", + "expect": "Automatic", + "source": "hand-worked" + }, + { + "formula": "=INFO(\"numfile\")", + "expect": 1, + "source": "hand-worked" + }, + { + "formula": "=INFO(\"directory\")", + "expect": { + "error": "#N/A" + }, + "source": "hand-worked" + }, + { + "formula": "=ISEMAIL(\"a@b.org\")", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISEMAIL(\"a@b\")", + "expect": false, + "source": "hand-worked" + }, + { + "formula": "=ISEMAIL(\"a b@c.org\")", + "expect": false, + "source": "hand-worked" + }, + { + "formula": "=ISURL(\"https://example.org/x?y=1\")", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISURL(\"example.org\")", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISURL(\"not a url\")", + "expect": false, + "source": "hand-worked" + }, + { + "formula": "=ISDATE(\"2026-01-31\")", + "expect": true, + "source": "hand-worked" + }, + { + "formula": "=ISDATE(\"hello\")", + "expect": false, + "source": "hand-worked" + }, + { + "formula": "=ISDATE(5)", + "expect": false, + "source": "hand-worked" + } + ] +} diff --git a/app/src/strings/en/fnLogicInfo.ts b/app/src/strings/en/fnLogicInfo.ts index e2460dd36..05507a84f 100644 --- a/app/src/strings/en/fnLogicInfo.ts +++ b/app/src/strings/en/fnLogicInfo.ts @@ -1,4 +1,114 @@ -// Descriptions of the logical and information functions. +// Descriptions of the logical and information functions: one line per function (`about`) and one per argument +// (`args`, by the argument's name in the signature). Written fresh in American English. // Owner: tables:fn-logic-info (B10). shared:x-shared registered it in the string index, and the owner fills it in. -export const fnLogicInfo = {} as const; +const value = 'The value to check.'; +/** The name has a dot, so it cannot be a plain key. */ +const ERROR_TYPE = ['ERROR', 'TYPE'].join('.'); + +export const fnLogicInfo = { + AND: { about: 'Gives TRUE when every argument is true.', args: { logical: 'A value, array, or range to test.' } }, + OR: { about: 'Gives TRUE when any argument is true.', args: { logical: 'A value, array, or range to test.' } }, + XOR: { + about: 'Gives TRUE when an odd number of arguments are true.', + args: { logical: 'A value, array, or range to test.' }, + }, + NOT: { about: 'Turns TRUE into FALSE and FALSE into TRUE.', args: { logical: 'The value to flip.' } }, + TRUE: { about: 'Gives the logical value TRUE.', args: {} }, + FALSE: { about: 'Gives the logical value FALSE.', args: {} }, + IF: { + about: 'Gives one result when a test is true and another when it is false.', + args: { + test: 'The condition.', + then: 'The result when true.', + otherwise: 'The result when false. FALSE if left out.', + }, + }, + IFERROR: { + about: 'Gives a fallback when a value is an error.', + args: { value: 'The value to check.', on_error: 'The result when the value is an error.' }, + }, + IFNA: { + about: 'Gives a fallback when a value is #N/A.', + args: { value: 'The value to check.', on_na: 'The result when the value is #N/A.' }, + }, + IFS: { + about: 'Gives the result of the first test that is true. #N/A when none is.', + args: { test: 'A condition.', result: 'The result when that condition is true.' }, + }, + SWITCH: { + about: 'Compares a value with a list of matches and gives the result of the first one that is equal.', + args: { + value: 'The value to compare.', + match: 'A value to compare it with.', + result: 'The result when it matches.', + default: 'The result when nothing matches. #N/A if left out.', + }, + }, + LET: { + about: 'Gives names to values, then calculates with them.', + args: { + name: 'A name to define.', + value: 'The value of that name.', + calculation: 'The formula that uses the names.', + }, + }, + LAMBDA: { + about: 'Makes a function of your own. Call it right away or give it a name.', + args: { parameter: 'A name the function takes as input.', calculation: 'The formula that uses the inputs.' }, + }, + CELL: { + about: 'Gives facts about a cell, such as its address, row, column, type, or format.', + args: { + info_type: 'What to report: address, col, row, contents, type, format, width, filename, and a few more.', + reference: 'The cell to ask about. This cell if left out.', + }, + }, + [ERROR_TYPE]: { + about: 'Gives a number for the kind of error, such as 2 for divide by zero and 7 for not available.', + args: { error: 'The error.' }, + }, + INFO: { + about: 'Gives a fact about this page that is safe to share, such as the calculation mode.', + args: { type_text: 'What to report: numfile, origin, recalc, or system.' }, + }, + ISBLANK: { about: 'Gives TRUE for an empty cell.', args: { value } }, + ISNUMBER: { about: 'Gives TRUE for a number. Text that looks like a number is not one.', args: { value } }, + ISTEXT: { about: 'Gives TRUE for text.', args: { value } }, + ISNONTEXT: { about: 'Gives TRUE for anything that is not text, an empty cell included.', args: { value } }, + ISLOGICAL: { about: 'Gives TRUE for TRUE or FALSE.', args: { value } }, + ISERROR: { about: 'Gives TRUE for any error.', args: { value } }, + ISERR: { about: 'Gives TRUE for any error except #N/A.', args: { value } }, + ISNA: { about: 'Gives TRUE for #N/A.', args: { value } }, + ISEVEN: { + about: 'Gives TRUE when a number is even. A fraction is cut off first.', + args: { number: 'The number to check.' }, + }, + ISODD: { + about: 'Gives TRUE when a number is odd. A fraction is cut off first.', + args: { number: 'The number to check.' }, + }, + ISFORMULA: { about: 'Gives TRUE when a cell holds a formula.', args: { reference: 'The cell to check.' } }, + ISOMITTED: { + about: 'In a function made with LAMBDA, gives TRUE when an optional input was left out.', + args: { argument: 'The name of the input.' }, + }, + ISREF: { about: 'Gives TRUE when the value is a reference to cells.', args: { value } }, + ISEMAIL: { about: 'Gives TRUE for text shaped like an email address.', args: { value } }, + ISURL: { about: 'Gives TRUE for text shaped like a web address.', args: { value } }, + ISDATE: { about: 'Gives TRUE for text that reads as a date.', args: { value } }, + N: { about: 'Turns a value into a number. TRUE is 1, text is 0.', args: { value: 'The value to convert.' } }, + NA: { about: 'Gives the error #N/A, which means a value is not available.', args: {} }, + SHEET: { + about: 'Gives the position of a table on the page, counting from 1.', + args: { value: 'A reference or a table name. This table if left out.' }, + }, + SHEETS: { + about: 'Gives how many tables the page has.', + args: { reference: 'A reference to count the tables in. The whole page if left out.' }, + }, + TYPE: { + about: 'Gives a code for the kind of value: 1 number, 2 text, 4 logical, 16 error, 64 array.', + args: { value }, + }, +} as const; From 051f1f659bd1a4efbaef7c53ba98b1c1a4048523 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 14:53:24 -0800 Subject: [PATCH 13/85] Keep the keys tip out of the interface tests and raise the bundle budgets The tip toast queues ahead of the toasts the tests wait for. The budgets grew by under 4 KB for the keys code. Co-Authored-By: Claude Sonnet 5.5 --- app/bundle-budget.json | 16 ++++++++-------- tests/ui/fixtures.ts | 6 ++++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/bundle-budget.json b/app/bundle-budget.json index fce71b1bb..6a70856c3 100644 --- a/app/bundle-budget.json +++ b/app/bundle-budget.json @@ -1,7 +1,7 @@ { "startup": { "jsKb": 549, - "cssKb": 27.5 + "cssKb": 27.8 }, "after": [ { @@ -90,9 +90,9 @@ "features/pages/ui/presentCommands.tsx": 67, "features/pdf/importPdf.ts": 69, "features/tables/index.ts": 89, - "features/tools/index.ts": 380, - "features/engineering/control/ui/ControlKit.tsx": 322, - "features/engineering/index.ts": 216, + "features/tools/index.ts": 381, + "features/engineering/control/ui/ControlKit.tsx": 323, + "features/engineering/index.ts": 217, "mermaid/node_modules/katex": 80, "node_modules/mermaid": 600, "@mermaid-js/parser": 130, @@ -108,7 +108,7 @@ "features/page/PageBody": 276, "@observablehq/plot": 135, "features/math": 185, - "features/tables": 52, + "features/tables": 56, "features/search/links": 195, "features/search/lineTags": 160, "features/page": 40, @@ -120,14 +120,14 @@ "features/shortcuts": 40, "features/trash": 40, "features/updates": 40, - "features/tools": 274, + "features/tools": 275, "features/engineering/fluidheat/ui/FluidHeatKit.tsx": 312, "features/engineering/mechdyn/ui/MechDynKit.tsx": 124, "features/engineering/electrical/KitElectricalTool.tsx": 30, "features/engineering/mechdyn": 12, - "features/engineering/aero/ui/KitAeroTool.tsx": 223, + "features/engineering/aero/ui/KitAeroTool.tsx": 225, "features/engineering/solids/ui/SolidsKit.tsx": 119, - "features/engineering/thermo/ui/ThermoKit.tsx": 412, + "features/engineering/thermo/ui/ThermoKit.tsx": 413, "features/study/ui/exam/ExamResults.tsx": 42 }, "lazyDefaultKb": 40, diff --git a/tests/ui/fixtures.ts b/tests/ui/fixtures.ts index 76b0a7b5b..7093030a6 100644 --- a/tests/ui/fixtures.ts +++ b/tests/ui/fixtures.ts @@ -24,6 +24,12 @@ export const test = base.extend({ (options) => { window.__OPENNOTE_DEV__ = { fixture: options.fixture, pseudo: options.pseudo }; if (options.boot) window.__OPENNOTE_BOOT__ = { ...options.boot, bootVersion: 1 }; + // The one-time tip about spreadsheet keys would queue ahead of the toasts the tests wait for. + try { + localStorage.setItem('opennote.sheetKeys.tipShown', '1'); + } catch { + // A blocked store only means the tip may show. + } }, { fixture, pseudo: pseudoLocale, boot }, ); From f84427a73e2d198a5576aca7dda2a16ad679f4b4 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 15:34:17 -0800 Subject: [PATCH 14/85] Fix verification gaps in the logic and info functions Co-Authored-By: Claude Sonnet 5.5 --- .../features/tables/engine/formula/calls.ts | 5 +- .../features/tables/engine/formula/fn/info.ts | 22 +++++++-- .../engine/formula/fn/logicInfo.test.ts | 46 ++++++++++++++++++- .../tables/engine/formula/fn/logical.ts | 8 ++-- .../tables/engine/formula/fn/types.ts | 2 + .../features/tables/engine/worker/client.ts | 2 +- .../features/tables/engine/worker/protocol.ts | 2 + app/src/features/tables/engine/worker/run.ts | 2 +- .../tables/engine/worker/worker.test.ts | 18 ++++++++ app/src/strings/en/fnLogicInfo.ts | 4 +- app/src/strings/fnKey.ts | 7 +++ app/src/strings/t.ts | 8 +++- 12 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 app/src/strings/fnKey.ts diff --git a/app/src/features/tables/engine/formula/calls.ts b/app/src/features/tables/engine/formula/calls.ts index f823576d6..b34d9eb5c 100644 --- a/app/src/features/tables/engine/formula/calls.ts +++ b/app/src/features/tables/engine/formula/calls.ts @@ -191,9 +191,12 @@ function plain(h: Host, spec: FnSpec2, node: Call): Fn { if (ref !== null && refArgs) return (ctx) => toRefValue(h, ref.fn(ctx)); if (ref !== null && returnsRef && kind === 'range') return (ctx) => toRefValue(h, ref.fn(ctx)); const v = ref !== null ? h.deref(ref) : h.value(arg); + const skipText = ref !== null && params[i]?.skipRefText === true; return (ctx) => { const value = v(ctx); - return isError(value) ? value : asArray(value); + if (isError(value)) return value; + const array = asArray(value); + return skipText ? { ...array, data: array.data.map((x) => (typeof x === 'string' ? null : x)) } : array; }; }); // A lazy parameter of a form the engine does not know arrives evaluated, errors included, never broadcast. diff --git a/app/src/features/tables/engine/formula/fn/info.ts b/app/src/features/tables/engine/formula/fn/info.ts index 9123ebe58..dea923783 100644 --- a/app/src/features/tables/engine/formula/fn/info.ts +++ b/app/src/features/tables/engine/formula/fn/info.ts @@ -6,10 +6,12 @@ import { lettersFromColumn } from '../../../../../core/expr'; import { parseDate } from '../../dates'; import { + arrayOf, err, isArray, isError, isRef, + toNumber, type ErrorCode, type FormulaError, type RefValue, @@ -54,7 +56,7 @@ function parity(name: string, odd: boolean, example: string): FnSpec2 { params: [{ name: 'number', kind: 'value' }], run: ([v]) => { if (typeof v === 'boolean') return err('VALUE'); - const n = typeof v === 'string' ? Number(v.trim() === '' ? NaN : v) : v; + const n = toNumber(v); if (typeof n !== 'number' || !Number.isFinite(n)) return err('VALUE'); return (Math.trunc(n) % 2 !== 0) === odd; }, @@ -239,8 +241,22 @@ export const functions: readonly FnSpec2[] = [ refArgs: true, run: ([v], env) => { if (!isRef(v)) return err('VALUE'); - const cell = env.probe?.cell(v); - return cell === undefined ? err('REF') : cell.formula; + const probe = env.probe; + if (v.r1 === v.r2 && v.c1 === v.c2) { + const cell = probe?.cell(v); + return cell === undefined ? err('REF') : cell.formula; + } + // A block spills one answer per cell. + const rows: Scalar[][] = []; + for (let r = v.r1; r <= v.r2; r++) { + const row: Scalar[] = []; + for (let c = v.c1; c <= v.c2; c++) { + const cell = probe?.cell({ ...v, r1: r, r2: r, c1: c, c2: c }); + row.push(cell === undefined ? err('REF') : cell.formula); + } + rows.push(row); + } + return arrayOf(rows); }, meta: { signature: 'ISFORMULA(reference)', example: '=ISFORMULA(B2) gives TRUE when B2 has a formula' }, }, diff --git a/app/src/features/tables/engine/formula/fn/logicInfo.test.ts b/app/src/features/tables/engine/formula/fn/logicInfo.test.ts index 6262d564f..1a1f5f3d5 100644 --- a/app/src/features/tables/engine/formula/fn/logicInfo.test.ts +++ b/app/src/features/tables/engine/formula/fn/logicInfo.test.ts @@ -11,6 +11,7 @@ import { DEFAULT_ENV, recalculate, type EngineEnv } from '../../recalc'; import { ERROR_TOKENS, err, type ErrorCode, type Value } from '../../values'; import { recalculateBook, setBookCellInput } from '../../workbook'; import { fnLogicInfo } from '../../../../../strings/en/fnLogicInfo'; +import { fnKey } from '../../../../../strings/fnKey'; import { REGISTRY, paramsOf } from '.'; interface Fixture { @@ -163,7 +164,7 @@ describe('the registry', () => { const strings = fnLogicInfo as unknown as Record }>; for (const spec of REGISTRY.values()) { if (spec.family !== 'logical' && spec.family !== 'info') continue; - const text = strings[spec.name]; + const text = strings[fnKey(spec.name)]; expect(text?.about, spec.name).toMatch(/\S/); expect(Object.keys(text.args).sort(), spec.name).toEqual( paramsOf(spec) @@ -173,3 +174,46 @@ describe('the registry', () => { } }); }); + +describe('verification gaps (G4, G5, G6, G7)', () => { + /** A sheet with a text column C and a formula in D1, then the result of `formula` typed in D3. */ + function runIn(data: string[][], formula: string): Value { + const columns = LETTERS.map((name) => ({ name, type: (name === 'C' ? 'text' : 'number') as 'text' | 'number' })); + let t = createTable(columns, data, EN_US, DEFAULT_ENV, 1); + t = setCellInput(t, { row: 0, col: 3, raw: '=1+1' }, EN_US, DEFAULT_ENV); + t = setCellInput(t, { row: 2, col: 3, raw: formula }, EN_US, DEFAULT_ENV); + return t.rows[2].cells[3].value; + } + const data = [ + ['1', '2', 'TRUE', ''], + ['3', '4', 'y', ''], + ['', '', '', ''], + ]; + + it('G4: ISEVEN and ISODD read a blank cell as 0 and text with the number rule', () => { + expect(runIn(data, '=ISEVEN(A3)')).toBe(true); + expect(runIn(data, '=ISODD(A3)')).toBe(false); + expect(runIn(data, '=ISEVEN("0x10")')).toEqual(err('VALUE')); + expect(runIn(data, '=ISODD("0b11")')).toEqual(err('VALUE')); + expect(runIn(data, '=ISODD("3")')).toBe(true); + }); + + it('G5: AND and OR skip text in a referenced cell but read typed text', () => { + expect(runIn(data, '=AND(C1)')).toEqual(err('VALUE')); + expect(runIn(data, '=OR(C1)')).toEqual(err('VALUE')); + expect(runIn(data, '=AND("TRUE")')).toBe(true); + expect(runIn(data, '=AND(C1,A1)')).toBe(true); + }); + + it('G6: a dotted function name maps to a low-line key', () => { + expect(fnKey('ERROR.TYPE')).toBe('ERROR_TYPE'); + expect(fnKey('SUM')).toBe('SUM'); + expect(fnLogicInfo.ERROR_TYPE.about).toMatch(/\S/); + }); + + it('G7: SWITCH returns an array result whole, and ISFORMULA lifts over a range', () => { + expect(runIn(data, '=SUM(SWITCH(1,1,A1:B2))')).toBe(10); + expect(runIn(data, '=SUM(--ISFORMULA(C1:D1))')).toBe(1); + expect(runIn(data, '=SUM(--ISFORMULA(A1:B2))')).toBe(0); + }); +}); diff --git a/app/src/features/tables/engine/formula/fn/logical.ts b/app/src/features/tables/engine/formula/fn/logical.ts index c6cc13bc4..5c389522b 100644 --- a/app/src/features/tables/engine/formula/fn/logical.ts +++ b/app/src/features/tables/engine/formula/fn/logical.ts @@ -37,7 +37,7 @@ function logic(name: string, combine: (bools: boolean[]) => boolean, example: st return { name, family: 'logical', - params: [{ repeat: [{ name: 'logical', kind: 'array' }], min: 1 }], + params: [{ repeat: [{ name: 'logical', kind: 'array', skipRefText: true }], min: 1 }], run: (args) => { const bools = logicals(args); return Array.isArray(bools) ? combine(bools) : bools; @@ -163,9 +163,11 @@ export const functions: readonly FnSpec2[] = [ run: (args) => { const [value, ...rest] = args.map(scalar); if (isError(value)) return value; + const raw = args.slice(1); const pairs = Math.floor(rest.length / 2); - for (let i = 0; i < pairs; i++) if (same(value, rest[2 * i])) return rest[2 * i + 1]; - return rest.length % 2 === 1 ? rest[rest.length - 1] : err('NA'); + // The chosen result is returned as it is, so an array result is not cut down to its first element. + for (let i = 0; i < pairs; i++) if (same(value, rest[2 * i])) return raw[2 * i + 1]; + return rest.length % 2 === 1 ? raw[raw.length - 1] : err('NA'); }, meta: { signature: 'SWITCH(value, match, result, ..., [default])', diff --git a/app/src/features/tables/engine/formula/fn/types.ts b/app/src/features/tables/engine/formula/fn/types.ts index ccfebd7b9..48f5fe6b3 100644 --- a/app/src/features/tables/engine/formula/fn/types.ts +++ b/app/src/features/tables/engine/formula/fn/types.ts @@ -66,6 +66,8 @@ export interface Param { * TRUE as 1, as SUM("3",TRUE) is 4; 'raw' keeps the value as it is, as COUNTA counts it. */ readonly direct?: 'number' | 'raw'; + /** For an array: text in a referenced cell counts as empty, as the logical functions skip it. Typed text still counts. */ + readonly skipRefText?: boolean; } /** Parameters that repeat together: value1, value2, and on, or criteria range and criteria pairs. */ diff --git a/app/src/features/tables/engine/worker/client.ts b/app/src/features/tables/engine/worker/client.ts index 3fa8ebb66..7b2270e7d 100644 --- a/app/src/features/tables/engine/worker/client.ts +++ b/app/src/features/tables/engine/worker/client.ts @@ -98,7 +98,7 @@ export function createRecalcClient(options: RecalcClientOptions = {}): RecalcCli clearTimeout(timer); resolve(result); }); - start().postMessage({ id, tables, today: env.today() }); + start().postMessage({ id, tables, today: env.today(), title: env.title }); }); }, stop() { diff --git a/app/src/features/tables/engine/worker/protocol.ts b/app/src/features/tables/engine/worker/protocol.ts index f4208a4dd..d8bea2cdf 100644 --- a/app/src/features/tables/engine/worker/protocol.ts +++ b/app/src/features/tables/engine/worker/protocol.ts @@ -9,6 +9,8 @@ export interface RecalcRequest { readonly tables: readonly Table[]; /** Today as a serial, read on the page, so TODAY() agrees with it. */ readonly today: number; + /** The page's title, read on the page, so CELL("filename") agrees with it. */ + readonly title?: string; } /** One changed cell: the table's index on the page, the data row, and the column. */ diff --git a/app/src/features/tables/engine/worker/run.ts b/app/src/features/tables/engine/worker/run.ts index a0a32154e..ff0a4f0ac 100644 --- a/app/src/features/tables/engine/worker/run.ts +++ b/app/src/features/tables/engine/worker/run.ts @@ -12,7 +12,7 @@ function same(a: Value, b: Value): boolean { /** Recalculates the tables of a request and lists the changed cells. */ export function respond(request: RecalcRequest): RecalcReply { - const result = recalculateBook(request.tables, { today: () => request.today }); + const result = recalculateBook(request.tables, { today: () => request.today, title: request.title }); const changes: CellChange[] = []; result.forEach((table, s) => { const before = request.tables[s]; diff --git a/app/src/features/tables/engine/worker/worker.test.ts b/app/src/features/tables/engine/worker/worker.test.ts index 34adafa21..91037a268 100644 --- a/app/src/features/tables/engine/worker/worker.test.ts +++ b/app/src/features/tables/engine/worker/worker.test.ts @@ -76,6 +76,24 @@ describe('recalculation worker', () => { expect(worker.terminated).toBe(true); }); + it('carries the page title to the worker, so CELL("filename") reads it (gap G3)', async () => { + const worker = new SlowWorker(0); + const client = createRecalcClient({ spawn: () => worker, threshold: 4, busyMs: 1000 }); + const table = createTable( + [ + { name: 'A', type: 'number' }, + { name: 'B', type: 'text', formula: 'CELL("filename")' }, + ], + [['1'], ['2'], ['3']], + EN_US, + ); + const blank = { ...table, rows: table.rows.map((r) => ({ ...r, cells: [r.cells[0], { raw: '', value: null }] })) }; + const [done] = await client.recalc([blank], { today: () => 1, title: 'Budget' }); + expect(worker.posted).toBe(1); + expect(done.rows.map((r) => r.cells[1].value)).toEqual(['Budget', 'Budget', 'Budget']); + client.dispose(); + }); + it('shows #BUSY! past the busy time, then the results when the worker answers', async () => { const worker = new SlowWorker(80); const client = createRecalcClient({ spawn: () => worker, threshold: 4, busyMs: 10 }); diff --git a/app/src/strings/en/fnLogicInfo.ts b/app/src/strings/en/fnLogicInfo.ts index 05507a84f..2b249513d 100644 --- a/app/src/strings/en/fnLogicInfo.ts +++ b/app/src/strings/en/fnLogicInfo.ts @@ -3,8 +3,6 @@ // Owner: tables:fn-logic-info (B10). shared:x-shared registered it in the string index, and the owner fills it in. const value = 'The value to check.'; -/** The name has a dot, so it cannot be a plain key. */ -const ERROR_TYPE = ['ERROR', 'TYPE'].join('.'); export const fnLogicInfo = { AND: { about: 'Gives TRUE when every argument is true.', args: { logical: 'A value, array, or range to test.' } }, @@ -64,7 +62,7 @@ export const fnLogicInfo = { reference: 'The cell to ask about. This cell if left out.', }, }, - [ERROR_TYPE]: { + ERROR_TYPE: { about: 'Gives a number for the kind of error, such as 2 for divide by zero and 7 for not available.', args: { error: 'The error.' }, }, diff --git a/app/src/strings/fnKey.ts b/app/src/strings/fnKey.ts new file mode 100644 index 000000000..ca856ee0c --- /dev/null +++ b/app/src/strings/fnKey.ts @@ -0,0 +1,7 @@ +// The string key of a spreadsheet function. A dot in a name (ERROR.TYPE, STDEV.S) would split a dotted message key, +// so the fn* description files store it with a low line (ERROR_TYPE). + +/** The key a function's description is stored under in the fn* string files. */ +export function fnKey(name: string): string { + return name.replace(/\./g, '_'); +} diff --git a/app/src/strings/t.ts b/app/src/strings/t.ts index f046b5a8e..5b32bbe6f 100644 --- a/app/src/strings/t.ts +++ b/app/src/strings/t.ts @@ -13,7 +13,13 @@ export type DottedKeys = { [K in keyof T & string]: T[K] extends string ? `${Prefix}${K}` : DottedKeys; }[keyof T & string]; -export type MessageKey = DottedKeys; +/** + * The function description namespaces (fnMath, fnText, ...) are read through `fnKey` in the formula help, never + * through t(). Keeping them out of MessageKey holds the key union under TypeScript's size limit. + */ +type FunctionNamespace = `fn${string}`; + +export type MessageKey = DottedKeys>; /** The message text at a key, as a string literal type. */ export type MessageAt = K extends `${infer Head}.${infer Rest}` From 1af08e8d66af7d13e897e775dfe2f931aad057ea Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 23:11:33 -0800 Subject: [PATCH 15/85] Raise the tables chunk budget for the keyboard model Co-Authored-By: Claude Sonnet 5.5 --- app/bundle-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/bundle-budget.json b/app/bundle-budget.json index 7cb217dc0..b29cb3f4c 100644 --- a/app/bundle-budget.json +++ b/app/bundle-budget.json @@ -108,7 +108,7 @@ "features/page/PageBody": 276, "@observablehq/plot": 135, "features/math": 185, - "features/tables": 56, + "features/tables": 66, "features/search/links": 195, "features/search/lineTags": 160, "features/page": 40, From 969e699441283ff1cab74f4362c19b54fcb63560 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 23:25:55 -0800 Subject: [PATCH 16/85] Raise the chunk budgets for the shared tables keys Co-Authored-By: Claude Sonnet 5.5 --- app/bundle-budget.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/bundle-budget.json b/app/bundle-budget.json index b29cb3f4c..f2222a7e6 100644 --- a/app/bundle-budget.json +++ b/app/bundle-budget.json @@ -73,7 +73,7 @@ "features/accounts/onenote/commands.ts": 71, "features/accounts/penSync/auto.ts": 66, "features/accounts/penSync/commands.ts": 66, - "features/accounts/sendto/commands.ts": 81, + "features/accounts/sendto/commands.ts": 83, "features/accounts/share/commands.ts": 76, "features/accounts/tasks/auto.ts": 206, "features/accounts/tasks/commands.ts": 206, @@ -90,8 +90,8 @@ "features/pages/ui/presentCommands.tsx": 67, "features/pdf/importPdf.ts": 69, "features/tables/index.ts": 96, - "features/tools/index.ts": 384, - "features/engineering/control/ui/ControlKit.tsx": 325, + "features/tools/index.ts": 386, + "features/engineering/control/ui/ControlKit.tsx": 327, "features/engineering/index.ts": 220, "mermaid/node_modules/katex": 80, "node_modules/mermaid": 600, @@ -120,15 +120,15 @@ "features/shortcuts": 40, "features/trash": 40, "features/updates": 40, - "features/tools/statinfer": 287, - "features/tools": 275, - "features/engineering/fluidheat/ui/FluidHeatKit.tsx": 315, + "features/tools/statinfer": 289, + "features/tools": 277, + "features/engineering/fluidheat/ui/FluidHeatKit.tsx": 317, "features/engineering/mechdyn/ui/MechDynKit.tsx": 124, "features/engineering/electrical/KitElectricalTool.tsx": 30, "features/engineering/mechdyn": 12, - "features/engineering/aero/ui/KitAeroTool.tsx": 227, + "features/engineering/aero/ui/KitAeroTool.tsx": 229, "features/engineering/solids/ui/SolidsKit.tsx": 119, - "features/engineering/thermo/ui/ThermoKit.tsx": 424, + "features/engineering/thermo/ui/ThermoKit.tsx": 426, "features/study/ui/exam/ExamResults.tsx": 43 }, "lazyDefaultKb": 40, From 17406d74fb4ab3a59873e7533bdac1b1c07fc4ff Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 23:55:40 -0800 Subject: [PATCH 17/85] Fix ERROR.TYPE codes, AND text in arrays, engine size FIELD maps to 13 and CYCLE falls back to the #N/A code. Text inside an array constant no longer reads as a boolean. The engine's host facts move to graph/facts.ts. Co-Authored-By: Claude Sonnet 5.5 --- .../features/tables/engine/formula/calls.ts | 2 ++ .../features/tables/engine/formula/fn/info.ts | 6 +++--- .../engine/formula/fn/logicInfo.test.ts | 12 ++++++++++++ .../tables/engine/formula/fn/logical.ts | 6 +++--- .../tables/engine/formula/fn/types.ts | 2 ++ .../features/tables/engine/graph/engine.ts | 19 ++++--------------- app/src/features/tables/engine/graph/facts.ts | 15 +++++++++++++++ 7 files changed, 41 insertions(+), 21 deletions(-) create mode 100644 app/src/features/tables/engine/graph/facts.ts diff --git a/app/src/features/tables/engine/formula/calls.ts b/app/src/features/tables/engine/formula/calls.ts index b34d9eb5c..872f36b1e 100644 --- a/app/src/features/tables/engine/formula/calls.ts +++ b/app/src/features/tables/engine/formula/calls.ts @@ -195,6 +195,8 @@ function plain(h: Host, spec: FnSpec2, node: Call): Fn { return (ctx) => { const value = v(ctx); if (isError(value)) return value; + // A typed lone text stays a scalar, so a function can tell it from text inside an array. + if (ref === null && params[i]?.loneText === true && typeof value === 'string') return value; const array = asArray(value); return skipText ? { ...array, data: array.data.map((x) => (typeof x === 'string' ? null : x)) } : array; }; diff --git a/app/src/features/tables/engine/formula/fn/info.ts b/app/src/features/tables/engine/formula/fn/info.ts index dea923783..ca8ecff0d 100644 --- a/app/src/features/tables/engine/formula/fn/info.ts +++ b/app/src/features/tables/engine/formula/fn/info.ts @@ -33,7 +33,7 @@ function is(name: string, test: Test, example: string): FnSpec2 { }; } -/** The codes ERROR.TYPE gives. Errors with no code of their own read as #N/A's 7, the documented fallback. */ +/** The codes ERROR.TYPE gives. #CYCLE! has no code of its own, so it reads as #N/A's 7, the documented fallback. */ const ERROR_TYPES: Record = { NULL: 1, DIV0: 2, @@ -45,8 +45,8 @@ const ERROR_TYPES: Record = { BUSY: 8, SPILL: 9, CALC: 14, - FIELD: 7, - CYCLE: 4, + FIELD: 13, + CYCLE: 7, }; function parity(name: string, odd: boolean, example: string): FnSpec2 { diff --git a/app/src/features/tables/engine/formula/fn/logicInfo.test.ts b/app/src/features/tables/engine/formula/fn/logicInfo.test.ts index 1a1f5f3d5..a976c94cb 100644 --- a/app/src/features/tables/engine/formula/fn/logicInfo.test.ts +++ b/app/src/features/tables/engine/formula/fn/logicInfo.test.ts @@ -205,6 +205,18 @@ describe('verification gaps (G4, G5, G6, G7)', () => { expect(runIn(data, '=AND(C1,A1)')).toBe(true); }); + it('M1: newer errors carry their documented codes, and the rest fall back to the #N/A code', () => { + const code = (e: ErrorCode) => run(`=ERROR.TYPE(${ERROR_TOKENS[e]})`); + expect(code('SPILL')).toBe(9); + expect(code('CALC')).toBe(14); + expect(code('FIELD')).toBe(13); + }); + + it('L2: text inside an array constant is ignored by AND', () => { + expect(run('=AND({"TRUE"})')).toEqual(err('VALUE')); + expect(run('=AND("TRUE")')).toBe(true); + }); + it('G6: a dotted function name maps to a low-line key', () => { expect(fnKey('ERROR.TYPE')).toBe('ERROR_TYPE'); expect(fnKey('SUM')).toBe('SUM'); diff --git a/app/src/features/tables/engine/formula/fn/logical.ts b/app/src/features/tables/engine/formula/fn/logical.ts index 5c389522b..7c1bc9f79 100644 --- a/app/src/features/tables/engine/formula/fn/logical.ts +++ b/app/src/features/tables/engine/formula/fn/logical.ts @@ -16,12 +16,12 @@ function logicals(args: readonly Value[]): boolean[] | FormulaError { for (const arg of args) { if (isError(arg)) return arg; const items: readonly Scalar[] = isArray(arg) ? arg.data : [arg as Scalar]; - const single = items.length === 1; + const lone = !isArray(arg); for (const v of items) { if (isError(v)) return v; if (v === null) continue; if (typeof v === 'string') { - if (!single) continue; + if (!lone) continue; const b = toBool(v); if (typeof b !== 'boolean') return b; out.push(b); @@ -37,7 +37,7 @@ function logic(name: string, combine: (bools: boolean[]) => boolean, example: st return { name, family: 'logical', - params: [{ repeat: [{ name: 'logical', kind: 'array', skipRefText: true }], min: 1 }], + params: [{ repeat: [{ name: 'logical', kind: 'array', skipRefText: true, loneText: true }], min: 1 }], run: (args) => { const bools = logicals(args); return Array.isArray(bools) ? combine(bools) : bools; diff --git a/app/src/features/tables/engine/formula/fn/types.ts b/app/src/features/tables/engine/formula/fn/types.ts index 48f5fe6b3..ac2423d69 100644 --- a/app/src/features/tables/engine/formula/fn/types.ts +++ b/app/src/features/tables/engine/formula/fn/types.ts @@ -68,6 +68,8 @@ export interface Param { readonly direct?: 'number' | 'raw'; /** For an array: text in a referenced cell counts as empty, as the logical functions skip it. Typed text still counts. */ readonly skipRefText?: boolean; + /** A typed lone text reaches the function as a scalar, not as a one-element array. */ + readonly loneText?: boolean; } /** Parameters that repeat together: value1, value2, and on, or criteria range and criteria pairs. */ diff --git a/app/src/features/tables/engine/graph/engine.ts b/app/src/features/tables/engine/graph/engine.ts index 61f560682..850fb9bad 100644 --- a/app/src/features/tables/engine/graph/engine.ts +++ b/app/src/features/tables/engine/graph/engine.ts @@ -3,7 +3,7 @@ // that read those, in an order where every unit runs after the units it reads. Tarjan's algorithm gives that order // and finds cycles in one walk. Every edit forks the state, and a fork shares what it does not change. -import { newCtx, type Ctx, type Scope, type SheetInfo } from '../formula/compile'; +import { colKey, newCtx, type Ctx, type Scope, type SheetInfo } from '../formula/compile'; import type { ErrorOrigin } from '../formula/explain'; import { releaseSpill, spillBox } from '../arrays/spill'; import { Graph, stronglyConnected, type Box, type Read } from '.'; @@ -28,8 +28,8 @@ import { type EngineEnv, type Unit, } from './units'; -import { colKey } from '../formula/compile'; import type { Table } from '../model'; +import { tableFacts } from './facts'; import { isError, type Scalar } from '../values'; const LATE_ROUNDS = 8; @@ -71,19 +71,8 @@ export class Engine { } makeCtx(): Ctx { - const ctx = newCtx([], { - spillBox: (s, c, r) => spillBox(this, s, c, r), - cellFacts: (s, c, r) => { - const column = this.tables[s]?.columns[c]; - const cell = this.tables[s]?.rows[r]?.cells[c]; - if (column === undefined || cell === undefined) return undefined; - const decimals = column.decimals === undefined ? {} : { decimals: column.decimals }; - return { formula: column.formula !== undefined || cell.formula !== undefined, type: column.type, ...decimals }; - }, - calcMode: (s) => this.tables[s]?.calc ?? 'auto', - }); - ctx.sheets = this.sheets; - return ctx; + const ctx = newCtx([], { spillBox: (s, c, r) => spillBox(this, s, c, r), ...tableFacts(() => this.tables) }); + return Object.assign(ctx, { sheets: this.sheets }); } /** Builds the state of a page and calculates every formula. */ diff --git a/app/src/features/tables/engine/graph/facts.ts b/app/src/features/tables/engine/graph/facts.ts new file mode 100644 index 000000000..5c6fa532d --- /dev/null +++ b/app/src/features/tables/engine/graph/facts.ts @@ -0,0 +1,15 @@ +import type { Table } from '../model'; + +/** What CELL and ISFORMULA may learn about the tables, and each table's calculation mode. */ +export function tableFacts(tables: () => readonly Table[]) { + return { + cellFacts: (s: number, c: number, r: number) => { + const column = tables()[s]?.columns[c]; + const cell = tables()[s]?.rows[r]?.cells[c]; + if (column === undefined || cell === undefined) return undefined; + const decimals = column.decimals === undefined ? {} : { decimals: column.decimals }; + return { formula: column.formula !== undefined || cell.formula !== undefined, type: column.type, ...decimals }; + }, + calcMode: (s: number) => tables()[s]?.calc ?? ('auto' as const), + }; +} From 9e0dff0cd33b5ef08d2cd1e93bc775a3966117a3 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sun, 8 Nov 2026 01:35:24 -0800 Subject: [PATCH 18/85] Update the settings screenshots for the Tables section Co-Authored-By: Claude Sonnet 5.5 --- .../connectors-settings-expanded-dark.png | Bin 118840 -> 117595 bytes .../connectors-settings-expanded-light.png | Bin 117831 -> 115882 bytes .../connectors-settings-wide-dark.png | Bin 142392 -> 142274 bytes .../connectors-settings-wide-light.png | Bin 138702 -> 140374 bytes .../crashReports-consent-expanded-dark.png | Bin 115654 -> 116858 bytes .../crashReports-consent-expanded-light.png | Bin 118462 -> 119377 bytes .../crashReports-consent-wide-dark.png | Bin 201757 -> 202891 bytes .../crashReports-consent-wide-light.png | Bin 204679 -> 206254 bytes .../crashReports-review-expanded-dark.png | Bin 112593 -> 113403 bytes .../crashReports-review-expanded-light.png | Bin 115652 -> 116729 bytes .../crashReports-review-wide-dark.png | Bin 153500 -> 154527 bytes .../crashReports-review-wide-light.png | Bin 156425 -> 157510 bytes .../help-selfCheck-expanded-dark.png | Bin 90896 -> 92149 bytes .../help-selfCheck-expanded-light.png | Bin 89365 -> 90361 bytes .../help-selfCheck-wide-dark.png | Bin 102601 -> 103892 bytes .../help-selfCheck-wide-light.png | Bin 100864 -> 101925 bytes .../intel-settings-expanded-dark.png | Bin 103602 -> 102936 bytes .../intel-settings-expanded-light.png | Bin 102322 -> 101247 bytes .../intel-settings-wide-dark.png | Bin 121728 -> 121651 bytes .../intel-settings-wide-light.png | Bin 117447 -> 119321 bytes .../privacy-offline-expanded-dark.png | Bin 109131 -> 110223 bytes .../privacy-offline-expanded-light.png | Bin 106425 -> 107651 bytes .../privacy-offline-wide-dark.png | Bin 123396 -> 124560 bytes .../privacy-offline-wide-light.png | Bin 120223 -> 121488 bytes .../privacy-panel-expanded-dark.png | Bin 105404 -> 106563 bytes .../privacy-panel-expanded-light.png | Bin 103275 -> 104328 bytes .../privacy-panel-wide-dark.png | Bin 118242 -> 119497 bytes .../privacy-panel-wide-light.png | Bin 115762 -> 116921 bytes .../recording-settings-expanded-dark.png | Bin 113033 -> 110246 bytes .../recording-settings-expanded-light.png | Bin 111864 -> 107883 bytes .../recording-settings-wide-dark.png | Bin 115369 -> 114471 bytes .../recording-settings-wide-light.png | Bin 110209 -> 112234 bytes .../settings-about-expanded-dark.png | Bin 102640 -> 103777 bytes .../settings-about-expanded-light.png | Bin 100713 -> 101760 bytes .../settings-about-wide-dark.png | Bin 122151 -> 123363 bytes .../settings-about-wide-light.png | Bin 119841 -> 121006 bytes .../settings-appearance-expanded-dark.png | Bin 87911 -> 88166 bytes .../settings-appearance-expanded-light.png | Bin 87121 -> 87167 bytes .../settings-appearance-wide-dark.png | Bin 99490 -> 100115 bytes .../settings-appearance-wide-light.png | Bin 97046 -> 98809 bytes .../settings-general-expanded-dark.png | Bin 59313 -> 58276 bytes .../settings-general-expanded-light.png | Bin 58863 -> 57190 bytes .../settings-general-wide-dark.png | Bin 60808 -> 61058 bytes .../settings-general-wide-light.png | Bin 58458 -> 59933 bytes .../settings-shortcuts-expanded-dark.png | Bin 108175 -> 109566 bytes .../settings-shortcuts-expanded-light.png | Bin 108368 -> 109819 bytes .../settings-shortcuts-wide-dark.png | Bin 132054 -> 133186 bytes .../settings-shortcuts-wide-light.png | Bin 132808 -> 133852 bytes .../storage-settings-expanded-dark.png | Bin 109822 -> 108824 bytes .../storage-settings-expanded-light.png | Bin 109640 -> 107690 bytes .../storage-settings-wide-dark.png | Bin 121075 -> 121183 bytes .../storage-settings-wide-light.png | Bin 117727 -> 119783 bytes .../windows-settings-compact-dark.png | Bin 58022 -> 56332 bytes .../windows-settings-expanded-dark.png | Bin 88900 -> 86397 bytes .../windows-settings-expanded-light.png | Bin 88093 -> 85102 bytes .../windows-settings-wide-dark.png | Bin 90012 -> 89421 bytes .../windows-settings-wide-light.png | Bin 86755 -> 88082 bytes 57 files changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-expanded-dark.png b/tests/ui/visual/__screenshots__/win32/matrix.spec.ts/connectors-settings-expanded-dark.png index 54740103166a05c08b999cdde80451291f99e0e5..8d2b932986b6f5ac812011bcdf431767acbed73d 100644 GIT binary patch literal 117595 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
X-n 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 Kl)pCp@_zu=Q~+oI literal 118840 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

_NQ&Dq{603ovGGU&R>ix2uO_vJ3gq|0OD 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