From 7d61c04668b5cd4b732b5571c4269a7cc7be3654 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Tue, 27 Oct 2026 23:30:22 +0200 Subject: [PATCH 01/16] feat(write-core): native table cells and grid items as lines Cell and grid item line attributes, validator and normalizer rules, the legacy embed upgrade, DocTree from cell lines, plain text and stats, and CellEdits for every structural table and grid edit (spec 12). Co-Authored-By: Claude Opus 5.5 --- .../wonky_rich_text/lib/src/attributes.dart | 14 +- .../lib/src/delta/cell_upgrade.dart | 93 ++++ .../wonky_write_core/lib/src/delta/cells.dart | 457 ++++++++++++++++ .../lib/src/delta/normalizer.dart | 46 ++ .../lib/src/delta/validator.dart | 38 ++ .../lib/src/derived/plain_text.dart | 20 +- .../lib/src/derived/stats.dart | 7 + .../lib/src/edit/cell_edits.dart | 513 ++++++++++++++++++ .../lib/src/import/import_support.dart | 16 +- .../wonky_write_core/lib/src/ir/doc_tree.dart | 53 +- .../lib/src/ir/doc_tree_builder.dart | 90 ++- .../lib/src/markdown/markdown_importer.dart | 33 +- .../lib/src/markdown/wonky_to_gfm.dart | 32 +- .../lib/wonky_write_core.dart | 3 + .../test/edit/cell_edits_test.dart | 445 +++++++++++++++ specs/wonky_write/12-native-cells.md | 313 +++++++++++ 16 files changed, 2095 insertions(+), 78 deletions(-) create mode 100644 packages/wonky_write_core/lib/src/delta/cell_upgrade.dart create mode 100644 packages/wonky_write_core/lib/src/delta/cells.dart create mode 100644 packages/wonky_write_core/lib/src/edit/cell_edits.dart create mode 100644 packages/wonky_write_core/test/edit/cell_edits_test.dart create mode 100644 specs/wonky_write/12-native-cells.md diff --git a/packages/wonky_rich_text/lib/src/attributes.dart b/packages/wonky_rich_text/lib/src/attributes.dart index 82debb42..70bb307b 100644 --- a/packages/wonky_rich_text/lib/src/attributes.dart +++ b/packages/wonky_rich_text/lib/src/attributes.dart @@ -65,6 +65,14 @@ abstract final class WonkyAttr { /// Valid next to every other block attribute and on embed lines. static const box = 'box'; + /// `Map` `{table, row, align?}` — a table cell line (Write 12 §1.1): + /// consecutive lines of one table, row by row; valid next to [box] only. + static const cell = 'cell'; + + /// `Map` `{id, columns, bullets}` — a Wonky Markdown grid item line + /// (Write 12 §1.2); valid next to [box] only. + static const grid = 'grid'; + // Overlays. /// Prefix of comment anchor keys: `cmt_: true`. @@ -111,11 +119,13 @@ abstract final class WonkyAttr { indent, step, box, + cell, + grid, }; /// The block keys a [BlockFormat] describes (a block type change rewrites - /// them all); [box] is not one of them, so containers survive block type - /// changes. + /// them all); [box], [cell] and [grid] are not among them, so containers, + /// tables and grids survive block type changes. static const blockFormatKeys = { header, blockquote, diff --git a/packages/wonky_write_core/lib/src/delta/cell_upgrade.dart b/packages/wonky_write_core/lib/src/delta/cell_upgrade.dart new file mode 100644 index 00000000..5879a5d7 --- /dev/null +++ b/packages/wonky_write_core/lib/src/delta/cell_upgrade.dart @@ -0,0 +1,93 @@ +/// The upgrade of legacy `table` and `grid` embeds to cell and grid item +/// lines (specs/wonky_write/12-native-cells.md §1.5). +library; + +import 'package:wonky_rich_text/wonky_rich_text.dart'; + +import 'cells.dart'; +import 'document.dart'; +import 'embeds.dart'; + +/// Converts legacy `table` and `grid` embeds (Write 12 §1.5). +abstract final class CellUpgrade { + /// Whether [document] still holds a legacy `table` or `grid` embed. + static bool hasLegacy(Delta document) { + for (final op in document.toList()) { + final data = op.data; + if (data is Map && + (data.containsKey(TableEmbed.typeKey) || + data.containsKey(GridEmbed.typeKey))) { + return true; + } + } + return false; + } + + /// The change that replaces every legacy embed line of [document] with + /// cell or grid item lines, or `null` when there is none. The embed id is + /// kept as the table or grid id; row ids come from [rowId] (random by + /// default); the embed line's `box` goes on every new line; overlays of + /// the embed (comments, suggestions) are dropped. + static Delta? upgradeChange(Delta document, {String Function()? rowId}) { + if (!hasLegacy(document)) return null; + final doc = WonkyDocument(document); + final change = Delta(); + var cursor = 0; + var changed = false; + for (final line in doc.lines) { + if (!line.isEmbedLine || !line.hasNewline) continue; + final embed = WonkyEmbed.tryParse(line.segments.single.data); + final box = line.attributes[WonkyAttr.box]; + final Delta lines; + if (embed is TableEmbed) { + lines = tableLines(embed, box: box, rowId: rowId); + } else if (embed is GridEmbed) { + lines = gridLines(embed, box: box); + } else { + continue; + } + if (line.start > cursor) change.retain(line.start - cursor); + change.delete(line.length); + for (final op in lines.toList()) { + change.push(op); + } + cursor = line.end; + changed = true; + } + return changed ? change : null; + } + + /// [document] with every legacy embed upgraded. + static Delta upgrade(Delta document, {String Function()? rowId}) { + final change = upgradeChange(document, rowId: rowId); + return change == null ? document : document.compose(change); + } + + /// The lines of a legacy table embed. + static Delta tableLines( + TableEmbed table, { + Object? box, + String Function()? rowId, + }) { + final padded = table.padded(); + return CellLines.table( + id: table.id, + rows: [ + for (final row in padded.rows) + [for (final c in row) CellLines.markdown(c)], + ], + align: padded.align, + box: box, + rowId: rowId, + ); + } + + /// The lines of a legacy grid embed. + static Delta gridLines(GridEmbed grid, {Object? box}) => CellLines.grid( + id: grid.id, + items: [for (final item in grid.items) CellLines.markdown(item)], + columns: grid.columns, + bullets: grid.bullets, + box: box, + ); +} diff --git a/packages/wonky_write_core/lib/src/delta/cells.dart b/packages/wonky_write_core/lib/src/delta/cells.dart new file mode 100644 index 00000000..e4ef3bdd --- /dev/null +++ b/packages/wonky_write_core/lib/src/delta/cells.dart @@ -0,0 +1,457 @@ +/// Table cell and grid item lines (specs/wonky_write/12-native-cells.md §1). +library; + +import 'dart:math'; + +import 'package:uuid/uuid.dart'; +import 'package:wonky_rich_text/wonky_rich_text.dart'; + +import 'document.dart'; +import 'embeds.dart'; + +/// The `cell` line attribute: the line is a cell of table [table], in row +/// [row] (Write 12 §1.1). +final class CellAttr { + /// Creates a cell attribute. + const CellAttr({required this.table, required this.row, this.align}); + + /// The table's id. + final String table; + + /// The row's id (unique within the table). + final String row; + + /// The cell's alignment (`null` = default). + final CellAlign? align; + + /// Parses a `cell` value; `null` when it is not a map with non-empty + /// `table` and `row` strings. Unknown keys and invalid `align` values are + /// ignored (the normalizer removes them). + static CellAttr? tryParse(Object? value) { + if (value is! Map) return null; + final table = value['table']; + final row = value['row']; + if (table is! String || table.isEmpty || row is! String || row.isEmpty) { + return null; + } + return CellAttr( + table: table, + row: row, + align: CellAlign.tryParse(value['align']), + ); + } + + /// The cell attribute of a line's [attributes], if any. + static CellAttr? of(Map? attributes) => + tryParse(attributes?[WonkyAttr.cell]); + + /// Whether [value] is a `cell` value in canonical form (validator). + static bool isCanonical(Object? value) { + final cell = tryParse(value); + return cell != null && _sameJson(cell.toJson(), value as Map); + } + + /// The JSON value. + Map toJson() => { + 'table': table, + 'row': row, + if (align != null) 'align': align!.value, + }; + + /// Copy with changed fields. + CellAttr copyWith({ + String? table, + String? row, + CellAlign? align, + bool clearAlign = false, + }) => CellAttr( + table: table ?? this.table, + row: row ?? this.row, + align: clearAlign ? null : (align ?? this.align), + ); + + @override + bool operator ==(Object other) => + other is CellAttr && + other.table == table && + other.row == row && + other.align == align; + + @override + int get hashCode => Object.hash(table, row, align); + + @override + String toString() => 'CellAttr(${toJson()})'; +} + +/// The `grid` line attribute: the line is an item of grid [id] (Write 12 +/// §1.2). +final class GridItemAttr { + /// Creates a grid item attribute. + const GridItemAttr({ + required this.id, + this.columns = GridEmbed.defaultColumns, + this.bullets = GridBullets.check, + }); + + /// The grid's id. + final String id; + + /// The grid's column count, 1–4 (canonical, 11 §2.2). + final int columns; + + /// The grid's bullets. + final GridBullets bullets; + + /// Parses a `grid` value; `null` when it is not a map with a non-empty + /// `id`. Other values are canonicalized. + static GridItemAttr? tryParse(Object? value) { + if (value is! Map) return null; + final id = value['id']; + if (id is! String || id.isEmpty) return null; + return GridItemAttr( + id: id, + columns: GridEmbed.canonicalColumns(value['columns']), + bullets: GridBullets.parse(value['bullets']), + ); + } + + /// The grid item attribute of a line's [attributes], if any. + static GridItemAttr? of(Map? attributes) => + tryParse(attributes?[WonkyAttr.grid]); + + /// Whether [value] is a `grid` value in canonical form (validator). + static bool isCanonical(Object? value) { + final item = tryParse(value); + return item != null && _sameJson(item.toJson(), value as Map); + } + + /// The JSON value. + Map toJson() => { + 'id': id, + 'columns': columns, + 'bullets': bullets.value, + }; + + /// Copy with changed fields. + GridItemAttr copyWith({String? id, int? columns, GridBullets? bullets}) => + GridItemAttr( + id: id ?? this.id, + columns: columns == null + ? this.columns + : GridEmbed.canonicalColumns(columns), + bullets: bullets ?? this.bullets, + ); + + @override + bool operator ==(Object other) => + other is GridItemAttr && + other.id == id && + other.columns == columns && + other.bullets == bullets; + + @override + int get hashCode => Object.hash(id, columns, bullets); + + @override + String toString() => 'GridItemAttr(${toJson()})'; +} + +bool _sameJson(Map canonical, Map value) { + if (canonical.length != value.length) return false; + for (final e in canonical.entries) { + if (value[e.key] != e.value) return false; + } + return true; +} + +/// New table, grid and row ids (Write 12 §1.3). +abstract final class CellIds { + static final _random = Random.secure(); + static const _alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; + + /// A new table or grid id (UUIDv7). + static String group() => const Uuid().v7(); + + /// A new row id: 8 random base-36 characters. + static String row([Random? random]) { + final r = random ?? _random; + return String.fromCharCodes([ + for (var i = 0; i < 8; i++) _alphabet.codeUnitAt(r.nextInt(36)), + ]); + } +} + +/// A table read from its lines (Write 12 §1.1). +final class CellTable { + /// Creates a table view. + CellTable({required this.id, required this.rows}); + + /// The table's id. + final String id; + + /// The rows, in document order; row 0 is the header row. + final List rows; + + /// The first line of the table. + DocumentLine get firstLine => rows.first.cells.first; + + /// The last line of the table. + DocumentLine get lastLine => rows.last.cells.last; + + /// Document offset of the table's first character. + int get start => firstLine.start; + + /// Document offset just after the table's last line. + int get end => lastLine.end; + + /// The column count: the length of the longest row. + int get columnCount => + rows.fold(0, (n, r) => r.cells.length > n ? r.cells.length : n); + + /// Whether a row is shorter than [columnCount]. + bool get isRagged => rows.any((r) => r.cells.length != columnCount); + + /// The column alignments (the header cells', padded to [columnCount]). + List get align => [ + for (var c = 0; c < columnCount; c++) + c < rows.first.cells.length + ? CellAttr.of(rows.first.cells[c].attributes)?.align + : null, + ]; + + /// The `box` value of the table's first cell (containers it is in). + Object? get box => firstLine.attributes[WonkyAttr.box]; + + /// Every line of the table, in order. + Iterable get lines => rows.expand((r) => r.cells); + + /// The (row, column) of the line starting at [lineStart], if it is a cell + /// of this table. + (int, int)? cellOf(int lineStart) { + for (var r = 0; r < rows.length; r++) { + final cells = rows[r].cells; + for (var c = 0; c < cells.length; c++) { + if (cells[c].start == lineStart) return (r, c); + } + } + return null; + } +} + +/// A row of a [CellTable]. +final class CellRow { + /// Creates a row view. + CellRow({required this.id, required this.cells}); + + /// The row's id. + final String id; + + /// The row's cell lines, column by column. + final List cells; +} + +/// A grid read from its lines (Write 12 §1.2). +final class CellGrid { + /// Creates a grid view. + CellGrid({required this.id, required this.items}); + + /// The grid's id. + final String id; + + /// The item lines, in order. + final List items; + + /// The first item's attribute (the grid's columns and bullets). + GridItemAttr get attr => GridItemAttr.of(items.first.attributes)!; + + /// The grid's column count. + int get columns => attr.columns; + + /// The grid's bullets. + GridBullets get bullets => attr.bullets; + + /// Document offset of the grid's first character. + int get start => items.first.start; + + /// Document offset just after the grid's last line. + int get end => items.last.end; + + /// The `box` value of the first item. + Object? get box => items.first.attributes[WonkyAttr.box]; +} + +/// Finds the tables and grids of a document (Write 12 §1.1–1.2). +abstract final class CellRuns { + /// Every table and grid of [doc], in document order ([CellTable] or + /// [CellGrid]). + static List of(WonkyDocument doc) { + final out = []; + final lines = doc.lines; + var i = 0; + while (i < lines.length) { + final run = _runAt(lines, i); + if (run == null) { + i++; + continue; + } + out.add(run.$1); + i = run.$2; + } + return out; + } + + /// The table or grid that contains line [lineIndex] of [doc], if any. + static Object? at(WonkyDocument doc, int lineIndex) { + final lines = doc.lines; + if (lineIndex < 0 || lineIndex >= lines.length) return null; + final line = lines[lineIndex]; + final cell = CellAttr.of(line.attributes); + final item = cell == null ? GridItemAttr.of(line.attributes) : null; + if (cell == null && item == null) return null; + // Back to the run's first line. + var first = lineIndex; + while (first > 0 && _sameGroup(lines[first - 1], cell, item)) { + first--; + } + return _runAt(lines, first)?.$1; + } + + /// The table with id [id] in [doc], if any (the first run with that id). + static CellTable? table(WonkyDocument doc, String id) { + for (final run in of(doc)) { + if (run is CellTable && run.id == id) return run; + } + return null; + } + + /// The grid with id [id] in [doc], if any (the first run with that id). + static CellGrid? grid(WonkyDocument doc, String id) { + for (final run in of(doc)) { + if (run is CellGrid && run.id == id) return run; + } + return null; + } + + static bool _sameGroup( + DocumentLine line, + CellAttr? cell, + GridItemAttr? item, + ) { + if (cell != null) return CellAttr.of(line.attributes)?.table == cell.table; + return GridItemAttr.of(line.attributes)?.id == item!.id; + } + + /// The run starting at line [i] and the index after it. + static (Object, int)? _runAt(List lines, int i) { + final cell = CellAttr.of(lines[i].attributes); + if (cell != null) { + final rows = []; + var j = i; + while (j < lines.length) { + final c = CellAttr.of(lines[j].attributes); + if (c == null || c.table != cell.table) break; + if (rows.isEmpty || rows.last.id != c.row) { + rows.add(CellRow(id: c.row, cells: [lines[j]])); + } else { + rows.last.cells.add(lines[j]); + } + j++; + } + return (CellTable(id: cell.table, rows: rows), j); + } + final item = GridItemAttr.of(lines[i].attributes); + if (item != null) { + final items = []; + var j = i; + while (j < lines.length && + GridItemAttr.of(lines[j].attributes)?.id == item.id) { + items.add(lines[j]); + j++; + } + return (CellGrid(id: item.id, items: items), j); + } + return null; + } +} + +/// Builds the Delta of new tables and grids (Write 12 §1). +abstract final class CellLines { + /// The inline Delta of [runs] (no newline). + static Delta inline(Iterable runs) { + final d = Delta(); + for (final r in runs) { + if (r.text.isEmpty) continue; + d.insert(r.text, r.toAttributes()); + } + return d; + } + + /// The inline Delta of an inline-Markdown cell (Wonky flavor). + static Delta markdown(String cell) => inline(InlineMarkdown.parseCell(cell)); + + /// The lines of a new table: [rows] of inline Deltas (row 0 the header), + /// padded to the longest row; [align] per column; [box] on every line. + static Delta table({ + required String id, + required List> rows, + List align = const [], + Object? box, + String Function()? rowId, + }) { + final columns = rows.fold( + align.length, + (n, r) => r.length > n ? r.length : n, + ); + final d = Delta(); + for (final row in rows) { + final rid = (rowId ?? CellIds.row)(); + for (var c = 0; c < (columns == 0 ? 1 : columns); c++) { + if (c < row.length) _appendInline(d, row[c]); + d.insert('\n', { + WonkyAttr.cell: CellAttr( + table: id, + row: rid, + align: c < align.length ? align[c] : null, + ).toJson(), + WonkyAttr.box: ?box, + }); + } + } + return d; + } + + /// The lines of a new grid: one per item of [items] (at least one). + static Delta grid({ + required String id, + required List items, + int columns = GridEmbed.defaultColumns, + GridBullets bullets = GridBullets.check, + Object? box, + }) { + final attr = GridItemAttr( + id: id, + columns: GridEmbed.canonicalColumns(columns), + bullets: bullets, + ).toJson(); + final d = Delta(); + for (final item in items.isEmpty ? [Delta()] : items) { + _appendInline(d, item); + d.insert('\n', {WonkyAttr.grid: attr, WonkyAttr.box: ?box}); + } + return d; + } + + static void _appendInline(Delta out, Delta inline) { + for (final op in inline.toList()) { + final data = op.data; + if (data is String) { + final text = data + .replaceAll('\n', ' ') + .replaceAll('
', ' ') + .replaceAll('
', ' '); + if (text.isNotEmpty) out.insert(text, op.attributes); + } + } + } +} diff --git a/packages/wonky_write_core/lib/src/delta/normalizer.dart b/packages/wonky_write_core/lib/src/delta/normalizer.dart index 4a3c5e98..04c61935 100644 --- a/packages/wonky_write_core/lib/src/delta/normalizer.dart +++ b/packages/wonky_write_core/lib/src/delta/normalizer.dart @@ -5,6 +5,7 @@ import 'package:dart_quill_delta/dart_quill_delta.dart'; import 'attributes.dart'; import 'boxes.dart'; +import 'cells.dart'; import 'delta_utils.dart'; import 'document.dart'; import 'embeds.dart'; @@ -369,6 +370,10 @@ final class WonkyDeltaNormalizer { stampAt, ); final isCode = blockOut.containsKey(WonkyAttr.codeBlock); + // A table cell or grid item is one line (Write 12 §1.4). + final isCell = + blockOut.containsKey(WonkyAttr.cell) || + blockOut.containsKey(WonkyAttr.grid); // Group segments into text runs and embeds (after embed validation). final groups = <_Group>[]; @@ -460,6 +465,7 @@ final class WonkyDeltaNormalizer { b, fixes, isCode: isCode, + isCell: isCell, codeLineAttrs: blockOut, stampAt: stampAt, ); @@ -523,6 +529,7 @@ final class WonkyDeltaNormalizer { _Builder b, Set fixes, { required bool isCode, + bool isCell = false, required Map codeLineAttrs, required ({Set offsets, int at})? stampAt, }) { @@ -594,6 +601,12 @@ final class WonkyDeltaNormalizer { b.insert('\n', Attributes.orNull(nlAttrs)); runStart = i + 1; fixes.add(NormalizationFix.replacedCharacters); + } else if (isSoft && isCell) { + flush(i); + b.delete(1); + b.insert(' ', attrsOut); + runStart = i + 1; + fixes.add(NormalizationFix.replacedCharacters); } else if (u == 0x2029) { flush(i); b.delete(1); @@ -795,6 +808,7 @@ final class WonkyDeltaNormalizer { fixes.add(NormalizationFix.removedAttributes); } } + _normalizeCell(attrs, out, fixes); for (final key in attrs.keys) { if (!WonkyAttr.isBlock(key) && !WonkyAttr.isOverlay(key)) { fixes.add(NormalizationFix.removedAttributes); @@ -811,6 +825,38 @@ final class WonkyDeltaNormalizer { return out; } + /// A table cell or grid item line (Write 12 §1.4): its canonical `cell` + /// or `grid` value, and no other block attribute than `box`. + static void _normalizeCell( + Map attrs, + Map out, + Set fixes, + ) { + final hasCell = attrs.containsKey(WonkyAttr.cell); + final hasGrid = attrs.containsKey(WonkyAttr.grid); + if (!hasCell && !hasGrid) return; + final cell = hasCell ? CellAttr.tryParse(attrs[WonkyAttr.cell]) : null; + final grid = cell == null && hasGrid + ? GridItemAttr.tryParse(attrs[WonkyAttr.grid]) + : null; + final value = cell?.toJson() ?? grid?.toJson(); + if (value == null) { + fixes.add(NormalizationFix.removedAttributes); + return; + } + for (final key in [...out.keys]) { + if (key != WonkyAttr.box && WonkyAttr.isBlock(key)) { + out.remove(key); + fixes.add(NormalizationFix.removedAttributes); + } + } + final key = cell != null ? WonkyAttr.cell : WonkyAttr.grid; + out[key] = value; + if (!_deepEquals(value, attrs[key]) || (hasCell && hasGrid)) { + fixes.add(NormalizationFix.removedAttributes); + } + } + /// The canonical `box` value of [value]: valid frames only (Write 11 /// §2.4 rule 11), distinct ids, at most [BoxFrame.maxDepth] (the /// outermost), canonical titles; `null` when none is left. diff --git a/packages/wonky_write_core/lib/src/delta/validator.dart b/packages/wonky_write_core/lib/src/delta/validator.dart index a9d677cf..235277e0 100644 --- a/packages/wonky_write_core/lib/src/delta/validator.dart +++ b/packages/wonky_write_core/lib/src/delta/validator.dart @@ -6,6 +6,7 @@ import 'package:wonky_rich_text/wonky_rich_text.dart' show TextRules; import 'attributes.dart'; import 'boxes.dart'; +import 'cells.dart'; import 'embeds.dart'; import 'links.dart'; @@ -57,6 +58,10 @@ enum ValidationCode { /// rule 12). invalidStep, + /// An invalid `cell` or `grid` value, both on one line, or another block + /// attribute than `box` next to them (Write 12 §1.4). + invalidCell, + /// An embed that is not on its own line, or followed by a `"\n"` with block /// attributes. embedPlacement, @@ -274,6 +279,9 @@ final class WonkyDeltaValidator { add(ValidationCode.tabOutsideCode, t, 'Tab outside a code block'); } } + final lineIsCell = + attrs.containsKey(WonkyAttr.cell) || + attrs.containsKey(WonkyAttr.grid); for (final s in pendingSoftBreaks) { if (lineIsCode) { add( @@ -281,6 +289,12 @@ final class WonkyDeltaValidator { s, 'Soft break inside a code block', ); + } else if (lineIsCell) { + add( + ValidationCode.invalidCell, + s, + 'Soft break inside a table cell or grid item', + ); } } if (lineIsCode) { @@ -601,6 +615,30 @@ final class WonkyDeltaValidator { if (!isValidBox(v)) { add(ValidationCode.invalidBox, offset, 'Invalid "box" value'); } + case WonkyAttr.cell: + if (!CellAttr.isCanonical(v)) { + add(ValidationCode.invalidCell, offset, 'Invalid "cell" value'); + } + case WonkyAttr.grid: + if (!GridItemAttr.isCanonical(v)) { + add(ValidationCode.invalidCell, offset, 'Invalid "grid" value'); + } + } + } + if (attrs.containsKey(WonkyAttr.cell) || + attrs.containsKey(WonkyAttr.grid)) { + // A cell or grid item is one plain line (Write 12 §1.4). + final others = Attributes.block(attrs).keys.where( + (k) => k != WonkyAttr.box && k != WonkyAttr.cell && k != WonkyAttr.grid, + ); + if (others.isNotEmpty || + (attrs.containsKey(WonkyAttr.cell) && + attrs.containsKey(WonkyAttr.grid))) { + add( + ValidationCode.invalidCell, + offset, + 'A cell or grid line with other block attributes', + ); } } if (types > 1) { diff --git a/packages/wonky_write_core/lib/src/derived/plain_text.dart b/packages/wonky_write_core/lib/src/derived/plain_text.dart index 6c75f044..c06c8c54 100644 --- a/packages/wonky_write_core/lib/src/derived/plain_text.dart +++ b/packages/wonky_write_core/lib/src/derived/plain_text.dart @@ -4,6 +4,7 @@ library; import 'package:dart_quill_delta/dart_quill_delta.dart'; import '../collab/suggestion_view.dart'; +import '../delta/cells.dart'; import '../delta/delta_utils.dart'; import '../delta/document.dart'; import '../delta/embeds.dart'; @@ -13,7 +14,8 @@ import '../markdown/inline_markdown.dart'; abstract final class PlainTextRenderer { /// Plain text of [document]: one line per line, soft breaks as `"\n"`, /// images as `[image: alt]`, dividers as `---`, tables as TSV (cells as - /// plain text), grid items one per line. Suggestions rendered per [view]. + /// plain text, cell lines and legacy embeds alike), grid items one per + /// line. Suggestions rendered per [view]. static String render( Delta document, { SuggestionViewMode view = SuggestionViewMode.accepted, @@ -23,7 +25,23 @@ abstract final class PlainTextRenderer { : SuggestionView.render(document, view); final doc = WonkyDocument(viewed); final out = []; + // The row of the previous cell line: cells of one row join with tabs. + CellAttr? prevCell; for (final line in doc.lines) { + final cell = CellAttr.of(line.attributes); + if (cell != null) { + final text = line.text.replaceAll(embedChar, '').replaceAll('\t', ' '); + if (prevCell != null && + prevCell.table == cell.table && + prevCell.row == cell.row) { + out.last = '${out.last}\t$text'; + } else { + out.add(text); + } + prevCell = cell; + continue; + } + prevCell = null; if (line.isEmbedLine) { final e = line.embed; switch (e) { diff --git a/packages/wonky_write_core/lib/src/derived/stats.dart b/packages/wonky_write_core/lib/src/derived/stats.dart index 8e6da875..0d9b2549 100644 --- a/packages/wonky_write_core/lib/src/derived/stats.dart +++ b/packages/wonky_write_core/lib/src/derived/stats.dart @@ -4,6 +4,7 @@ library; import 'package:dart_quill_delta/dart_quill_delta.dart'; import '../delta/attributes.dart'; +import '../delta/cells.dart'; import '../delta/document.dart'; import '../delta/embeds.dart'; @@ -123,6 +124,7 @@ final class DocumentStatsCalculator { final comments = {}; final suggestions = {}; String? prevCodeLanguage; + String? prevTable; var inWord = false; for (final line in doc.lines) { @@ -191,6 +193,11 @@ final class DocumentStatsCalculator { continue; } prevCodeLanguage = null; + // A table counts once; cells and grid items are not paragraphs. + final cell = CellAttr.of(line.attributes); + if (cell != null && cell.table != prevTable) tables++; + prevTable = cell?.table; + if (cell != null || GridItemAttr.of(line.attributes) != null) continue; if (line.isEmbedLine) continue; if (f.type == BlockType.heading) headings++; if (f.isList) listItems++; diff --git a/packages/wonky_write_core/lib/src/edit/cell_edits.dart b/packages/wonky_write_core/lib/src/edit/cell_edits.dart new file mode 100644 index 00000000..2c6a13d0 --- /dev/null +++ b/packages/wonky_write_core/lib/src/edit/cell_edits.dart @@ -0,0 +1,513 @@ +/// Structural edits of tables and grids made of cell and grid item lines +/// (specs/wonky_write/12-native-cells.md §3.5, §4). +library; + +import 'package:wonky_rich_text/wonky_rich_text.dart'; + +import '../delta/cells.dart'; +import '../delta/document.dart'; +import '../delta/embeds.dart'; + +/// An invalid structural edit (an index out of range, an unknown table). +final class CellEditException implements Exception { + /// Creates the exception. + const CellEditException(this.message); + + /// What is wrong. + final String message; + + @override + String toString() => 'CellEditException: $message'; +} + +/// Builds the changes of the table and grid commands (Write 12 §3.5) and +/// the MCP tools (§4). Every method returns a change relative to the +/// document it was given (not normalized: callers normalize it), or `null` +/// when the document already has the requested value. +/// +/// Edits keep the lines they do not touch: a cell that keeps its place +/// keeps its text, its comments and its row id, so concurrent typing in +/// other cells transforms cleanly. Ragged rows are completed with empty +/// cells as part of every table edit (§1.1); row and column indices count +/// the completed table. +abstract final class CellEdits { + // -- Tables -------------------------------------------------------------- + + /// The table with id [tableId] in [doc]. + static CellTable tableOf(WonkyDocument doc, String tableId) => + CellRuns.table(doc, tableId) ?? + (throw CellEditException('Table $tableId not found')); + + /// The grid with id [gridId] in [doc]. + static CellGrid gridOf(WonkyDocument doc, String gridId) => + CellRuns.grid(doc, gridId) ?? + (throw CellEditException('Grid $gridId not found')); + + /// Completes the ragged rows of the table (empty cells at their ends); + /// `null` when no row is short. + static Delta? completeRows(WonkyDocument doc, String tableId) { + final t = tableOf(doc, tableId); + if (!t.isRagged) return null; + return _TableModel(doc, t).compile(); + } + + /// Inserts a row before row [at] (`rows.length` appends) with [cells] + /// (inline Deltas; missing cells are empty). + static Delta insertRow( + WonkyDocument doc, + String tableId, + int at, { + List? cells, + String Function()? rowId, + }) { + final m = _TableModel(doc, tableOf(doc, tableId)); + _check(at >= 0 && at <= m.rows.length, 'Row index $at out of range'); + m.rows.insert( + at, + _RowSlot.fresh((rowId ?? CellIds.row)(), [ + for (var c = 0; c < m.columns; c++) + _CellSlot.fresh( + cells != null && c < cells.length ? cells[c] : Delta(), + m.align[c], + ), + ]), + ); + return m.compile(); + } + + /// Deletes row [row]; deleting the only row deletes the table. + static Delta deleteRow(WonkyDocument doc, String tableId, int row) { + final t = tableOf(doc, tableId); + final m = _TableModel(doc, t); + _check(row >= 0 && row < m.rows.length, 'Row $row out of range'); + if (m.rows.length == 1) return deleteTable(doc, tableId); + m.rows.removeAt(row); + return m.compile(); + } + + /// Moves row [from] to index [to] (indices before the move). + static Delta? moveRow(WonkyDocument doc, String tableId, int from, int to) { + final m = _TableModel(doc, tableOf(doc, tableId)); + _check(from >= 0 && from < m.rows.length, 'Row $from out of range'); + _check(to >= 0 && to < m.rows.length, 'Row $to out of range'); + if (from == to) return m.compileOrNull(); + final row = m.rows.removeAt(from).moved(); + m.rows.insert(to, row); + return m.compile(); + } + + /// Inserts a column before column [at] (`columnCount` appends): [header] + /// in row 0 and [cells] in the body rows (inline Deltas; missing cells are + /// empty). + static Delta insertColumn( + WonkyDocument doc, + String tableId, + int at, { + Delta? header, + List? cells, + }) { + final m = _TableModel(doc, tableOf(doc, tableId)); + _check(at >= 0 && at <= m.columns, 'Column index $at out of range'); + for (var r = 0; r < m.rows.length; r++) { + final content = r == 0 + ? header + : (cells != null && r - 1 < cells.length ? cells[r - 1] : null); + m.rows[r].cells.insert(at, _CellSlot.fresh(content ?? Delta(), null)); + } + m.align.insert(at, null); + return m.compile(); + } + + /// Deletes column [col]; deleting the only column deletes the table. + static Delta deleteColumn(WonkyDocument doc, String tableId, int col) { + final m = _TableModel(doc, tableOf(doc, tableId)); + _check(col >= 0 && col < m.columns, 'Column $col out of range'); + if (m.columns == 1) return deleteTable(doc, tableId); + for (final r in m.rows) { + r.cells.removeAt(col); + } + m.align.removeAt(col); + return m.compile(); + } + + /// Moves column [from] to index [to] (indices before the move). + static Delta? moveColumn( + WonkyDocument doc, + String tableId, + int from, + int to, + ) { + final m = _TableModel(doc, tableOf(doc, tableId)); + _check(from >= 0 && from < m.columns, 'Column $from out of range'); + _check(to >= 0 && to < m.columns, 'Column $to out of range'); + if (from == to) return m.compileOrNull(); + for (final r in m.rows) { + r.cells.insert(to, r.cells.removeAt(from).moved()); + } + m.align.insert(to, m.align.removeAt(from)); + return m.compile(); + } + + /// Sets the alignment of column [col] (on every cell of the column). + static Delta? setColumnAlignment( + WonkyDocument doc, + String tableId, + int col, + CellAlign? align, + ) { + final m = _TableModel(doc, tableOf(doc, tableId)); + _check(col >= 0 && col < m.columns, 'Column $col out of range'); + m.align[col] = align; + return m.compileOrNull(); + } + + /// Replaces the content of cell ([row], [col]) with [content] (an inline + /// Delta); `null` when the cell already holds it. + static Delta? setCell( + WonkyDocument doc, + String tableId, + int row, + int col, + Delta content, + ) { + final m = _TableModel(doc, tableOf(doc, tableId)); + _check( + row >= 0 && row < m.rows.length && col >= 0 && col < m.columns, + 'Cell ($row, $col) out of range', + ); + m.rows[row].cells[col].setContent(content); + return m.compileOrNull(); + } + + /// Replaces the table's content with [rows] (inline Deltas, row 0 the + /// header; padded to the longest row) and [align] (kept when `null`). + /// Cells that keep their position and content keep their lines (Write + /// 12 §3.6); `null` when nothing changes. + static Delta? replaceTable( + WonkyDocument doc, + String tableId, + List> rows, { + List? align, + String Function()? rowId, + }) { + _check(rows.isNotEmpty, 'A table needs at least one row'); + final m = _TableModel(doc, tableOf(doc, tableId)); + final columns = rows.fold( + align?.length ?? 1, + (n, r) => r.length > n ? r.length : n, + ); + final newAlign = [ + for (var c = 0; c < columns; c++) + align != null + ? (c < align.length ? align[c] : null) + : (c < m.align.length ? m.align[c] : null), + ]; + final out = <_RowSlot>[]; + for (var r = 0; r < rows.length; r++) { + final old = r < m.rows.length ? m.rows[r] : null; + final cells = <_CellSlot>[ + for (var c = 0; c < columns; c++) + if (old != null && c < old.cells.length) + old.cells[c]..setContent(c < rows[r].length ? rows[r][c] : Delta()) + else + _CellSlot.fresh( + c < rows[r].length ? rows[r][c] : Delta(), + newAlign[c], + ), + ]; + out.add( + old == null + ? _RowSlot.fresh((rowId ?? CellIds.row)(), cells) + : _RowSlot.existing(old.id, cells), + ); + } + m.rows + ..clear() + ..addAll(out); + m.align + ..clear() + ..addAll(newAlign); + return m.compileOrNull(); + } + + /// Deletes the table. + static Delta deleteTable(WonkyDocument doc, String tableId) { + final t = tableOf(doc, tableId); + return Delta() + ..retain(t.start) + ..delete(t.end - t.start); + } + + // -- Grids --------------------------------------------------------------- + + /// Sets the grid's column count (canonical 1–4) on every item. + static Delta? setGridColumns(WonkyDocument doc, String gridId, int columns) { + final g = gridOf(doc, gridId); + return _patchGrid(g, g.attr.copyWith(columns: columns)); + } + + /// Sets the grid's bullets on every item. + static Delta? setGridBullets( + WonkyDocument doc, + String gridId, + GridBullets bullets, + ) { + final g = gridOf(doc, gridId); + return _patchGrid(g, g.attr.copyWith(bullets: bullets)); + } + + /// Inserts an item with [content] before item [at] (`items.length` + /// appends). + static Delta addGridItem( + WonkyDocument doc, + String gridId, + int at, { + Delta? content, + }) { + final g = gridOf(doc, gridId); + _check(at >= 0 && at <= g.items.length, 'Item index $at out of range'); + _check( + g.items.length < GridEmbed.maxItems, + 'A grid has at most ${GridEmbed.maxItems} items', + ); + final offset = at == g.items.length ? g.end : g.items[at].start; + return _append( + Delta()..retain(offset), + CellLines.grid( + id: g.id, + items: [content ?? Delta()], + columns: g.columns, + bullets: g.bullets, + box: g.box, + ), + ); + } + + /// Removes item [index]; removing the only item deletes the grid. + static Delta removeGridItem(WonkyDocument doc, String gridId, int index) { + final g = gridOf(doc, gridId); + _check(index >= 0 && index < g.items.length, 'Item $index out of range'); + final item = g.items[index]; + return Delta() + ..retain(item.start) + ..delete(item.length); + } + + /// Replaces the content of item [index] with [content] (an inline Delta); + /// `null` when the item already holds it. + static Delta? setGridItem( + WonkyDocument doc, + String gridId, + int index, + Delta content, + ) { + final g = gridOf(doc, gridId); + _check(index >= 0 && index < g.items.length, 'Item $index out of range'); + final item = g.items[index]; + final clean = _singleLine(content); + if (_content(item) == clean) return null; + return _append( + Delta() + ..retain(item.start) + ..delete(item.contentLength), + clean, + ); + } + + /// Deletes the grid. + static Delta deleteGrid(WonkyDocument doc, String gridId) { + final g = gridOf(doc, gridId); + return Delta() + ..retain(g.start) + ..delete(g.end - g.start); + } + + static Delta? _patchGrid(CellGrid g, GridItemAttr attr) { + final json = attr.toJson(); + final d = Delta(); + var cursor = 0; + var changed = false; + for (final item in g.items) { + if (GridItemAttr.of(item.attributes) == attr) continue; + d + ..retain(item.newlineOffset - cursor) + ..retain(1, {WonkyAttr.grid: json}); + cursor = item.end; + changed = true; + } + return changed ? d : null; + } + + // -- Helpers ------------------------------------------------------------- + + /// [d] with the ops of [other] pushed onto it. + static Delta _append(Delta d, Delta other) { + for (final op in other.toList()) { + d.push(op); + } + return d; + } + + static void _check(bool ok, String message) { + if (!ok) throw CellEditException(message); + } + + /// The inline content of [line] (without its newline). + static Delta _content(DocumentLine line) { + final d = Delta(); + for (final s in line.segments) { + d.insert(s.data, s.attributes); + } + return d; + } + + /// [content] as one line: newlines, soft breaks and tabs become spaces, + /// embeds are dropped (cells hold text only, §1.1). + static Delta _singleLine(Delta content) { + final d = Delta(); + for (final op in content.toList()) { + final data = op.data; + if (op.isInsert && data is String) { + final text = data.replaceAll(RegExp('[\n\r\t

]'), ' '); + if (text.isNotEmpty) d.insert(text, op.attributes); + } + } + return d; + } +} + +/// A cell of a [_TableModel]: an existing line (kept in place, possibly with +/// new content) or a new cell. +final class _CellSlot { + _CellSlot.existing(DocumentLine this.line) + : align = CellAttr.of(line.attributes)?.align; + + _CellSlot.fresh(Delta content, this.align) + : line = null, + content = CellEdits._singleLine(content); + + /// The kept line, `null` for a new cell. + final DocumentLine? line; + + /// The new content: of a new cell, or replacing the kept line's text. + Delta? content; + + /// The cell's alignment before the table's alignment is applied. + CellAlign? align; + + /// Replaces the content (no change when equal to the current one). + void setContent(Delta value) { + final clean = CellEdits._singleLine(value); + final current = + content ?? (line == null ? Delta() : CellEdits._content(line!)); + if (current == clean) return; + content = clean; + } + + /// A new cell with this cell's content (moving deletes and re-inserts). + _CellSlot moved() => line == null + ? this + : (_CellSlot.fresh(Delta(), align) + ..content = content ?? CellEdits._content(line!)); +} + +/// A row of a [_TableModel]. +final class _RowSlot { + _RowSlot.existing(this.id, this.cells) : isNew = false; + + _RowSlot.fresh(this.id, this.cells) : isNew = true; + + final String id; + final bool isNew; + final List<_CellSlot> cells; + + /// This row as new lines (moving deletes and re-inserts), same row id. + _RowSlot moved() => _RowSlot.fresh(id, [for (final c in cells) c.moved()]); +} + +/// A completed (non-ragged) table being edited; [compile] turns it into the +/// change from the document's table. +final class _TableModel { + _TableModel(this.doc, this.table) + : align = [...table.align], + rows = [ + for (final r in table.rows) + _RowSlot.existing(r.id, [ + for (final line in r.cells) _CellSlot.existing(line), + // Completes a ragged row (§1.1). + for (var c = r.cells.length; c < table.columnCount; c++) + _CellSlot.fresh(Delta(), table.align[c]), + ]), + ]; + + final WonkyDocument doc; + final CellTable table; + + /// The column alignments (applied to every cell on [compile]). + final List align; + + final List<_RowSlot> rows; + + int get columns => rows.isEmpty ? align.length : rows.first.cells.length; + + /// [compile], or `null` when it changes nothing. + Delta? compileOrNull() { + final d = compile(); + return d.isEmpty ? null : d; + } + + /// The change from the document's table to this model. Kept lines must + /// be in their original order (moves re-insert). + Delta compile() { + final box = table.box; + final old = table.lines.toList(); + final kept = { + for (final r in rows) + for (final c in r.cells) + if (c.line != null) c.line!, + }; + final d = Delta()..retain(table.start); + var next = 0; // Index of the next old line not yet passed. + void deleteUpTo(int index) { + while (next < index) { + final line = old[next]; + if (!kept.contains(line)) d.delete(line.length); + next++; + } + } + + for (final r in rows) { + for (var c = 0; c < r.cells.length; c++) { + final slot = r.cells[c]; + final attr = CellAttr( + table: table.id, + row: r.id, + align: c < align.length ? align[c] : null, + ); + final line = slot.line; + if (line == null) { + CellEdits._append( + d, + slot.content ?? Delta(), + ).insert('\n', {WonkyAttr.cell: attr.toJson(), WonkyAttr.box: ?box}); + continue; + } + deleteUpTo(old.indexOf(line)); + if (slot.content != null) { + CellEdits._append(d..delete(line.contentLength), slot.content!); + } else if (line.contentLength > 0) { + d.retain(line.contentLength); + } + d.retain( + 1, + CellAttr.of(line.attributes) == attr + ? null + : {WonkyAttr.cell: attr.toJson()}, + ); + next++; + } + } + deleteUpTo(old.length); + return d..trim(); + } +} diff --git a/packages/wonky_write_core/lib/src/import/import_support.dart b/packages/wonky_write_core/lib/src/import/import_support.dart index c64ea872..57fa3368 100644 --- a/packages/wonky_write_core/lib/src/import/import_support.dart +++ b/packages/wonky_write_core/lib/src/import/import_support.dart @@ -12,7 +12,6 @@ import '../delta/embeds.dart'; import '../delta/validator.dart'; import '../ir/doc_tree.dart'; import '../ir/doc_tree_builder.dart'; -import '../markdown/inline_writer.dart'; import 'import_api.dart'; /// Soft line break inside a line (U+2028). @@ -409,7 +408,7 @@ abstract final class RunTools { } /// Builds a [TableBlock] from cell runs: pads rows to the widest row, writes -/// canonical inline Markdown cells. +/// single-line cells. TableBlock buildTable( String id, List>> rows, @@ -433,18 +432,7 @@ TableBlock buildTable( final paddedAlign = [ for (var c = 0; c < columns; c++) c < align.length ? align[c] : null, ]; - return TableBlock( - id, - paddedAlign, - paddedRows, - cellMarkdown: [ - for (final r in paddedRows) - [ - for (final c in r) - InlineMarkdownWriter.write(c, context: InlineContext.tableCell), - ], - ], - ); + return TableBlock(id, paddedAlign, paddedRows); } /// The placeholder paragraph for an image that could not be imported. diff --git a/packages/wonky_write_core/lib/src/ir/doc_tree.dart b/packages/wonky_write_core/lib/src/ir/doc_tree.dart index 00d0d675..f8a9a99b 100644 --- a/packages/wonky_write_core/lib/src/ir/doc_tree.dart +++ b/packages/wonky_write_core/lib/src/ir/doc_tree.dart @@ -3,7 +3,7 @@ library; import 'package:collection/collection.dart'; import 'package:wonky_rich_text/wonky_rich_text.dart' - show InlineRun, MarkdownFlavor; + show InlineContext, InlineMarkdownWriter, InlineRun, MarkdownFlavor; import '../delta/boxes.dart'; import '../delta/embeds.dart'; @@ -261,19 +261,20 @@ final class RuleBlock extends DocBlock { final String id; } -/// A table. +/// A table: a run of cell lines (Write 12 §2), or a legacy table embed. final class TableBlock extends DocBlock { /// Creates a table. const TableBlock( this.id, this.align, this.rows, { - required this.cellMarkdown, + this.rowIds, + this.cellFlavor = MarkdownFlavor.wonky, super.deltaStart, super.deltaLength, }); - /// Embed id. + /// The table's id. final String id; /// Column alignment. @@ -282,14 +283,29 @@ final class TableBlock extends DocBlock { /// Parsed cells: rows → cells → runs. Row 0 is the header. final List>> rows; - /// The original inline-Markdown cell strings (same shape as [rows]). - final List> cellMarkdown; + /// The row ids of the cell lines (same length as [rows]), when the table + /// was read from a document; new ids are made otherwise. + final List? rowIds; + + /// The inline syntax of [cellMarkdown]: chips and styled links are + /// directives in [MarkdownFlavor.wonky] and plain in [MarkdownFlavor.gfm]. + final MarkdownFlavor cellFlavor; /// Number of columns. int get columnCount => align.length; - /// As an embed. - TableEmbed toEmbed() => TableEmbed(id: id, align: align, rows: cellMarkdown); + /// The cells as inline Markdown (same shape as [rows]). + List> get cellMarkdown => [ + for (final r in rows) + [ + for (final c in r) + InlineMarkdownWriter.write( + c, + context: InlineContext.tableCell, + flavor: cellFlavor, + ), + ], + ]; } /// A Wonky Markdown container (callout, details, card; Write 11 §6) and the @@ -324,7 +340,8 @@ final class ContainerBlock extends DocBlock { List get body => summary == null ? children : children.sublist(1); } -/// A Wonky Markdown grid (Write 11 §2.3). +/// A Wonky Markdown grid (Write 11 §2.3): a run of grid item lines (Write +/// 12 §2), or a legacy grid embed. final class GridBlock extends DocBlock { /// Creates a grid block. const GridBlock( @@ -332,12 +349,11 @@ final class GridBlock extends DocBlock { this.columns, this.bullets, this.items, { - required this.itemMarkdown, super.deltaStart, super.deltaLength, }); - /// Embed id. + /// The grid's id. final String id; /// Number of columns, 1–4. @@ -349,16 +365,11 @@ final class GridBlock extends DocBlock { /// Parsed items. final List> items; - /// The stored inline-Markdown items (same shape as [items]). - final List itemMarkdown; - - /// As an embed. - GridEmbed toEmbed() => GridEmbed( - id: id, - columns: columns, - bullets: bullets, - items: itemMarkdown, - ); + /// The items as inline Markdown (Wonky syntax; same shape as [items]). + List get itemMarkdown => [ + for (final runs in items) + InlineMarkdownWriter.write(runs, context: InlineContext.tableCell), + ]; } /// A Wonky Markdown YouTube video (Write 11 §2.3). diff --git a/packages/wonky_write_core/lib/src/ir/doc_tree_builder.dart b/packages/wonky_write_core/lib/src/ir/doc_tree_builder.dart index 9356c16e..f44ca686 100644 --- a/packages/wonky_write_core/lib/src/ir/doc_tree_builder.dart +++ b/packages/wonky_write_core/lib/src/ir/doc_tree_builder.dart @@ -6,6 +6,7 @@ import 'package:dart_quill_delta/dart_quill_delta.dart'; import '../collab/suggestion_view.dart'; import '../delta/attributes.dart'; import '../delta/boxes.dart'; +import '../delta/cells.dart'; import '../delta/document.dart'; import '../delta/embeds.dart'; import '../delta/normalizer.dart'; @@ -60,6 +61,24 @@ final class DocTreeBuilder { quotedRun = f.isList && f.quoted; runPath = path; final blockCount = blocks.length; + if (CellAttr.of(line.attributes) != null || + GridItemAttr.of(line.attributes) != null) { + // A table or grid: one block for its run of lines (Write 12 §2). + final run = CellRuns.at(doc, i)!; + final int count; + if (run is CellTable) { + blocks.add(_table(run)); + count = run.rows.fold(0, (n, r) => n + r.cells.length); + } else { + run as CellGrid; + blocks.add(_grid(run)); + count = run.items.length; + } + paths.add(path); + ordinals.clear(); + i += count; + continue; + } if (line.isEmbedLine) { final embed = line.embed; switch (embed) { @@ -92,7 +111,6 @@ final class DocTreeBuilder { for (final r in t.rows) [for (final c in r) InlineMarkdown.parseCell(c)], ], - cellMarkdown: t.rows, deltaStart: line.start, deltaLength: line.length, ), @@ -107,7 +125,6 @@ final class DocTreeBuilder { for (final item in embed.items) InlineMarkdown.parseCell(item), ], - itemMarkdown: embed.items, deltaStart: line.start, deltaLength: line.length, ), @@ -275,6 +292,36 @@ final class DocTreeBuilder { for (final s in line.segments) if (!s.isEmbed) InlineRun.fromAttributes(s.text, s.attributes), ]); + + /// The block of a table's cell lines; ragged rows are padded with empty + /// cells (Write 12 §1.1). + static TableBlock _table(CellTable t) { + final columns = t.columnCount; + return TableBlock( + t.id, + t.align, + [ + for (final r in t.rows) + [ + for (var c = 0; c < columns; c++) + c < r.cells.length ? _runs(r.cells[c]) : const [], + ], + ], + rowIds: [for (final r in t.rows) r.id], + deltaStart: t.start, + deltaLength: t.end - t.start, + ); + } + + /// The block of a grid's item lines. + static GridBlock _grid(CellGrid g) => GridBlock( + g.id, + g.columns, + g.bullets, + [for (final item in g.items) _runs(item)], + deltaStart: g.start, + deltaLength: g.end - g.start, + ); } /// Converts an IR tree back into a normalized document delta. @@ -359,12 +406,39 @@ final class DocTreeToDelta { case RuleBlock(:final id): d.insert(DividerEmbed(id: id).toInsert()); line(path); - case TableBlock(): - d.insert(b.toEmbed().toInsert()); - line(path); - case GridBlock(): - d.insert(b.toEmbed().toInsert()); - line(path); + case TableBlock(:final id, :final align, :final rowIds): + final box = path.isEmpty ? null : Boxes.toValue(path); + var r = 0; + for (final op in CellLines.table( + id: id, + rows: [ + for (final row in b.rows) + [for (final c in row) CellLines.inline(c)], + ], + align: align, + box: box, + rowId: rowIds != null && rowIds.length == b.rows.length + ? () => rowIds[r++] + : null, + ).toList()) { + d.push(op); + } + case GridBlock( + :final id, + :final columns, + :final bullets, + :final items, + ): + final box = path.isEmpty ? null : Boxes.toValue(path); + for (final op in CellLines.grid( + id: id, + items: [for (final item in items) CellLines.inline(item)], + columns: columns, + bullets: bullets, + box: box, + ).toList()) { + d.push(op); + } case YouTubeBlock(:final embed): d.insert(embed.toInsert()); line(path); diff --git a/packages/wonky_write_core/lib/src/markdown/markdown_importer.dart b/packages/wonky_write_core/lib/src/markdown/markdown_importer.dart index 66b77ba1..b6e056ca 100644 --- a/packages/wonky_write_core/lib/src/markdown/markdown_importer.dart +++ b/packages/wonky_write_core/lib/src/markdown/markdown_importer.dart @@ -16,7 +16,6 @@ import '../ir/doc_tree.dart'; import '../ir/doc_tree_builder.dart'; import 'front_matter.dart'; import 'inline_reader.dart'; -import 'inline_writer.dart'; import 'md_syntax.dart'; import 'package:wonky_rich_text/wonky_rich_text.dart' show WonkyMdTags; @@ -592,10 +591,6 @@ final class MarkdownImporter { GridEmbed.canonicalColumns(node.attributes[WonkyMdTags.columns]), GridBullets.parse(node.attributes[WonkyMdTags.bullets]), items, - itemMarkdown: [ - for (final runs in items) - InlineMarkdownWriter.write(runs, context: InlineContext.tableCell), - ], ), ); } @@ -649,24 +644,24 @@ final class MarkdownImporter { } if (rows.isEmpty) return; final cols = rows.fold(align.length, (m, r) => r.length > m ? r.length : m); - final markdown = [ - for (final r in rows) - [ - for (var c = 0; c < cols; c++) - c < r.length - ? InlineMarkdownWriter.write([ - for (final run in r[c]) - run.copyWith(text: run.text.replaceAll(softBreak, ' ')), - ], context: InlineContext.tableCell) - : '', - ], - ]; _blocks.add( TableBlock( options.idGenerator(), [for (var c = 0; c < cols; c++) c < align.length ? align[c] : null], - rows, - cellMarkdown: markdown, + [ + for (final r in rows) + [ + for (var c = 0; c < cols; c++) + c < r.length + ? InlineRun.merge([ + for (final run in r[c]) + run.copyWith( + text: run.text.replaceAll(softBreak, ' '), + ), + ]) + : const [], + ], + ], ), ); } diff --git a/packages/wonky_write_core/lib/src/markdown/wonky_to_gfm.dart b/packages/wonky_write_core/lib/src/markdown/wonky_to_gfm.dart index 95fb03a3..1a394703 100644 --- a/packages/wonky_write_core/lib/src/markdown/wonky_to_gfm.dart +++ b/packages/wonky_write_core/lib/src/markdown/wonky_to_gfm.dart @@ -6,13 +6,13 @@ import 'package:dart_quill_delta/dart_quill_delta.dart'; import '../delta/attributes.dart'; import '../delta/boxes.dart'; +import '../delta/cells.dart'; import '../delta/document.dart'; import '../delta/embeds.dart'; import '../delta/normalizer.dart'; import '../ir/doc_tree.dart'; import '../import/import_api.dart' show MarkdownFlavor; import 'inline_markdown.dart'; -import 'inline_writer.dart'; /// The Wonky elements of a document, counted for the conversion dialog. final class WonkyInventory { @@ -91,6 +91,7 @@ abstract final class WonkyToGfm { void add(String key) => counts[key] = (counts[key] ?? 0) + 1; var prevPath = const []; String? prevStyleRun; + String? prevGrid; for (final line in WonkyDocument(document).lines) { final path = Boxes.of(line.attributes); final common = Boxes.commonDepth(prevPath, path); @@ -99,6 +100,9 @@ abstract final class WonkyToGfm { } prevPath = path; if (line.format.step != null) add('step'); + final item = GridItemAttr.of(line.attributes); + if (item != null && item.id != prevGrid) add('grid'); + prevGrid = item?.id; for (final s in line.segments) { if (s.isEmbed) { switch (WonkyEmbed.tryParse(s.data)) { @@ -142,7 +146,8 @@ abstract final class WonkyToGfm { /// The change that converts every Wonky element of [document] to GFM, /// normalized (`document.compose(change)` is valid). Comments and - /// suggestions on converted text stay; a grid's are dropped with it. + /// suggestions on converted text stay (a legacy grid embed's are dropped + /// with it). static Delta change(Delta document) { final doc = WonkyDocument(document); final d = Delta(); @@ -211,10 +216,21 @@ abstract final class WonkyToGfm { styled ? {WonkyAttr.chip: null, WonkyAttr.linkStyle: null} : null, ); } + // A grid item becomes a bullet; a cell stays a cell (and leaves a + // callout's quote: a cell line has no other block format). + final gridItem = line.attributes.containsKey(WonkyAttr.grid); + final isCell = line.attributes.containsKey(WonkyAttr.cell); final patch = { if (path.isNotEmpty) WonkyAttr.box: null, if (format.step != null) WonkyAttr.step: null, + if (gridItem) ...{ + WonkyAttr.grid: null, + WonkyAttr.list: ListKind.bullet.value, + if (inCallout) WonkyAttr.blockquote: true, + }, if (inCallout && + !gridItem && + !isCell && !line.isEmbedLine && (format.type == BlockType.paragraph || format.isList) && !format.inQuote) @@ -306,17 +322,7 @@ abstract final class WonkyToGfm { b.id, b.align, rows, - cellMarkdown: [ - for (final r in rows) - [ - for (final c in r) - InlineMarkdownWriter.write( - c, - context: InlineContext.tableCell, - flavor: MarkdownFlavor.gfm, - ), - ], - ], + cellFlavor: MarkdownFlavor.gfm, deltaStart: b.deltaStart, deltaLength: b.deltaLength, ), diff --git a/packages/wonky_write_core/lib/wonky_write_core.dart b/packages/wonky_write_core/lib/wonky_write_core.dart index 00c694b1..c3e55e19 100644 --- a/packages/wonky_write_core/lib/wonky_write_core.dart +++ b/packages/wonky_write_core/lib/wonky_write_core.dart @@ -10,6 +10,8 @@ export 'src/delta/attributes.dart'; export 'src/delta/delta_utils.dart'; export 'src/delta/document.dart'; export 'src/delta/boxes.dart'; +export 'src/delta/cell_upgrade.dart'; +export 'src/delta/cells.dart'; export 'src/delta/embeds.dart'; export 'src/delta/hash.dart'; export 'src/delta/links.dart'; @@ -35,6 +37,7 @@ export 'src/markdown/markdown_importer.dart' export 'src/delta/delta_diff.dart'; export 'src/derived/plain_text.dart'; export 'src/edit/address.dart'; +export 'src/edit/cell_edits.dart'; export 'src/edit/document_edits.dart'; export 'src/edit/document_reader.dart'; export 'src/edit/search.dart'; diff --git a/packages/wonky_write_core/test/edit/cell_edits_test.dart b/packages/wonky_write_core/test/edit/cell_edits_test.dart new file mode 100644 index 00000000..3905d90d --- /dev/null +++ b/packages/wonky_write_core/test/edit/cell_edits_test.dart @@ -0,0 +1,445 @@ +import 'package:test/test.dart'; +import 'package:wonky_write_core/wonky_write_core.dart'; + +/// A document with a paragraph, a table [id] of [rows] (row ids r0, r1, …) +/// and a paragraph. +Delta tableDoc( + List> rows, { + String id = 't', + List align = const [], +}) { + var n = 0; + final d = Delta()..insert('Before\n'); + for (final op in CellLines.table( + id: id, + rows: [ + for (final r in rows) [for (final c in r) Delta()..insert(c)], + ], + align: align, + rowId: () => 'r${n++}', + ).toList()) { + d.push(op); + } + return d..insert('After\n'); +} + +/// The table's cell texts, row by row. +List> cells(Delta doc, [String id = 't']) { + final t = CellRuns.table(WonkyDocument(doc), id); + if (t == null) return const []; + return [ + for (final r in t.rows) [for (final c in r.cells) c.text], + ]; +} + +/// [doc] with [change] applied; checks the result is valid. +Delta apply(Delta doc, Delta? change) { + expect(change, isNotNull); + final out = const WonkyDeltaNormalizer() + .normalize(doc.compose(change!)) + .document; + final validation = const WonkyDeltaValidator().validate(out); + expect(validation.isValid, isTrue, reason: '${validation.issues}'); + return out; +} + +void main() { + var ids = 0; + String rowId() => 'n${ids++}'; + setUp(() => ids = 0); + + group('Given a 2×2 table', () { + final doc = tableDoc([ + ['A', 'B'], + ['1', '2'], + ]); + final w = WonkyDocument(doc); + + test('when inserting a row in the middle then the cells are empty and ' + 'the row gets a new id', () { + final out = apply(doc, CellEdits.insertRow(w, 't', 1, rowId: rowId)); + expect(cells(out), [ + ['A', 'B'], + ['', ''], + ['1', '2'], + ]); + final t = CellRuns.table(WonkyDocument(out), 't')!; + expect(t.rows.map((r) => r.id), ['r0', 'n0', 'r1']); + }); + + test('when appending a row with content then it is the last row', () { + final out = apply( + doc, + CellEdits.insertRow( + w, + 't', + 2, + cells: [ + Delta()..insert('x', {'bold': true}), + ], + rowId: rowId, + ), + ); + expect(cells(out).last, ['x', '']); + expect(WonkyDocument(out).lines.last.text, 'After'); + }); + + test('when inserting a row then the change only inserts', () { + final change = CellEdits.insertRow(w, 't', 1, rowId: rowId); + expect(change.toList().where((op) => op.isDelete), isEmpty); + }); + + test('when deleting a row then the others keep their ids', () { + final out = apply(doc, CellEdits.deleteRow(w, 't', 0)); + expect(cells(out), [ + ['1', '2'], + ]); + expect(CellRuns.table(WonkyDocument(out), 't')!.rows.single.id, 'r1'); + }); + + test('when deleting every row then the table is gone', () { + final one = apply(doc, CellEdits.deleteRow(w, 't', 1)); + final out = apply(one, CellEdits.deleteRow(WonkyDocument(one), 't', 0)); + expect(cells(out), isEmpty); + expect(DeltaUtils.text(out), 'Before\nAfter\n'); + }); + + test('when inserting a column at each position then every row grows', () { + for (final at in [0, 1, 2]) { + final out = apply( + doc, + CellEdits.insertColumn( + w, + 't', + at, + header: Delta()..insert('H'), + cells: [Delta()..insert('v')], + ), + ); + final expected = [ + ['A', 'B'], + ['1', '2'], + ]; + expected[0].insert(at, 'H'); + expected[1].insert(at, 'v'); + expect(cells(out), expected, reason: 'at $at'); + } + }); + + test('when deleting a column then every row shrinks', () { + final out = apply(doc, CellEdits.deleteColumn(w, 't', 0)); + expect(cells(out), [ + ['B'], + ['2'], + ]); + }); + + test('when deleting the only column then the table is gone', () { + final one = apply(doc, CellEdits.deleteColumn(w, 't', 0)); + final out = apply( + one, + CellEdits.deleteColumn(WonkyDocument(one), 't', 0), + ); + expect(DeltaUtils.text(out), 'Before\nAfter\n'); + }); + + test('when moving a row then its cells and id move', () { + final out = apply(doc, CellEdits.moveRow(w, 't', 1, 0)); + expect(cells(out), [ + ['1', '2'], + ['A', 'B'], + ]); + final t = CellRuns.table(WonkyDocument(out), 't')!; + expect(t.rows.map((r) => r.id), ['r1', 'r0']); + }); + + test('when moving a column then every row is reordered', () { + final out = apply(doc, CellEdits.moveColumn(w, 't', 0, 1)); + expect(cells(out), [ + ['B', 'A'], + ['2', '1'], + ]); + }); + + test('when moving to the same place then nothing changes', () { + expect(CellEdits.moveRow(w, 't', 0, 0), isNull); + expect(CellEdits.moveColumn(w, 't', 1, 1), isNull); + }); + + test('when setting a column alignment then every cell of the column has ' + 'it and setting it again is a no-op', () { + final out = apply( + doc, + CellEdits.setColumnAlignment(w, 't', 1, CellAlign.right), + ); + final t = CellRuns.table(WonkyDocument(out), 't')!; + expect(t.align, [null, CellAlign.right]); + for (final r in t.rows) { + expect(CellAttr.of(r.cells[1].attributes)!.align, CellAlign.right); + expect(CellAttr.of(r.cells[0].attributes)!.align, isNull); + } + expect( + CellEdits.setColumnAlignment( + WonkyDocument(out), + 't', + 1, + CellAlign.right, + ), + isNull, + ); + }); + + test('when setting a cell then only that cell changes', () { + final out = apply( + doc, + CellEdits.setCell(w, 't', 1, 0, Delta()..insert('one\ntwo')), + ); + expect(cells(out), [ + ['A', 'B'], + ['one two', '2'], + ]); + expect( + CellEdits.setCell( + WonkyDocument(out), + 't', + 1, + 0, + Delta()..insert('one two'), + ), + isNull, + ); + }); + + test('when an index is out of range then CellEditException is thrown', () { + expect( + () => CellEdits.deleteRow(w, 't', 5), + throwsA(isA()), + ); + expect( + () => CellEdits.insertColumn(w, 't', 3), + throwsA(isA()), + ); + expect( + () => CellEdits.setCell(w, 't', 0, 2, Delta()), + throwsA(isA()), + ); + expect( + () => CellEdits.deleteTable(w, 'nope'), + throwsA(isA()), + ); + }); + + test('when replacing the table then unchanged cells keep their lines', () { + final change = CellEdits.replaceTable(w, 't', [ + [Delta()..insert('A'), Delta()..insert('B')], + [Delta()..insert('1'), Delta()..insert('two')], + [Delta()..insert('3'), Delta()], + ], rowId: rowId)!; + final out = apply(doc, change); + expect(cells(out), [ + ['A', 'B'], + ['1', 'two'], + ['3', ''], + ]); + // Only the changed cell's text is deleted. + final deleted = change + .toList() + .where((op) => op.isDelete) + .fold(0, (n, op) => n + op.length!); + expect(deleted, 1); + }); + + test('when replacing the table with fewer columns then the extra cells ' + 'are deleted', () { + final out = apply( + doc, + CellEdits.replaceTable(w, 't', [ + [Delta()..insert('A')], + [Delta()..insert('1')], + ]), + ); + expect(cells(out), [ + ['A'], + ['1'], + ]); + }); + + test('when replacing with the same content then nothing changes', () { + expect( + CellEdits.replaceTable(w, 't', [ + [Delta()..insert('A'), Delta()..insert('B')], + [Delta()..insert('1'), Delta()..insert('2')], + ]), + isNull, + ); + }); + }); + + group('Given a table with a comment in a cell', () { + final doc = + tableDoc([ + ['A', 'B'], + ['1', '2'], + ]).compose( + Delta() + ..retain('Before\nA\nB\n'.length) + ..retain(1, {'cmt_c1': true}), + ); + + test('when inserting a column then the comment stays on its text', () { + final out = apply( + doc, + CellEdits.insertColumn(WonkyDocument(doc), 't', 0), + ); + final line = CellRuns.table(WonkyDocument(out), 't')!.rows[1].cells[1]; + expect(line.text, '1'); + expect(line.segments.single.attributes, {'cmt_c1': true}); + }); + + test('when moving its row then the comment moves with the text', () { + final out = apply(doc, CellEdits.moveRow(WonkyDocument(doc), 't', 1, 0)); + final line = CellRuns.table(WonkyDocument(out), 't')!.rows[0].cells[0]; + expect(line.segments.single.attributes, {'cmt_c1': true}); + }); + }); + + group('Given a ragged table', () { + // Row 1 has one cell, row 2 three. + final doc = Delta() + ..insert('A\n', { + 'cell': {'table': 't', 'row': 'a'}, + }) + ..insert('B\n', { + 'cell': {'table': 't', 'row': 'a'}, + }) + ..insert('1\n', { + 'cell': {'table': 't', 'row': 'b'}, + }) + ..insert('x\n', { + 'cell': {'table': 't', 'row': 'c'}, + }) + ..insert('y\n', { + 'cell': {'table': 't', 'row': 'c'}, + }) + ..insert('z\n', { + 'cell': {'table': 't', 'row': 'c'}, + }); + final w = WonkyDocument(doc); + + test('when completing the rows then every row has three cells', () { + final out = apply(doc, CellEdits.completeRows(w, 't')); + expect(cells(out), [ + ['A', 'B', ''], + ['1', '', ''], + ['x', 'y', 'z'], + ]); + expect(CellEdits.completeRows(WonkyDocument(out), 't'), isNull); + }); + + test('when inserting a column then the rows are completed too', () { + final out = apply(doc, CellEdits.insertColumn(w, 't', 3)); + expect(cells(out), [ + ['A', 'B', '', ''], + ['1', '', '', ''], + ['x', 'y', 'z', ''], + ]); + }); + + test('when deleting the column a short row lacks then that row is ' + 'completed up to the new width', () { + final out = apply(doc, CellEdits.deleteColumn(w, 't', 2)); + expect(cells(out), [ + ['A', 'B'], + ['1', ''], + ['x', 'y'], + ]); + }); + }); + + group('Given a table in a callout', () { + final box = Boxes.toValue([BoxFrame(id: 'b1', type: BoxType.callout)]); + final doc = Delta() + ..insert('A\n', { + 'cell': {'table': 't', 'row': 'a'}, + 'box': box, + }) + ..insert('Tail\n'); + + test('when inserting a row then its cells are in the callout', () { + final out = apply( + doc, + CellEdits.insertRow(WonkyDocument(doc), 't', 1, rowId: rowId), + ); + final t = CellRuns.table(WonkyDocument(out), 't')!; + expect(t.rows.last.cells.single.attributes['box'], box); + }); + }); + + group('Given a grid of two items', () { + final doc = Delta() + ..insert('Intro\n') + ..insert('One\n', { + 'grid': {'id': 'g', 'columns': 2, 'bullets': 'check'}, + }) + ..insert('Two\n', { + 'grid': {'id': 'g', 'columns': 2, 'bullets': 'check'}, + }); + final w = WonkyDocument(doc); + List items(Delta d) => [ + for (final l + in CellRuns.grid(WonkyDocument(d), 'g')?.items ?? + const []) + l.text, + ]; + + test('when setting the columns then every item has them', () { + final out = apply(doc, CellEdits.setGridColumns(w, 'g', 3)); + final g = CellRuns.grid(WonkyDocument(out), 'g')!; + for (final item in g.items) { + expect(GridItemAttr.of(item.attributes)!.columns, 3); + } + expect(CellEdits.setGridColumns(WonkyDocument(out), 'g', 3), isNull); + }); + + test('when setting the bullets then every item has them', () { + final out = apply( + doc, + CellEdits.setGridBullets(w, 'g', GridBullets.none), + ); + expect(CellRuns.grid(WonkyDocument(out), 'g')!.bullets, GridBullets.none); + }); + + test('when adding items then they are inserted in place', () { + final first = apply( + doc, + CellEdits.addGridItem(w, 'g', 0, content: Delta()..insert('Zero')), + ); + expect(items(first), ['Zero', 'One', 'Two']); + final last = apply(doc, CellEdits.addGridItem(w, 'g', 2)); + expect(items(last), ['One', 'Two', '']); + }); + + test('when removing items then the last removal deletes the grid', () { + final one = apply(doc, CellEdits.removeGridItem(w, 'g', 0)); + expect(items(one), ['Two']); + final none = apply( + one, + CellEdits.removeGridItem(WonkyDocument(one), 'g', 0), + ); + expect(CellRuns.grid(WonkyDocument(none), 'g'), isNull); + }); + + test('when setting an item then only its text changes', () { + final out = apply( + doc, + CellEdits.setGridItem(w, 'g', 1, Delta()..insert('2')), + ); + expect(items(out), ['One', '2']); + }); + + test('when deleting the grid then only the intro is left', () { + final out = apply(doc, CellEdits.deleteGrid(w, 'g')); + expect(DeltaUtils.text(out), 'Intro\n'); + }); + }); +} diff --git a/specs/wonky_write/12-native-cells.md b/specs/wonky_write/12-native-cells.md new file mode 100644 index 00000000..f4632183 --- /dev/null +++ b/specs/wonky_write/12-native-cells.md @@ -0,0 +1,313 @@ +# Wonky Write — Native table and grid cells + +Tables (04 §18) and Wonky Markdown grids (11 §2.2) used to be single block embeds whose cells were +inline-Markdown strings. They were edited through text fields overlaid on the rendered block (the +"in-place editing" of SE-4 and `InlineGridEditing`). Two editors then competed for focus, text +input, clicks and selection: every interaction between them needed its own workaround (tap region +groups, a gesture opt-out, a delayed presence selection, click counting, a selection hold), and +each fix exposed the next case. Collaboration was coarse: every edit replaced the whole embed, so +concurrent edits to one table overwrote each other, and comments, suggestions and remote carets +could not point inside a cell. + +This spec makes every **table cell** and every **grid item** an ordinary line of the document. The +document editor's own caret, selection, typing, IME, spellcheck, inline formatting, comments, +suggestions, undo and presence work in cells exactly as elsewhere. Only layout (cells side by +side), navigation between cells and structural commands are specific to cells. + +Status: **built** (branch `feat/native-table-grid-cells`). It supersedes 02 §1.3 (`table` embed), +04 §18.1–18.4 (model, in-place editing) and the grid embed of 11 §2.2, which now point here. + +## 1. Document model + +### 1.1 Table cells + +A table is a run of consecutive lines carrying the line attribute **`cell`**: + +```json +{"insert": "Name"}, {"insert": "\n", "attributes": {"cell": {"table": "t1", "row": "r1"}}}, +{"insert": "Qty"}, {"insert": "\n", "attributes": {"cell": {"table": "t1", "row": "r1", "align": "right"}}}, +{"insert": "Apples"},{"insert": "\n", "attributes": {"cell": {"table": "t1", "row": "r2"}}}, +{"insert": "3"}, {"insert": "\n", "attributes": {"cell": {"table": "t1", "row": "r2", "align": "right"}}} +``` + +- `table`: the table's id (a UUIDv7 for new tables, the old embed id for upgraded ones). `row`: the + row's id, unique within the table (8 random base-36 characters). `align`: `left`, `center` or + `right`; absent means default. Nothing else. +- **Order is the structure.** A table is a maximal run of consecutive lines with the same `table` + id. A row is a maximal run of consecutive lines of that table with the same `row` id. Columns are + positional: the k-th line of a row is in column k. Row 0 is the header row (GFM semantics). +- A table's **column count** is the length of its longest row. A shorter row ("ragged", possible + after concurrent structural edits, §5.3) renders its missing cells as empty, non-editable + placeholders; every structural command first completes ragged rows (§3.6). +- A column's alignment is the `align` of its header cell; commands set it on every cell of the + column (so a cell renders with the alignment it carries, and exports use the header's). +- Cell text is ordinary inline content: every inline attribute (bold, italic, code, links, + mentions, chips, buttons, comments, suggestions) is allowed. A cell is **one line**: no soft line + breaks (U+2028 becomes a space) and no other block attributes (`header`, `list`, `indent`, + `blockquote`, `code-block`, `align`, `step` are removed). The only other line attribute a cell + may carry is `box` (a table inside containers, 11 §2.1), the same on every cell of the table. + +### 1.2 Grid items + +A grid (Wonky Markdown only) is a run of consecutive lines carrying the line attribute **`grid`**: + +```json +{"insert": "Fast"}, {"insert": "\n", "attributes": {"grid": {"id": "g1", "columns": 3, "bullets": "check"}}}, +{"insert": "Reliable"}, {"insert": "\n", "attributes": {"grid": {"id": "g1", "columns": 3, "bullets": "check"}}} +``` + +- `id`: the grid's id. `columns`: 1–4 (canonical as in 11 §2.2: 1, 2 or 4, else 3). `bullets`: + `check`, `circle` or `none`. Every item of a grid carries the same `columns` and `bullets`; the + normalizer copies the first item's values onto the others. +- A grid is a maximal run of consecutive lines with the same grid `id`; items are in line order. + At most 100 items (`GridLines.maxItems`); an empty grid does not exist (its last item removed, + the grid is gone). +- Items are one line each with inline content, like cells (§1.1), and may also carry `box`. + +### 1.3 Ids + +New ids come from `CellIds` in `wonky_write_core`: table and grid ids are UUIDv7, row ids 8 +base-36 characters from a random source. Ids only group lines; they are not unique across the +document by construction (two pasted copies of one table would share an id), so **paste and +import regenerate** them (§3.8). Two adjacent runs that share a table id are one table; the +normalizer does not try to repair that (it cannot happen through commands). + +### 1.4 Validator and normalizer + +`WonkyDeltaValidator` accepts `cell` and `grid` line attributes with the shapes above and rejects +cell or grid lines that carry block attributes other than `box`. `WonkyDeltaNormalizer` (on every +change, client and server): + +1. removes unknown keys inside `cell`/`grid`, invalid `align`/`bullets` values and canonicalizes + `columns`; +2. removes block attributes other than `box` from cell and grid lines, and `cell` from a line that + also has `grid` (a line is either); +3. replaces U+2028 in cell and grid lines with a space; +4. copies the first item's `columns`/`bullets` onto the other items of a grid run. + +### 1.5 Legacy embeds and the upgrade + +Documents written before this change hold `table` and `grid` **embeds** (02 §1.3, 11 §2.2). They +stay valid input everywhere, and `CellUpgrade` (`wonky_write_core`) converts them: + +- `CellUpgrade.upgradeChange(Delta document)` returns the change that replaces every legacy + `table`/`grid` embed line with cell or item lines (inline Markdown parsed into inline attributes, + the embed id kept as the table/grid id, new row ids, the embed line's `box` copied to each line, + `cmt_*`/`sug_*` of the embed dropped), or `null` when there are none. +- **Server**: when a document's state is loaded, and after every committed op whose result still + contains a legacy embed (an old client, a restored version), the server commits the upgrade as a + system op ("Upgrade tables"), so stored documents converge to the new format. Clients receive it + as an ordinary remote op. +- **Client**: a legacy embed that is still in a local document (an offline cache from before the + upgrade) renders read-only as before (`TableBlockNode`, the grid component without editing) + until the server's upgrade op arrives. The editor never creates legacy embeds. +- **Everything else** that reads Deltas (exporters, `DocTree`, plain text, stats, history diffs, + importers of old exports) handles both forms: `DocTreeBuilder` turns either into the same + `TableBlock`/`GridBlock`. + +### 1.6 Markdown and other formats + +Nothing changes in the formats: a table is a GFM table, a grid the `::::grid` directive (11 §4). +Import produces cell/item lines; export reads them through `DocTree`. A cell's inline content is +written as inline Markdown (pipes escaped), so a cell with a comment or a mention round-trips as +its text. + +## 2. DocTree + +`TableBlock(id, align, rows, cellMarkdown)` and `GridBlock(id, columns, bullets, items, +itemMarkdown)` keep their shape. `DocTreeBuilder` builds them from a run of cell/item lines (rows +and columns as in §1.1; ragged rows padded with empty cells; `cellMarkdown`/`itemMarkdown` written +from the runs) or from a legacy embed. `deltaStart`/`deltaLength` span the whole run. Exporters are +unchanged apart from reading runs instead of re-parsing Markdown where they did. + +## 3. Editor + +### 3.1 Nodes + +Each cell or item line is a `ParagraphNode` (block type paragraph) whose metadata holds the line +attribute: `WonkyNodeMetadata.cell` (`{table, row, align?}`) or `WonkyNodeMetadata.gridItem` +(`{id, columns, bullets}`), next to `box` when in containers. `DeltaNodeCodec` maps them both ways; +the mirror, `DeltaApplier` and `ChangeDeltaExtractor` need nothing else. Helpers in +`model/cells.dart` answer "which table/grid, row, column, item index" for a node and list a +table's rows (`CellTable`) or a grid's items (`CellGrid`) from the document. + +### 3.2 Layout (super_editor patch SE-15) + +super_editor stacks components in one column. **SE-15** adds `DocumentCellLayout`, passed to +`SuperEditor` and `SuperReader` (`cellLayout:`) and on to `SingleColumnDocumentLayout`: + +```dart +abstract class DocumentCellLayout implements Listenable { + DocumentCellPlacement? placementOf(String nodeId); // group, row/column or flow index + Widget wrapGroup(BuildContext context, DocumentCellGroup group, Widget child); + void paintGroup(Canvas canvas, Offset offset, DocumentCellGroupGeometry geometry); +} +``` + +- Consecutive components whose placements share a `group` are laid out by one + `DocumentCellGroupView` (a custom render object) instead of the column: either **rows** (each + placement names its row and column) or **flow** (an index, a maximum column count and a minimum + column width: the count drops while columns would be narrower). Columns share the width equally + (`minColumnWidth` 64 px for tables); each row is as tall as its tallest cell and every cell is + given that height, so borders line up. Missing cells of ragged rows are empty space. +- `paintGroup` paints behind the cells with the cell rectangles (table borders, header background); + `wrapGroup` wraps the whole group (container frames, chrome); both come from the app. +- The layout rebuilds when `placementOf` changes (the cell layout notifies). +- Geometry stays exact: components keep their keys and render boxes, so positions, selection + rectangles, carets and hit tests work as for any component. The patch also makes the + "nearest component" fallback measure 2D distance (it measured only vertical distance, which + picked the first cell of a row for a click between cells). +- Mouse and touch interactors are unchanged: clicks, double/triple clicks and drags in cells are + the editor's. + +Write's `WriteCellLayout` provides placements from the node metadata, table borders and the header +background from `TableStyles`, grid gaps from the theme, container frames around a group (the +frame segments of the group's first and last node) and the chrome (§3.7). + +### 3.3 Styling + +`CellStylePhase` (after the stylesheet, before pagination) gives cell and item view models their +padding (table cells: `TableStyles.cellPadding`; grid items: the grid gap), the header row a bold +text style, a cell its `align` as text alignment, and removes block spacing. Grid items render +their bullet before the text (`GridItemComponentBuilder`). Containers no longer frame cell +components one by one; `WriteCellLayout.wrapGroup` frames the group. + +### 3.4 Caret, selection and keys + +The caret is the document caret. Clicking, double click (word), triple click (line), dragging, +Shift+arrows and Shift+click select as in paragraphs; a selection may span cells and continues in +document order (row by row). Keys in a cell or item (`CellKeyboard`, before the defaults): + +| Key | Table cell | Grid item | +|-----|------------|-----------| +| Enter | caret to the cell below; in the last row, appends a row first | inserts an item after this one (text after the caret moves into it) | +| Shift+Enter | caret to the cell above | — (ignored) | +| Tab / Shift+Tab | next / previous cell, selecting its text; Tab in the last cell appends a row | next / previous item; Tab in the last item appends one | +| ↑ / ↓ | same column, row above / below (keeping the x position); from the first / last row, to the block before / after the table | the item above / below in the flowed layout | +| ← / → | across the cell edge to the previous / next cell (document order), out of the table at its ends | same, in item order | +| Backspace at the start | moves the caret to the end of the previous cell (never merges cells) | in an empty item, removes it (and the grid with its last item), caret to the previous item's end; otherwise like a cell | +| Delete at the end | moves the caret to the start of the next cell | removes the next item if it is empty, else moves | +| Mod+A | selects the document | selects the document | + +- **Deleting a selection that spans cells** clears the selected text in each cell; cells and rows + stay (`CellDeletionHandler`, a request handler before super_editor's). Typing or pasting over + such a selection clears it, then inserts into the first cell. +- **Selections that start outside a table** and end inside it (or the reverse) delete whole lines + outside and clear text inside. A selection covering a whole table (every cell, as from Mod+A or + dragging across it) deletes the table. +- **Paste into a cell** inserts plain text with line breaks as spaces; rich content keeps its + inline formatting. Pasting cell lines (copied from a table) into a cell pastes their text joined + with spaces. Pasting them into an ordinary line inserts a new table (new ids, §1.3). +- **Copy** of a selection inside one table writes its text as TSV (cells separated by tabs, rows by + newlines) as plain text, the Delta (with new ids on paste) and an HTML table. +- Inline formatting commands work on cell text. Block commands (headings, lists, quote, code + block, alignment, indentation, numbered heading, insert table/grid/container) are disabled in a + cell; the normalizer removes such attributes anyway (§1.4). + +### 3.5 Commands + +All structural commands are one undo step, run on the caret's table/grid (or `nodeId:` any of its +nodes) and are **not suggestable** (Suggesting mode disables them; typing in cells is suggestable +like any text). Each builds a Delta through `CellEdits` (`wonky_write_core`), the same code the MCP +tools use (§4). + +| Command | Effect | +|---------|--------| +| `write.insert.table {rows, cols}` | inserts a table after the current block (or replacing an empty paragraph), caret in the first cell | +| `write.table.insertRow {index?, position}` | inserts a row above/below (default: the caret's row) | +| `write.table.insertColumn {index?, position}` | inserts a column left/right (default: the caret's column) | +| `write.table.deleteRow {index?}` / `deleteColumn` | deletes; deleting the last row or column deletes the table; the header row cannot be deleted while body rows exist (it can be emptied) | +| `write.table.moveRow {from, to}` / `moveColumn` | moves (the moved cells' text is deleted and re-inserted) | +| `write.table.setColumnAlignment {index?, align}` | sets `align` on the column's cells | +| `write.table.setCell {row, col, markdown}` | replaces a cell's content (MCP) | +| `write.table.delete` | deletes the table | +| `write.table.edit` | opens the table editor popover (§3.6) | +| `write.insert.grid` | inserts a grid of three empty items, caret in the first | +| `write.grid.setColumns {columns}` / `setBullets {bullets}` | sets on every item | +| `write.grid.addItem` / `removeItem {index}` / `delete` | as named | + +Choosing a value the table or grid already has succeeds without an edit (04 radio rule). + +### 3.6 Table editor popover + +The popover (04 §18.4) stays for bulk work (reordering, sorting, pasting a grid of values). It +edits a `TableDraft` built from the table's lines; **Done** replaces the table's lines with the +draft's (one undo step "Edit table"), keeping the table id. Inline attributes that inline Markdown +cannot express (comments, suggestions) in changed cells are lost; unchanged cells keep their lines. +Before any structural command or the popover, ragged rows are completed with empty cells (part of +the same step). + +### 3.7 Chrome and menus + +- **Table**: on hover or with the caret inside, the chrome above the table's top-right corner has + *Edit table* (the popover) and ⋯ (insert row above/below, insert column left/right, delete row, + delete column, alignment, delete table). The context menu in a cell has the same row/column + items. *Format › Table ▸* lists them when the caret is in a table. +- **Grid**: the chrome of 11 §7.2 (columns, bullets, add item, ⋯ delete) sits on the group. +- The chrome is in `WriteCellLayout.wrapGroup`, so it belongs to the group, not to one cell. + +### 3.8 Pagination + +In Pages mode a table row moves to the next page as a unit: `PaginationController` measures a row +as the union of its cells and gives every cell of the row the same gap. Grid rows likewise. A row +taller than a page overflows the seam (as an oversized block does). + +### 3.9 Collaboration + +Edits to cell text merge character by character, like paragraphs. Remote carets and selections +show inside cells. Comments and suggestions anchor to cell text. Structural edits are line +inserts/deletes and merge by OT; the only anomaly is a **ragged row** (one user inserts a column +while another inserts a row: the new row lacks the new column's cell), shown with placeholders and +completed by the next structural command. Two users deleting the same row converge (the second +delete is empty after the transform). + +## 4. Server, MCP and AI + +- `CellEdits` (`wonky_write_core/edit/cell_edits.dart`) builds every structural change from a + document and a table/grid id (insert/delete/move row and column, alignment, set cell, insert + table/grid, grid columns/bullets/items, replace a table from rows). `DocumentEdits` (MCP on the + server) uses it; so do the editor commands. +- MCP tools keep their names and arguments (`write.table.*`, `write.grid.*`, 09 §3); cell + arguments are inline Markdown, as before. +- The AI assistant reads documents as Markdown and writes Markdown: unchanged. +- `DocumentStore` upgrades legacy embeds on load and after commits (§1.5). + +## 5. Tests and acceptance + +### 5.1 Tests + +- **Core**: codec round trips (Markdown ⇄ lines, legacy embed ⇄ lines), validator and normalizer + rules, `CellUpgrade` (shapes, ids, inline Markdown, box, comments dropped), `CellEdits` (every + command, ragged tables, last row/column), `DocTreeBuilder` for both forms, every exporter on a + line table (golden text where present), OT convergence fuzz with tables and grids + (`EditGen`/`DeltaGen` generate cell and grid lines). +- **super_editor (SE-15)**: rows and flow layout geometry, nearest-component in 2D, hit testing, + caret and selection rectangles in a group, rebuild on placement change, `SuperReader`. +- **Editor**: typing, click and double click inside a cell (the caret and word land in that cell), + every key in §3.4, deletion across cells, paste and copy, every command (one undo step each), + container frame around a table, pagination of rows, the legacy read-only path. +- **Server**: upgrade on load and after a legacy commit (one system op, idempotent), MCP table and + grid tools on line tables. + +### 5.2 Acceptance criteria + +**AC-12.1** Clicking a word in any cell puts the caret there; double-clicking selects the word; +typing goes into that cell; nothing else in the document moves. +**AC-12.2** Tab walks the cells in order and appends a row at the end; Enter goes to the cell +below; ↑/↓ keep the column. +**AC-12.3** Selecting across three cells and pressing Delete clears the selected text and keeps +the table's shape; undo restores it. +**AC-12.4** Two users typing in different cells of one table at the same time both keep their +text; a comment on a cell's word survives a remote edit in another cell. +**AC-12.5** A document saved before this change opens with its tables and grids intact and +editable; its Markdown export is unchanged by the upgrade. +**AC-12.6** Bold, links, chips and mentions in a cell export to Markdown, HTML, PDF and DOCX. +**AC-12.7** A grid item: Enter adds the next item, Backspace in an empty item removes it, the +chrome changes columns and bullets. + +## 6. Removed + +`InlineTableEditing`, `InlineGridEditing`, `FieldClicks` and their tap-region, focus and +click-counting logic; the editor no longer uses `DocumentGestureOptOut` (SE-14 stays in +super_editor, unused by Write). The `table` and `grid` embeds remain readable (§1.5) but are +never written. From 8b16e99c5254855ef39234dd1c2d3250757efa73 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Tue, 27 Oct 2026 23:41:30 +0200 Subject: [PATCH 02/16] feat(write-core): MCP table edits on CellEdits; cells in generators and tests DocumentEdits builds table changes with CellEdits, the generators make cell and grid lines, the Markdown exporter picks the emphasis style per cell, and CellCanon compares tables in tests. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/markdown/inline_writer.dart | 8 +- .../lib/src/edit/document_edits.dart | 218 ++++++------ .../lib/src/markdown/markdown_exporter.dart | 37 +- .../lib/src/testing/cell_canon.dart | 78 +++++ .../lib/src/testing/generators.dart | 160 +++++++-- packages/wonky_write_core/lib/testing.dart | 1 + .../test/delta/cells_test.dart | 327 ++++++++++++++++++ .../test/edit/document_edits_test.dart | 70 +++- .../test/import/odt_importer_test.dart | 25 +- .../test/import/rtf_importer_test.dart | 51 ++- .../test/markdown/markdown_codec_test.dart | 38 +- .../test/markdown/wonky_markdown_test.dart | 24 +- .../property/markdown_round_trip_test.dart | 9 +- specs/wonky_write/12-native-cells.md | 15 +- 14 files changed, 838 insertions(+), 223 deletions(-) create mode 100644 packages/wonky_write_core/lib/src/testing/cell_canon.dart create mode 100644 packages/wonky_write_core/test/delta/cells_test.dart diff --git a/packages/wonky_rich_text/lib/src/markdown/inline_writer.dart b/packages/wonky_rich_text/lib/src/markdown/inline_writer.dart index 5bce29ab..d97195f8 100644 --- a/packages/wonky_rich_text/lib/src/markdown/inline_writer.dart +++ b/packages/wonky_rich_text/lib/src/markdown/inline_writer.dart @@ -392,6 +392,9 @@ abstract final class InlineMarkdownWriter { hashIndex = to - 1; } String? prevOut; + // The last character written (a backtick only ends a code span: text + // backticks are written as ```). + String? lastWritten; var recent = ''; for (var i = from; i < to; i++) { final t = toks[i]; @@ -400,10 +403,12 @@ abstract final class InlineMarkdownWriter { case _Markup(:final s): b.write(s); prevOut = s[s.length - 1]; + lastWritten = prevOut; recent = ''; case _Break(): b.write('
'); prevOut = '>'; + lastWritten = prevOut; recent = ''; case _Char(:final ch): final atStart = i == from; @@ -422,11 +427,12 @@ abstract final class InlineMarkdownWriter { ); // package:markdown does not decode a character reference right // after a backtick; an empty HTML comment separates them. - if (prevOut == '`' && escaped.startsWith('&') && escaped != '&') { + if (lastWritten == '`' && escaped.startsWith('&') && escaped != '&') { b.write(''); } b.write(escaped); prevOut = ch; + lastWritten = escaped[escaped.length - 1]; recent = (recent + ch).length > 4 ? (recent + ch).substring((recent + ch).length - 4) : recent + ch; diff --git a/packages/wonky_write_core/lib/src/edit/document_edits.dart b/packages/wonky_write_core/lib/src/edit/document_edits.dart index 18b78632..299f0d7e 100644 --- a/packages/wonky_write_core/lib/src/edit/document_edits.dart +++ b/packages/wonky_write_core/lib/src/edit/document_edits.dart @@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart'; import '../delta/attributes.dart'; import '../delta/boxes.dart'; +import '../delta/cells.dart'; import '../delta/delta_diff.dart'; import '../delta/delta_utils.dart'; import '../delta/document.dart'; @@ -19,6 +20,7 @@ import '../import/import_api.dart'; import '../markdown/inline_markdown.dart'; import '../markdown/markdown_codec.dart'; import 'address.dart'; +import 'cell_edits.dart'; import 'search.dart'; /// How `insertMarkdown` places content (09 §2.2). @@ -949,7 +951,8 @@ final class DocumentEdits { } /// Inserts a table at [at]. Rows are padded to the widest row; row 0 is the - /// header; cells are inline Markdown. + /// header; cells are inline Markdown. The table is made of cell lines + /// (Write 12 §1.1). EditResult insertTable( Delta document, int at, @@ -962,50 +965,45 @@ final class DocumentEdits { 'A table needs at least one row and column', ); } - final embed = TableEmbed( - id: idGenerator(), - align: align ?? const [], - rows: rows, - ).padded(); + final id = idGenerator(); final r = insertFragment( document, at, - Delta() - ..insert(embed.toInsert()) - ..insert('\n'), + CellLines.table( + id: id, + rows: [ + for (final row in rows) [for (final c in row) _cell(c)], + ], + align: align ?? const [], + ), as: InsertAs.blocks, ); - final doc = WonkyDocument(r.document); - var tableIndex = 0; - for (final (_, e) in doc.embeds) { - if (e is! TableEmbed) continue; - if (e.id == embed.id) break; - tableIndex++; - } + final tables = CellRuns.of( + WonkyDocument(r.document), + ).whereType().toList(); + final table = tables.firstWhere((t) => t.id == id); return EditResult( change: r.change, document: r.document, appliedRange: r.appliedRange, - summary: 'Inserted a ${embed.rows.length}×${embed.columnCount} table', - extra: {'table_id': embed.id, 'table_index': tableIndex}, + summary: 'Inserted a ${table.rows.length}×${table.columnCount} table', + extra: {'table_id': id, 'table_index': tables.indexOf(table)}, ); } - /// Finds a table by id or by 0-based index among tables; returns its - /// offset and embed. - static (int offset, TableEmbed table, Map? attributes) - findTable(Delta document, {String? tableId, int? tableIndex}) { - final doc = WonkyDocument(document); - var i = 0; - for (final line in doc.lines) { - for (final s in line.segments) { - final e = s.isEmbed ? WonkyEmbed.tryParse(s.data) : null; - if (e is! TableEmbed) continue; - if ((tableId != null && e.id == tableId) || - (tableId == null && tableIndex == i)) { - return (s.start, e, s.attributes); - } - i++; + /// Finds a table by id or by 0-based index among the document's tables. + static CellTable findTable( + Delta document, { + String? tableId, + int? tableIndex, + }) { + final tables = CellRuns.of( + WonkyDocument(document), + ).whereType().toList(); + for (final (i, t) in tables.indexed) { + if ((tableId != null && t.id == tableId) || + (tableId == null && tableIndex == i)) { + return t; } } throw AddressException( @@ -1014,8 +1012,9 @@ final class DocumentEdits { ); } - /// Replaces a table's rows, or applies [operations] in order. The embed - /// keeps its id (embed identity rule). + /// Replaces a table's rows, or applies [operations] in order. The table + /// keeps its id, and cells that do not change keep their lines (Write 12 + /// §3.6). EditResult updateTable( Delta document, { String? tableId, @@ -1023,12 +1022,16 @@ final class DocumentEdits { List>? rows, List operations = const [], }) { - final (offset, table, attrs) = findTable( - document, - tableId: tableId, - tableIndex: tableIndex, - ); - var t = table.padded(); + final id = findTable(document, tableId: tableId, tableIndex: tableIndex).id; + var doc = WonkyDocument(document); + var change = Delta(); + void step(Delta? d) { + if (d == null) return; + change = change.compose(d); + doc = WonkyDocument(doc.delta.compose(d)); + } + + step(CellEdits.completeRows(doc, id)); if (rows != null) { if (rows.isEmpty) { throw const AddressException( @@ -1036,85 +1039,94 @@ final class DocumentEdits { 'rows must not be empty', ); } - t = TableEmbed(id: t.id, align: t.align, rows: rows).padded(); + step( + _cellEdit( + () => CellEdits.replaceTable(doc, id, [ + for (final r in rows) [for (final c in r) _cell(c)], + ]), + ), + ); } for (final op in operations) { - t = applyTableOperation(t, op); + step(applyTableOperation(doc, id, op)); } - final d = Delta() - ..retain(offset) - ..delete(1) - ..insert(t.toInsert(), Attributes.orNull(Attributes.overlays(attrs))); - final r = _finish( - document, - DeltaUtils.canonical(d), - appliedRange: ResolvedRange(offset, 1), - ); + final r = _finish(document, change); + final table = CellRuns.table(WonkyDocument(r.document), id)!; return EditResult( change: r.change, document: r.document, - appliedRange: r.appliedRange, + appliedRange: ResolvedRange(table.start, table.end - table.start), summary: 'Updated table', extra: { - 'table_id': t.id, - 'rows': t.rows.length, - 'columns': t.columnCount, + 'table_id': id, + 'rows': table.rows.length, + 'columns': table.columnCount, }, ); } - /// Applies one table operation (pure). - static TableEmbed applyTableOperation(TableEmbed table, TableOperation op) { - final t = table.padded(); - final rows = [ - for (final r in t.rows) [...r], - ]; - final align = [...t.align]; - final cols = t.columnCount; + /// The change of one table operation on the table [tableId] of [doc] + /// (`null` when it changes nothing). The only row and the only column + /// cannot be deleted (delete the table instead). + static Delta? applyTableOperation( + WonkyDocument doc, + String tableId, + TableOperation op, + ) { + final t = CellRuns.table(doc, tableId); + if (t == null) { + throw AddressException( + AddressErrorCode.notFound, + 'Table $tableId not found', + ); + } void check(bool ok, String message) { if (!ok) throw AddressException(AddressErrorCode.invalidArgs, message); } - String cell(String s) => - s.replaceAll(RegExp(r'[\n\r\u2028\u2029\t]'), ' ').trim(); - switch (op) { - case SetCellOp(:final row, :final col, :final markdown): - check( - row >= 0 && row < rows.length && col >= 0 && col < cols, - 'Cell ($row, $col) out of range', - ); - rows[row][col] = cell(markdown); - case InsertRowOp(:final at, :final cells): - check(at >= 0 && at <= rows.length, 'Row index $at out of range'); - rows.insert(at, [ - for (var c = 0; c < cols; c++) - cells != null && c < cells.length ? cell(cells[c]) : '', - ]); - case DeleteRowOp(:final row): - check(row >= 0 && row < rows.length, 'Row $row out of range'); - check(rows.length > 1, 'Cannot delete the only row'); - rows.removeAt(row); - case InsertColumnOp(:final at, :final header, :final cells): - check(at >= 0 && at <= cols, 'Column index $at out of range'); - for (var r = 0; r < rows.length; r++) { - final v = r == 0 - ? (header ?? '') - : (cells != null && r - 1 < cells.length ? cells[r - 1] : ''); - rows[r].insert(at, cell(v)); - } - align.insert(at, null); - case DeleteColumnOp(:final col): - check(col >= 0 && col < cols, 'Column $col out of range'); - check(cols > 1, 'Cannot delete the only column'); - for (final r in rows) { - r.removeAt(col); - } - align.removeAt(col); - case SetAlignOp(col: final col, align: final a): - check(col >= 0 && col < cols, 'Column $col out of range'); - align[col] = a; + return _cellEdit(() { + switch (op) { + case SetCellOp(:final row, :final col, :final markdown): + return CellEdits.setCell(doc, tableId, row, col, _cell(markdown)); + case InsertRowOp(:final at, :final cells): + return CellEdits.insertRow( + doc, + tableId, + at, + cells: cells?.map(_cell).toList(), + ); + case DeleteRowOp(:final row): + check(t.rows.length > 1, 'Cannot delete the only row'); + return CellEdits.deleteRow(doc, tableId, row); + case InsertColumnOp(:final at, :final header, :final cells): + return CellEdits.insertColumn( + doc, + tableId, + at, + header: header == null ? null : _cell(header), + cells: cells?.map(_cell).toList(), + ); + case DeleteColumnOp(:final col): + check(t.columnCount > 1, 'Cannot delete the only column'); + return CellEdits.deleteColumn(doc, tableId, col); + case SetAlignOp(col: final col, align: final a): + return CellEdits.setColumnAlignment(doc, tableId, col, a); + } + }); + } + + /// The inline Delta of an inline-Markdown cell argument. + static Delta _cell(String markdown) => CellLines.markdown( + markdown.replaceAll(RegExp(r'[\n\r\u2028\u2029\t]'), ' ').trim(), + ); + + /// Runs a [CellEdits] call, turning its errors into invalid_args. + static Delta? _cellEdit(Delta? Function() edit) { + try { + return edit(); + } on CellEditException catch (e) { + throw AddressException(AddressErrorCode.invalidArgs, e.message); } - return TableEmbed(id: t.id, align: align, rows: rows); } // --------------------------------------------------------------------- diff --git a/packages/wonky_write_core/lib/src/markdown/markdown_exporter.dart b/packages/wonky_write_core/lib/src/markdown/markdown_exporter.dart index 13c44295..20837249 100644 --- a/packages/wonky_write_core/lib/src/markdown/markdown_exporter.dart +++ b/packages/wonky_write_core/lib/src/markdown/markdown_exporter.dart @@ -7,6 +7,7 @@ import '../import/import_api.dart'; import '../delta/embeds.dart'; import '../ir/doc_tree.dart'; import 'front_matter.dart'; +import 'inline_markdown.dart'; import 'inline_writer.dart'; import 'markdown_importer.dart'; import 'wonky_to_gfm.dart'; @@ -195,11 +196,7 @@ final class MarkdownExporter { ':::grid{columns=${grid.columns} bullets=${grid.bullets.value}}', '', for (final item in grid.items) - switch (InlineMarkdownWriter.write( - _rewriteLinks(item), - context: InlineContext.tableCell, - flavor: options.flavor, - )) { + switch (_cell(item)) { '' => '- ', final text => '- $text', }, @@ -296,6 +293,29 @@ final class MarkdownExporter { return fallback!; } + /// A table cell or grid item as inline Markdown: the first emphasis + /// style whose output reads back as [runs] (as for paragraphs). + String _cell(List runs) { + final merged = InlineRun.merge(_rewriteLinks(runs)); + final expected = _written(merged); + for (final style in EmphasisStyle.values) { + final text = InlineMarkdownWriter.write( + merged, + context: InlineContext.tableCell, + style: style, + flavor: options.flavor, + ); + if (style == EmphasisStyle.html || + _sameRuns( + InlineMarkdown.parse(text, flavor: options.flavor), + expected, + )) { + return text; + } + } + throw StateError('unreachable'); + } + /// [runs] as the flavor writes them (GFM drops chips and link styles). List _written(List runs) => _wonky ? runs @@ -342,7 +362,10 @@ final class MarkdownExporter { String row(List cells) => '| ${cells.map(_escapeCell).join(' | ')} |'; final cols = t.columnCount; - final header = t.cellMarkdown.first; + final cells = [ + for (final r in t.rows) [for (final c in r) _cell(c)], + ]; + final header = cells.first; final delim = [ for (var c = 0; c < cols; c++) switch (c < t.align.length ? t.align[c] : null) { @@ -355,7 +378,7 @@ final class MarkdownExporter { return [ row(header), '| ${delim.join(' | ')} |', - for (final r in t.cellMarkdown.skip(1)) row(r), + for (final r in cells.skip(1)) row(r), ].join('\n'); } diff --git a/packages/wonky_write_core/lib/src/testing/cell_canon.dart b/packages/wonky_write_core/lib/src/testing/cell_canon.dart new file mode 100644 index 00000000..c99f29a0 --- /dev/null +++ b/packages/wonky_write_core/lib/src/testing/cell_canon.dart @@ -0,0 +1,78 @@ +/// Comparing documents with tables in tests (Write 12 §5.1). +library; + +import 'package:dart_quill_delta/dart_quill_delta.dart'; + +import '../delta/attributes.dart'; +import '../delta/cells.dart'; +import '../delta/document.dart'; +import '../edit/cell_edits.dart'; + +/// Normal forms of tables for comparisons: row ids are random and ragged +/// rows export as complete ones, so tests compare [canonical] forms. +abstract final class CellCanon { + /// [document] with every table's ragged rows completed, its row ids + /// replaced by `r0`, `r1`, … (per table, in order) and every cell's + /// alignment set to its column's (the header's, §1.1). + static Delta canonical(Delta document) { + var doc = WonkyDocument(document); + for (final run in CellRuns.of(doc)) { + if (run is! CellTable) continue; + final change = CellEdits.completeRows(doc, run.id); + if (change != null) doc = WonkyDocument(doc.delta.compose(change)); + } + final align = { + for (final run in CellRuns.of(doc)) + if (run is CellTable) run.id: run.align, + }; + final out = Delta(); + final rowIndex = >{}; + // The column of the next cell line per table and row. + final column = {}; + for (final line in doc.lines) { + for (final s in line.segments) { + out.insert(s.data, s.attributes); + } + if (!line.hasNewline) continue; + final cell = CellAttr.of(line.attributes); + if (cell == null) { + out.insert('\n', line.attributes.isEmpty ? null : line.attributes); + continue; + } + final rows = rowIndex.putIfAbsent(cell.table, () => {}); + final r = rows.putIfAbsent(cell.row, () => rows.length); + final key = '${cell.table}/${cell.row}'; + final c = column[key] = (column[key] ?? -1) + 1; + final columns = align[cell.table]!; + out.insert('\n', { + ...line.attributes, + WonkyAttr.cell: CellAttr( + table: cell.table, + row: 'r$r', + align: c < columns.length ? columns[c] : null, + ).toJson(), + }); + } + return out; + } + + /// The ids of [document]'s embeds, tables and grids in document order: + /// the order in which an importer asks its id generator for them. + static List blockIds(Delta document) { + final ids = []; + String? prevGroup; + for (final line in WonkyDocument(document).lines) { + final group = + CellAttr.of(line.attributes)?.table ?? + GridItemAttr.of(line.attributes)?.id; + if (group != null && group != prevGroup) ids.add(group); + prevGroup = group; + if (group != null) continue; + if (line.isEmbedLine) { + final embed = line.embed; + if (embed != null) ids.add(embed.id); + } + } + return ids; + } +} diff --git a/packages/wonky_write_core/lib/src/testing/generators.dart b/packages/wonky_write_core/lib/src/testing/generators.dart index 00a685b3..6248a63b 100644 --- a/packages/wonky_write_core/lib/src/testing/generators.dart +++ b/packages/wonky_write_core/lib/src/testing/generators.dart @@ -7,6 +7,7 @@ import 'package:dart_quill_delta/dart_quill_delta.dart'; import '../delta/attributes.dart'; import '../delta/boxes.dart'; +import '../delta/cells.dart'; import '../delta/delta_utils.dart'; import '../delta/document.dart'; import '../delta/embeds.dart'; @@ -42,7 +43,7 @@ final class DeltaGenOptions { /// Generate image/divider embeds. final bool embeds; - /// Generate table embeds. + /// Generate tables (and grids in Wonky documents) as cell lines. final bool tables; /// Generate `cmt_*`/`sug_*` overlays. @@ -231,11 +232,10 @@ final class DeltaGen { }; } - /// A random embed. + /// A random embed (image, divider; YouTube videos in Wonky documents). WonkyEmbed embed() { - final p = random.nextInt(options.tables ? (options.wonky ? 5 : 3) : 2); - switch (p) { - case 4: + switch (random.nextInt(options.wonky ? 3 : 2)) { + case 2: const ids = ['AGAQ_N56wCs', 'dQw4w9WgXcQ', 'o3kP3dT2C5k']; return YouTubeEmbed( id: nextId(), @@ -245,22 +245,6 @@ final class DeltaGen { ? null : YouTubeEmbed.canonicalTitle(_singleLine(text(5))), ); - case 3: - return GridEmbed( - id: nextId(), - columns: 1 + random.nextInt(4), - bullets: - GridBullets.values[random.nextInt(GridBullets.values.length)], - items: [ - for (var i = 0, n = 1 + random.nextInt(4); i < n; i++) - switch (random.nextInt(5)) { - 0 => '', - 1 => ':chip[${_plainWord()}]', - 2 => '**${_plainWord()}** ${_plainWord()}', - _ => 'item ${_plainWord()}', - }, - ], - ); case 0: return ImageEmbed( id: nextId(), @@ -270,26 +254,115 @@ final class DeltaGen { alt: random.nextBool() ? '' : _singleLine(text(6)), title: random.nextInt(3) == 0 ? _singleLine(text(5)) : null, ); - case 1: - return DividerEmbed(id: nextId()); default: - final cols = 1 + random.nextInt(3); - final rows = 1 + random.nextInt(3); - return TableEmbed( - id: nextId(), - align: [ - for (var c = 0; c < cols; c++) - [null, ...CellAlign.values][random.nextInt(4)], + return DividerEmbed(id: nextId()); + } + } + + /// The lines of a random table (sometimes ragged) or, in Wonky + /// documents, grid (Write 12 §1.1–1.2), with [box] on every line. + Delta cells({Object? box}) { + if (options.wonky && random.nextInt(3) == 0) { + return CellLines.grid( + id: nextId(), + items: [ + for (var i = 0, n = 1 + random.nextInt(4); i < n; i++) _cellContent(), + ], + columns: 1 + random.nextInt(4), + bullets: GridBullets.values[random.nextInt(GridBullets.values.length)], + box: box, + ); + } + final cols = 1 + random.nextInt(3); + final ragged = random.nextInt(6) == 0; + final d = CellLines.table( + id: nextId(), + rows: [ + for (var r = 0, n = 1 + random.nextInt(3); r < n; r++) + [ + for ( + var c = 0, m = ragged ? 1 + random.nextInt(cols) : cols; + c < m; + c++ + ) + _cellContent(), ], - rows: [ - for (var r = 0; r < rows; r++) - [ - for (var c = 0; c < cols; c++) - random.nextInt(4) == 0 ? '' : 'c${r}x$c ${_plainWord()}', - ], + ], + align: [ + for (var c = 0; c < cols; c++) + [null, ...CellAlign.values][random.nextInt(4)], + ], + box: box, + rowId: () => CellIds.row(random), + ); + if (!ragged) return d; + // Drops the padding of short rows: a ragged table as an old or remote + // edit can leave it. + final out = Delta(); + for (final line in WonkyDocument(d).lines) { + if (line.isEmpty && random.nextBool()) continue; + for (final s in line.segments) { + out.insert(s.data, s.attributes); + } + out.insert('\n', line.attributes); + } + return out; + } + + /// A random legacy table embed (for the upgrade, Write 12 §1.5). + TableEmbed legacyTable() { + final cols = 1 + random.nextInt(3); + final rows = 1 + random.nextInt(3); + return TableEmbed( + id: nextId(), + align: [ + for (var c = 0; c < cols; c++) + [null, ...CellAlign.values][random.nextInt(4)], + ], + rows: [ + for (var r = 0; r < rows; r++) + [ + for (var c = 0; c < cols; c++) + switch (random.nextInt(5)) { + 0 => '', + 1 => '**c${r}x$c** ${_plainWord()}', + 2 => ':chip[${_plainWord()}]', + _ => 'c${r}x$c ${_plainWord()}', + }, ], - ); + ], + ); + } + + /// A random legacy grid embed (for the upgrade, Write 12 §1.5). + GridEmbed legacyGrid() => GridEmbed( + id: nextId(), + columns: 1 + random.nextInt(4), + bullets: GridBullets.values[random.nextInt(GridBullets.values.length)], + items: [ + for (var i = 0, n = 1 + random.nextInt(4); i < n; i++) + switch (random.nextInt(5)) { + 0 => '', + 1 => ':chip[${_plainWord()}]', + 2 => '**${_plainWord()}** ${_plainWord()}', + _ => 'item ${_plainWord()}', + }, + ], + ); + + /// The inline content of a random cell or grid item (may be empty). + Delta _cellContent() { + final d = Delta(); + if (random.nextInt(5) == 0) return d; + for (var r = 0, n = 1 + random.nextInt(2); r < n; r++) { + final attrs = _inlineAttributes(); + final styled = + attrs != null && + (attrs.containsKey(WonkyAttr.chip) || + attrs.containsKey(WonkyAttr.linkStyle)); + d.insert(styled ? _label() : _singleLine(text(4)), attrs); } + return d; } String _plainWord() { @@ -333,6 +406,19 @@ final class DeltaGen { boxes.isEmpty ? attrs : {...?attrs, WonkyAttr.box: Boxes.toValue(boxes)}; + if (!opensDetails && + options.embeds && + options.tables && + random.nextInt(12) == 0) { + for (final op in cells( + box: boxes.isEmpty ? null : Boxes.toValue(boxes), + ).toList()) { + d.push(op); + } + prevListIndent = null; + prevInQuote = false; + continue; + } if (!opensDetails && options.embeds && random.nextInt(8) == 0) { final e = embed(); final attrs = options.overlays && random.nextInt(4) == 0 diff --git a/packages/wonky_write_core/lib/testing.dart b/packages/wonky_write_core/lib/testing.dart index bdec7ae7..9861ecae 100644 --- a/packages/wonky_write_core/lib/testing.dart +++ b/packages/wonky_write_core/lib/testing.dart @@ -2,4 +2,5 @@ /// Never import this from `lib/` code. library; +export 'src/testing/cell_canon.dart'; export 'src/testing/generators.dart'; diff --git a/packages/wonky_write_core/test/delta/cells_test.dart b/packages/wonky_write_core/test/delta/cells_test.dart new file mode 100644 index 00000000..0ef1fb35 --- /dev/null +++ b/packages/wonky_write_core/test/delta/cells_test.dart @@ -0,0 +1,327 @@ +import 'package:test/test.dart'; +import 'package:wonky_write_core/testing.dart'; +import 'package:wonky_write_core/wonky_write_core.dart'; + +import '../support/property.dart'; + +Map cell(String row, {String table = 't', String? align}) => { + 'cell': {'table': table, 'row': row, 'align': ?align}, +}; + +Map grid(String id, {int columns = 3, String b = 'check'}) => { + 'grid': {'id': id, 'columns': columns, 'bullets': b}, +}; + +void main() { + const normalizer = WonkyDeltaNormalizer(); + const validator = WonkyDeltaValidator(); + + group('Given cell and grid attribute values', () { + test('when parsing then non-canonical parts are dropped', () { + expect( + CellAttr.tryParse({ + 'table': 't', + 'row': 'r', + 'align': 'middle', + 'x': 1, + }), + const CellAttr(table: 't', row: 'r'), + ); + expect(CellAttr.tryParse({'table': '', 'row': 'r'}), isNull); + expect(CellAttr.tryParse('t'), isNull); + expect( + GridItemAttr.tryParse({'id': 'g', 'columns': 7, 'bullets': 'stars'}), + const GridItemAttr(id: 'g', columns: 3, bullets: GridBullets.none), + ); + }); + + test('when checking canonical form then only exact shapes pass', () { + expect(CellAttr.isCanonical({'table': 't', 'row': 'r'}), isTrue); + expect( + CellAttr.isCanonical({'table': 't', 'row': 'r', 'align': 'right'}), + isTrue, + ); + expect( + CellAttr.isCanonical({'table': 't', 'row': 'r', 'align': null}), + isFalse, + ); + expect( + GridItemAttr.isCanonical({'id': 'g', 'columns': 2, 'bullets': 'none'}), + isTrue, + ); + expect(GridItemAttr.isCanonical({'id': 'g', 'columns': 3}), isFalse); + }); + + test('when making ids then row ids are 8 base-36 characters', () { + final ids = {for (var i = 0; i < 50; i++) CellIds.row()}; + expect(ids, hasLength(50)); + expect(ids.every(RegExp(r'^[0-9a-z]{8}$').hasMatch), isTrue); + expect(CellIds.group(), hasLength(36)); + }); + }); + + group('Given a document with two tables and a grid', () { + final doc = WonkyDocument( + normalizer + .normalize( + Delta() + ..insert('Intro\n') + ..insert('A\n', cell('r1')) + ..insert('B\n', cell('r1', align: 'right')) + ..insert('1\n', cell('r2')) + ..insert('Between\n') + ..insert('X\n', cell('a', table: 'u')) + ..insert('One\n', grid('g')) + ..insert('Two\n', grid('g')) + ..insert('Outro\n'), + ) + .document, + ); + + test('when finding runs then each table and grid is one run', () { + final runs = CellRuns.of(doc); + expect(runs, hasLength(3)); + final t = runs[0] as CellTable; + expect(t.id, 't'); + expect(t.rows.map((r) => r.cells.length), [2, 1]); + expect(t.columnCount, 2); + expect(t.isRagged, isTrue); + expect(t.align, [null, CellAlign.right]); + expect(t.cellOf(t.rows[1].cells[0].start), (1, 0)); + expect((runs[1] as CellTable).id, 'u'); + final g = runs[2] as CellGrid; + expect(g.items.map((l) => l.text), ['One', 'Two']); + expect(g.columns, 3); + }); + + test( + 'when asking for the run at a line then any of its lines finds it', + () { + expect((CellRuns.at(doc, 3) as CellTable).id, 't'); + expect((CellRuns.at(doc, 7) as CellGrid).id, 'g'); + expect(CellRuns.at(doc, 4), isNull); + expect(CellRuns.table(doc, 'u')!.rows.single.cells.single.text, 'X'); + expect(CellRuns.grid(doc, 'nope'), isNull); + }, + ); + + test('when rendering plain text then rows are tab separated', () { + expect( + PlainTextRenderer.render(doc.delta), + 'Intro\nA\tB\n1\nBetween\nX\nOne\nTwo\nOutro', + ); + }); + + test('when counting stats then each table counts once and cells are not ' + 'paragraphs', () { + final stats = const DocumentStatsCalculator().statsOf(doc); + expect(stats.tables, 2); + expect(stats.paragraphs, 3); + expect(stats.words, 3 + 4 + 2); + }); + + test('when building the DocTree then tables are padded and grids keep ' + 'their items', () { + final tree = const DocTreeBuilder().fromDelta(doc.delta); + final t = tree.blocks[1] as TableBlock; + expect(t.id, 't'); + expect(t.align, [null, CellAlign.right]); + expect(t.cellMarkdown, [ + ['A', 'B'], + ['1', ''], + ]); + expect(t.rowIds, ['r1', 'r2']); + expect(t.deltaStart, 'Intro\n'.length); + expect(t.deltaLength, 'A\nB\n1\n'.length); + final g = tree.blocks[4] as GridBlock; + expect(g.itemMarkdown, ['One', 'Two']); + }); + + test('when converting the DocTree back then the lines return with their ' + 'row ids (rows completed)', () { + final tree = const DocTreeBuilder().fromDelta(doc.delta); + final back = const DocTreeToDelta().convert(tree); + expect( + CellCanon.canonical(back).toJson(), + CellCanon.canonical(doc.delta).toJson(), + ); + final t = CellRuns.table(WonkyDocument(back), 't')!; + expect(t.rows.map((r) => r.id), ['r1', 'r2']); + }); + }); + + group('Given invalid cell lines', () { + test('when validating then other block attributes are rejected', () { + final d = Delta() + ..insert('A\n', {...cell('r'), 'header': 2}) + ..insert('B\n', {...cell('r'), ...grid('g')}) + ..insert('C
D\n', cell('r')); + final codes = validator.validate(d).issues.map((i) => i.code).toList(); + expect(codes.where((c) => c == ValidationCode.invalidCell), hasLength(3)); + }); + + test('when normalizing then the lines become valid cells', () { + final d = Delta() + ..insert('A\n', { + ...cell('r'), + 'header': 2, + 'box': [ + {'id': 'b', 'type': 'card'}, + ], + }) + ..insert('B\n', {...cell('r'), ...grid('g')}) + ..insert('C
D\n', { + 'cell': {'table': 't', 'row': 'r', 'align': 'middle', 'x': 1}, + }); + final out = normalizer.normalize(d).document; + expect(validator.validate(out).isValid, isTrue); + final lines = WonkyDocument(out).lines; + expect(lines[0].attributes.keys.toSet(), {'cell', 'box'}); + expect(lines[1].attributes.keys.toSet(), {'cell'}); + expect(lines[2].text, 'C D'); + expect(lines[2].attributes, cell('r')); + }); + }); + + group('Given legacy table and grid embeds', () { + final box = [ + {'id': 'b', 'type': 'card'}, + ]; + final legacy = Delta() + ..insert('Intro\n') + ..insert( + const TableEmbed( + id: 't1', + align: [null, CellAlign.center], + rows: [ + ['**Name**', 'Qty'], + ['Apples'], + ], + ).toInsert(), + {'cmt_c1': true}, + ) + ..insert('\n', {'box': box}) + ..insert( + const GridEmbed( + id: 'g1', + columns: 2, + bullets: GridBullets.circle, + items: [':chip[Fast]', 'Reliable'], + ).toInsert(), + ) + ..insert('\n') + ..insert('Outro\n'); + + test('when detecting then they are found', () { + expect(CellUpgrade.hasLegacy(legacy), isTrue); + expect(CellUpgrade.hasLegacy(Delta()..insert('x\n')), isFalse); + expect(CellUpgrade.upgradeChange(Delta()..insert('x\n')), isNull); + }); + + test('when upgrading then they become lines with their ids, box and ' + 'inline Markdown as formatting; comments are dropped', () { + var n = 0; + final out = normalizer + .normalize(CellUpgrade.upgrade(legacy, rowId: () => 'r${n++}')) + .document; + expect(validator.validate(out).isValid, isTrue); + expect(CellUpgrade.hasLegacy(out), isFalse); + final doc = WonkyDocument(out); + final t = CellRuns.table(doc, 't1')!; + expect(t.rows.map((r) => r.id), ['r0', 'r1']); + expect( + [ + for (final r in t.rows) [for (final c in r.cells) c.text], + ], + [ + ['Name', 'Qty'], + ['Apples', ''], + ], + ); + expect(t.rows[0].cells[0].segments.single.attributes, {'bold': true}); + expect(t.align, [null, CellAlign.center]); + expect(t.box, box); + for (final line in t.lines) { + expect(line.attributes['box'], box); + expect( + line.segments.every((s) => s.attributes?['cmt_c1'] == null), + isTrue, + ); + } + final g = CellRuns.grid(doc, 'g1')!; + expect(g.items.map((l) => l.text), ['Fast', 'Reliable']); + expect(g.items.first.segments.single.attributes, {'chip': true}); + expect(g.columns, 2); + expect(g.bullets, GridBullets.circle); + expect(doc.lines.first.text, 'Intro'); + expect(doc.lines.last.text, 'Outro'); + }); + + test('when upgrading then the Markdown export is unchanged (AC-12.5)', () { + const codec = MarkdownCodec(); + const options = MarkdownExportOptions(flavor: MarkdownFlavor.wonky); + expect( + codec.exportMarkdown(CellUpgrade.upgrade(legacy), options: options), + codec.exportMarkdown(legacy, options: options), + ); + }); + + test('when building the DocTree then both forms give the same blocks', () { + String md(Delta d) => const MarkdownCodec().exportMarkdown( + d, + options: const MarkdownExportOptions(flavor: MarkdownFlavor.wonky), + ); + final legacyTree = const DocTreeBuilder().fromDelta(legacy); + final upgradedTree = const DocTreeBuilder().fromDelta( + CellUpgrade.upgrade(legacy), + ); + final a = legacyTree.blocks.whereType().single; + final b = upgradedTree.blocks.whereType().single; + final ta = a.children.single as TableBlock; + final tb = b.children.single as TableBlock; + expect(tb.cellMarkdown, ta.cellMarkdown); + expect(tb.align, ta.align); + expect(md(CellUpgrade.upgrade(legacy)), md(legacy)); + }); + }); + + group('Given random documents with legacy embeds', () { + propertyTest('when upgrading then the result is valid, has no legacy ' + 'embeds, and upgrading again changes nothing', (random, _) { + final gen = DeltaGen( + random, + options: const DeltaGenOptions(wonky: true, overlays: true), + ); + final doc = gen.document(); + // Replace some lines with legacy embeds. + final d = Delta(); + for (final line in WonkyDocument(doc).lines) { + if (random.nextInt(6) == 0) { + d + ..insert( + (random.nextBool() ? gen.legacyTable() : gen.legacyGrid()) + .toInsert(), + ) + ..insert('\n', {'box': ?line.attributes['box']}); + } + for (final s in line.segments) { + d.insert(s.data, s.attributes); + } + d.insert('\n', line.attributes.isEmpty ? null : line.attributes); + } + final legacy = normalizer.normalize(d).document; + final change = CellUpgrade.upgradeChange(legacy); + final out = normalizer + .normalize(change == null ? legacy : legacy.compose(change)) + .document; + + expect(validator.validate(out).issues, isEmpty); + expect(CellUpgrade.hasLegacy(out), isFalse); + expect(CellUpgrade.upgradeChange(out), isNull); + expect( + PlainTextRenderer.render(out).replaceAll('\t', ' '), + PlainTextRenderer.render(legacy).replaceAll('\t', ' '), + ); + }, runs: 200); + }); +} diff --git a/packages/wonky_write_core/test/edit/document_edits_test.dart b/packages/wonky_write_core/test/edit/document_edits_test.dart index 1e445e3f..534b087c 100644 --- a/packages/wonky_write_core/test/edit/document_edits_test.dart +++ b/packages/wonky_write_core/test/edit/document_edits_test.dart @@ -14,6 +14,10 @@ Delta md(String markdown) => const MarkdownCodec() String toMd(Delta doc) => const MarkdownCodec().exportMarkdown(doc); +List _runs(DocumentLine line) => [ + for (final s in line.segments) InlineRun.fromAttributes(s.text, s.attributes), +]; + void main() { var ids = 0; final edits = DocumentEdits(idGenerator: () => 'id${ids++}'); @@ -447,7 +451,7 @@ void main() { }); group('Given tables', () { - test('when inserting and updating a table then the embed keeps its id', () { + test('when inserting and updating a table then the table keeps its id', () { final doc = md('text\n'); final ins = edits.insertTable(doc, 4, [ ['a', 'b'], @@ -466,30 +470,58 @@ void main() { ], ); - final (_, table, _) = DocumentEdits.findTable( - upd.document, - tableIndex: 0, - ); + final table = DocumentEdits.findTable(upd.document, tableIndex: 0); expect(table.id, id); - expect(table.rows, [ - ['a', 'b', 'c'], - ['1', '**2**', ''], - ['x', 'y', 'z'], - ]); + expect( + [ + for (final r in table.rows) + [ + for (final c in r.cells) + InlineMarkdownWriter.write( + _runs(c), + context: InlineContext.tableCell, + ), + ], + ], + [ + ['a', 'b', 'c'], + ['1', '**2**', ''], + ['x', 'y', 'z'], + ], + ); expect(table.align, [CellAlign.right, null, null]); }); + test('when updating a table then cells that do not change keep their ' + 'lines', () { + final ins = edits.insertTable(md('text\n'), 4, [ + ['a', 'b'], + ['1', '2'], + ]); + final id = ins.extra['table_id'] as String; + + final upd = edits.updateTable( + ins.document, + tableId: id, + operations: const [SetCellOp(1, 1, 'two')], + ); + + final deleted = upd.change + .toList() + .where((op) => op.isDelete) + .fold(0, (n, op) => n + op.length!); + expect(deleted, 1); + }); + test('when deleting the only row then invalid_args is thrown', () { + final ins = edits.insertTable(md('text\n'), 4, [ + ['a'], + ]); expect( - () => DocumentEdits.applyTableOperation( - const TableEmbed( - id: 't', - align: [null], - rows: [ - ['a'], - ], - ), - const DeleteRowOp(0), + () => edits.updateTable( + ins.document, + tableIndex: 0, + operations: const [DeleteRowOp(0)], ), throwsA(isA()), ); diff --git a/packages/wonky_write_core/test/import/odt_importer_test.dart b/packages/wonky_write_core/test/import/odt_importer_test.dart index 27c29f48..084dd8d2 100644 --- a/packages/wonky_write_core/test/import/odt_importer_test.dart +++ b/packages/wonky_write_core/test/import/odt_importer_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:test/test.dart'; +import 'package:wonky_write_core/testing.dart'; import 'package:wonky_write_core/wonky_write_core.dart'; import '../export/export_support.dart'; @@ -122,7 +123,7 @@ List _normalized(Delta delta) { var id = 0; final srcs = {}; return [ - for (final op in delta.toJson()) + for (final op in CellCanon.canonical(delta).toJson()) if (op['insert'] is Map) { ...op, @@ -180,7 +181,7 @@ void main() { _p(' abcd e '), ), ); - final ops = r.delta.toJson(); + final ops = CellCanon.canonical(r.delta).toJson(); expect(ops.first['insert'], 'a b c
d e \n'); }, ); @@ -245,15 +246,14 @@ void main() { ), ); - final table = r.delta.toJson().first['insert'] as Map; - expect(table['table'], { - 'id': 'e0', - 'align': [null, 'right', null], - 'rows': [ - ['Name', 'Value', ''], - ['a **b**', '1', 'x y'], - ], - }); + final table = + const DocTreeBuilder().fromDelta(r.delta).blocks.first as TableBlock; + expect(table.id, 'e0'); + expect(table.align, [null, CellAlign.right, null]); + expect(table.cellMarkdown, [ + ['Name', 'Value', ''], + ['a **b**', '1', 'x y'], + ]); expect(_kinds(r), contains(ImportWarningKind.tableFlattened)); }); @@ -276,7 +276,8 @@ void main() { ), ); - final image = r.delta.toJson().first['insert'] as Map; + final image = + CellCanon.canonical(r.delta).toJson().first['insert'] as Map; expect(image['image'], { 'id': 'e0', 'src': 'import-image://img1', diff --git a/packages/wonky_write_core/test/import/rtf_importer_test.dart b/packages/wonky_write_core/test/import/rtf_importer_test.dart index 050bcabc..5c2d3369 100644 --- a/packages/wonky_write_core/test/import/rtf_importer_test.dart +++ b/packages/wonky_write_core/test/import/rtf_importer_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:test/test.dart'; +import 'package:wonky_write_core/testing.dart'; import 'package:wonky_write_core/wonky_write_core.dart'; import '../export/export_support.dart'; @@ -161,7 +162,7 @@ void main() { setUp(() => r = import(wordFixture(hex(pngBytes())))); test('when importing then the content maps to the delta schema', () { - expect(r.delta.toJson(), [ + expect(CellCanon.canonical(r.delta).toJson(), [ {'insert': 'Café heading'}, { 'insert': '\n', @@ -227,19 +228,34 @@ void main() { 'insert': '\n', 'attributes': {'code-block': ''}, }, + {'insert': 'H1'}, { - 'insert': { - 'table': { - 'id': 'e0', - 'align': ['left', 'right'], - 'rows': [ - ['H1', 'H2'], - ['a', 'b'], - ], - }, + 'insert': '\n', + 'attributes': { + 'cell': {'table': 'e0', 'row': 'r0', 'align': 'left'}, + }, + }, + {'insert': 'H2'}, + { + 'insert': '\n', + 'attributes': { + 'cell': {'table': 'e0', 'row': 'r0', 'align': 'right'}, + }, + }, + {'insert': 'a'}, + { + 'insert': '\n', + 'attributes': { + 'cell': {'table': 'e0', 'row': 'r1', 'align': 'left'}, + }, + }, + {'insert': 'b'}, + { + 'insert': '\n', + 'attributes': { + 'cell': {'table': 'e0', 'row': 'r1', 'align': 'right'}, }, }, - {'insert': '\n'}, { 'insert': { 'image': { @@ -367,7 +383,7 @@ void main() { test('when braces are unbalanced then the text is still imported', () { final r = import(r'{\rtf1 {\b one}} two\par}}} {three'); - expect(r.delta.toJson(), [ + expect(CellCanon.canonical(r.delta).toJson(), [ { 'insert': 'one', 'attributes': {'bold': true}, @@ -380,7 +396,7 @@ void main() { 'skipped', () { final r = import(r'{\rtf1 a{\*\unknown {nested} text}b\par}'); - expect(r.delta.toJson(), [ + expect(CellCanon.canonical(r.delta).toJson(), [ {'insert': 'ab\n'}, ]); }); @@ -404,9 +420,9 @@ void main() { expect( jsonEncode( - r.delta.toJson(), + CellCanon.canonical(r.delta).toJson(), ).replaceAll('import-image://img1', 'https://img.example.com/logo.png'), - jsonEncode(source.toJson()), + jsonEncode(CellCanon.canonical(source).toJson()), ); expect(r.title, 'Q3 planning'); }); @@ -447,7 +463,10 @@ after empty final r = const RtfImporter().importSync(exported.bytes, opts()); - expect(jsonEncode(r.delta.toJson()), jsonEncode(source.toJson())); + expect( + jsonEncode(CellCanon.canonical(r.delta).toJson()), + jsonEncode(CellCanon.canonical(source).toJson()), + ); }); }); } diff --git a/packages/wonky_write_core/test/markdown/markdown_codec_test.dart b/packages/wonky_write_core/test/markdown/markdown_codec_test.dart index 58d676ae..165fd46f 100644 --- a/packages/wonky_write_core/test/markdown/markdown_codec_test.dart +++ b/packages/wonky_write_core/test/markdown/markdown_codec_test.dart @@ -1,4 +1,5 @@ import 'package:test/test.dart'; +import 'package:wonky_write_core/testing.dart'; import 'package:wonky_write_core/wonky_write_core.dart'; import '../property/markdown_round_trip_test.dart' show checkRoundTrip; @@ -223,19 +224,33 @@ void main() { 'a ragged table', '| a | b |\n|---|:-:|\n| 1 |\n', [ + {'insert': 'a'}, { - 'insert': { - 'table': { - 'id': 'id0', - 'align': [null, 'center'], - 'rows': [ - ['a', 'b'], - ['1', ''], - ], - }, + 'insert': '\n', + 'attributes': { + 'cell': {'table': 'id0', 'row': 'r0'}, + }, + }, + {'insert': 'b'}, + { + 'insert': '\n', + 'attributes': { + 'cell': {'table': 'id0', 'row': 'r0', 'align': 'center'}, + }, + }, + {'insert': '1'}, + { + 'insert': '\n', + 'attributes': { + 'cell': {'table': 'id0', 'row': 'r1'}, + }, + }, + { + 'insert': '\n', + 'attributes': { + 'cell': {'table': 'id0', 'row': 'r1', 'align': 'center'}, }, }, - {'insert': '\n'}, ], {}, ), @@ -252,7 +267,8 @@ void main() { test('when importing $name then the delta and warnings match', () { final r = imp(md); - expect(r.delta.toJson(), equals(expected)); + // Row ids are random: compared in the canonical form. + expect(CellCanon.canonical(r.delta).toJson(), equals(expected)); expect(r.warnings.map((w) => w.kind).toSet(), equals(warnings)); }); } diff --git a/packages/wonky_write_core/test/markdown/wonky_markdown_test.dart b/packages/wonky_write_core/test/markdown/wonky_markdown_test.dart index d716647c..6a765ba1 100644 --- a/packages/wonky_write_core/test/markdown/wonky_markdown_test.dart +++ b/packages/wonky_write_core/test/markdown/wonky_markdown_test.dart @@ -114,21 +114,22 @@ void main() { ]); }); - test('when importing a grid then it becomes a grid embed', () { + test('when importing a grid then it becomes grid item lines', () { final d = importWonky( ':::grid{columns=3 bullets=check}\n- Automatic migrations\n' '- Stateful hot reload\n- Integrated **AI** tooling\n:::\n', ).delta; - expect(WonkyDocument(d).lines.single.embed, isA()); - final grid = WonkyDocument(d).lines.single.embed! as GridEmbed; + final grids = CellRuns.of(WonkyDocument(d)); + final grid = grids.single as CellGrid; expect(grid.columns, 3); expect(grid.bullets, GridBullets.check); - expect(grid.items, [ + expect(grid.items.map((l) => l.text), [ 'Automatic migrations', 'Stateful hot reload', - 'Integrated **AI** tooling', + 'Integrated AI tooling', ]); + expect(grid.items.last.segments[1].attributes, {'bold': true}); }); test('when importing a card with an arrow link then both survive', () { @@ -537,18 +538,25 @@ void main() { } /// `import(export(D, wonky), wonky) == normalize(D)` (Write 11 §5.4), with -/// container and embed ids replayed in import order. +/// container, embed, table and grid ids replayed in import order, tables +/// compared in their canonical form (Write 12 §5.1). void checkWonkyRoundTrip(Delta d) { const codec = MarkdownCodec(); final normalized = const WonkyDeltaNormalizer().normalize(d).document; final ids = []; var prevPath = const []; + String? prevGroup; for (final line in WonkyDocument(normalized).lines) { final path = Boxes.of(line.attributes); for (var k = Boxes.commonDepth(prevPath, path); k < path.length; k++) { ids.add(path[k].id); } prevPath = path; + final group = + CellAttr.of(line.attributes)?.table ?? + GridItemAttr.of(line.attributes)?.id; + if (group != null && group != prevGroup) ids.add(group); + prevGroup = group; if (line.embed case final e?) ids.add(e.id); } var next = 0; @@ -566,8 +574,8 @@ void checkWonkyRoundTrip(Delta d) { ); expect( - back.delta.toJson(), - equals(normalized.toJson()), + CellCanon.canonical(back.delta).toJson(), + equals(CellCanon.canonical(normalized).toJson()), reason: 'Markdown:\n$markdown', ); } diff --git a/packages/wonky_write_core/test/property/markdown_round_trip_test.dart b/packages/wonky_write_core/test/property/markdown_round_trip_test.dart index 5bed0ce2..b420850c 100644 --- a/packages/wonky_write_core/test/property/markdown_round_trip_test.dart +++ b/packages/wonky_write_core/test/property/markdown_round_trip_test.dart @@ -4,11 +4,12 @@ import 'package:wonky_write_core/wonky_write_core.dart'; import '../support/property.dart'; -/// `import(export(D)) == normalize(D)` (02 §2.2), embed ids replayed. +/// `import(export(D)) == normalize(D)` (02 §2.2), embed, table and grid ids +/// replayed, tables compared in their canonical form (Write 12 §5.1). void checkRoundTrip(Delta d) { const codec = MarkdownCodec(); final normalized = const WonkyDeltaNormalizer().normalize(d).document; - final ids = [for (final (_, e) in WonkyDocument(normalized).embeds) e.id]; + final ids = CellCanon.blockIds(normalized); var next = 0; final markdown = codec.exportMarkdown(normalized); @@ -20,8 +21,8 @@ void checkRoundTrip(Delta d) { ); expect( - back.delta.toJson(), - equals(normalized.toJson()), + CellCanon.canonical(back.delta).toJson(), + equals(CellCanon.canonical(normalized).toJson()), reason: 'Markdown:\n$markdown', ); } diff --git a/specs/wonky_write/12-native-cells.md b/specs/wonky_write/12-native-cells.md index f4632183..8d1bc5c0 100644 --- a/specs/wonky_write/12-native-cells.md +++ b/specs/wonky_write/12-native-cells.md @@ -57,10 +57,11 @@ A grid (Wonky Markdown only) is a run of consecutive lines carrying the line att ``` - `id`: the grid's id. `columns`: 1–4 (canonical as in 11 §2.2: 1, 2 or 4, else 3). `bullets`: - `check`, `circle` or `none`. Every item of a grid carries the same `columns` and `bullets`; the - normalizer copies the first item's values onto the others. + `check`, `circle` or `none`. Commands set the same `columns` and `bullets` on every item; when + items disagree (a concurrent edit), the first item's values are the grid's (rendering, exports, + `CellGrid`), and the next command on the grid makes them agree. - A grid is a maximal run of consecutive lines with the same grid `id`; items are in line order. - At most 100 items (`GridLines.maxItems`); an empty grid does not exist (its last item removed, + At most 100 items (`GridEmbed.maxItems`); an empty grid does not exist (its last item removed, the grid is gone). - Items are one line each with inline content, like cells (§1.1), and may also carry `box`. @@ -82,8 +83,12 @@ change, client and server): `columns`; 2. removes block attributes other than `box` from cell and grid lines, and `cell` from a line that also has `grid` (a line is either); -3. replaces U+2028 in cell and grid lines with a space; -4. copies the first item's `columns`/`bullets` onto the other items of a grid run. +3. replaces U+2028 in cell and grid lines with a space. + +The rules are line-local, like every other normalizer rule, so normalizing a change on its lines +equals normalizing it on the whole document. Cross-line facts (equal `columns`/`bullets` on a grid's +items, equal alignment in a column, complete rows) are kept by the commands and read +leniently (first item, header cell, placeholders). ### 1.5 Legacy embeds and the upgrade From 2712236342d676a9f8569465821029e4e5210ed7 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Tue, 27 Oct 2026 23:52:57 +0200 Subject: [PATCH 03/16] refactor(write): table and grid commands on CellEdits; drop overlay editors Cell and grid item lines map to paragraph nodes; table and grid commands build their changes with CellEdits; InlineTableEditing, InlineGridEditing and FieldClicks are gone; legacy embeds render read-only. Co-Authored-By: Claude Opus 5.5 --- wonky_write/lib/commands/extras_commands.dart | 22 +- wonky_write/lib/commands/screen_commands.dart | 36 +- .../commands/editor_command_catalog.dart | 170 +++-- .../commands/wonky_command_catalog.dart | 5 +- .../commands/wonky_markdown_commands.dart | 125 ++-- .../commands/write_editor_commands.dart | 217 ++++-- .../editor/components/grid_component.dart | 243 +------ .../editor/components/wonky_components.dart | 77 +- .../controller/write_editor_controller.dart | 23 - .../editor/grid/inline_grid_editing.dart | 452 ------------ .../editor/mapping/delta_node_codec.dart | 25 +- .../features/editor/mapping/wonky_schema.dart | 30 +- .../lib/features/editor/model/cells.dart | 117 +++ .../features/editor/model/field_clicks.dart | 156 ---- .../features/editor/model/table_model.dart | 25 + .../reactions/block_conversion_reactions.dart | 6 +- .../editor/reactions/wonky_shortcuts.dart | 41 +- .../features/editor/setup/editor_factory.dart | 2 - .../editor/tables/inline_table_editing.dart | 682 ------------------ .../lib/features/editor/ui/wonky_editor.dart | 85 +-- .../features/screen/selection_toolbar.dart | 4 - .../editor/model/field_clicks_test.dart | 90 --- 22 files changed, 630 insertions(+), 2003 deletions(-) delete mode 100644 wonky_write/lib/features/editor/grid/inline_grid_editing.dart create mode 100644 wonky_write/lib/features/editor/model/cells.dart delete mode 100644 wonky_write/lib/features/editor/model/field_clicks.dart delete mode 100644 wonky_write/lib/features/editor/tables/inline_table_editing.dart delete mode 100644 wonky_write/test/features/editor/model/field_clicks_test.dart diff --git a/wonky_write/lib/commands/extras_commands.dart b/wonky_write/lib/commands/extras_commands.dart index 260d03ad..363cb2b1 100644 --- a/wonky_write/lib/commands/extras_commands.dart +++ b/wonky_write/lib/commands/extras_commands.dart @@ -112,25 +112,19 @@ List> extrasCommands(ShellServices s) => [ }, ), needsEditable: true, - enabled: (d, a) => - d.editor.tableEditing != null && - d.commands.canEditTable(nodeId: a.string('nodeId')), + enabled: (d, a) => d.commands.canEditTable(nodeId: a.string('nodeId')), run: (ctx, d, a) { - final table = d.commands.tableOf(nodeId: a.string('nodeId')); - final editing = d.editor.tableEditing; final l = writeL10nOf(s); - if (table == null || editing == null) { + if (d.commands.tableRunAt(nodeId: a.string('nodeId')) == null) { return CommandResult.failed(l.commandToastNoTableSelected); } - return okIf( - editing.activate( - table.id, - a.integer('row') ?? 0, - a.integer('col') ?? 0, - selectAll: true, - ), - l.reasonNotAvailable, + final ok = d.commands.selectCell( + a.integer('row') ?? 0, + a.integer('col') ?? 0, + nodeId: a.string('nodeId'), ); + if (ok) d.focusEditor(); + return okIf(ok, l.reasonNotAvailable); }, ), ]; diff --git a/wonky_write/lib/commands/screen_commands.dart b/wonky_write/lib/commands/screen_commands.dart index a250e68d..7356f1ce 100644 --- a/wonky_write/lib/commands/screen_commands.dart +++ b/wonky_write/lib/commands/screen_commands.dart @@ -12,7 +12,6 @@ import '../features/editor/commands/write_editor_commands.dart'; import '../features/editor/controller/editor_types.dart'; import '../features/editor/model/document_queries.dart'; import '../features/editor/model/link_url_policy.dart'; -import '../features/editor/model/table_model.dart'; import '../features/export/export_service.dart'; import '../features/images/image_dialogs.dart'; import '../features/images/image_services.dart'; @@ -140,26 +139,10 @@ Map _overrides( d.focusEditor(); return runSpec(spec, d, JsonArgs({'url': r.url, 'alt': r.alt})); }, - // Inserting from the UI opens the table editor on the new table (04 §18.3). + // Inserting from the UI puts the caret in the first cell (Write 12 §3.5). 'write.insert.table': (spec) => (ctx, d, a) async { final r = await runSpec(spec, d, a); - final c = _ctx(ctx); - final data = r.data; - if (r.isOk && _ui(ctx) && c != null && data is Map) { - final id = data['id'] as String?; - // In-place cell editing (SE-4) starts on the first cell; without it - // the table editor opens. - final inline = d.editor.tableEditing; - if (id != null && inline != null) { - scheduleMicrotask(() => inline.activate(id, 0, 0, selectAll: true)); - } else if (id != null) { - scheduleMicrotask(() { - if (c.mounted) { - unawaited(_editTable(c, d, nodeId: id)); - } - }); - } - } + if (r.isOk && _ui(ctx)) d.focusEditor(); return r; }, // Modes are clamped to the role (Write 04 §31). @@ -190,9 +173,8 @@ Future _editTable( int col = 0, }) async { final l = writeL10n(context); - final data = d.embedData('table', nodeId); - if (data == null) return CommandResult.failed(l.commandToastNoTableSelected); - final table = TableModel.fromEmbed(data); + final table = d.commands.tableOf(nodeId: nodeId); + if (table == null) return CommandResult.failed(l.commandToastNoTableSelected); final edited = await showTableEditor(context, table, row: row, column: col); d.focusEditor(); if (edited == null) return const CommandResult.cancelled(); @@ -897,16 +879,14 @@ List> insertCommands(ShellServices s) => [ properties: {'nodeId': JsonSchema.string()}, ), enabled: (d, a) => - d.copyAllowed && d.embedData('table', a.string('nodeId')) != null, + d.copyAllowed && d.commands.tableOf(nodeId: a.string('nodeId')) != null, run: (ctx, d, a) async { - final data = d.embedData('table', a.string('nodeId')); + final table = d.commands.tableOf(nodeId: a.string('nodeId')); final l = writeL10nOf(s); - if (data == null) { + if (table == null) { return CommandResult.failed(l.commandToastNoTableSelected); } - await Clipboard.setData( - ClipboardData(text: TableModel.fromEmbed(data).toTsv()), - ); + await Clipboard.setData(ClipboardData(text: table.toTsv())); s.toasts.show(l.commandToastTableCopiedAsTsv); return const CommandResult.ok(); }, diff --git a/wonky_write/lib/features/editor/commands/editor_command_catalog.dart b/wonky_write/lib/features/editor/commands/editor_command_catalog.dart index 2e4b6d22..75b9f5ed 100644 --- a/wonky_write/lib/features/editor/commands/editor_command_catalog.dart +++ b/wonky_write/lib/features/editor/commands/editor_command_catalog.dart @@ -3,12 +3,18 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:wonky_core/wonky_core.dart'; import 'package:wonky_write_core/wonky_write_core.dart' - show BoxType, CalloutVariant, GridBullets; + show + BoxType, + CalloutVariant, + CellAlign, + CellEditException, + CellEdits, + CellLines, + GridBullets; import '../applier/delta_applier.dart'; import '../controller/editor_types.dart'; import '../controller/write_editor_controller.dart'; -import '../model/table_model.dart'; import '../paste/editor_clipboard.dart'; import 'write_editor_commands.dart'; @@ -717,7 +723,8 @@ List editorCommandCatalog() { ), ), - // Tables (§18.4). + // Tables (§18.4, Write 12 §3.5). `nodeId` is any cell of the table (or + // the table id); `index` defaults to the caret's row or column. EditorCommandSpec( id: 'write.table.insertRow', title: 'Insert row', @@ -728,18 +735,20 @@ List editorCommandCatalog() { 'index': JsonSchema.integer(minimum: 0), 'position': JsonSchema.string(enumValues: ['above', 'below']), }, - required: ['index'], ), enabled: (t, a) => t.commands.canEditTable(nodeId: a.string('nodeId')), - run: (t, a) => _result( - t.commands.editTable( - (table) => table.insertRow( - a.integer('index')! + (a.string('position') == 'above' ? 0 : 1), + run: (t, a) { + final row = a.integer('index') ?? t.commands.caretCell()?.$1; + if (row == null) return _result(false); + final at = row + (a.string('position') == 'above' ? 0 : 1); + return _result( + t.commands.editCells( + (run) => CellEdits.insertRow(run.doc, run.id, at), + nodeId: a.string('nodeId'), + label: t.controller.strings().undoInsertRow, ), - nodeId: a.string('nodeId'), - label: t.controller.strings().undoInsertRow, - ), - ), + ); + }, ), EditorCommandSpec( id: 'write.table.insertColumn', @@ -751,18 +760,20 @@ List editorCommandCatalog() { 'index': JsonSchema.integer(minimum: 0), 'position': JsonSchema.string(enumValues: ['left', 'right']), }, - required: ['index'], ), enabled: (t, a) => t.commands.canEditTable(nodeId: a.string('nodeId')), - run: (t, a) => _result( - t.commands.editTable( - (table) => table.insertColumn( - a.integer('index')! + (a.string('position') == 'left' ? 0 : 1), + run: (t, a) { + final col = a.integer('index') ?? t.commands.caretCell()?.$2; + if (col == null) return _result(false); + final at = col + (a.string('position') == 'left' ? 0 : 1); + return _result( + t.commands.editCells( + (run) => CellEdits.insertColumn(run.doc, run.id, at), + nodeId: a.string('nodeId'), + label: t.controller.strings().undoInsertColumn, ), - nodeId: a.string('nodeId'), - label: t.controller.strings().undoInsertColumn, - ), - ), + ); + }, ), EditorCommandSpec( id: 'write.table.deleteRow', @@ -771,18 +782,28 @@ List editorCommandCatalog() { argsSchema: const JsonSchema.object( properties: { 'nodeId': JsonSchema.string(), - 'index': JsonSchema.integer(minimum: 1), + 'index': JsonSchema.integer(minimum: 0), }, - required: ['index'], ), enabled: (t, a) => t.commands.canEditTable(nodeId: a.string('nodeId')), - run: (t, a) => _result( - t.commands.editTable( - (table) => table.deleteRow(a.integer('index')!), - nodeId: a.string('nodeId'), - label: t.controller.strings().undoDeleteRow, - ), - ), + run: (t, a) { + final row = a.integer('index') ?? t.commands.caretCell()?.$1; + if (row == null) return _result(false); + return _result( + t.commands.editCells( + (run) { + // The header row stays while there are body rows (it can be + // emptied, Write 12 §3.5). + if (row == 0 && run.table!.rows.length > 1) { + throw const CellEditException('The header row stays'); + } + return CellEdits.deleteRow(run.doc, run.id, row); + }, + nodeId: a.string('nodeId'), + label: t.controller.strings().undoDeleteRow, + ), + ); + }, ), EditorCommandSpec( id: 'write.table.deleteColumn', @@ -793,16 +814,19 @@ List editorCommandCatalog() { 'nodeId': JsonSchema.string(), 'index': JsonSchema.integer(minimum: 0), }, - required: ['index'], ), enabled: (t, a) => t.commands.canEditTable(nodeId: a.string('nodeId')), - run: (t, a) => _result( - t.commands.editTable( - (table) => table.deleteColumn(a.integer('index')!), - nodeId: a.string('nodeId'), - label: t.controller.strings().undoDeleteColumn, - ), - ), + run: (t, a) { + final col = a.integer('index') ?? t.commands.caretCell()?.$2; + if (col == null) return _result(false); + return _result( + t.commands.editCells( + (run) => CellEdits.deleteColumn(run.doc, run.id, col), + nodeId: a.string('nodeId'), + label: t.controller.strings().undoDeleteColumn, + ), + ); + }, ), EditorCommandSpec( id: 'write.table.moveRow', @@ -818,8 +842,13 @@ List editorCommandCatalog() { ), enabled: (t, a) => t.commands.canEditTable(nodeId: a.string('nodeId')), run: (t, a) => _result( - t.commands.editTable( - (table) => table.moveRow(a.integer('from')!, a.integer('to')!), + t.commands.editCells( + (run) => CellEdits.moveRow( + run.doc, + run.id, + a.integer('from')!, + a.integer('to')!, + ), nodeId: a.string('nodeId'), label: t.controller.strings().undoMoveRow, ), @@ -839,8 +868,13 @@ List editorCommandCatalog() { ), enabled: (t, a) => t.commands.canEditTable(nodeId: a.string('nodeId')), run: (t, a) => _result( - t.commands.editTable( - (table) => table.moveColumn(a.integer('from')!, a.integer('to')!), + t.commands.editCells( + (run) => CellEdits.moveColumn( + run.doc, + run.id, + a.integer('from')!, + a.integer('to')!, + ), nodeId: a.string('nodeId'), label: t.controller.strings().undoMoveColumn, ), @@ -858,19 +892,34 @@ List editorCommandCatalog() { enumValues: ['none', 'left', 'center', 'right'], ), }, - required: ['index', 'align'], + required: ['align'], ), + radio: true, enabled: (t, a) => t.commands.canEditTable(nodeId: a.string('nodeId')), - run: (t, a) => _result( - t.commands.editTable( - (table) => table.setColumnAlignment( - a.integer('index')!, - TableAlign.parse(a.string('align')), + checked: (t, a) { + final run = t.commands.tableRunAt(nodeId: a.string('nodeId')); + final col = a.integer('index') ?? t.commands.caretCell()?.$2; + if (run == null || col == null || col >= run.table!.columnCount) { + return null; + } + return (run.table!.align[col]?.value ?? 'none') == a.string('align'); + }, + run: (t, a) { + final col = a.integer('index') ?? t.commands.caretCell()?.$2; + if (col == null) return _result(false); + return _result( + t.commands.editCells( + (run) => CellEdits.setColumnAlignment( + run.doc, + run.id, + col, + CellAlign.tryParse(a.string('align')), + ), + nodeId: a.string('nodeId'), + label: t.controller.strings().undoColumnAlignment, ), - nodeId: a.string('nodeId'), - label: t.controller.strings().undoColumnAlignment, - ), - ), + ); + }, ), EditorCommandSpec( id: 'write.table.setCell', @@ -887,11 +936,13 @@ List editorCommandCatalog() { ), enabled: (t, a) => t.commands.canEditTable(nodeId: a.string('nodeId')), run: (t, a) => _result( - t.commands.editTable( - (table) => table.setCell( + t.commands.editCells( + (run) => CellEdits.setCell( + run.doc, + run.id, a.integer('row')!, a.integer('col')!, - a.requireString('markdown'), + CellLines.markdown(a.requireString('markdown')), ), nodeId: a.string('nodeId'), label: t.controller.strings().undoEditTable, @@ -906,8 +957,13 @@ List editorCommandCatalog() { properties: {'nodeId': JsonSchema.string()}, ), enabled: (t, a) => t.commands.canEditTable(nodeId: a.string('nodeId')), - run: (t, a) => - _result(t.commands.deleteBlock(nodeId: a.string('nodeId'))), + run: (t, a) => _result( + t.commands.editCells( + (run) => CellEdits.deleteTable(run.doc, run.id), + nodeId: a.string('nodeId'), + label: t.controller.strings().undoDelete, + ), + ), ), // View (§29, not undoable). diff --git a/wonky_write/lib/features/editor/commands/wonky_command_catalog.dart b/wonky_write/lib/features/editor/commands/wonky_command_catalog.dart index 45d59d7b..a06f83ee 100644 --- a/wonky_write/lib/features/editor/commands/wonky_command_catalog.dart +++ b/wonky_write/lib/features/editor/commands/wonky_command_catalog.dart @@ -420,10 +420,7 @@ List wonkyEditorCommandCatalog() => [ t.commands.canEdit && !t.commands.isSuggesting && t.commands.gridOf(nodeId: a.string('nodeId')) != null, - run: (t, a) { - final grid = t.commands.gridOf(nodeId: a.string('nodeId')); - return _result(grid != null && t.commands.deleteBlock(nodeId: grid.id)); - }, + run: (t, a) => _result(t.commands.deleteGrid(nodeId: a.string('nodeId'))), ), EditorCommandSpec( id: 'write.link.setStyle', diff --git a/wonky_write/lib/features/editor/commands/wonky_markdown_commands.dart b/wonky_write/lib/features/editor/commands/wonky_markdown_commands.dart index acaab350..127071f9 100644 --- a/wonky_write/lib/features/editor/commands/wonky_markdown_commands.dart +++ b/wonky_write/lib/features/editor/commands/wonky_markdown_commands.dart @@ -418,33 +418,25 @@ extension WonkyMarkdownCommands on WriteEditorCommands { // --------------------------------------------------------------------------- // Grids. - /// The grid at [nodeId], or at the selection. - GridEmbed? gridOf({String? nodeId}) { - final block = _embedBlock(nodeId, DeltaKeys.gridEmbed); - if (block == null) return null; - final value = block.node.getMetadataValue(WonkyNodeMetadata.embed); - final embed = WonkyEmbed.tryParse(value); - return embed is GridEmbed ? embed : null; - } + /// The grid at [nodeId] (an item node or the grid id), or at the + /// selection (Write 12 §1.2). + CellGrid? gridOf({String? nodeId}) => cellRunAt(nodeId: nodeId)?.grid; - /// `write.insert.grid {columns?, bullets?}`: three empty items. + /// `write.insert.grid {columns?, bullets?}`: three empty items, the caret + /// in the first. String? insertGrid({ int columns = 3, GridBullets bullets = GridBullets.check, }) { - if (isSuggesting) return null; - final grid = GridEmbed( - id: controller.newEmbedId(), - columns: GridEmbed.canonicalColumns(columns), + if (isSuggesting || caretInCell) return null; + final id = CellIds.group(); + final lines = CellLines.grid( + id: id, + items: [Delta(), Delta(), Delta()], + columns: columns, bullets: bullets, - items: const ['', '', ''], ); - if (_insertEmbeds([grid.toInsert()], _l.cmdInsertGrid) == null) { - return null; - } - // The caret goes into the first item. - controller.gridEditing?.activate(grid.id, 0); - return grid.id; + return _insertLines(lines, _l.cmdInsertGrid) == null ? null : id; } // --------------------------------------------------------------------------- @@ -500,70 +492,59 @@ extension WonkyMarkdownCommands on WriteEditorCommands { return _replaceEmbed(block, next.toInsert(), _l.cmdYouTubeEdit) != null; } - /// Replaces the grid at [nodeId] with [grid] (same id). - bool replaceGrid(GridEmbed grid, {String? nodeId, String? label}) { - final block = _embedBlock(nodeId ?? grid.id, DeltaKeys.gridEmbed); - if (!canEdit || isSuggesting || block == null) return false; - final current = gridOf(nodeId: block.id); - final next = grid.copyWith( - id: current?.id ?? grid.id, - items: grid.items.isEmpty ? const [''] : grid.items, - ); - if (next == current) return true; - return _replaceEmbed(block, next.toInsert(), label ?? _l.undoEditGrid) != - null; - } - /// `write.grid.setColumns {columns}`. - bool setGridColumns(int columns, {String? nodeId}) { - final grid = gridOf(nodeId: nodeId); - if (grid == null) return false; - return replaceGrid( - grid.copyWith(columns: GridEmbed.canonicalColumns(columns)), - nodeId: nodeId, - label: _l.cmdGridSetColumns, - ); - } + bool setGridColumns(int columns, {String? nodeId}) => editCells( + (run) => run.grid == null + ? null + : CellEdits.setGridColumns(run.doc, run.id, columns), + nodeId: nodeId, + label: _l.cmdGridSetColumns, + ); /// `write.grid.setBullets {bullets}`. - bool setGridBullets(GridBullets bullets, {String? nodeId}) { - final grid = gridOf(nodeId: nodeId); - if (grid == null) return false; - return replaceGrid( - grid.copyWith(bullets: bullets), - nodeId: nodeId, - label: _l.cmdGridSetBullets, - ); - } + bool setGridBullets(GridBullets bullets, {String? nodeId}) => editCells( + (run) => run.grid == null + ? null + : CellEdits.setGridBullets(run.doc, run.id, bullets), + nodeId: nodeId, + label: _l.cmdGridSetBullets, + ); /// `write.grid.addItem {index?}`: an empty item at [index] (default: the - /// end). + /// end), the caret in it. bool addGridItem({String? nodeId, int? index}) { final grid = gridOf(nodeId: nodeId); - if (grid == null || grid.items.length >= GridEmbed.maxItems) return false; - final items = [...grid.items] - ..insert((index ?? grid.items.length).clamp(0, grid.items.length), ''); - return replaceGrid( - grid.copyWith(items: items), + if (grid == null) return false; + final at = (index ?? grid.items.length).clamp(0, grid.items.length); + return editCells( + (run) => CellEdits.addGridItem(run.doc, run.id, at), nodeId: nodeId, label: _l.cmdGridAddItem, + selectionAfter: (run, _) => DeltaSelection.collapsed( + run.start + + (at == grid.items.length + ? run.doc.length + : run.grid!.items[at].start), + ), ); } - /// `write.grid.removeItem {index}`. - bool removeGridItem(int index, {String? nodeId}) { - final grid = gridOf(nodeId: nodeId); - if (grid == null || index < 0 || index >= grid.items.length) return false; - if (grid.items.length == 1) { - return deleteBlock(nodeId: nodeId ?? grid.id); - } - final items = [...grid.items]..removeAt(index); - return replaceGrid( - grid.copyWith(items: items), - nodeId: nodeId, - label: _l.cmdGridRemoveItem, - ); - } + /// `write.grid.removeItem {index}`; removing the last item deletes the + /// grid. + bool removeGridItem(int index, {String? nodeId}) => editCells( + (run) => run.grid == null + ? null + : CellEdits.removeGridItem(run.doc, run.id, index), + nodeId: nodeId, + label: _l.cmdGridRemoveItem, + ); + + /// `write.grid.delete`. + bool deleteGrid({String? nodeId}) => editCells( + (run) => run.grid == null ? null : CellEdits.deleteGrid(run.doc, run.id), + nodeId: nodeId, + label: _l.undoDelete, + ); // --------------------------------------------------------------------------- // Link styles and chips. diff --git a/wonky_write/lib/features/editor/commands/write_editor_commands.dart b/wonky_write/lib/features/editor/commands/write_editor_commands.dart index 5a71810d..709caed2 100644 --- a/wonky_write/lib/features/editor/commands/write_editor_commands.dart +++ b/wonky_write/lib/features/editor/commands/write_editor_commands.dart @@ -7,12 +7,18 @@ import 'package:wonky_write_core/wonky_write_core.dart' BoxType, Boxes, CalloutVariant, + CellAlign, + CellEditException, + CellEdits, + CellGrid, + CellIds, + CellLines, DeltaUtils, GridBullets, - GridEmbed, MarkdownFlavor, WonkyAttr, WonkyDeltaNormalizer, + WonkyDocument, WonkyEmbed, WonkyToGfm, YouTubeEmbed; @@ -25,6 +31,7 @@ import '../controller/write_editor_controller.dart'; import '../mapping/delta_node_codec.dart'; import '../mapping/delta_ops.dart'; import '../mapping/wonky_schema.dart'; +import '../model/cells.dart'; import '../model/code_languages.dart'; import '../model/document_queries.dart'; import '../model/embeds.dart'; @@ -888,7 +895,9 @@ class WriteEditorCommands { return s != null && controller.mirror.blockAtOffset(s.start).isCode; } - bool canInsertBlock() => canEdit && !_caretInCode; + /// Blocks are not inserted in code blocks, table cells or grid items + /// (Write 12 §3.4). + bool canInsertBlock() => canEdit && !_caretInCode && !caretInCell; /// Inserts the embed [values] (each followed by `"\n"`) after the current /// block (or replacing an empty paragraph), creating a text block after the @@ -990,17 +999,19 @@ class WriteEditorCommands { return _insertEmbeds(values, label) == null ? const [] : ids; } - /// `write.insert.table {rows, cols}`. Returns the table's embed id. + /// `write.insert.table {rows, cols}`: a table of empty cell lines (Write + /// 12 §3.5), the caret in its first cell. Returns the table's id. String? insertTable({int rows = 3, int columns = 3}) { - if (isSuggesting) return null; - final table = TableModel.empty( - controller.newEmbedId(), - rowCount: rows.clamp(1, 2000), - columnCount: columns.clamp(1, 64), + if (isSuggesting || caretInCell) return null; + final id = CellIds.group(); + final lines = CellLines.table( + id: id, + rows: List.generate( + rows.clamp(1, 2000), + (_) => List.generate(columns.clamp(1, 64), (_) => Delta()), + ), ); - return _insertEmbeds([table.toInsert()], _l.undoInsertTable) == null - ? null - : table.id; + return _insertLines(lines, _l.undoInsertTable) == null ? null : id; } /// `write.insert.mention {userUuid, replaceComposing}`: replaces `[start, @@ -1471,48 +1482,168 @@ class WriteEditorCommands { } // --------------------------------------------------------------------------- - // Tables (§18). + // Tables (§18, Write 12 §3.5). + + /// The table or grid around node [nodeId] (a cell or item node, or the + /// table or grid id), or around the caret. + EditorCellRun? cellRunAt({String? nodeId}) { + if (nodeId != null) return EditorCellRun.of(controller.mirror, nodeId); + final s = selection; + if (s == null) return null; + return EditorCellRun.at( + controller.mirror, + controller.mirror.blockAtOffset(s.end).index, + ); + } + + /// The table around [nodeId] or the caret. + EditorCellRun? tableRunAt({String? nodeId}) { + final run = cellRunAt(nodeId: nodeId); + return run?.table == null ? null : run; + } + + /// The (row, column) of the caret in its table. + (int, int)? caretCell() { + final s = selection; + if (s == null) return null; + final index = controller.mirror.blockAtOffset(s.end).index; + return EditorCellRun.at( + controller.mirror, + index, + )?.cellAt(controller.mirror, index); + } + + /// `write.table.editCell {row, col}`: selects the text of cell ([row], + /// [col]) of the table at [nodeId] or the caret (the caret when empty). + bool selectCell(int row, int col, {String? nodeId}) { + final run = tableRunAt(nodeId: nodeId); + final index = run?.nodeIndexOfCell(row, col); + if (run == null || index == null) return false; + final block = controller.mirror.blockAt(index); + controller.select(DeltaSelection(block.start, block.lineEnd)); + return true; + } - /// The table at [nodeId] (or the selected one). + /// Whether the caret is in a table cell or grid item. + bool get caretInCell { + final s = selection; + return s != null && + isCellNode(controller.mirror.blockAtOffset(s.start).node); + } + + /// The table at [nodeId] (or the caret's) as a [TableModel]. TableModel? tableOf({String? nodeId}) { - final block = _embedBlock(nodeId, DeltaKeys.tableEmbed); - return block == null - ? null - : TableModel.fromEmbed(EmbedNodes.embedDataOf(block.node)!); + final t = tableRunAt(nodeId: nodeId)?.table; + return t == null ? null : TableModel.fromCells(t); } bool canEditTable({String? nodeId}) => - canEdit && !isSuggesting && tableOf(nodeId: nodeId) != null; + canEdit && !isSuggesting && tableRunAt(nodeId: nodeId) != null; + + /// Runs the structural edit [edit] on the table or grid at [nodeId] (or + /// the caret's) as one undo step [label]. Structural edits are not + /// suggestable (Write 12 §3.5). `true` when the edit applied or the table + /// already had the requested value. + bool editCells( + Delta? Function(EditorCellRun run) edit, { + String? nodeId, + required String label, + DeltaSelection? Function(EditorCellRun run, Delta change)? selectionAfter, + }) { + if (!canEdit || isSuggesting) return false; + final run = cellRunAt(nodeId: nodeId); + if (run == null) return false; + final Delta? change; + try { + change = edit(run); + } on CellEditException { + return false; + } + if (change == null) return true; + final shifted = run.shift(change); + final normalized = const WonkyDeltaNormalizer() + .normalizeChange( + controller.mirror.slice(0, controller.mirror.length), + shifted, + ) + .change; + return _apply( + normalized, + label, + selectionAfter: selectionAfter?.call(run, normalized), + ) != + null; + } - /// `write.table.replace {nodeId, table}`: one undo step "Edit table". + /// `write.table.replace {nodeId, table}` (the table editor's Done): one + /// undo step "Edit table". Cells whose Markdown did not change keep their + /// lines (comments and all, Write 12 §3.6). bool replaceTable(TableModel table, {String? nodeId, String? label}) { - label ??= _l.undoEditTable; - final block = - _embedBlock(nodeId ?? table.id, DeltaKeys.tableEmbed) ?? - _embedBlock(nodeId, DeltaKeys.tableEmbed); - if (!canEditTable(nodeId: block?.id) || block == null) return false; - final current = TableModel.fromEmbed(EmbedNodes.embedDataOf(block.node)!); - final next = TableModel( - id: current.id, - align: table.align, - rows: table.rows, + if (!canEditTable(nodeId: nodeId ?? table.id)) return false; + return editCells( + (run) { + final current = TableModel.fromCells(run.table!); + Delta cellAt(int r, int c) { + final markdown = table.rows[r][c]; + if (r < current.rows.length && + c < current.rows[r].length && + current.rows[r][c] == markdown && + c < run.table!.rows[r].cells.length) { + final line = run.table!.rows[r].cells[c]; + final d = Delta(); + for (final s in line.segments) { + d.insert(s.data, s.attributes); + } + return d; + } + return CellLines.markdown(markdown); + } + + return CellEdits.replaceTable( + run.doc, + run.id, + [ + for (var r = 0; r < table.rows.length; r++) + [for (var c = 0; c < table.rows[r].length; c++) cellAt(r, c)], + ], + align: [for (final a in table.align) CellAlign.tryParse(a.value)], + ); + }, + nodeId: nodeId ?? table.id, + label: label ?? _l.undoEditTable, ); - if (next == current) return false; - return _replaceEmbed(block, next.toInsert(), label) != null; } - /// Applies [edit] to the table and replaces it (direct table commands). - bool editTable( - TableModel Function(TableModel table) edit, { - String? nodeId, - required String label, - }) { - final table = tableOf(nodeId: nodeId); - if (table == null) return false; - return replaceTable( - edit(table), - nodeId: nodeId ?? _targetBlock(null)?.id, - label: label, + /// Inserts the complete lines [lines] after the current block (or in place + /// of an empty paragraph) in the caret's containers, with a text block + /// after them, and puts the caret at [caret] (an offset in [lines]). + LocalDeltaChange? _insertLines(Delta lines, String label, {int caret = 0}) { + if (!canInsertBlock()) return null; + final (at, replaces) = _embedInsertionPoint(); + final host = _hostLineAttributes(); + final box = host?[DeltaKeys.box]; + final change = Delta()..retain(at); + if (replaces) change.delete(1); + for (final line in WonkyDocument(lines).lines) { + for (final seg in line.segments) { + change.insert(seg.data, seg.attributes); + } + change.insert('\n', {...line.attributes, DeltaKeys.box: ?box}); + } + final length = DeltaUtils.documentLength(lines); + final nextIndex = replaces + ? controller.mirror.indexAtOffset(at) + 1 + : controller.mirror.indexAtOffset(at); + final hasTextAfter = + at < controller.mirror.length && + nextIndex < controller.mirror.nodeCount && + controller.mirror.blockAt(nextIndex).isText && + !isCellNode(controller.mirror.blockAt(nextIndex).node); + if (!hasTextAfter) change.insert('\n', host); + return _apply( + change, + label, + selectionAfter: DeltaSelection.collapsed(at + caret.clamp(0, length)), ); } diff --git a/wonky_write/lib/features/editor/components/grid_component.dart b/wonky_write/lib/features/editor/components/grid_component.dart index 4dd28ee8..1eb60fb2 100644 --- a/wonky_write/lib/features/editor/components/grid_component.dart +++ b/wonky_write/lib/features/editor/components/grid_component.dart @@ -1,38 +1,23 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:shad/shad.dart' show LucideIcons; import 'package:super_editor/super_editor.dart'; import 'package:wonky_write_core/wonky_write_core.dart' show GridBullets, GridEmbed, InlineMarkdown, InlineRun, WonkyEmbed; import '../../../l10n/write_l10n.dart'; -import '../grid/inline_grid_editing.dart'; import '../mapping/wonky_embed_node.dart'; import '../mapping/wonky_schema.dart'; -import '../model/field_clicks.dart'; import '../styles/editor_theme.dart'; -import 'editor_services.dart'; -import 'wonky_components.dart'; -/// The Wonky Markdown grid (Write 11 §7.2): its items in `columns` columns -/// (fewer when the content is narrow), each with its bullet; items are -/// edited in place ([InlineGridEditing]). +/// A legacy Wonky Markdown grid embed (Write 11 §7.2), read-only until the +/// server upgrades it to grid item lines (Write 12 §1.5): its items in +/// `columns` columns (fewer when the content is narrow), each with its +/// bullet. class WonkyGridComponentBuilder implements ComponentBuilder { - const WonkyGridComponentBuilder({ - required this.theme, - required this.zoom, - this.editing, - this.callbacks = const EditorBlockCallbacks(), - this.editable = true, - }); + const WonkyGridComponentBuilder({required this.theme, required this.zoom}); final EditorTheme theme; final double zoom; - final InlineGridEditing? editing; - final EditorBlockCallbacks callbacks; - final bool editable; @override SingleColumnLayoutComponentViewModel? createViewModel( @@ -58,75 +43,23 @@ class WonkyGridComponentBuilder implements ComponentBuilder { selection is UpstreamDownstreamNodeSelection && !selection.isCollapsed; final l = writeL10n(componentContext.context); final grid = viewModel.grid; - final gridCallbacks = editable ? callbacks.grid : null; - final chrome = gridCallbacks == null - ? null - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Builder( - builder: (c) => EditorChromeButton( - key: ValueKey('write.editor.grid.columns.${grid.id}'), - label: l.editorGridColumns(grid.columns), - tooltip: l.cmdGridSetColumns, - onTap: (rect) => gridCallbacks.onColumnsTap(grid.id, rect), - ), - ), - const SizedBox(width: 4), - EditorChromeButton( - key: ValueKey('write.editor.grid.bullets.${grid.id}'), - icon: switch (grid.bullets) { - GridBullets.check => LucideIcons.check, - GridBullets.circle => LucideIcons.circleSmall, - GridBullets.none => LucideIcons.minus, - }, - tooltip: l.cmdGridSetBullets, - onTap: (rect) => gridCallbacks.onBulletsTap(grid.id, rect), - ), - const SizedBox(width: 4), - EditorChromeButton( - key: ValueKey('write.editor.grid.add.${grid.id}'), - icon: LucideIcons.plus, - tooltip: l.cmdGridAddItem, - onTap: (_) => gridCallbacks.onAddItem(grid.id), - ), - const SizedBox(width: 4), - EditorChromeButton( - key: ValueKey('write.editor.grid.menu.${grid.id}'), - icon: LucideIcons.ellipsis, - tooltip: l.editorGridMenu, - onTap: (rect) => gridCallbacks.onMenu(grid.id, rect), - ), - ], - ); return Semantics( label: l.editorGridLabel(grid.items.length, grid.columns), child: BoxComponent( key: componentContext.componentKey, - child: EditorHoverChrome( - showAlways: isSelected, - above: true, - chrome: chrome, - child: DecoratedBox( - position: DecorationPosition.foreground, - decoration: BoxDecoration( - border: isSelected - ? Border.all(color: theme.ringColor, width: 2) - : null, - ), - child: Padding( - padding: EdgeInsets.only( - top: theme.wonky.gridSpaceBefore * zoom, - bottom: theme.wonky.gridSpaceAfter * zoom, - right: chrome == null ? 0 : 4, - ), - child: _GridItems( - grid: grid, - theme: theme, - zoom: zoom, - editing: editable ? editing : null, - ), + child: DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + border: isSelected + ? Border.all(color: theme.ringColor, width: 2) + : null, + ), + child: Padding( + padding: EdgeInsets.only( + top: theme.wonky.gridSpaceBefore * zoom, + bottom: theme.wonky.gridSpaceAfter * zoom, ), + child: _GridItems(grid: grid, theme: theme, zoom: zoom), ), ), ), @@ -178,13 +111,11 @@ class _GridItems extends StatelessWidget { required this.grid, required this.theme, required this.zoom, - required this.editing, }); final GridEmbed grid; final EditorTheme theme; final double zoom; - final InlineGridEditing? editing; /// The columns that fit [width] (px): two need 320 px and three 480 px at /// 100 % (Write 11 §7.2). @@ -222,7 +153,6 @@ class _GridItems extends StatelessWidget { index: start + c, theme: theme, zoom: zoom, - editing: editing, ) : const SizedBox(), ), @@ -248,81 +178,21 @@ class _GridItem extends StatelessWidget { required this.index, required this.theme, required this.zoom, - required this.editing, }); final GridEmbed grid; final int index; final EditorTheme theme; final double zoom; - final InlineGridEditing? editing; TextStyle get _base => theme.body.copyWith(fontSize: (theme.body.fontSize ?? 14.67) * zoom); @override Widget build(BuildContext context) { - final style = theme.wonky; final size = (_base.fontSize ?? 14.67) * 1.3; - final bullet = switch (grid.bullets) { - GridBullets.check => Icon( - LucideIcons.check, - size: size, - color: style.gridBulletColor, - ), - GridBullets.circle => Icon( - LucideIcons.circleSmall, - size: size, - color: style.gridBulletColor, - ), - GridBullets.none => null, - }; - final editing = this.editing; - final content = editing == null - ? _rendered(context) - : ListenableBuilder( - listenable: editing, - builder: (context, _) { - if (editing.active == GridItemRef(grid.id, index)) { - return DocumentGestureOptOut(child: _field(editing)); - } - if (!editing.canEdit(grid.id)) return _rendered(context); - // In the field's tap region group: pressing another item is - // not a tap outside the item being edited, which would end - // editing (the editor showed its selection for a moment) - // before this tap moves the editing here. - return DocumentGestureOptOut( - child: TapRegion( - groupId: editing, - child: MouseRegion( - cursor: SystemMouseCursors.text, - child: Listener( - onPointerDown: editing.clicks.down, - onPointerUp: editing.clicks.up, - child: Builder( - builder: (item) => GestureDetector( - behavior: HitTestBehavior.opaque, - // The caret where the click was. - // (A fast second click can still land here: - // it selects the word.) - onTapUp: (details) { - final clicks = editing.clicks.count; - editing.activate( - grid.id, - index, - caret: _caretAt(item, details.globalPosition), - ); - editing.selectForClicks(clicks); - }, - child: _rendered(context), - ), - ), - ), - ), - ), - ); - }, - ); + final bullet = gridBullet(theme, grid.bullets, size); + final content = _rendered(context); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -338,20 +208,6 @@ class _GridItem extends StatelessWidget { ); } - /// The source offset of the rendered text at [global]. - int? _caretAt(BuildContext item, Offset global) { - final source = grid.items[index]; - final plain = InlineRun.plainText(InlineMarkdown.parseCell(source)); - if (plain.isEmpty) return 0; - final offset = FieldClicks.renderedOffsetAt( - item.findRenderObject(), - global, - ); - return offset == null - ? null - : FieldClicks.sourceOffset(source, plain, offset); - } - Widget _rendered(BuildContext context) { final source = grid.items[index]; final runs = InlineMarkdown.parseCell(source); @@ -406,53 +262,20 @@ class _GridItem extends StatelessWidget { } return TextSpan(text: r.text.replaceAll('
', ' '), style: s); } +} - Widget _field(InlineGridEditing editing) { - return Focus( - parentNode: editing.focusParent, - canRequestFocus: false, - skipTraversal: true, - // After the field handled the click (a microtask), a double click's - // second click selects the word. - child: Listener( - onPointerDown: editing.clicks.down, - onPointerUp: (event) { - final clicks = editing.clicks.up(event); - if (clicks > 1) { - scheduleMicrotask(() => editing.selectForClicks(clicks)); - } - }, - child: _textField(editing), - ), - ); - } - - Widget _textField(InlineGridEditing editing) { - return TextSelectionTheme( - // The editor's selection color, not Material's. - data: TextSelectionThemeData( - selectionColor: theme.selectionColor, - cursorColor: theme.caretColor, +/// The bullet of a grid item (Write 11 §7.2), `null` for [GridBullets.none]. +Widget? gridBullet(EditorTheme theme, GridBullets bullets, double size) => + switch (bullets) { + GridBullets.check => Icon( + LucideIcons.check, + size: size, + color: theme.wonky.gridBulletColor, ), - child: TextField( - key: editing.fieldKey, - controller: editing.text, - focusNode: editing.focusNode, - groupId: editing, - undoController: editing.undo, - style: _base, - maxLines: null, - cursorColor: theme.caretColor, - cursorWidth: 2, - decoration: const InputDecoration.collapsed(hintText: ''), - inputFormatters: [ - FilteringTextInputFormatter.deny( - RegExp(r'[\r\n]+'), - replacementString: ' ', - ), - ], - onChanged: editing.onChanged, + GridBullets.circle => Icon( + LucideIcons.circleSmall, + size: size, + color: theme.wonky.gridBulletColor, ), - ); - } -} + GridBullets.none => null, + }; diff --git a/wonky_write/lib/features/editor/components/wonky_components.dart b/wonky_write/lib/features/editor/components/wonky_components.dart index 09de8f51..5c7c7964 100644 --- a/wonky_write/lib/features/editor/components/wonky_components.dart +++ b/wonky_write/lib/features/editor/components/wonky_components.dart @@ -16,7 +16,6 @@ import '../mapping/wonky_schema.dart'; import '../model/code_languages.dart'; import '../model/embeds.dart'; import '../styles/editor_theme.dart'; -import '../tables/inline_table_editing.dart'; import 'editor_services.dart'; import 'image_size_cache.dart'; @@ -644,23 +643,13 @@ class _FailedOverlay extends StatelessWidget { // ----------------------------------------------------------------------------- // Tables (Write 04 §18.2). -/// Wraps super_editor's Markdown table component with an "Edit table" button -/// and the block-selection ring. With [inlineEditing] (patch SE-4) cells are -/// edited in place; otherwise double-click opens the table editor. +/// A legacy `table` embed (super_editor's Markdown table component with the +/// block-selection ring), read-only until the server upgrades it to cell +/// lines (Write 12 §1.5). class WonkyTableComponentBuilder implements ComponentBuilder { - const WonkyTableComponentBuilder({ - required this.theme, - this.callbacks = const EditorBlockCallbacks(), - this.editable = true, - this.inlineEditing, - }); + const WonkyTableComponentBuilder({required this.theme}); final EditorTheme theme; - final EditorBlockCallbacks callbacks; - final bool editable; - - /// In-place cell editing (Write 04 §18.4). - final InlineTableEditing? inlineEditing; static const _inner = MarkdownTableComponentBuilder(); @override @@ -675,61 +664,25 @@ class WonkyTableComponentBuilder implements ComponentBuilder { SingleColumnLayoutComponentViewModel viewModel, ) { if (viewModel is! MarkdownTableViewModel) return null; - var tableModel = viewModel; - final inline = editable ? inlineEditing : null; - // Selected tables show the ring, not a translucent fill over the cells - // (which would also cover the cell being edited). On a copy: the - // presenter keeps `viewModel` to compare with the next pass, and a - // mutated one never equals it, so every table would be rebuilt on every - // keystroke (W-12). - if (inline != null) { - tableModel = tableModel.copy() as MarkdownTableViewModel - ..selectionColor = const Color(0x00000000); - } - final table = inline == null - ? _inner.createComponent(componentContext, tableModel) - : MarkdownTableComponent( - componentKey: componentContext.componentKey, - viewModel: tableModel, - cellBuilder: inlineTableCellBuilder(inline, theme), - ); + final table = _inner.createComponent(componentContext, viewModel); if (table == null) return null; - final selection = tableModel.selection?.nodeSelection; + final selection = viewModel.selection?.nodeSelection; final isSelected = selection is UpstreamDownstreamNodeSelection && !selection.isCollapsed; - final rows = tableModel.cells.length; - final columns = rows == 0 ? 0 : tableModel.cells.first.length; + final rows = viewModel.cells.length; + final columns = rows == 0 ? 0 : viewModel.cells.first.length; return Semantics( label: writeL10n( componentContext.context, ).editorTableLabel(columns, rows), - child: GestureDetector( - onDoubleTap: - !editable || inline != null || callbacks.onEditTable == null - ? null - : () => callbacks.onEditTable!(viewModel.nodeId), - child: EditorHoverChrome( - showAlways: isSelected, - chrome: !editable || callbacks.onEditTable == null - ? null - : EditorChromeButton( - key: ValueKey( - '${WonkyEditorKeys.tableEdit}.${viewModel.nodeId}', - ), - icon: Icons.edit_outlined, - tooltip: writeL10n(componentContext.context).editorEditTable, - onTap: (_) => callbacks.onEditTable!(viewModel.nodeId), - ), - child: DecoratedBox( - position: DecorationPosition.foreground, - decoration: BoxDecoration( - border: isSelected - ? Border.all(color: theme.ringColor, width: 2) - : null, - ), - child: table, - ), + child: DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + border: isSelected + ? Border.all(color: theme.ringColor, width: 2) + : null, ), + child: table, ), ); } diff --git a/wonky_write/lib/features/editor/controller/write_editor_controller.dart b/wonky_write/lib/features/editor/controller/write_editor_controller.dart index 717300c6..0d1ef705 100644 --- a/wonky_write/lib/features/editor/controller/write_editor_controller.dart +++ b/wonky_write/lib/features/editor/controller/write_editor_controller.dart @@ -18,7 +18,6 @@ import '../mapping/mirror_overlays.dart'; import '../requests/block_editing_handlers.dart'; import '../setup/editor_factory.dart'; import '../components/box_frames.dart' show BoxFrameLayout; -import '../grid/inline_grid_editing.dart' show InlineGridEditing; import '../reactions/pill_boundary.dart'; import '../view/editor_view_state.dart'; import 'editor_pipeline.dart'; @@ -77,8 +76,6 @@ class WriteEditorController extends ChangeNotifier { mode: () => _mode, newEmbedId: this.newEmbedId, isWonky: () => markdownFlavor() == MarkdownFlavor.wonky, - // A new grid gets the caret in its first item (Write 11 §7.5). - onGridInserted: (id) => gridEditing?.activateSoon(id, 0), pillBoundary: pillBoundary, ), onTransaction: _onTransaction, @@ -158,14 +155,6 @@ class WriteEditorController extends ChangeNotifier { /// pasted fragments follow it. Set by the document session. MarkdownFlavor Function() markdownFlavor = () => MarkdownFlavor.gfm; - /// In-place table cell editing of the mounted editor widget (SE-4, - /// Write 04 §18.4); `null` while no editor is mounted. - InlineTableEditingHandle? tableEditing; - - /// In-place grid item editing of the mounted editor widget (Write 11 - /// §7.4); `null` while no editor is mounted. - InlineGridEditing? gridEditing; - /// The document layout, once the editor is laid out. DocumentLayout? get documentLayout => documentLayoutKey.currentState as DocumentLayout?; @@ -781,18 +770,6 @@ class SuggestionSession { } } -/// What commands need from the in-place table editor (`write.table.editCell`). -abstract interface class InlineTableEditingHandle { - /// Starts editing cell ([row], [column]) of the table [tableId]. - bool activate(String tableId, int row, int column, {bool selectAll}); - - /// Stops editing (saving the cell). - void deactivate(); - - /// Whether a cell is being edited. - bool get isEditing; -} - /// A notice for the UI (toast, announcement). enum EditorNoticeKind { /// The document reached its maximum size (Write 04 §3.1). diff --git a/wonky_write/lib/features/editor/grid/inline_grid_editing.dart b/wonky_write/lib/features/editor/grid/inline_grid_editing.dart deleted file mode 100644 index 655b5f06..00000000 --- a/wonky_write/lib/features/editor/grid/inline_grid_editing.dart +++ /dev/null @@ -1,452 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:wonky_write_core/wonky_write_core.dart' - show GridEmbed, InlineMarkdown; - -import '../applier/delta_applier.dart'; -import '../commands/write_editor_commands.dart'; -import '../controller/write_editor_controller.dart'; -import '../model/document_queries.dart'; -import '../model/field_clicks.dart'; - -/// The grid item being edited in place. -@immutable -class GridItemRef { - const GridItemRef(this.gridId, this.index); - - /// The grid's node id (= its embed id). - final String gridId; - final int index; - - @override - bool operator ==(Object other) => - other is GridItemRef && other.gridId == gridId && other.index == index; - - @override - int get hashCode => Object.hash(gridId, index); -} - -/// In-place editing of grid items (Write 11 §7.4), like table cells (Write -/// 04 §18.4): clicking an item turns it into a text field that edits its -/// inline Markdown source; the document caret never enters the grid. -/// -/// - Enter adds an item after the current one; Backspace in an empty item -/// removes it (the last item removes the grid). -/// - Tab / Shift+Tab and ↑ / ↓ move to the next / previous item and leave the -/// grid at its ends; ← → at the text edges move too. -/// - Mod+B / I / E / Shift+X / K wrap the selection like in table cells. -/// - Esc leaves the item and selects the grid; Mod+Enter puts the caret after -/// it. -/// -/// Every commit replaces the `grid` embed (one undo step): when leaving an -/// item, when the field loses focus and after [idleCommit] without typing. -class InlineGridEditing extends ChangeNotifier { - InlineGridEditing({ - required this.controller, - required this.commands, - this.onUndo, - this.onRedo, - this.idleCommit = const Duration(milliseconds: 1500), - }) { - focusNode = FocusNode(debugLabel: 'write-grid-item', onKeyEvent: _onKey); - focusNode.addListener(_onFocusChanged); - controller.addListener(_onDocumentChanged); - } - - final WriteEditorController controller; - final WriteEditorCommands commands; - - VoidCallback? onUndo; - VoidCallback? onRedo; - - final Duration idleCommit; - - /// The focus node the field is parented to (outside the editor). - FocusNode? focusParent; - - /// The field's text (the item's inline Markdown source). - final TextEditingController text = TextEditingController(); - - /// The field's undo history. - final UndoHistoryController undo = UndoHistoryController(); - - late final FocusNode focusNode; - - /// Keeps the field's state while the grid component rebuilds. - final GlobalKey fieldKey = GlobalKey(debugLabel: 'write-grid-item-field'); - - /// The caret where an item was clicked; double and triple clicks. - final FieldClicks clicks = FieldClicks(); - - /// Selects the word at the field's caret (a double click). - void selectWordAtCaret() { - if (_active == null) return; - clicks.select( - text, - FieldClicks.wordAt(text.text, text.selection.baseOffset), - ); - } - - /// Selects the field's text (a triple click). - void selectAllText() { - if (_active == null) return; - clicks.select( - text, - TextSelection(baseOffset: 0, extentOffset: text.text.length), - ); - } - - /// After [clicks] clicks in a row at one place: a word for two, the text - /// for three or more. - void selectForClicks(int clicks) { - if (clicks == 2) selectWordAtCaret(); - if (clicks >= 3) selectAllText(); - } - - GridItemRef? _active; - String _base = ''; - Timer? _idle; - bool _committing = false; - bool _disposed = false; - - /// The item being edited, or `null`. - GridItemRef? get active => _active; - - bool get isEditing => _active != null; - - bool get isDirty => _active != null && text.text != _base; - - /// Whether the items of [gridId] can be edited now. - bool canEdit(String gridId) => - commands.canEdit && !commands.isSuggesting && _grid(gridId) != null; - - GridEmbed? _grid(String gridId) => commands.gridOf(nodeId: gridId); - - /// Starts editing item [index] of [gridId]. - bool activate(String gridId, int index, {int? caret}) { - final grid = _grid(gridId); - if (grid == null || !canEdit(gridId)) return false; - if (index < 0 || index >= grid.items.length) return false; - final next = GridItemRef(gridId, index); - if (_active != null && _active != next) commit(); - final source = _grid(gridId)!.items[index]; - final changed = _active != next; - _active = next; - if (changed) { - _base = source; - text.value = TextEditingValue( - text: source, - selection: TextSelection.collapsed( - offset: (caret ?? source.length).clamp(0, source.length), - ), - ); - undo.value = UndoHistoryValue.empty; - } - _selectGridOnceFocused(); - notifyListeners(); - _focusField(); - return true; - } - - bool _selectionPending = false; - - /// Presence: the local selection becomes the grid embed, once the field - /// has the focus. Selecting it while the document editor still has the - /// focus (the field is built a frame later) let the editor take the text - /// input back after the field had it: typing went to the editor, which - /// moved its caret before or after the grid. - void _selectGridOnceFocused() { - if (!focusNode.hasFocus) { - _selectionPending = true; - return; - } - _selectionPending = false; - final item = _active; - final block = item == null ? null : controller.mirror.blockOf(item.gridId); - if (block == null) return; - final s = controller.selection; - if (s == null || s.start != block.start || s.end != block.start + 1) { - controller.select( - DeltaSelection(block.start, block.start + 1), - userInteraction: false, - ); - } - } - - /// [activate] once the current transaction is applied (from a reaction, - /// before the new grid is in the mirror). - void activateSoon(String gridId, int index) => scheduleMicrotask(() { - if (!_disposed) activate(gridId, index); - }); - - void _focusField() { - if (focusNode.context != null) { - focusNode.requestFocus(); - return; - } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!_disposed && _active != null) focusNode.requestFocus(); - }); - } - - /// Writes the field into the grid (one undo step). - bool commit() { - _idle?.cancel(); - final item = _active; - if (item == null || !isDirty || _committing) return false; - final grid = _grid(item.gridId); - if (grid == null || item.index >= grid.items.length) return false; - _committing = true; - try { - final source = InlineMarkdown.canonicalCell( - text.text.replaceAll(RegExp(r'[\r\n]+'), ' '), - ); - final items = [...grid.items]..[item.index] = source; - final ok = commands.replaceGrid( - grid.copyWith(items: items), - nodeId: item.gridId, - ); - _base = text.text; - return ok; - } finally { - _committing = false; - } - } - - /// Stops editing; [focusEditor] gives the document editor the focus back - /// and [caretAfter] / [caretBefore] place the caret around the grid. - void deactivate({ - bool save = true, - bool focusEditor = false, - bool caretAfter = false, - bool caretBefore = false, - }) { - final item = _active; - if (item == null) return; - if (save) commit(); - _idle?.cancel(); - clicks.release(); - _active = null; - _base = ''; - notifyListeners(); - final block = controller.mirror.blockOf(item.gridId); - if (block != null && (caretAfter || caretBefore)) { - final offset = caretAfter - ? (block.start + block.length).clamp(0, controller.length - 1) - : (block.start - 1).clamp(0, controller.length - 1); - controller.select(DeltaSelection.collapsed(offset)); - } - if (focusEditor) controller.focusNode.requestFocus(); - } - - void onChanged(String value) { - _idle?.cancel(); - if (isDirty) _idle = Timer(idleCommit, commit); - } - - void _onFocusChanged() { - if (focusNode.hasFocus && _selectionPending) _selectGridOnceFocused(); - if (!focusNode.hasFocus && _active != null) deactivate(); - } - - void _onDocumentChanged() { - final item = _active; - if (item == null || _committing) return; - final grid = _grid(item.gridId); - if (grid == null || item.index >= grid.items.length) { - _active = null; - _base = ''; - _idle?.cancel(); - notifyListeners(); - return; - } - final source = grid.items[item.index]; - if (source == _base) return; - if (isDirty) { - _base = source; - return; - } - _base = source; - final caret = text.selection.baseOffset.clamp(0, source.length); - text.value = TextEditingValue( - text: source, - selection: TextSelection.collapsed(offset: caret), - ); - } - - /// Moves [step] items in reading order, leaving the grid at its ends. - void move(int step, {bool toStart = false}) { - final item = _active; - if (item == null) return; - final grid = _grid(item.gridId); - if (grid == null) return; - final index = item.index + step; - if (index < 0) { - deactivate(focusEditor: true, caretBefore: true); - return; - } - if (index >= grid.items.length) { - deactivate(focusEditor: true, caretAfter: true); - return; - } - activate(item.gridId, index, caret: toStart ? 0 : null); - } - - /// Enter: a new item after the current one, edited. - void _newItemAfter() { - final item = _active; - if (item == null) return; - commit(); - final gridId = item.gridId; - final index = item.index + 1; - if (!commands.addGridItem(nodeId: gridId, index: index)) return; - _active = null; - activate(gridId, index); - } - - /// Backspace in an empty item: removes it and edits the previous one. - void _removeEmpty() { - final item = _active; - if (item == null) return; - final gridId = item.gridId; - final grid = _grid(gridId); - if (grid == null) return; - _idle?.cancel(); - _active = null; - _base = ''; - notifyListeners(); - if (grid.items.length == 1) { - commands.removeGridItem(0, nodeId: gridId); - controller.focusNode.requestFocus(); - return; - } - commands.removeGridItem(item.index, nodeId: gridId); - activate(gridId, (item.index - 1).clamp(0, grid.items.length - 2)); - } - - static bool get _isApple => - defaultTargetPlatform == TargetPlatform.macOS || - defaultTargetPlatform == TargetPlatform.iOS; - - KeyEventResult _onKey(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { - return KeyEventResult.ignored; - } - clicks.release(); - if (_active == null) return KeyEventResult.ignored; - final keyboard = HardwareKeyboard.instance; - final mod = _isApple ? keyboard.isMetaPressed : keyboard.isControlPressed; - final shift = keyboard.isShiftPressed; - final alt = keyboard.isAltPressed; - final key = event.logicalKey; - final selection = text.selection; - final length = text.text.length; - - if (key == LogicalKeyboardKey.escape) { - deactivate(focusEditor: true); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.tab) { - move(shift ? -1 : 1, toStart: !shift); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.numpadEnter) { - if (mod) { - deactivate(focusEditor: true, caretAfter: true); - } else { - _newItemAfter(); - } - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.backspace && - !mod && - length == 0 && - event is KeyDownEvent) { - _removeEmpty(); - return KeyEventResult.handled; - } - if (!mod && !alt && !shift) { - if (key == LogicalKeyboardKey.arrowUp) { - move(-1); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.arrowDown) { - move(1, toStart: true); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.arrowLeft && - selection.isCollapsed && - selection.baseOffset <= 0) { - move(-1); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.arrowRight && - selection.isCollapsed && - selection.baseOffset >= length) { - move(1, toStart: true); - return KeyEventResult.handled; - } - } - if (mod && !alt) { - if (key == LogicalKeyboardKey.keyZ) { - final history = undo.value; - if (shift ? history.canRedo : history.canUndo) { - return KeyEventResult.ignored; - } - commit(); - (shift ? onRedo : onUndo)?.call(); - return KeyEventResult.handled; - } - if (!shift && key == LogicalKeyboardKey.keyB) return _wrap('**', '**'); - if (!shift && key == LogicalKeyboardKey.keyI) return _wrap('*', '*'); - if (!shift && key == LogicalKeyboardKey.keyE) return _wrap('`', '`'); - if (shift && key == LogicalKeyboardKey.keyX) return _wrap('~~', '~~'); - if (!shift && key == LogicalKeyboardKey.keyK) return _wrap('[', ']()'); - } - return KeyEventResult.ignored; - } - - KeyEventResult _wrap(String before, String after) { - final value = text.value; - final selection = value.selection; - if (!selection.isValid) return KeyEventResult.handled; - final start = selection.start; - final end = selection.end; - final inner = value.text.substring(start, end); - final replaced = value.text.replaceRange(start, end, '$before$inner$after'); - final caret = after == ']()' - ? start + before.length + inner.length + 2 - : start + before.length + inner.length + after.length; - text.value = TextEditingValue( - text: replaced, - selection: inner.isEmpty || after == ']()' - ? TextSelection.collapsed( - offset: inner.isEmpty ? start + before.length : caret, - ) - : TextSelection( - baseOffset: start + before.length, - extentOffset: start + before.length + inner.length, - ), - ); - onChanged(text.text); - return KeyEventResult.handled; - } - - @override - void dispose() { - _disposed = true; - _idle?.cancel(); - if (_active != null) commit(); - controller.removeListener(_onDocumentChanged); - focusNode - ..removeListener(_onFocusChanged) - ..dispose(); - text.dispose(); - undo.dispose(); - super.dispose(); - } -} diff --git a/wonky_write/lib/features/editor/mapping/delta_node_codec.dart b/wonky_write/lib/features/editor/mapping/delta_node_codec.dart index 1c0618e3..aa52c6c0 100644 --- a/wonky_write/lib/features/editor/mapping/delta_node_codec.dart +++ b/wonky_write/lib/features/editor/mapping/delta_node_codec.dart @@ -19,7 +19,8 @@ const _deepEquality = DeepCollectionEquality(); /// | consecutive `code-block` lines of one language | one [ParagraphNode] (`codeAttribution`) whose text contains `\n`; language in [WonkyNodeMetadata.codeLanguage] | /// | `list: bullet/ordered` (+ `indent`, `blockquote`) | [ListItemNode] ([WonkyNodeMetadata.quoted] inside a quote) | /// | `list: checked/unchecked` (+ `indent`, `blockquote`) | [TaskNode] (same) | -/// | `image` / `divider` / `table` embed + `\n` | [ImageNode] / [HorizontalRuleNode] / [TableBlockNode] (raw embed in [WonkyNodeMetadata.embed]) | +/// | `image` / `divider` / (legacy) `table` embed + `\n` | [ImageNode] / [HorizontalRuleNode] / [TableBlockNode] (raw embed in [WonkyNodeMetadata.embed]) | +/// | `cell` / `grid` line (Write 12 §1) | [ParagraphNode] with [WonkyNodeMetadata.cell] / [WonkyNodeMetadata.gridItem] | /// /// Soft line breaks (U+2028) are `\n` inside non-code node text. Overlay /// attributes of a line end are kept in [WonkyNodeMetadata.lineAttributes]; @@ -133,6 +134,11 @@ class WonkyDeltaSerializer implements DeltaSerializer { }; } if (node is ParagraphNode) { + // A table cell or grid item is a plain line (Write 12 §1.4). + final cell = node.getMetadataValue(WonkyNodeMetadata.cell); + if (cell is Map) return {DeltaKeys.cell: cell}; + final item = node.getMetadataValue(WonkyNodeMetadata.gridItem); + if (item is Map) return {DeltaKeys.gridItem: item}; final blockType = node.getMetadataValue(NodeMetadata.blockType); if (blockType == codeAttribution) { return {DeltaKeys.codeBlock: codeLanguageOf(node)}; @@ -371,6 +377,23 @@ class WonkyDeltaParser { WonkyNodeMetadata.lineAttributes: ?overlays, if (box is List && box.isNotEmpty) WonkyNodeMetadata.box: box, }; + // A table cell or grid item (Write 12 §3.1). + final cell = end?[DeltaKeys.cell]; + final item = end?[DeltaKeys.gridItem]; + if (cell is Map || item is Map) { + return ParagraphNode( + id: id, + text: text, + metadata: { + ...metadata, + NodeMetadata.blockType: paragraphAttribution, + if (cell is Map) + WonkyNodeMetadata.cell: cell.cast() + else if (item is Map) + WonkyNodeMetadata.gridItem: item.cast(), + }, + ); + } final list = end?[DeltaKeys.list]; final rawIndent = end?[DeltaKeys.indent]; diff --git a/wonky_write/lib/features/editor/mapping/wonky_schema.dart b/wonky_write/lib/features/editor/mapping/wonky_schema.dart index 0ea42549..be7d046e 100644 --- a/wonky_write/lib/features/editor/mapping/wonky_schema.dart +++ b/wonky_write/lib/features/editor/mapping/wonky_schema.dart @@ -1,5 +1,6 @@ import 'package:super_editor/super_editor.dart'; -import 'package:wonky_write_core/wonky_write_core.dart' show BoxFrame, Boxes; +import 'package:wonky_write_core/wonky_write_core.dart' + show BoxFrame, Boxes, CellAttr, GridItemAttr; /// Delta attribute keys of the Wonky Delta Schema v1 used by the editor /// mapping (Write 02 §1). Mirrors `WonkyAttr` of `wonky_write_core`. @@ -21,6 +22,12 @@ abstract final class DeltaKeys { static const step = 'step'; static const box = 'box'; + /// A table cell line (Write 12 §1.1). + static const cell = 'cell'; + + /// A grid item line (Write 12 §1.2; the same key as the legacy embed's). + static const gridItem = 'grid'; + static const commentPrefix = 'cmt_'; static const suggestionPrefix = 'sug_'; @@ -48,6 +55,8 @@ abstract final class DeltaKeys { indent, step, box, + cell, + gridItem, }; static bool isComment(String key) => @@ -98,8 +107,27 @@ abstract final class WonkyNodeMetadata { /// The number of a numbered heading (`step`, Write 11 §2.2). static const step = 'wonkyStep'; + + /// The `cell` value of a table cell node (Write 12 §3.1). + static const cell = 'wonkyCell'; + + /// The `grid` value of a grid item node (Write 12 §3.1). + static const gridItem = 'wonkyGridItem'; } +/// The cell attribute of a table cell node, or `null`. +CellAttr? cellOf(DocumentNode? node) => + CellAttr.tryParse(node?.getMetadataValue(WonkyNodeMetadata.cell)); + +/// The grid item attribute of a grid item node, or `null`. +GridItemAttr? gridItemOf(DocumentNode? node) => + GridItemAttr.tryParse(node?.getMetadataValue(WonkyNodeMetadata.gridItem)); + +/// Whether [node] is a table cell or a grid item (Write 12 §3.1). +bool isCellNode(DocumentNode? node) => + node?.getMetadataValue(WonkyNodeMetadata.cell) != null || + node?.getMetadataValue(WonkyNodeMetadata.gridItem) != null; + /// The container frames of [node] (outermost first; empty when none). List boxOf(DocumentNode? node) => node == null ? const [] diff --git a/wonky_write/lib/features/editor/model/cells.dart b/wonky_write/lib/features/editor/model/cells.dart new file mode 100644 index 00000000..4dff9508 --- /dev/null +++ b/wonky_write/lib/features/editor/model/cells.dart @@ -0,0 +1,117 @@ +import 'package:super_editor/super_editor.dart'; +import 'package:wonky_write_core/wonky_write_core.dart'; + +import '../mapping/document_mirror.dart'; +import '../mapping/wonky_schema.dart'; + +/// A table or grid of the editor document (Write 12 §3.1): its nodes in the +/// mirror and its lines as a [WonkyDocument] of their own, so [CellEdits] +/// can run on it; [shift] moves a change of [doc] to the whole document. +class EditorCellRun { + EditorCellRun._(this.first, this.last, this.start, this.doc, this.run); + + /// Mirror index of the run's first node. + final int first; + + /// Mirror index of the run's last node. + final int last; + + /// Delta offset of the run's first character. + final int start; + + /// The run's lines (offset 0 = [start]). + final WonkyDocument doc; + + /// The [CellTable] or [CellGrid] of [doc]. + final Object run; + + CellTable? get table => run is CellTable ? run as CellTable : null; + + CellGrid? get grid => run is CellGrid ? run as CellGrid : null; + + /// The table or grid id. + String get id => table?.id ?? grid!.id; + + /// Delta offset just after the run. + int get end => start + doc.length; + + /// [change] (relative to [doc]) relative to the whole document. + Delta shift(Delta change) { + final out = Delta(); + if (start > 0) out.retain(start); + for (final op in change.toList()) { + out.push(op); + } + return out; + } + + /// The (row, column) of the cell node [nodeIndex] (a mirror index). + (int, int)? cellAt(DocumentMirror mirror, int nodeIndex) { + final t = table; + if (t == null || nodeIndex < first || nodeIndex > last) return null; + return t.cellOf(mirror.startOfIndex(nodeIndex) - start); + } + + /// The item index of the grid item node [nodeIndex] (a mirror index). + int? itemOf(int nodeIndex) => + grid == null || nodeIndex < first || nodeIndex > last + ? null + : nodeIndex - first; + + /// The mirror index of the node of cell ([row], [column]), if it exists. + int? nodeIndexOfCell(int row, int column) { + final t = table; + if (t == null || row < 0 || row >= t.rows.length) return null; + final cells = t.rows[row].cells; + if (column < 0 || column >= cells.length) return null; + return first + cells[column].index; + } + + /// The run around mirror node [index], or `null` when that node is not a + /// table cell or grid item. + static EditorCellRun? at(DocumentMirror mirror, int index) { + if (index < 0 || index >= mirror.nodeCount) return null; + final key = _groupOf(mirror.entryAt(index).node); + if (key == null) return null; + var first = index; + while (first > 0 && _groupOf(mirror.entryAt(first - 1).node) == key) { + first--; + } + var last = index; + while (last + 1 < mirror.nodeCount && + _groupOf(mirror.entryAt(last + 1).node) == key) { + last++; + } + final doc = WonkyDocument(mirror.entriesDelta(first, last + 1)); + final runs = CellRuns.of(doc); + if (runs.length != 1) return null; + return EditorCellRun._( + first, + last, + mirror.startOfIndex(first), + doc, + runs.single, + ); + } + + /// The run of node [nodeId], or of the table or grid with id [nodeId]. + static EditorCellRun? of(DocumentMirror mirror, String nodeId) { + final index = mirror.indexOfNode(nodeId); + if (index >= 0) return at(mirror, index); + for (var i = 0; i < mirror.nodeCount; i++) { + final node = mirror.entryAt(i).node; + if (cellOf(node)?.table == nodeId || gridItemOf(node)?.id == nodeId) { + return at(mirror, i); + } + } + return null; + } + + /// `t:` for a cell node, `g:` for a grid item node, else `null`. + static String? _groupOf(DocumentNode node) { + final cell = cellOf(node); + if (cell != null) return 't:${cell.table}'; + final item = gridItemOf(node); + return item == null ? null : 'g:${item.id}'; + } +} diff --git a/wonky_write/lib/features/editor/model/field_clicks.dart b/wonky_write/lib/features/editor/model/field_clicks.dart deleted file mode 100644 index 0c946ec8..00000000 --- a/wonky_write/lib/features/editor/model/field_clicks.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart' show TextEditingController; - -/// Clicks on items edited in place (grid items, table cells; Write 04 -/// §18.4), counted from raw pointer events: the first click turns the -/// rendered item into a text field, which never sees that click, and on the -/// web the field's own double click doesn't fire there, so double and triple -/// clicks are recognized here, on the rendered item and on the field alike. -/// -/// - The caret goes where the click was: [renderedOffsetAt] finds the -/// position in the rendered text, [sourceOffset] the matching position in -/// the item's inline Markdown source. -/// - [down] and [up] count consecutive clicks at one place ([count]): the -/// second selects the word at the caret ([wordAt]), the third the text. -class FieldClicks { - Duration? _upAt; - Offset? _upPosition; - int _count = 0; - - /// The clicks of the current series (1 for a single click), as of the - /// last [up]. - int get count => _count; - - /// A press: the next click of a series when it follows the last click - /// closely, at the same place. - void down(PointerDownEvent event) { - release(); - final upAt = _upAt; - final upPosition = _upPosition; - final next = - upAt != null && - upPosition != null && - event.timeStamp - upAt <= kDoubleTapTimeout && - (event.position - upPosition).distance <= kDoubleTapSlop; - _count = next ? _count + 1 : 1; - } - - /// The press was released: the clicks of the series so far. - int up(PointerUpEvent event) { - _upAt = event.timeStamp; - _upPosition = event.position; - return _count; - } - - TextEditingController? _heldIn; - TextSelection? _held; - String? _heldText; - - /// Selects [selection] in [text] for a double or triple click and keeps it - /// until the next press, key or edit ([release]): the field's own handling - /// of the second click (a single click, [kDoubleTapTimeout] later) - /// collapsed it again. - void select(TextEditingController text, TextSelection selection) { - release(); - text.selection = selection; - _heldIn = text; - _held = selection; - _heldText = text.text; - text.addListener(_keep); - } - - void _keep() { - final text = _heldIn; - final held = _held; - if (text == null || held == null) return; - if (text.text != _heldText) { - release(); - return; - } - if (text.selection != held && text.selection.isCollapsed) { - text.selection = held; - } - } - - /// Stops keeping a selection made by [select]. - void release() { - _heldIn?.removeListener(_keep); - _heldIn = null; - _held = null; - _heldText = null; - } - - /// The word around [offset] in [text] (letters, digits, `_` and - /// apostrophes), or a collapsed selection at [offset] outside words. - static TextSelection wordAt(String text, int offset) { - final at = offset.clamp(0, text.length); - bool isWord(int i) => i >= 0 && i < text.length && _word.hasMatch(text[i]); - var start = at; - var end = at; - if (!isWord(at) && isWord(at - 1)) start = end = at - 1; - if (!isWord(start)) return TextSelection.collapsed(offset: at); - while (isWord(start - 1)) { - start--; - } - while (isWord(end)) { - end++; - } - return TextSelection(baseOffset: start, extentOffset: end); - } - - static final _word = RegExp(r"[\p{L}\p{N}_'’]", unicode: true); - - /// The character offset of [global] in the first text under [box] (the - /// rendered item), or `null` when it holds no text. - static int? renderedOffsetAt(RenderObject? box, Offset global) { - RenderParagraph? paragraph; - void find(RenderObject node) { - if (paragraph != null) return; - if (node is RenderParagraph) { - paragraph = node; - return; - } - node.visitChildren(find); - } - - if (box == null) return null; - find(box); - final p = paragraph; - if (p == null || !p.attached) return null; - return p.getPositionForOffset(p.globalToLocal(global)).offset; - } - - /// The offset in [source] (inline Markdown) of the character at - /// [plainOffset] in its rendered text [plain]: both are walked together, - /// skipping what the rendering drops (markup, escapes, a link's - /// `](url)`). - static int sourceOffset(String source, String plain, int plainOffset) { - var s = 0; - for (var p = 0; p < plainOffset && p < plain.length; p++) { - while (s < source.length) { - final link = _linkTail.matchAsPrefix(source, s); - if (link != null) { - s = link.end; - continue; - } - if (source[s] == plain[p]) break; - s++; - } - if (s >= source.length) return source.length; - s++; - } - // Past the markup that opens the next character (`**b`: before `b` - // would split the marker; after it keeps the formatting). - if (plainOffset < plain.length) { - while (s < source.length && source[s] != plain[plainOffset]) { - final link = _linkTail.matchAsPrefix(source, s); - if (link != null && plainOffset > 0) break; - s++; - } - } - return s.clamp(0, source.length); - } - - static final _linkTail = RegExp(r'\]\([^)]*\)'); -} diff --git a/wonky_write/lib/features/editor/model/table_model.dart b/wonky_write/lib/features/editor/model/table_model.dart index 2e2fe1be..abaee108 100644 --- a/wonky_write/lib/features/editor/model/table_model.dart +++ b/wonky_write/lib/features/editor/model/table_model.dart @@ -1,5 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:wonky_write_core/wonky_write_core.dart' + show CellTable, InlineContext, InlineMarkdownWriter, InlineRun; /// Column alignment of a GFM table. enum TableAlign { @@ -39,6 +41,29 @@ class TableModel { rows: List.generate(rowCount, (_) => List.filled(columnCount, '')), ); + /// A table of cell lines (Write 12 §1.1) with its cells as inline Markdown; + /// pads ragged rows. + factory TableModel.fromCells(CellTable table) { + final columns = table.columnCount; + return TableModel( + id: table.id, + align: [for (final a in table.align) TableAlign.parse(a?.value)], + rows: [ + for (final row in table.rows) + [ + for (var c = 0; c < columns; c++) + c < row.cells.length + ? InlineMarkdownWriter.write([ + for (final s in row.cells[c].segments) + if (!s.isEmbed) + InlineRun.fromAttributes(s.text, s.attributes), + ], context: InlineContext.tableCell) + : '', + ], + ], + ); + } + /// Reads the inner embed value; pads ragged rows. factory TableModel.fromEmbed(Map value) { final rawRows = value['rows'] is List diff --git a/wonky_write/lib/features/editor/reactions/block_conversion_reactions.dart b/wonky_write/lib/features/editor/reactions/block_conversion_reactions.dart index 3737de20..f13284d0 100644 --- a/wonky_write/lib/features/editor/reactions/block_conversion_reactions.dart +++ b/wonky_write/lib/features/editor/reactions/block_conversion_reactions.dart @@ -17,7 +17,6 @@ class BlockPrefixConversionReaction extends EditReaction { BlockPrefixConversionReaction({ required this.newEmbedId, this.isWonky, - this.onGridInserted, }); /// Creates ids for new embeds (UUIDv7). @@ -28,7 +27,6 @@ class BlockPrefixConversionReaction extends EditReaction { final bool Function()? isWonky; /// Called with the id of a grid the `:::grid ` shortcut inserted. - final void Function(String gridId)? onGridInserted; static final _step = RegExp(r'^\((\d{1,4})\) $'); @@ -239,7 +237,8 @@ class BlockPrefixConversionReaction extends EditReaction { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: node.id, + // A new grid gets the caret in its first item (Write 11 §7.5). + nodeId: insertedGrid ?? node.id, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -247,7 +246,6 @@ class BlockPrefixConversionReaction extends EditReaction { SelectionReason.contentChange, ), ]); - if (insertedGrid != null) onGridInserted?.call(insertedGrid); } /// Metadata that survives a node type conversion (line-end overlays). diff --git a/wonky_write/lib/features/editor/reactions/wonky_shortcuts.dart b/wonky_write/lib/features/editor/reactions/wonky_shortcuts.dart index 166c47c2..ea8bed66 100644 --- a/wonky_write/lib/features/editor/reactions/wonky_shortcuts.dart +++ b/wonky_write/lib/features/editor/reactions/wonky_shortcuts.dart @@ -1,10 +1,17 @@ import 'package:super_editor/super_editor.dart'; import 'package:wonky_write_core/wonky_write_core.dart' - show BoxFrame, BoxType, Boxes, CalloutVariant, GridBullets, GridEmbed; + show + BoxFrame, + BoxType, + Boxes, + CalloutVariant, + CellIds, + GridBullets, + GridEmbed, + GridItemAttr; import '../attributions/wonky_attributions.dart'; import '../mapping/attribution_mapping.dart'; -import '../mapping/wonky_embed_node.dart'; import '../mapping/wonky_schema.dart'; import 'pill_boundary.dart'; @@ -77,23 +84,27 @@ abstract final class WonkyShortcuts { ) { final host = boxOf(node); if (shortcut.grid) { - final grid = GridEmbed( - id: newId(), + // Three empty grid item lines before the paragraph (Write 12 §1.2). + final item = GridItemAttr( + id: CellIds.group(), columns: shortcut.columns, bullets: shortcut.bullets, - items: const ['', '', ''], - ); + ).toJson(); + final index = document.getNodeIndexById(node.id); return [ - InsertNodeAtIndexRequest( - nodeIndex: document.getNodeIndexById(node.id), - newNode: WonkyEmbedNode( - id: grid.id, - metadata: { - WonkyNodeMetadata.embed: grid.toInsert(), - if (host.isNotEmpty) WonkyNodeMetadata.box: Boxes.toValue(host), - }, + for (var i = 0; i < 3; i++) + InsertNodeAtIndexRequest( + nodeIndex: index + i, + newNode: ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText(''), + metadata: { + NodeMetadata.blockType: paragraphAttribution, + WonkyNodeMetadata.gridItem: item, + if (host.isNotEmpty) WonkyNodeMetadata.box: Boxes.toValue(host), + }, + ), ), - ), ]; } final frame = shortcut.type == BoxType.callout diff --git a/wonky_write/lib/features/editor/setup/editor_factory.dart b/wonky_write/lib/features/editor/setup/editor_factory.dart index a3e418c0..8bd7b7e9 100644 --- a/wonky_write/lib/features/editor/setup/editor_factory.dart +++ b/wonky_write/lib/features/editor/setup/editor_factory.dart @@ -108,7 +108,6 @@ List wonkyReactions( required EditorMode Function() mode, required String Function() newEmbedId, bool Function()? isWonky, - void Function(String gridId)? onGridInserted, PillBoundary? pillBoundary, }) { final extractor = pipeline.extractor; @@ -150,7 +149,6 @@ List wonkyReactions( BlockPrefixConversionReaction( newEmbedId: newEmbedId, isWonky: isWonky, - onGridInserted: onGridInserted, ), 'Autoformat', markdown, diff --git a/wonky_write/lib/features/editor/tables/inline_table_editing.dart b/wonky_write/lib/features/editor/tables/inline_table_editing.dart deleted file mode 100644 index d83922ab..00000000 --- a/wonky_write/lib/features/editor/tables/inline_table_editing.dart +++ /dev/null @@ -1,682 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:wonky_write_core/wonky_write_core.dart' - show InlineMarkdown, InlineRun; - -import '../applier/delta_applier.dart'; -import '../commands/write_editor_commands.dart'; -import '../controller/write_editor_controller.dart'; -import '../model/document_queries.dart'; -import '../model/field_clicks.dart'; -import '../model/table_model.dart'; -import '../styles/editor_theme.dart'; - -/// The cell being edited in place. -@immutable -class TableCellRef { - const TableCellRef(this.tableId, this.row, this.column); - - /// The table's node id (= its embed id). - final String tableId; - final int row; - final int column; - - @override - bool operator ==(Object other) => - other is TableCellRef && - other.tableId == tableId && - other.row == row && - other.column == column; - - @override - int get hashCode => Object.hash(tableId, row, column); - - @override - String toString() => 'TableCellRef($tableId, $row, $column)'; -} - -/// In-place table cell editing (Write 04 §18.4, super_editor patch SE-4). -/// -/// The document caret never enters a table: a table embed stays one block -/// node. Clicking a cell (or Enter / F2 on a selected table, or -/// `write.table.editCell`) turns that cell into a text field that edits the -/// cell's **inline Markdown source**; other cells keep their rendering. -/// -/// - Tab / Shift+Tab move to the next / previous cell; Tab in the last cell -/// appends a row. Enter moves down and appends a row from the last row; -/// Shift+Enter moves up. ↑ ↓ move between rows, ← → at the text edges move -/// between cells, and leave the table at its edges. -/// - Mod+B / I / E and Mod+Shift+X wrap the field selection in `**`, `*`, -/// `` ` `` and `~~`; Mod+K wraps it as a link. -/// - Esc leaves the cell and selects the table; Mod+Enter leaves it and puts -/// the caret after the table. -/// -/// Every commit replaces the `table` embed (`write.table.replace` semantics, -/// one undo step "Edit cell"): when leaving a cell, when the field loses -/// focus, and after [idleCommit] without typing (so collaborators see the -/// edit). Undo/redo in a clean field run the document's undo/redo. -/// -/// The field's focus node is parented **outside** the `SuperEditor` (see -/// [focusParent]), so the editor's keyboard handlers and IME connection never -/// see the cell's keys. -class InlineTableEditing extends ChangeNotifier - implements InlineTableEditingHandle { - InlineTableEditing({ - required this.controller, - required this.commands, - this.onUndo, - this.onRedo, - this.idleCommit = const Duration(milliseconds: 1500), - }) { - focusNode = FocusNode(debugLabel: 'write-table-cell', onKeyEvent: _onKey); - focusNode.addListener(_onFocusChanged); - controller.addListener(_onDocumentChanged); - } - - final WriteEditorController controller; - final WriteEditorCommands commands; - - /// Document undo/redo (the shell's undo stack). - VoidCallback? onUndo; - VoidCallback? onRedo; - - /// Commit after this long without typing. - final Duration idleCommit; - - /// The focus node the cell field is parented to (outside the editor). - FocusNode? focusParent; - - /// The field's text (the cell's inline Markdown source). - final TextEditingController text = TextEditingController(); - - /// The field's undo history (draft-local undo). - final UndoHistoryController undo = UndoHistoryController(); - - /// Focus of the field. - late final FocusNode focusNode; - - /// Keeps the field's state while the table component rebuilds. - final GlobalKey fieldKey = GlobalKey(debugLabel: 'write-table-cell-field'); - - /// The caret where a cell was clicked; double and triple clicks. - final FieldClicks clicks = FieldClicks(); - - /// Selects the word at the field's caret (a double click). - void selectWordAtCaret() { - if (_active == null) return; - clicks.select( - text, - FieldClicks.wordAt(text.text, text.selection.baseOffset), - ); - } - - /// Selects the field's text (a triple click). - void selectAllText() { - if (_active == null) return; - clicks.select( - text, - TextSelection(baseOffset: 0, extentOffset: text.text.length), - ); - } - - /// After [clicks] clicks in a row at one place: a word for two, the text - /// for three or more. - void selectForClicks(int clicks) { - if (clicks == 2) selectWordAtCaret(); - if (clicks >= 3) selectAllText(); - } - - /// The source offset of cell ([row], [column]) of [tableId] under - /// [global] in its rendered content [box]. - int? caretAt( - String tableId, - int row, - int column, - RenderObject? box, - Offset global, - ) { - final table = commands.tableOf(nodeId: tableId); - if (table == null || row >= table.rowCount || column >= table.columnCount) { - return null; - } - final source = table.rows[row][column]; - final plain = InlineRun.plainText(InlineMarkdown.parseCell(source)); - if (plain.isEmpty) return 0; - final offset = FieldClicks.renderedOffsetAt(box, global); - return offset == null - ? null - : FieldClicks.sourceOffset(source, plain, offset); - } - - TableCellRef? _active; - String _base = ''; - Timer? _idle; - bool _committing = false; - bool _disposed = false; - - /// The cell being edited, or `null`. - TableCellRef? get active => _active; - - @override - bool get isEditing => _active != null; - - /// Whether the field differs from the cell's content. - bool get isDirty => _active != null && text.text != _base; - - /// Whether cells of [tableId] can be edited in place now. - bool canEdit(String tableId) => commands.canEditTable(nodeId: tableId); - - /// Starts editing cell ([row], [column]) of [tableId]. [caret] places the - /// caret (default: end of the text); [selectAll] selects the whole cell. - @override - bool activate( - String tableId, - int row, - int column, { - int? caret, - bool selectAll = false, - }) { - final table = commands.tableOf(nodeId: tableId); - if (table == null || !canEdit(tableId)) return false; - if (row < 0 || row >= table.rowCount) return false; - if (column < 0 || column >= table.columnCount) return false; - final next = TableCellRef(tableId, row, column); - if (_active != null && _active != next) commit(); - final source = table.rows[row][column]; - final changedCell = _active != next; - _active = next; - if (changedCell) { - _base = source; - text.value = TextEditingValue( - text: source, - selection: selectAll - ? TextSelection(baseOffset: 0, extentOffset: source.length) - : TextSelection.collapsed( - offset: (caret ?? source.length).clamp(0, source.length), - ), - ); - undo.value = UndoHistoryValue.empty; - } - _selectTableOnceFocused(); - notifyListeners(); - _focusField(); - return true; - } - - bool _selectionPending = false; - - /// Presence: the local selection is the table embed (Write 04 §18.4), - /// set once the field has the focus. Selecting it while the document - /// editor still has the focus (the field is built a frame later) let the - /// editor take the text input back after the field had it: typing went to - /// the editor, which moved its caret before or after the table. - void _selectTableOnceFocused() { - if (!focusNode.hasFocus) { - _selectionPending = true; - return; - } - _selectionPending = false; - final cell = _active; - final block = cell == null ? null : controller.mirror.blockOf(cell.tableId); - if (block == null) return; - final s = controller.selection; - if (s == null || s.start != block.start || s.end != block.start + 1) { - controller.select( - DeltaSelection(block.start, block.start + 1), - userInteraction: false, - ); - } - } - - void _focusField() { - if (focusNode.context != null) { - focusNode.requestFocus(); - return; - } - // The field mounts with the next frame. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!_disposed && _active != null) focusNode.requestFocus(); - }); - } - - /// Writes the field into the table (one undo step). Returns whether the - /// document changed. - bool commit({String? label}) { - label ??= controller.strings().undoEditCell; - _idle?.cancel(); - final cell = _active; - if (cell == null || !isDirty || _committing) return false; - _committing = true; - try { - final source = InlineMarkdown.canonicalCell( - text.text.replaceAll(RegExp(r'[\r\n]+'), ' '), - ); - final ok = commands.editTable( - (table) => table.setCell(cell.row, cell.column, source), - nodeId: cell.tableId, - label: label, - ); - _base = text.text; - return ok; - } finally { - _committing = false; - } - } - - /// Stops editing. With [focusEditor] the document editor gets the focus - /// back, and [caretAfter] / [caretBefore] place the caret around the table; - /// otherwise the table stays selected. - @override - void deactivate({ - bool save = true, - bool focusEditor = false, - bool caretAfter = false, - bool caretBefore = false, - }) { - final cell = _active; - if (cell == null) return; - if (save) commit(); - _idle?.cancel(); - clicks.release(); - _active = null; - _base = ''; - notifyListeners(); - final block = controller.mirror.blockOf(cell.tableId); - if (block != null && (caretAfter || caretBefore)) { - final offset = caretAfter - ? (block.start + block.length).clamp(0, controller.length - 1) - : (block.start - 1).clamp(0, controller.length - 1); - controller.select(DeltaSelection.collapsed(offset)); - } - if (focusEditor) controller.focusNode.requestFocus(); - } - - /// Called by the field on every change. - void onChanged(String value) { - _idle?.cancel(); - if (isDirty) _idle = Timer(idleCommit, commit); - } - - void _onFocusChanged() { - if (focusNode.hasFocus && _selectionPending) _selectTableOnceFocused(); - if (!focusNode.hasFocus && _active != null) { - // Focus left the cell (click elsewhere, a dialog, the menubar). - deactivate(); - } - } - - void _onDocumentChanged() { - final cell = _active; - if (cell == null || _committing) return; - final table = commands.tableOf(nodeId: cell.tableId); - if (table == null || - cell.row >= table.rowCount || - cell.column >= table.columnCount) { - // Removed by a collaborator (or undo): nothing to edit any more. - final wasDirty = isDirty; - _active = null; - _base = ''; - _idle?.cancel(); - notifyListeners(); - if (wasDirty) { - controller.notify( - EditorNotice( - EditorNoticeKind.message, - table == null - ? controller.strings().editorTableDeleted - : controller.strings().editorCellRemoved, - ), - ); - } - return; - } - final source = table.rows[cell.row][cell.column]; - if (source == _base) return; - if (isDirty) { - // Keep the draft; it wins when committed (last writer wins). - _base = source; - return; - } - _base = source; - final caret = text.selection.baseOffset.clamp(0, source.length); - text.value = TextEditingValue( - text: source, - selection: TextSelection.collapsed(offset: caret), - ); - } - - // --------------------------------------------------------------------------- - // Navigation. - - TableModel? get _table { - final cell = _active; - return cell == null ? null : commands.tableOf(nodeId: cell.tableId); - } - - /// Moves to the next (or previous) cell in reading order. Past the last - /// cell a row is appended ([appendRow]). - void moveHorizontally(int step, {bool appendRow = true}) { - final cell = _active; - final table = _table; - if (cell == null || table == null) return; - var index = cell.row * table.columnCount + cell.column + step; - final count = table.rowCount * table.columnCount; - if (index < 0) { - deactivate(focusEditor: true, caretBefore: true); - return; - } - if (index >= count) { - if (!appendRow) { - deactivate(focusEditor: true, caretAfter: true); - return; - } - commit(); - _appendRow(cell.tableId); - index = count; - } - activate( - cell.tableId, - index ~/ table.columnCount, - index % table.columnCount, - caret: step > 0 ? 0 : null, - selectAll: appendRow, - ); - } - - /// Moves to the cell above/below; below the last row Enter appends a row. - void moveVertically(int step, {bool appendRow = false}) { - final cell = _active; - final table = _table; - if (cell == null || table == null) return; - final row = cell.row + step; - if (row < 0) { - deactivate(focusEditor: true, caretBefore: true); - return; - } - if (row >= table.rowCount) { - if (!appendRow) { - deactivate(focusEditor: true, caretAfter: true); - return; - } - commit(); - _appendRow(cell.tableId); - } - activate(cell.tableId, row, cell.column); - } - - void _appendRow(String tableId) { - commands.editTable( - (table) => table.insertRow(table.rowCount), - nodeId: tableId, - label: controller.strings().undoInsertRow, - ); - } - - // --------------------------------------------------------------------------- - // Keys. - - static bool get _isApple => - defaultTargetPlatform == TargetPlatform.macOS || - defaultTargetPlatform == TargetPlatform.iOS; - - KeyEventResult _onKey(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { - return KeyEventResult.ignored; - } - clicks.release(); - if (_active == null) return KeyEventResult.ignored; - final keyboard = HardwareKeyboard.instance; - final mod = _isApple ? keyboard.isMetaPressed : keyboard.isControlPressed; - final shift = keyboard.isShiftPressed; - final alt = keyboard.isAltPressed; - final key = event.logicalKey; - final selection = text.selection; - final length = text.text.length; - - if (key == LogicalKeyboardKey.escape) { - deactivate(focusEditor: true); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.tab) { - moveHorizontally(shift ? -1 : 1, appendRow: !shift); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.numpadEnter) { - if (mod) { - deactivate(focusEditor: true, caretAfter: true); - } else { - moveVertically(shift ? -1 : 1, appendRow: !shift); - } - return KeyEventResult.handled; - } - if (!mod && !alt && !shift) { - if (key == LogicalKeyboardKey.arrowUp) { - moveVertically(-1); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.arrowDown) { - moveVertically(1); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.arrowLeft && - selection.isCollapsed && - selection.baseOffset <= 0) { - moveHorizontally(-1, appendRow: false); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.arrowRight && - selection.isCollapsed && - selection.baseOffset >= length) { - moveHorizontally(1, appendRow: false); - return KeyEventResult.handled; - } - } - if (mod && !alt) { - if (key == LogicalKeyboardKey.keyZ) { - final history = undo.value; - if (shift ? history.canRedo : history.canUndo) { - return KeyEventResult.ignored; // the field's own undo - } - commit(); - (shift ? onRedo : onUndo)?.call(); - return KeyEventResult.handled; - } - if (key == LogicalKeyboardKey.keyY && !_isApple) { - if (undo.value.canRedo) return KeyEventResult.ignored; - commit(); - onRedo?.call(); - return KeyEventResult.handled; - } - if (!shift && key == LogicalKeyboardKey.keyB) return _wrap('**', '**'); - if (!shift && key == LogicalKeyboardKey.keyI) return _wrap('*', '*'); - if (!shift && key == LogicalKeyboardKey.keyE) return _wrap('`', '`'); - if (shift && key == LogicalKeyboardKey.keyX) return _wrap('~~', '~~'); - if (!shift && key == LogicalKeyboardKey.keyK) return _wrap('[', ']()'); - } - return KeyEventResult.ignored; - } - - KeyEventResult _wrap(String before, String after) { - final value = text.value; - final selection = value.selection; - if (!selection.isValid) return KeyEventResult.handled; - final start = selection.start; - final end = selection.end; - final inner = value.text.substring(start, end); - final replaced = value.text.replaceRange(start, end, '$before$inner$after'); - final caret = after == ']()' - ? start + before.length + inner.length + 2 - : start + before.length + inner.length + after.length; - text.value = TextEditingValue( - text: replaced, - selection: inner.isEmpty || after == ']()' - ? TextSelection.collapsed( - offset: inner.isEmpty ? start + before.length : caret, - ) - : TextSelection( - baseOffset: start + before.length, - extentOffset: start + before.length + inner.length, - ), - ); - onChanged(text.text); - return KeyEventResult.handled; - } - - @override - void dispose() { - _disposed = true; - _idle?.cancel(); - if (_active != null) commit(); - controller.removeListener(_onDocumentChanged); - focusNode - ..removeListener(_onFocusChanged) - ..dispose(); - text.dispose(); - undo.dispose(); - super.dispose(); - } -} - -/// The `SE-4` cell builder: the active cell becomes a text field, the others -/// keep their rendering and start editing on tap. -TableCellContentBuilder inlineTableCellBuilder( - InlineTableEditing editing, - EditorTheme theme, -) { - return (context, cell) => _InlineCell( - key: ValueKey( - 'write.editor.tableCell.${cell.tableNodeId}.${cell.rowIndex}.${cell.columnIndex}', - ), - editing: editing, - theme: theme, - cell: cell, - ); -} - -class _InlineCell extends StatelessWidget { - const _InlineCell({ - super.key, - required this.editing, - required this.theme, - required this.cell, - }); - - final InlineTableEditing editing; - final EditorTheme theme; - final TableCellBuildContext cell; - - bool get _isActive => - editing.active == - TableCellRef(cell.tableNodeId, cell.rowIndex, cell.columnIndex); - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: editing, - builder: (context, _) { - if (_isActive) return DocumentGestureOptOut(child: _field(context)); - if (!editing.canEdit(cell.tableNodeId)) return cell.defaultContent; - // In the field's tap region group: pressing another cell is not a - // tap outside the cell being edited, which would end editing (the - // editor showed its selection for a moment) before this tap moves - // the editing here. - return DocumentGestureOptOut( - child: TapRegion( - groupId: editing, - child: MouseRegion( - cursor: SystemMouseCursors.text, - child: Listener( - onPointerDown: editing.clicks.down, - onPointerUp: editing.clicks.up, - child: Builder( - builder: (content) => GestureDetector( - behavior: HitTestBehavior.opaque, - // The caret where the click was. - // (A fast second click can still land here: it - // selects the word.) - onTapUp: (details) { - final clicks = editing.clicks.count; - editing.activate( - cell.tableNodeId, - cell.rowIndex, - cell.columnIndex, - caret: editing.caretAt( - cell.tableNodeId, - cell.rowIndex, - cell.columnIndex, - content.findRenderObject(), - details.globalPosition, - ), - ); - editing.selectForClicks(clicks); - }, - child: ConstrainedBox( - constraints: const BoxConstraints(minWidth: 48), - child: cell.defaultContent, - ), - ), - ), - ), - ), - ), - ); - }, - ); - } - - Widget _field(BuildContext context) { - final style = cell.cell.textStyleBuilder(const {}); - return Focus( - parentNode: editing.focusParent, - canRequestFocus: false, - skipTraversal: true, - // After the field handled the click (a microtask), a double click's - // second click selects the word. - child: Listener( - onPointerDown: editing.clicks.down, - onPointerUp: (event) { - final clicks = editing.clicks.up(event); - if (clicks > 1) { - scheduleMicrotask(() => editing.selectForClicks(clicks)); - } - }, - // The editor's selection color, not Material's. - child: TextSelectionTheme( - data: TextSelectionThemeData( - selectionColor: theme.selectionColor, - cursorColor: theme.caretColor, - ), - child: _textField(style), - ), - ), - ); - } - - Widget _textField(TextStyle style) { - return TextField( - key: editing.fieldKey, - controller: editing.text, - focusNode: editing.focusNode, - groupId: editing, - undoController: editing.undo, - style: style, - textAlign: cell.cell.textAlign, - maxLines: null, - cursorColor: theme.caretColor, - cursorWidth: 2, - decoration: const InputDecoration.collapsed(hintText: ''), - inputFormatters: [ - FilteringTextInputFormatter.deny( - RegExp(r'[\r\n]+'), - replacementString: ' ', - ), - ], - onChanged: editing.onChanged, - ); - } -} diff --git a/wonky_write/lib/features/editor/ui/wonky_editor.dart b/wonky_write/lib/features/editor/ui/wonky_editor.dart index 28413997..790c4c39 100644 --- a/wonky_write/lib/features/editor/ui/wonky_editor.dart +++ b/wonky_write/lib/features/editor/ui/wonky_editor.dart @@ -14,11 +14,9 @@ import '../applier/delta_applier.dart'; import '../commands/write_editor_commands.dart'; import '../components/box_frames.dart'; import '../components/editor_services.dart'; -import '../mapping/wonky_embed_node.dart'; import '../components/grid_component.dart'; import '../components/youtube_component.dart'; import '../components/inline_pills.dart'; -import '../grid/inline_grid_editing.dart'; import '../components/wonky_components.dart'; import '../controller/editor_types.dart'; import '../controller/write_editor_controller.dart'; @@ -42,7 +40,6 @@ import '../setup/editor_factory.dart' show selectionGuard; import '../styles/editor_theme.dart'; import '../styles/style_phases.dart'; import '../styles/write_stylesheet.dart'; -import '../tables/inline_table_editing.dart'; /// The Write document editor surface (Write 04): `SuperEditor` (Editing, /// Suggesting) or `SuperReader` (Viewing, suggestion previews) over the @@ -76,7 +73,6 @@ class WonkyEditor extends StatefulWidget { this.extraStylePhases = const [], this.emojiSuggestions, this.onDropFiles, - this.inlineTableEditing = true, this.externalDrops, this.endInset = 0, this.compactPadding, @@ -141,9 +137,6 @@ class WonkyEditor extends StatefulWidget { /// drops are pasted by the editor itself. final void Function(List files, int blockIndex)? onDropFiles; - /// In-place table cell editing (SE-4, Write 04 §18.4). - final bool inlineTableEditing; - /// Pages mode: width at the end of the canvas that something is drawn over /// (the comment margin). The page centers in the rest — moving left, down /// to an 8 px gutter — so it stays clear of it when there is room. @@ -191,8 +184,6 @@ class WonkyEditorState extends State { skipTraversal: true, ); late TextDragController _textDrag; - late InlineTableEditing _tables; - late InlineGridEditing _grids; ExternalDropSource? _drops; /// The slash menu items in the UI language (set in [didChangeDependencies]). @@ -229,9 +220,6 @@ class WonkyEditorState extends State { /// The pagination state (page count for the status bar). PaginationController get pagination => _pagination; - /// In-place table cell editing (SE-4). - InlineTableEditing get tableEditing => _tables; - /// Drag and drop of the selection (Write 04 §24). TextDragController get textDrag => _textDrag; @@ -275,20 +263,6 @@ class WonkyEditorState extends State { commands: _commands, indicator: _dropIndicator, ); - _tables = InlineTableEditing( - controller: controller, - commands: _commands, - onUndo: widget.shortcutCallbacks?.onUndo, - onRedo: widget.shortcutCallbacks?.onRedo, - )..focusParent = _outerFocus; - controller.tableEditing = _tables; - _grids = InlineGridEditing( - controller: controller, - commands: _commands, - onUndo: widget.shortcutCallbacks?.onUndo, - onRedo: widget.shortcutCallbacks?.onRedo, - )..focusParent = _outerFocus; - controller.gridEditing = _grids; controller.addListener(_onControllerChanged); controller.view.addListener(_onViewChanged); controller.pillBoundary.addListener(_updateLinkCardFromCaret); @@ -302,8 +276,6 @@ class WonkyEditorState extends State { } void _detach(WriteEditorController old) { - // First: disposing the inline editors below commits pending edits, - // which notifies the controller. old.removeListener(_onControllerChanged); old.view.removeListener(_onViewChanged); old.pillBoundary.removeListener(_updateLinkCardFromCaret); @@ -312,10 +284,6 @@ class WonkyEditorState extends State { _triggers.emoji.removeListener(_onEmoji); _triggers.dispose(); _textDrag.cancel(); - if (identical(old.tableEditing, _tables)) old.tableEditing = null; - if (identical(old.gridEditing, _grids)) old.gridEditing = null; - _tables.dispose(); - _grids.dispose(); _drops?.detach(); } @@ -481,7 +449,6 @@ class WonkyEditorState extends State { return true; } } - if (_tableKeys(event)) return true; if (_triggers.slash.value != null && _slashPicker.items.isNotEmpty) { return _slashPicker.handleKey( event, @@ -511,37 +478,6 @@ class WonkyEditorState extends State { return false; } - // --------------------------------------------------------------------------- - // Tables (SE-4, §18.4). - - /// Enter or F2 on a selected table starts editing its first cell. - bool _tableKeys(KeyEvent event) { - if (event is! KeyDownEvent || !widget.inlineTableEditing) return false; - final key = event.logicalKey; - if (key != LogicalKeyboardKey.enter && key != LogicalKeyboardKey.f2) { - return false; - } - final keyboard = HardwareKeyboard.instance; - if (keyboard.isShiftPressed || - keyboard.isAltPressed || - keyboard.isMetaPressed || - keyboard.isControlPressed) { - return false; - } - final selection = controller.composer.selection; - if (selection == null || - selection.isCollapsed || - selection.base.nodeId != selection.extent.nodeId) { - return false; - } - final node = controller.document.getNodeById(selection.base.nodeId); - if (node is WonkyEmbedNode && _grids.canEdit(node.id)) { - return _grids.activate(node.id, 0); - } - if (node is! TableBlockNode || !_tables.canEdit(node.id)) return false; - return _tables.activate(node.id, 0, 0, selectAll: true); - } - // --------------------------------------------------------------------------- // Drops from other applications (§24). @@ -781,12 +717,6 @@ class WonkyEditorState extends State { ), lastBlockBottom: compact, ); - _tables - ..onUndo = widget.shortcutCallbacks?.onUndo - ..onRedo = widget.shortcutCallbacks?.onRedo; - _grids - ..onUndo = widget.shortcutCallbacks?.onUndo - ..onRedo = widget.shortcutCallbacks?.onRedo; // The SuperEditor/SuperReader widget is reused while its inputs are the // same: a new instance rebuilds super_editor's layout, which walks every @@ -1034,12 +964,7 @@ class WonkyEditorState extends State { ? _pagination.contentHeight : null, ), - WonkyTableComponentBuilder( - theme: theme, - callbacks: widget.blockCallbacks, - editable: editable, - inlineEditing: editable && widget.inlineTableEditing ? _tables : null, - ), + WonkyTableComponentBuilder(theme: theme), WonkyTaskComponentBuilder( controller.editor, editable: editable, @@ -1061,13 +986,7 @@ class WonkyEditorState extends State { editable: editable, ), WonkyRuleComponentBuilder(theme: theme), - WonkyGridComponentBuilder( - theme: theme, - zoom: zoom, - editing: editable ? _grids : null, - callbacks: widget.blockCallbacks, - editable: editable, - ), + WonkyGridComponentBuilder(theme: theme, zoom: zoom), WonkyYouTubeComponentBuilder( services: widget.imageServices, theme: theme, diff --git a/wonky_write/lib/features/screen/selection_toolbar.dart b/wonky_write/lib/features/screen/selection_toolbar.dart index ba6377d8..47868ea4 100644 --- a/wonky_write/lib/features/screen/selection_toolbar.dart +++ b/wonky_write/lib/features/screen/selection_toolbar.dart @@ -84,10 +84,6 @@ class _SelectionToolbarLayerState extends State { if (!mounted || s == null || s.isCollapsed || _pointerDown) return; if (_dismissedFor == s) return; if (!doc.viewPrefs.showSelectionToolbar) return; - // A table cell or grid item is edited in place (its own field has the - // focus). - if (doc.editor.tableEditing?.isEditing ?? false) return; - if (doc.editor.gridEditing?.isEditing ?? false) return; // A selected grid has its own chrome; text formatting doesn't apply. final ds = doc.editor.composer.selection; if (ds != null && ds.base.nodeId == ds.extent.nodeId) { diff --git a/wonky_write/test/features/editor/model/field_clicks_test.dart b/wonky_write/test/features/editor/model/field_clicks_test.dart deleted file mode 100644 index 5970ba68..00000000 --- a/wonky_write/test/features/editor/model/field_clicks_test.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:wonky_write/features/editor/model/field_clicks.dart'; - -void main() { - group('Given text with words', () { - test('when asking for the word in or right after it then it is the ' - 'whole word', () { - expect( - FieldClicks.wordAt('Alpha Beta Gamma', 7), - const TextSelection(baseOffset: 6, extentOffset: 10), - ); - expect( - FieldClicks.wordAt('Alpha Beta Gamma', 10), - const TextSelection(baseOffset: 6, extentOffset: 10), - ); - expect( - FieldClicks.wordAt("it's here", 1), - const TextSelection(baseOffset: 0, extentOffset: 4), - ); - }); - - test('when asking between words then the caret stays', () { - expect( - FieldClicks.wordAt('a b', 2), - const TextSelection.collapsed(offset: 2), - ); - }); - }); - - group('Given inline Markdown and its rendered text', () { - test('when mapping offsets then markup and link targets are skipped', () { - // "Use **bold** and [docs](https://x.dev) now" - const source = 'Use **bold** and [docs](https://x.dev) now'; - const plain = 'Use bold and docs now'; - - expect(FieldClicks.sourceOffset(source, plain, 0), 0); - // Before "bold": inside the markers. - expect(FieldClicks.sourceOffset(source, plain, 4), 6); - // In "bold". - expect(FieldClicks.sourceOffset(source, plain, 6), 8); - // Before "docs". - expect(FieldClicks.sourceOffset(source, plain, 13), 18); - // After "docs": still in the link text. - expect(FieldClicks.sourceOffset(source, plain, 17), 22); - // At the end. - expect(FieldClicks.sourceOffset(source, plain, 21), source.length); - }); - }); - - group('Given a word selected by a double click', () { - const word = TextSelection(baseOffset: 6, extentOffset: 10); - late TextEditingController text; - late FieldClicks clicks; - - setUp(() { - text = TextEditingController(text: 'Alpha Beta Gamma'); - clicks = FieldClicks()..select(text, word); - }); - - tearDown(() { - clicks.release(); - text.dispose(); - }); - - test('when the field collapses it afterwards (its late single click) ' - 'then the word stays selected', () { - text.selection = const TextSelection.collapsed(offset: 8); - - expect(text.selection, word); - }); - - test('when the text is edited then the selection is not kept', () { - text.value = const TextEditingValue( - text: 'Alpha X Gamma', - selection: TextSelection.collapsed(offset: 7), - ); - text.selection = const TextSelection.collapsed(offset: 2); - - expect(text.selection, const TextSelection.collapsed(offset: 2)); - }); - - test('when the next press comes then the selection is not kept', () { - clicks.down(const PointerDownEvent(position: Offset(5, 5))); - text.selection = const TextSelection.collapsed(offset: 2); - - expect(text.selection, const TextSelection.collapsed(offset: 2)); - }); - }); -} From b515287aaa11e1e6b012f9464d2da72fc201a964 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Tue, 27 Oct 2026 23:56:00 +0200 Subject: [PATCH 04/16] feat(super_editor): SE-15 cell groups lay out components side by side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocumentCellLayout groups consecutive components into rows or a flow; nearest-component and region selection work in 2D (spec 12 §3.2). Co-Authored-By: Claude Opus 5.5 --- third_party/super_editor/PATCHES.md | 42 ++ .../layout_single_column/_cell_layout.dart | 519 ++++++++++++++++++ .../layout_single_column/_layout.dart | 364 +++++++++++- .../layout_single_column.dart | 2 + .../lib/src/default_editor/super_editor.dart | 9 + .../documents/document_scaffold.dart | 9 + .../lib/src/super_reader/super_reader.dart | 11 + .../wonky_patches/se15_cell_groups_test.dart | 502 +++++++++++++++++ 8 files changed, 1455 insertions(+), 3 deletions(-) create mode 100644 third_party/super_editor/lib/src/default_editor/layout_single_column/_cell_layout.dart create mode 100644 third_party/super_editor/test/wonky_patches/se15_cell_groups_test.dart diff --git a/third_party/super_editor/PATCHES.md b/third_party/super_editor/PATCHES.md index 108d2a2a..8a29acb9 100644 --- a/third_party/super_editor/PATCHES.md +++ b/third_party/super_editor/PATCHES.md @@ -253,6 +253,48 @@ Every change is marked with `// WONKY PATCH ` in the code. Tests of our patc - **Spec**: Write 04 §18.4, Write 11 §7.4 - **Upstream**: generally useful (embedded interactive content); to be proposed. +## SE-15 — Cell groups lay out components side by side + +- **Reason**: Wonky Write makes every table cell and grid item an ordinary `ParagraphNode` + (native document text instead of overlay text fields), so the single-column layout must place + those components in 2D: table rows and flowing grids. +- **Changes**: + - New public API (`layout_single_column/_cell_layout.dart`): `DocumentCellLayout` (supplied by + the app, a `Listenable`: `placementOf(nodeId)`, `wrapGroup`, `paintGroup`), + `DocumentCellPlacement.rows(group, row, column, minColumnWidth = 64, gap, spaceBefore)` / + `.flow(group, index, maxColumns, minColumnWidth = 160, gap, spaceBefore)`, + `DocumentCellGroupKind`, `DocumentCellGroup` (id, kind, node ids in document order), + `DocumentCellGroupGeometry` (size, row/column counts, `columnEdges`, `rowEdges`, `rowBottoms`, + `slotRects` per node) and the render widget `DocumentCellGroupView` / + `RenderDocumentCellGroup`. The group-wide values (`minColumnWidth`, `gap`, `maxColumns`) come + from the group's first placement. + - `SingleColumnDocumentLayout(cellLayout:)`: maximal runs of consecutive components whose + placements share a group id become one `DocumentCellGroupView` (wrapped by `wrapGroup`) in + the column; components keep their keys and presenter builders. Rebuilds when the cell layout + notifies (after the frame when it notifies during build/layout) or is replaced. + - Rows: columns share the width equally (minus gaps), never narrower than `minColumnWidth` + (they overflow); a row is as tall as its tallest cell; missing cells are empty space; the + largest `spaceBefore` of a row is added above it (outside the row's edges and slots). Flow: + the column count drops from `maxColumns` while columns would be narrower than + `minColumnWidth` (a single column takes the available width); items fill rows in index + order. `paintGroup` paints behind the cells; dry layout and intrinsics are supported. + - 2D geometry: a point in a cell's slot outside its component (below a short cell, padding) + resolves to that cell (`getDocumentPositionAtOffset`, `getDesiredCursorAtOffset`); the + nearest component beside a group is the cell with the nearest slot (2D distance; ordinary + components keep the vertical distance, they span the width); the start/end-of-content checks + use the group's bounds; the binary search used by region selection treats a group as one + block and starts at its first cell, and a cell that doesn't overlap the region no longer + ends the search; a region end inside a group resolves to the cell at that point (so a drag + from a later cell of a row starts there). + - `SuperEditor(cellLayout:)`, `SuperReader(cellLayout:)` → `DocumentScaffold` → layout. + Keyboard caret movement (document order) is unchanged. +- **Files**: `lib/src/default_editor/layout_single_column/{_cell_layout.dart (new),_layout.dart,layout_single_column.dart}`, + `lib/src/infrastructure/documents/document_scaffold.dart`, `lib/src/default_editor/super_editor.dart`, + `lib/src/super_reader/super_reader.dart`; test: `test/wonky_patches/se15_cell_groups_test.dart` (new). +- **Tests**: the file above; the package's layout and selection tests. +- **Spec**: Write 12 §3.2 +- **Upstream**: generally useful (tables and grids of ordinary components); to be proposed. + ## Infra - `pubspec.yaml`: `dependency_overrides: attributed_text: {path: ../attributed_text}` so the diff --git a/third_party/super_editor/lib/src/default_editor/layout_single_column/_cell_layout.dart b/third_party/super_editor/lib/src/default_editor/layout_single_column/_cell_layout.dart new file mode 100644 index 00000000..15567ed7 --- /dev/null +++ b/third_party/super_editor/lib/src/default_editor/layout_single_column/_cell_layout.dart @@ -0,0 +1,519 @@ +// WONKY PATCH SE-15: cell groups. Consecutive components that a +// [DocumentCellLayout] places in the same group are laid out side by side +// (table rows or a flowing grid) instead of being stacked in the column. +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// How a cell group arranges its components. +enum DocumentCellGroupKind { + /// Every placement names its row and column (a table). + rows, + + /// Items fill rows left to right in index order (a grid). + flow, +} + +/// Where a node's component goes inside a cell group. +/// +/// The group-wide values ([minColumnWidth], [gap] and, for flows, +/// [maxColumns]) are taken from the group's first placement. +@immutable +class DocumentCellPlacement { + /// A table cell: [row] and [column] inside group [group]. + const DocumentCellPlacement.rows({ + required this.group, + required this.row, + required this.column, + this.minColumnWidth = 64, + this.gap = 0, + this.spaceBefore = 0, + }) : kind = DocumentCellGroupKind.rows, + index = 0, + maxColumns = 0; + + /// A grid item: [index] in a flow of at most [maxColumns] columns; the + /// column count drops while columns would be narrower than [minColumnWidth]. + const DocumentCellPlacement.flow({ + required this.group, + required this.index, + required this.maxColumns, + this.minColumnWidth = 160, + this.gap = 0, + this.spaceBefore = 0, + }) : kind = DocumentCellGroupKind.flow, + row = 0, + column = 0; + + /// The group id; consecutive components with the same id form one group. + final String group; + + final DocumentCellGroupKind kind; + + /// [DocumentCellGroupKind.rows]: the cell's row (0 for flows). + final int row; + + /// [DocumentCellGroupKind.rows]: the cell's column (0 for flows). + final int column; + + /// [DocumentCellGroupKind.flow]: the item's index (0 for rows). + final int index; + + /// [DocumentCellGroupKind.flow]: the maximum column count (0 for rows). + final int maxColumns; + + /// Columns are never narrower than this (rows: they overflow; flows: the + /// column count drops). + final double minColumnWidth; + + /// Space between columns and between rows. + final double gap; + + /// Vertical space above this placement's row (the maximum of the row's + /// placements is used), e.g. to push a row to the next page. + final double spaceBefore; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DocumentCellPlacement && + other.group == group && + other.kind == kind && + other.row == row && + other.column == column && + other.index == index && + other.maxColumns == maxColumns && + other.minColumnWidth == minColumnWidth && + other.gap == gap && + other.spaceBefore == spaceBefore; + + @override + int get hashCode => Object.hash(group, kind, row, column, index, maxColumns, minColumnWidth, gap, spaceBefore); + + @override + String toString() => kind == DocumentCellGroupKind.rows + ? 'DocumentCellPlacement.rows($group, row: $row, column: $column)' + : 'DocumentCellPlacement.flow($group, index: $index, maxColumns: $maxColumns)'; +} + +/// One group as laid out: its id, kind and its nodes in document order. +@immutable +class DocumentCellGroup { + const DocumentCellGroup({ + required this.id, + required this.kind, + required this.nodeIds, + }); + + final String id; + final DocumentCellGroupKind kind; + final List nodeIds; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DocumentCellGroup && other.id == id && other.kind == kind && listEquals(other.nodeIds, nodeIds); + + @override + int get hashCode => Object.hash(id, kind, Object.hashAll(nodeIds)); + + @override + String toString() => 'DocumentCellGroup($id, $kind, $nodeIds)'; +} + +/// The geometry handed to [DocumentCellLayout.paintGroup], in the group's +/// local coordinates. +@immutable +class DocumentCellGroupGeometry { + DocumentCellGroupGeometry({ + required this.group, + required this.size, + required this.rowCount, + required this.columnCount, + required this.columnEdges, + required this.rowEdges, + required this.slotRects, + List? rowBottoms, + }) : rowBottoms = rowBottoms ?? [for (int i = 1; i < rowEdges.length; i++) rowEdges[i]]; + + final DocumentCellGroup group; + + /// The size of the group. + final Size size; + + final int rowCount; + final int columnCount; + + /// `columnCount + 1` x positions: the left edge of every column, then the + /// right edge of the last column (with a gap, a column ends `gap` before the + /// next one starts). + final List columnEdges; + + /// `rowCount + 1` y positions: the top of every row, then the bottom of the + /// last row. The `spaceBefore` above a row is not part of the row (it lies + /// between the previous row's bottom and the row's top). + final List rowEdges; + + /// The bottom of every row (`rowCount` y positions). Differs from the next + /// row's top when there is a gap or a `spaceBefore`. + final List rowBottoms; + + /// Node id → its full slot (column width × row height), not the child's + /// natural size. + final Map slotRects; +} + +/// Supplied by the app; tells the layout which components form cell groups. +/// +/// Notify listeners when a placement (or anything [wrapGroup] or +/// [paintGroup] depends on) changes: the document layout rebuilds its groups +/// and the groups repaint. +abstract class DocumentCellLayout implements Listenable { + /// The placement of the component of [nodeId], or `null` when it is an + /// ordinary component of the column. + DocumentCellPlacement? placementOf(String nodeId); + + /// Wraps a group's [child] (a [DocumentCellGroupView]), e.g. in a frame. + /// Return [child] for no decoration. + Widget wrapGroup(BuildContext context, DocumentCellGroup group, Widget child); + + /// Paints behind the group's cells, e.g. table borders. + void paintGroup(Canvas canvas, Offset offset, DocumentCellGroupGeometry geometry); +} + +/// Lays out the components of one cell group: [children] and [placements] +/// correspond one to one, in document order ([DocumentCellGroup.nodeIds]). +class DocumentCellGroupView extends MultiChildRenderObjectWidget { + DocumentCellGroupView({ + super.key, + required this.group, + required this.placements, + required this.cellLayout, + required super.children, + }) : assert(placements.length == children.length), + assert(group.nodeIds.length == children.length); + + final DocumentCellGroup group; + final List placements; + final DocumentCellLayout cellLayout; + + @override + RenderDocumentCellGroup createRenderObject(BuildContext context) { + return RenderDocumentCellGroup(group: group, placements: placements, cellLayout: cellLayout); + } + + @override + void updateRenderObject(BuildContext context, RenderDocumentCellGroup renderObject) { + renderObject + ..group = group + ..placements = placements + ..cellLayout = cellLayout; + } +} + +class _CellGroupParentData extends ContainerBoxParentData {} + +/// The render object of [DocumentCellGroupView]. +class RenderDocumentCellGroup extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + RenderDocumentCellGroup({ + required DocumentCellGroup group, + required List placements, + required DocumentCellLayout cellLayout, + }) : _group = group, + _placements = placements, + _cellLayout = cellLayout; + + DocumentCellGroup get group => _group; + DocumentCellGroup _group; + set group(DocumentCellGroup value) { + if (value == _group) return; + _group = value; + markNeedsLayout(); + } + + List get placements => _placements; + List _placements; + set placements(List value) { + if (listEquals(value, _placements)) return; + _placements = value; + markNeedsLayout(); + } + + DocumentCellLayout get cellLayout => _cellLayout; + DocumentCellLayout _cellLayout; + set cellLayout(DocumentCellLayout value) { + if (identical(value, _cellLayout)) return; + if (attached) { + _cellLayout.removeListener(markNeedsPaint); + value.addListener(markNeedsPaint); + } + _cellLayout = value; + markNeedsPaint(); + } + + /// The geometry of the last layout, `null` before the first. + DocumentCellGroupGeometry? get geometry => _geometry; + DocumentCellGroupGeometry? _geometry; + + /// The slot of [nodeId] in this group's coordinates (after layout). + Rect? slotRectOf(String nodeId) => _geometry?.slotRects[nodeId]; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _cellLayout.addListener(markNeedsPaint); + } + + @override + void detach() { + _cellLayout.removeListener(markNeedsPaint); + super.detach(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _CellGroupParentData) { + child.parentData = _CellGroupParentData(); + } + } + + List get _children { + final children = []; + RenderBox? child = firstChild; + while (child != null) { + children.add(child); + child = childAfter(child); + } + return children; + } + + DocumentCellPlacement? get _first => _placements.isEmpty ? null : _placements.first; + + double get _gap => max(0.0, _first?.gap ?? 0.0); + + double get _minColumnWidth => max(0.0, _first?.minColumnWidth ?? 0.0); + + /// Assigns every child (by index) a row and a column, and returns the + /// column count. + _CellGrid _grid(double width) { + final count = min(_placements.length, childCount); + final rows = List.filled(count, 0); + final columns = List.filled(count, 0); + if (count == 0) { + return _CellGrid(rows, columns, 0, 0); + } + final gap = _gap; + final minColumnWidth = _minColumnWidth; + if (_group.kind == DocumentCellGroupKind.rows) { + int rowCount = 0; + int columnCount = 0; + for (int i = 0; i < count; i++) { + rows[i] = max(0, _placements[i].row); + columns[i] = max(0, _placements[i].column); + rowCount = max(rowCount, rows[i] + 1); + columnCount = max(columnCount, columns[i] + 1); + } + return _CellGrid(rows, columns, rowCount, columnCount); + } + + // Flow: the column count drops while columns would be too narrow. + int columnCount = max(1, _first!.maxColumns); + if (width.isFinite) { + while (columnCount > 1 && (width - gap * (columnCount - 1)) / columnCount < minColumnWidth) { + columnCount--; + } + } + final order = List.generate(count, (i) => i) + ..sort((a, b) { + final byIndex = _placements[a].index.compareTo(_placements[b].index); + return byIndex != 0 ? byIndex : a.compareTo(b); + }); + for (int rank = 0; rank < count; rank++) { + rows[order[rank]] = rank ~/ columnCount; + columns[order[rank]] = rank % columnCount; + } + return _CellGrid(rows, columns, (count + columnCount - 1) ~/ columnCount, columnCount); + } + + double _columnWidth(double width, int columnCount) { + if (columnCount == 0) return 0; + final minColumnWidth = _minColumnWidth; + if (!width.isFinite) return minColumnWidth; + final shared = max(0.0, (width - _gap * (columnCount - 1)) / columnCount); + // Rows keep columns at their minimum width (and overflow); a flow already + // dropped columns, and a single column narrower than the minimum takes + // the available width. + return _group.kind == DocumentCellGroupKind.rows ? max(minColumnWidth, shared) : shared; + } + + /// Computes the geometry for [width] with the children's heights from + /// [childHeight] (layout, dry layout or intrinsics). + DocumentCellGroupGeometry _computeGeometry( + double width, + double Function(RenderBox child, double columnWidth) childHeight, { + List? childOffsets, + }) { + final children = _children; + final grid = _grid(width); + final gap = _gap; + final columnWidth = _columnWidth(width, grid.columnCount); + + final rowHeights = List.filled(grid.rowCount, 0); + final rowSpaceBefore = List.filled(grid.rowCount, 0); + for (int i = 0; i < grid.rows.length; i++) { + final row = grid.rows[i]; + rowHeights[row] = max(rowHeights[row], childHeight(children[i], columnWidth)); + rowSpaceBefore[row] = max(rowSpaceBefore[row], max(0.0, _placements[i].spaceBefore)); + } + + final columnEdges = [ + for (int c = 0; c < grid.columnCount; c++) c * (columnWidth + gap), + if (grid.columnCount > 0) grid.columnCount * columnWidth + (grid.columnCount - 1) * gap else 0, + ]; + + final rowTops = []; + final rowBottoms = []; + double y = 0; + for (int r = 0; r < grid.rowCount; r++) { + if (r > 0) y += gap; + y += rowSpaceBefore[r]; + rowTops.add(y); + y += rowHeights[r]; + rowBottoms.add(y); + } + + final slotRects = {}; + for (int i = 0; i < grid.rows.length; i++) { + final row = grid.rows[i]; + final column = grid.columns[i]; + final slot = Rect.fromLTWH(columnEdges[column], rowTops[row], columnWidth, rowHeights[row]); + if (i < _group.nodeIds.length) { + slotRects[_group.nodeIds[i]] = slot; + } + childOffsets?.add(slot.topLeft); + } + + final contentWidth = columnEdges.last; + return DocumentCellGroupGeometry( + group: _group, + size: Size(width.isFinite ? width : contentWidth, y), + rowCount: grid.rowCount, + columnCount: grid.columnCount, + columnEdges: columnEdges, + rowEdges: [...rowTops, if (rowBottoms.isNotEmpty) rowBottoms.last else 0], + rowBottoms: rowBottoms, + slotRects: slotRects, + ); + } + + double _widthFor(BoxConstraints constraints) { + if (constraints.hasBoundedWidth) return constraints.maxWidth; + // Unbounded: as wide as the narrowest columns allow. + return constraints.constrainWidth(computeMinIntrinsicWidth(double.infinity)); + } + + @override + void performLayout() { + final width = _widthFor(constraints); + final offsets = []; + final geometry = _computeGeometry( + width, + (child, columnWidth) { + child.layout(BoxConstraints.tightFor(width: columnWidth), parentUsesSize: true); + return child.size.height; + }, + childOffsets: offsets, + ); + final children = _children; + for (int i = 0; i < children.length; i++) { + final parentData = children[i].parentData as _CellGroupParentData; + if (i < offsets.length) { + parentData.offset = offsets[i]; + } else { + // A child without a placement (never expected): lay it out hidden. + children[i].layout(const BoxConstraints.tightFor(width: 0, height: 0)); + parentData.offset = Offset.zero; + } + } + _geometry = geometry; + size = constraints.constrain(geometry.size); + } + + @override + Size computeDryLayout(covariant BoxConstraints constraints) { + final width = _widthFor(constraints); + final geometry = _computeGeometry( + width, + (child, columnWidth) => child.getDryLayout(BoxConstraints.tightFor(width: columnWidth)).height, + ); + return constraints.constrain(geometry.size); + } + + @override + double computeMinIntrinsicWidth(double height) { + final grid = _grid(double.infinity); + if (grid.columnCount == 0) return 0; + final columns = _group.kind == DocumentCellGroupKind.rows ? grid.columnCount : 1; + return columns * _minColumnWidth + (columns - 1) * _gap; + } + + @override + double computeMaxIntrinsicWidth(double height) { + final children = _children; + final grid = _grid(double.infinity); + if (grid.columnCount == 0) return 0; + final columnWidths = List.filled(grid.columnCount, _minColumnWidth); + for (int i = 0; i < grid.columns.length; i++) { + final column = grid.columns[i]; + columnWidths[column] = max(columnWidths[column], children[i].getMaxIntrinsicWidth(double.infinity)); + } + // Columns share the width equally, so all are as wide as the widest. + final widest = columnWidths.reduce(max); + return grid.columnCount * widest + (grid.columnCount - 1) * _gap; + } + + @override + double computeMinIntrinsicHeight(double width) { + return _computeGeometry(width, (child, columnWidth) => child.getMinIntrinsicHeight(columnWidth)).size.height; + } + + @override + double computeMaxIntrinsicHeight(double width) { + return _computeGeometry(width, (child, columnWidth) => child.getMaxIntrinsicHeight(columnWidth)).size.height; + } + + @override + void paint(PaintingContext context, Offset offset) { + final geometry = _geometry; + if (geometry != null) { + _cellLayout.paintGroup(context.canvas, offset, geometry); + } + defaultPaint(context, offset); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('group', _group)); + } +} + +class _CellGrid { + _CellGrid(this.rows, this.columns, this.rowCount, this.columnCount); + + /// Row and column of every child, by child index. + final List rows; + final List columns; + final int rowCount; + final int columnCount; +} diff --git a/third_party/super_editor/lib/src/default_editor/layout_single_column/_layout.dart b/third_party/super_editor/lib/src/default_editor/layout_single_column/_layout.dart index bc072e4f..df212dc6 100644 --- a/third_party/super_editor/lib/src/default_editor/layout_single_column/_layout.dart +++ b/third_party/super_editor/lib/src/default_editor/layout_single_column/_layout.dart @@ -1,6 +1,8 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; +// WONKY PATCH SE-15 +import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_layout.dart'; @@ -8,6 +10,8 @@ import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; +// WONKY PATCH SE-15 +import 'package:super_editor/src/default_editor/layout_single_column/_cell_layout.dart'; /// Displays a document in a single-column layout. /// @@ -32,6 +36,8 @@ class SingleColumnDocumentLayout extends StatefulWidget { this.onBuildScheduled, this.wrapWithSliverAdapter = true, this.showDebugPaint = false, + // WONKY PATCH SE-15 + this.cellLayout, }) : super(key: key); /// Presenter that provides a view model for a complete single-column @@ -63,6 +69,13 @@ class SingleColumnDocumentLayout extends StatefulWidget { /// Adds a debugging UI to the document layout, when true. final bool showDebugPaint; + /// WONKY PATCH SE-15: places components in cell groups. Maximal runs of + /// consecutive components whose placements share a group id are laid out + /// side by side by a [DocumentCellGroupView] (wrapped by + /// [DocumentCellLayout.wrapGroup]) instead of being stacked in the column. + /// The layout rebuilds when the cell layout notifies. + final DocumentCellLayout? cellLayout; + @override State createState() => _SingleColumnDocumentLayoutState(); } @@ -82,6 +95,11 @@ class _SingleColumnDocumentLayoutState extends State final GlobalKey _boxKey = GlobalKey(); BuildContext get boxContext => _boxKey.currentContext!; + // WONKY PATCH SE-15: the cell group runs of the last build (indices into + // [_topToBottomComponentKeys]) and the run of every grouped component. + final List<_CellGroupRun> _cellGroupRuns = []; + final Map _componentKeysToCellGroupRuns = {}; + @override void initState() { super.initState(); @@ -91,6 +109,8 @@ class _SingleColumnDocumentLayoutState extends State onViewModelChange: _onViewModelChange, ); widget.presenter.addChangeListener(_presenterListener); + // WONKY PATCH SE-15 + widget.cellLayout?.addListener(_onCellLayoutChange); // Build the view model now, so that any further changes to the // presenter send us a dirty notification. @@ -107,14 +127,36 @@ class _SingleColumnDocumentLayoutState extends State widget.presenter.updateViewModel(); } + + // WONKY PATCH SE-15 + if (widget.cellLayout != oldWidget.cellLayout) { + oldWidget.cellLayout?.removeListener(_onCellLayoutChange); + widget.cellLayout?.addListener(_onCellLayoutChange); + } } @override void dispose() { widget.presenter.removeChangeListener(_presenterListener); + // WONKY PATCH SE-15 + widget.cellLayout?.removeListener(_onCellLayoutChange); super.dispose(); } + /// WONKY PATCH SE-15: placements changed, regroup the components. + void _onCellLayoutChange() { + if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) { + // Notified during a build or layout: rebuild after this frame. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() {}); + }); + return; + } + setState(() { + // Re-flow the whole layout. + }); + } + Future _onPresenterMarkedDirty() async { editorLayoutLog.fine("Layout presenter is dirty. Instructing it to update the view model."); widget.presenter.updateViewModel(); @@ -213,6 +255,11 @@ class _SingleColumnDocumentLayoutState extends State } final componentKey = _topToBottomComponentKeys.first; + // WONKY PATCH SE-15: a group starts at its box's top. + final groupRect = _cellGroupRectOf(componentKey); + if (groupRect != null) { + return documentOffset.dy < groupRect.top; + } final componentBox = componentKey.currentContext!.findRenderObject() as RenderBox; final offsetAtComponent = _componentOffset(componentBox, documentOffset); @@ -227,6 +274,12 @@ class _SingleColumnDocumentLayoutState extends State } final componentKey = _topToBottomComponentKeys.last; + // WONKY PATCH SE-15: a group ends at its box's bottom (its last + // component may be shorter than another cell of the last row). + final groupRect = _cellGroupRectOf(componentKey); + if (groupRect != null) { + return documentOffset.dy > groupRect.bottom; + } final componentBox = componentKey.currentContext!.findRenderObject() as RenderBox; final offsetAtComponent = _componentOffset(componentBox, documentOffset); @@ -408,6 +461,23 @@ class _SingleColumnDocumentLayoutState extends State for (int i = componentSearchStartIndex; i < _topToBottomComponentKeys.length; i++) { final componentKey = _topToBottomComponentKeys[i]; editorLayoutLog.info(' - considering component "$componentKey"'); + + // WONKY PATCH SE-15: components of a cell group sit side by side, so a + // component that doesn't overlap the region doesn't end the search + // inside a group. A group that doesn't overlap at all is skipped (or + // ends the search, like any component below the region). + final cellGroupRun = _componentKeysToCellGroupRuns[componentKey]; + if (cellGroupRun != null && i == cellGroupRun.start) { + final groupRect = _cellGroupRectOf(componentKey); + if (groupRect != null && !region.overlaps(groupRect)) { + if (topNodeId != null) { + break; + } + i = cellGroupRun.end; + continue; + } + } + if (componentKey.currentState is! DocumentComponent) { editorLayoutLog.info(' - found unknown component: ${componentKey.currentState}'); continue; @@ -453,7 +523,7 @@ class _SingleColumnDocumentLayoutState extends State bottomNodeId = _componentKeysToNodeIds[componentKey]; bottomNodeBasePosition = _getNodePositionForComponentOffset(component, componentBaseOffset); bottomNodeExtentPosition = _getNodePositionForComponentOffset(component, componentExtentOffset); - } else if (topNodeId != null) { + } else if (topNodeId != null && cellGroupRun == null) { // We already found an overlapping component and the current component doesn't // overlap with the region. // Because we're iterating through components from top to bottom, @@ -463,6 +533,26 @@ class _SingleColumnDocumentLayoutState extends State } } + // WONKY PATCH SE-15: an end of the region inside a cell group resolves to + // the cell at that point (the nearest one), not to the first overlapping + // component in document order (which may be an earlier cell of the row). + final baseCell = _cellGroupComponentNearestTo(baseOffset); + final extentCell = _cellGroupComponentNearestTo(extentOffset); + if (baseCell != null || extentCell != null) { + return _cellAwareSelectionInRegion( + baseOffset: baseOffset, + extentOffset: extentOffset, + baseCell: baseCell, + extentCell: extentCell, + topNodeId: topNodeId, + topNodeBasePosition: topNodeBasePosition, + topNodeExtentPosition: topNodeExtentPosition, + bottomNodeId: bottomNodeId, + bottomNodeBasePosition: bottomNodeBasePosition, + bottomNodeExtentPosition: bottomNodeExtentPosition, + ); + } + if (topNodeId == null || bottomNodeId == null) { // No document content exists in the given region. editorLayoutLog @@ -504,6 +594,55 @@ class _SingleColumnDocumentLayoutState extends State } } + /// WONKY PATCH SE-15: [getDocumentSelectionInRegion] when its base and/or + /// extent lies in a cell group: such an end is the position in [baseCell] / + /// [extentCell] at the offset; another end comes from the region search + /// (top or bottom node, by drag direction), or else from the nearest + /// position. + DocumentSelection? _cellAwareSelectionInRegion({ + required Offset baseOffset, + required Offset extentOffset, + required GlobalKey? baseCell, + required GlobalKey? extentCell, + required String? topNodeId, + required dynamic topNodeBasePosition, + required dynamic topNodeExtentPosition, + required String? bottomNodeId, + required dynamic bottomNodeBasePosition, + required dynamic bottomNodeExtentPosition, + }) { + DocumentPosition? positionInCell(GlobalKey cellKey, Offset offset) { + final component = cellKey.currentState as DocumentComponent; + final componentBox = cellKey.currentContext!.findRenderObject() as RenderBox; + final nodePosition = _getNodePositionForComponentOffset(component, _componentOffset(componentBox, offset)); + if (nodePosition == null) { + return null; + } + return DocumentPosition(nodeId: _componentKeysToNodeIds[cellKey]!, nodePosition: nodePosition); + } + + final isDraggingDown = baseOffset.dy < extentOffset.dy; + DocumentPosition? regionPosition({required bool isBase}) { + // The base is the top node when dragging down, the extent the bottom one. + final useTop = isBase == isDraggingDown; + final nodeId = useTop ? topNodeId : bottomNodeId; + final nodePosition = useTop + ? (isBase ? topNodeBasePosition : topNodeExtentPosition) + : (isBase ? bottomNodeBasePosition : bottomNodeExtentPosition); + if (nodeId != null && nodePosition is NodePosition) { + return DocumentPosition(nodeId: nodeId, nodePosition: nodePosition); + } + return getDocumentPositionNearestToOffset(isBase ? baseOffset : extentOffset); + } + + final base = baseCell != null ? positionInCell(baseCell, baseOffset) : regionPosition(isBase: true); + final extent = extentCell != null ? positionInCell(extentCell, extentOffset) : regionPosition(isBase: false); + if (base == null || extent == null) { + return null; + } + return DocumentSelection(base: base, extent: extent); + } + /// Returns the overlapping `Rect` between the given `region` and the given /// `component`'s bounding box. /// @@ -568,10 +707,121 @@ class _SingleColumnDocumentLayoutState extends State return componentKey; } } + + // WONKY PATCH SE-15: a point in a cell's slot outside its component (the + // empty area below a short cell, its padding) belongs to that cell. + for (final run in _cellGroupRuns) { + final groupRect = _cellGroupRectOf(_topToBottomComponentKeys[run.start]); + if (groupRect == null || !groupRect.contains(documentOffset)) { + continue; + } + for (int i = run.start; i <= run.end; i++) { + final componentKey = _topToBottomComponentKeys[i]; + if (componentKey.currentState is! DocumentComponent) { + continue; + } + final slot = _cellSlotRectOf(componentKey); + if (slot != null && slot.contains(documentOffset)) { + return componentKey; + } + } + } + return null; + } + + /// WONKY PATCH SE-15: the component of the cell group whose vertical extent + /// contains [documentOffset] that is nearest to it (2D distance to the + /// slots, selectable components only), or `null` when [documentOffset] is + /// not beside a cell group. + GlobalKey? _cellGroupComponentNearestTo(Offset documentOffset) { + for (final run in _cellGroupRuns) { + final groupRect = _cellGroupRectOf(_topToBottomComponentKeys[run.start]); + if (groupRect == null || documentOffset.dy < groupRect.top || documentOffset.dy > groupRect.bottom) { + continue; + } + GlobalKey? nearest; + double nearestDistance = double.infinity; + for (int i = run.start; i <= run.end; i++) { + final componentKey = _topToBottomComponentKeys[i]; + final state = componentKey.currentState; + if (state is! DocumentComponent || !state.isVisualSelectionSupported()) { + continue; + } + final slot = _cellSlotRectOf(componentKey); + if (slot == null) { + continue; + } + final distance = _distanceToRect(slot, documentOffset); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = componentKey; + } + } + return nearest; + } + return null; + } + + /// WONKY PATCH SE-15: the [RenderDocumentCellGroup] that lays out the + /// component of [componentKey], or `null` when it isn't in a cell group. + RenderDocumentCellGroup? _cellGroupBoxOf(GlobalKey componentKey) { + if (!_componentKeysToCellGroupRuns.containsKey(componentKey)) { + return null; + } + final layoutBox = _boxKey.currentContext?.findRenderObject(); + RenderObject? renderObject = componentKey.currentContext?.findRenderObject(); + while (renderObject != null && renderObject != layoutBox) { + if (renderObject is RenderDocumentCellGroup) { + return renderObject; + } + renderObject = renderObject.parent; + } return null; } + /// WONKY PATCH SE-15: the bounds of the cell group of [componentKey] in + /// document coordinates, or `null` when it isn't in a cell group. + Rect? _cellGroupRectOf(GlobalKey componentKey) { + final groupBox = _cellGroupBoxOf(componentKey); + if (groupBox == null || !groupBox.hasSize) { + return null; + } + final offset = groupBox.localToGlobal(Offset.zero, ancestor: boxContext.findRenderObject()); + return offset & groupBox.size; + } + + /// WONKY PATCH SE-15: the slot (column width × row height) of the grouped + /// component of [componentKey] in document coordinates, or `null` when it + /// isn't in a cell group. + Rect? _cellSlotRectOf(GlobalKey componentKey) { + final groupBox = _cellGroupBoxOf(componentKey); + final nodeId = _componentKeysToNodeIds[componentKey]; + if (groupBox == null || nodeId == null || !groupBox.hasSize) { + return null; + } + final slot = groupBox.slotRectOf(nodeId); + if (slot == null) { + return null; + } + final offset = groupBox.localToGlobal(Offset.zero, ancestor: boxContext.findRenderObject()); + return slot.shift(offset); + } + + /// WONKY PATCH SE-15: the 2D distance between [offset] and [rect] (0 inside). + double _distanceToRect(Rect rect, Offset offset) { + final dx = offset.dx < rect.left ? rect.left - offset.dx : (offset.dx > rect.right ? offset.dx - rect.right : 0.0); + final dy = offset.dy < rect.top ? rect.top - offset.dy : (offset.dy > rect.bottom ? offset.dy - rect.bottom : 0.0); + return sqrt(dx * dx + dy * dy); + } + GlobalKey? _findComponentClosestToOffset(Offset documentOffset) { + // WONKY PATCH SE-15: beside a cell group (within its vertical extent), + // the nearest cell of that group. + final nearestCell = _cellGroupComponentNearestTo(documentOffset); + if (nearestCell != null) { + return nearestCell; + } + GlobalKey? nearestComponentKey; double nearestDistance = double.infinity; for (final componentKey in _nodeIdsToComponentKeys.values) { @@ -587,6 +837,23 @@ class _SingleColumnDocumentLayoutState extends State return componentKey; } + // WONKY PATCH SE-15: a component in a cell group shares its rows with + // others, so its distance is the 2D distance to its slot (a point in + // the slot is in the cell). An ordinary component spans the layout's + // width, so its distance stays vertical. + final slot = _cellSlotRectOf(componentKey); + if (slot != null) { + final distance = _distanceToRect(slot, documentOffset); + if (distance == 0) { + return componentKey; + } + if (distance < nearestDistance) { + nearestDistance = distance; + nearestComponentKey = componentKey; + } + continue; + } + final distance = _getDistanceToComponent(componentBox, documentOffset); if (distance < nearestDistance) { nearestDistance = distance; @@ -636,6 +903,9 @@ class _SingleColumnDocumentLayoutState extends State /// Returns the vertical distance between the given [documentOffset] and the /// bounds of the given [componentBox]. + /// + /// WONKY PATCH SE-15: components in cell groups use the 2D distance to their + /// slot instead, see [_findComponentClosestToOffset]. double _getDistanceToComponent(RenderBox componentBox, Offset documentOffset) { final documentLayoutBox = boxContext.findRenderObject() as RenderBox; final componentOffset = componentBox.localToGlobal(Offset.zero, ancestor: documentLayoutBox); @@ -736,7 +1006,8 @@ class _SingleColumnDocumentLayoutState extends State child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, - children: _buildDocComponents(), + // WONKY PATCH SE-15: cell groups. + children: _groupCells(context, _buildDocComponents()), ), ); @@ -804,6 +1075,68 @@ class _SingleColumnDocumentLayoutState extends State return docComponents; } + /// WONKY PATCH SE-15: puts maximal runs of consecutive components whose + /// placements share a group id into one [DocumentCellGroupView], wrapped by + /// [DocumentCellLayout.wrapGroup]. [components] are in + /// [_topToBottomComponentKeys] order. + List _groupCells(BuildContext context, List components) { + _cellGroupRuns.clear(); + _componentKeysToCellGroupRuns.clear(); + final cellLayout = widget.cellLayout; + if (cellLayout == null) { + return components; + } + + final placements = [ + for (final key in _topToBottomComponentKeys) cellLayout.placementOf(_componentKeysToNodeIds[key]!), + ]; + final children = []; + final occurrences = {}; + int i = 0; + while (i < components.length) { + final placement = placements[i]; + if (placement == null) { + children.add(components[i]); + i += 1; + continue; + } + + int end = i; + while (end + 1 < components.length && placements[end + 1]?.group == placement.group) { + end += 1; + } + final run = _CellGroupRun(i, end); + _cellGroupRuns.add(run); + for (int j = i; j <= end; j++) { + _componentKeysToCellGroupRuns[_topToBottomComponentKeys[j]] = run; + } + + final group = DocumentCellGroup( + id: placement.group, + kind: placement.kind, + nodeIds: [for (int j = i; j <= end; j++) _componentKeysToNodeIds[_topToBottomComponentKeys[j]]!], + ); + final occurrence = occurrences[placement.group] = (occurrences[placement.group] ?? -1) + 1; + children.add( + KeyedSubtree( + key: ValueKey('wonky-cell-group:${placement.group}#$occurrence'), + child: cellLayout.wrapGroup( + context, + group, + DocumentCellGroupView( + group: group, + placements: [for (int j = i; j <= end; j++) placements[j]!], + cellLayout: cellLayout, + children: components.sublist(i, end + 1), + ), + ), + ), + ); + i = end + 1; + } + return children; + } + /// Obtains a `GlobalKey` that should be attached to the component /// that represents the given [nodeId]. /// @@ -829,7 +1162,16 @@ class _SingleColumnDocumentLayoutState extends State if (_topToBottomComponentKeys.isEmpty) { return -1; } - return _binarySearchComponentIndexAtOffset(dy, 0, _topToBottomComponentKeys.length - 1); + final index = _binarySearchComponentIndexAtOffset(dy, 0, _topToBottomComponentKeys.length - 1); + // WONKY PATCH SE-15: start at the first component of a cell group (the + // search treats a group as one block, see [_getComponentBoundsByIndex]). + if (index >= 0) { + final run = _componentKeysToCellGroupRuns[_topToBottomComponentKeys[index]]; + if (run != null) { + return run.start; + } + } + return index; } /// Performs a binary search starting from [minIndex] to [maxIndex] to find @@ -875,8 +1217,15 @@ class _SingleColumnDocumentLayoutState extends State } /// Gets the component bounds of the component at [componentIndex] from top to bottom order. + /// + /// WONKY PATCH SE-15: for a component in a cell group, the bounds of the + /// whole group, so that the bounds are ordered top to bottom. Rect _getComponentBoundsByIndex(int componentIndex) { final componentKey = _topToBottomComponentKeys[componentIndex]; + final groupRect = _cellGroupRectOf(componentKey); + if (groupRect != null) { + return groupRect; + } final component = componentKey.currentState as DocumentComponent; final componentBox = component.context.findRenderObject() as RenderBox; @@ -885,6 +1234,15 @@ class _SingleColumnDocumentLayoutState extends State } } +/// WONKY PATCH SE-15: the components of a cell group, as indices (inclusive) +/// into the layout's top-to-bottom component keys. +class _CellGroupRun { + const _CellGroupRun(this.start, this.end); + + final int start; + final int end; +} + class _PresenterComponentBuilder extends StatefulWidget { const _PresenterComponentBuilder({ Key? key, diff --git a/third_party/super_editor/lib/src/default_editor/layout_single_column/layout_single_column.dart b/third_party/super_editor/lib/src/default_editor/layout_single_column/layout_single_column.dart index 6fb168d4..4f8e8803 100644 --- a/third_party/super_editor/lib/src/default_editor/layout_single_column/layout_single_column.dart +++ b/third_party/super_editor/lib/src/default_editor/layout_single_column/layout_single_column.dart @@ -1,6 +1,8 @@ /// Everything needed to display a document in a single-column layout, /// similar to most blog posts. +// WONKY PATCH SE-15: cell groups. +export '_cell_layout.dart'; export '_layout.dart'; export '_presenter.dart'; export '_styler_per_component.dart'; diff --git a/third_party/super_editor/lib/src/default_editor/super_editor.dart b/third_party/super_editor/lib/src/default_editor/super_editor.dart index d4506725..ad0bd574 100644 --- a/third_party/super_editor/lib/src/default_editor/super_editor.dart +++ b/third_party/super_editor/lib/src/default_editor/super_editor.dart @@ -134,6 +134,8 @@ class SuperEditor extends StatefulWidget { this.contentTapDelegateFactories = const [superEditorLaunchLinkTapHandlerFactory], // WONKY PATCH SE-6 this.contentDragDelegate, + // WONKY PATCH SE-15 + this.cellLayout, this.selectionLayerLinks, this.documentUnderlayBuilders = const [], this.documentOverlayBuilders = defaultSuperEditorDocumentOverlayBuilders, @@ -316,6 +318,11 @@ class SuperEditor extends StatefulWidget { /// Only used with mouse gestures ([DocumentGestureMode.mouse]). final ContentDragDelegate? contentDragDelegate; + /// WONKY PATCH SE-15: places components in cell groups that are laid out + /// side by side (table rows, grids), see [DocumentCellLayout] and + /// [SingleColumnDocumentLayout.cellLayout]. + final DocumentCellLayout? cellLayout; + /// Leader links that connect leader widgets near the user's selection /// to carets, handles, and other things that want to follow the selection. /// @@ -764,6 +771,8 @@ class SuperEditorState extends State { presenter: presenter, componentBuilders: widget.componentBuilders, shrinkWrap: widget.shrinkWrap, + // WONKY PATCH SE-15 + cellLayout: widget.cellLayout, underlays: [ // Add all underlays from plugins. for (final plugin in widget.plugins) // diff --git a/third_party/super_editor/lib/src/infrastructure/documents/document_scaffold.dart b/third_party/super_editor/lib/src/infrastructure/documents/document_scaffold.dart index 6e96d107..605f1573 100644 --- a/third_party/super_editor/lib/src/infrastructure/documents/document_scaffold.dart +++ b/third_party/super_editor/lib/src/infrastructure/documents/document_scaffold.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:super_editor/src/core/document_debug_paint.dart'; import 'package:super_editor/src/default_editor/document_scrollable.dart'; +// WONKY PATCH SE-15 +import 'package:super_editor/src/default_editor/layout_single_column/_cell_layout.dart'; import 'package:super_editor/src/default_editor/layout_single_column/_layout.dart'; import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; import 'package:super_editor/src/infrastructure/content_layers.dart'; @@ -29,6 +31,8 @@ class DocumentScaffold extends StatefulWidget { this.underlays = const [], this.overlays = const [], this.debugPaint = const DebugPaintConfig(), + // WONKY PATCH SE-15 + this.cellLayout, }); /// [LayerLink] that's is attached to the document layout. @@ -85,6 +89,9 @@ class DocumentScaffold extends StatefulWidget { /// Only used when the document is not inside a scrollable. final bool shrinkWrap; + /// WONKY PATCH SE-15: see [SingleColumnDocumentLayout.cellLayout]. + final DocumentCellLayout? cellLayout; + @override State createState() => _DocumentScaffoldState(); } @@ -140,6 +147,8 @@ class _DocumentScaffoldState extends State { componentBuilders: widget.componentBuilders, onBuildScheduled: onBuildScheduled, showDebugPaint: widget.debugPaint.layout, + // WONKY PATCH SE-15 + cellLayout: widget.cellLayout, ), underlays: widget.underlays, overlays: widget.overlays, diff --git a/third_party/super_editor/lib/src/super_reader/super_reader.dart b/third_party/super_editor/lib/src/super_reader/super_reader.dart index 15c37587..c4032d7e 100644 --- a/third_party/super_editor/lib/src/super_reader/super_reader.dart +++ b/third_party/super_editor/lib/src/super_reader/super_reader.dart @@ -16,6 +16,8 @@ import 'package:super_editor/src/default_editor/document_scrollable.dart'; import 'package:super_editor/src/default_editor/horizontal_rule.dart'; import 'package:super_editor/src/default_editor/image.dart'; import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; +// WONKY PATCH SE-15 +import 'package:super_editor/src/default_editor/layout_single_column/_cell_layout.dart'; import 'package:super_editor/src/default_editor/layout_single_column/_styler_per_component.dart'; import 'package:super_editor/src/default_editor/layout_single_column/_styler_shylesheet.dart'; import 'package:super_editor/src/default_editor/layout_single_column/_styler_user_selection.dart'; @@ -72,6 +74,8 @@ class SuperReader extends StatefulWidget { this.createOverlayControlsClipper, this.debugPaint = const DebugPaintConfig(), this.shrinkWrap = false, + // WONKY PATCH SE-15 + this.cellLayout, }) : stylesheet = stylesheet ?? readOnlyDefaultStylesheet, selectionStyles = selectionStyle ?? readOnlyDefaultSelectionStyle, keyboardActions = keyboardActions ?? superReaderDefaultKeyboardActions, @@ -161,6 +165,11 @@ class SuperReader extends StatefulWidget { /// paragraph component, image component, horizontal rule component, etc. final List componentBuilders; + /// WONKY PATCH SE-15: places components in cell groups that are laid out + /// side by side (table rows, grids), see [DocumentCellLayout] and + /// [SingleColumnDocumentLayout.cellLayout]. + final DocumentCellLayout? cellLayout; + /// All actions that this editor takes in response to key /// events, e.g., text entry, newlines, character deletion, /// copy, paste, etc. @@ -402,6 +411,8 @@ class SuperReaderState extends State { presenter: _docLayoutPresenter!, componentBuilders: widget.componentBuilders, shrinkWrap: widget.shrinkWrap, + // WONKY PATCH SE-15 + cellLayout: widget.cellLayout, underlays: [ // Add any underlays that were provided by the client. for (final underlayBuilder in widget.documentUnderlayBuilders) // diff --git a/third_party/super_editor/test/wonky_patches/se15_cell_groups_test.dart b/third_party/super_editor/test/wonky_patches/se15_cell_groups_test.dart new file mode 100644 index 00000000..95d8ee36 --- /dev/null +++ b/third_party/super_editor/test/wonky_patches/se15_cell_groups_test.dart @@ -0,0 +1,502 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A [DocumentCellLayout] with placements by node id that records what it +/// wraps and paints. +class _TestCellLayout extends ChangeNotifier implements DocumentCellLayout { + _TestCellLayout([Map? placements]) : _placements = placements ?? {}; + + Map _placements; + set placements(Map value) { + _placements = value; + notifyListeners(); + } + + final wrapped = []; + final painted = []; + + @override + DocumentCellPlacement? placementOf(String nodeId) => _placements[nodeId]; + + @override + Widget wrapGroup(BuildContext context, DocumentCellGroup group, Widget child) { + wrapped.add(group); + return child; + } + + @override + void paintGroup(Canvas canvas, Offset offset, DocumentCellGroupGeometry geometry) { + painted.add(geometry); + } +} + +ParagraphNode _paragraph(String id, String text) => ParagraphNode(id: id, text: AttributedText(text)); + +/// No document padding and no component padding, so that slots and +/// components line up exactly. +final _stylesheet = defaultStylesheet.copyWith( + documentPadding: EdgeInsets.zero, + addRulesAfter: [ + StyleRule(BlockSelector.all, (doc, docNode) => {Styles.padding: const CascadingPadding.all(0.0)}), + ], +); + +final _readerStylesheet = readOnlyDefaultStylesheet.copyWith( + documentPadding: EdgeInsets.zero, + addRulesAfter: [ + StyleRule(BlockSelector.all, (doc, docNode) => {Styles.padding: const CascadingPadding.all(0.0)}), + ], +); + +const _long = 'This text is long enough to wrap onto several lines in a narrow column'; + +class _Harness { + _Harness(this.editor, this.composer, this.layoutKey); + + final Editor editor; + final MutableDocumentComposer composer; + final GlobalKey layoutKey; + + DocumentLayout get layout => layoutKey.currentState as DocumentLayout; + + RenderDocumentCellGroup group([int index = 0]) => + find.byType(DocumentCellGroupView).evaluate().elementAt(index).renderObject! as RenderDocumentCellGroup; + + DocumentCellGroupGeometry geometry([int index = 0]) => group(index).geometry!; + + /// The bounds of [nodeId]'s component in document coordinates. + Rect componentRect(String nodeId) { + final component = layout.getComponentByNodeId(nodeId)!; + final box = component.context.findRenderObject()! as RenderBox; + return (box.localToGlobal(Offset.zero) - layout.getGlobalOffsetFromDocumentOffset(Offset.zero)) & box.size; + } + + /// [nodeId]'s slot in document coordinates. + Rect slotRect(String nodeId, [int groupIndex = 0]) { + final groupBox = group(groupIndex); + final groupOrigin = groupBox.localToGlobal(Offset.zero) - layout.getGlobalOffsetFromDocumentOffset(Offset.zero); + return groupBox.slotRectOf(nodeId)!.shift(groupOrigin); + } +} + +Future<_Harness> _pumpEditor( + WidgetTester tester, { + required List nodes, + required DocumentCellLayout? cellLayout, + double width = 600, +}) async { + final document = MutableDocument(nodes: nodes); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + final layoutKey = GlobalKey(); + await _pumpEditorWidget(tester, editor, layoutKey, cellLayout, width); + return _Harness(editor, composer, layoutKey); +} + +Future _pumpEditorWidget( + WidgetTester tester, + Editor editor, + GlobalKey layoutKey, + DocumentCellLayout? cellLayout, + double width, +) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + height: 800, + child: SuperEditor( + editor: editor, + documentLayoutKey: layoutKey, + stylesheet: _stylesheet, + cellLayout: cellLayout, + ), + ), + ), + ), + ), + ); +} + +void main() { + group('Cell groups (SE-15) >', () { + group('rows >', () { + testWidgetsOnAllPlatforms('lays out cells in columns and rows, ragged rows leave space', (tester) async { + final cellLayout = _TestCellLayout({ + 'a': const DocumentCellPlacement.rows(group: 't', row: 0, column: 0, gap: 10), + 'b': const DocumentCellPlacement.rows(group: 't', row: 0, column: 1, gap: 10), + 'c': const DocumentCellPlacement.rows(group: 't', row: 0, column: 2, gap: 10), + 'd': const DocumentCellPlacement.rows(group: 't', row: 1, column: 0, gap: 10), + }); + final harness = await _pumpEditor( + tester, + nodes: [ + _paragraph('above', 'Above'), + _paragraph('a', 'A'), + _paragraph('b', _long), + _paragraph('c', 'C'), + _paragraph('d', 'D'), + _paragraph('below', 'Below'), + ], + cellLayout: cellLayout, + ); + + expect(find.byType(DocumentCellGroupView), findsOneWidget); + expect(cellLayout.wrapped.last.id, 't'); + expect(cellLayout.wrapped.last.kind, DocumentCellGroupKind.rows); + expect(cellLayout.wrapped.last.nodeIds, ['a', 'b', 'c', 'd']); + + final geometry = harness.geometry(); + expect(cellLayout.painted, isNotEmpty); + expect(geometry.columnCount, 3); + expect(geometry.rowCount, 2); + const columnWidth = (600 - 2 * 10) / 3; + expect(geometry.columnEdges, [0, columnWidth + 10, 2 * columnWidth + 20, 3 * columnWidth + 20]); + + // Every component is as wide as its column; the row is as tall as its + // tallest cell (the long one). + final heightA = harness.componentRect('a').height; + final heightB = harness.componentRect('b').height; + expect(heightB, greaterThan(heightA * 2)); + for (final id in ['a', 'b', 'c', 'd']) { + expect(harness.componentRect(id).width, moreOrLessEquals(columnWidth)); + } + expect(geometry.rowEdges, [0, heightB + 10, heightB + 10 + harness.componentRect('d').height]); + expect(geometry.rowBottoms, [heightB, geometry.rowEdges.last]); + expect(geometry.slotRects['a'], Rect.fromLTWH(0, 0, columnWidth, heightB)); + expect(geometry.slotRects['c'], Rect.fromLTWH(2 * columnWidth + 20, 0, columnWidth, heightB)); + expect(geometry.slotRects['d'], Rect.fromLTWH(0, heightB + 10, columnWidth, harness.componentRect('d').height)); + expect(geometry.slotRects.length, 4); + expect(geometry.size, Size(600, geometry.rowEdges.last)); + + // Components sit at their slots' top-left; the paragraphs around the + // group stay in the column. + expect(harness.componentRect('b').topLeft, harness.slotRect('b').topLeft); + expect(harness.componentRect('d').topLeft, harness.slotRect('d').topLeft); + expect(harness.componentRect('above').width, 600); + expect(harness.componentRect('a').top, harness.componentRect('above').bottom); + expect(harness.componentRect('below').top, moreOrLessEquals(harness.slotRect('d').bottom)); + }); + + testWidgetsOnAllPlatforms('keeps columns at their minimum width without throwing', (tester) async { + final cellLayout = _TestCellLayout({ + for (int c = 0; c < 3; c++) + 'c$c': DocumentCellPlacement.rows(group: 't', row: 0, column: c, minColumnWidth: 300), + }); + final harness = await _pumpEditor( + tester, + nodes: [for (int c = 0; c < 3; c++) _paragraph('c$c', 'Cell $c')], + cellLayout: cellLayout, + ); + + expect(tester.takeException(), isNull); + expect(harness.geometry().columnEdges, [0, 300, 600, 900]); + expect(harness.componentRect('c2').width, 300); + expect(harness.group().size.width, 600); + }); + + testWidgetsOnAllPlatforms('adds spaceBefore above a row, outside the row', (tester) async { + final cellLayout = _TestCellLayout({ + 'a': const DocumentCellPlacement.rows(group: 't', row: 0, column: 0), + 'b': const DocumentCellPlacement.rows(group: 't', row: 0, column: 1), + 'c': const DocumentCellPlacement.rows(group: 't', row: 1, column: 0, spaceBefore: 20), + 'd': const DocumentCellPlacement.rows(group: 't', row: 1, column: 1, spaceBefore: 50), + }); + final harness = await _pumpEditor( + tester, + nodes: [ + for (final id in ['a', 'b', 'c', 'd']) _paragraph(id, id.toUpperCase()) + ], + cellLayout: cellLayout, + ); + + final geometry = harness.geometry(); + final rowHeight = harness.componentRect('a').height; + expect(geometry.rowBottoms.first, rowHeight); + expect(geometry.rowEdges[1], rowHeight + 50); + expect(geometry.slotRects['c']!.top, rowHeight + 50); + expect(geometry.slotRects['c']!.height, rowHeight); + expect(harness.componentRect('d').top, rowHeight + 50); + expect(geometry.size.height, 2 * rowHeight + 50); + }); + }); + + group('flow >', () { + Map flowPlacements({double spaceBeforeOfFourth = 0}) => { + for (int i = 0; i < 5; i++) + 'i$i': DocumentCellPlacement.flow( + group: 'g', + index: i, + maxColumns: 3, + spaceBefore: i == 3 ? spaceBeforeOfFourth : 0, + ), + }; + + List flowNodes() => [for (int i = 0; i < 5; i++) _paragraph('i$i', 'Item $i')]; + + testWidgetsOnAllPlatforms('fills rows in index order and drops columns when narrow', (tester) async { + final cellLayout = _TestCellLayout(flowPlacements()); + // Document order differs from the flow order for the first two items. + cellLayout.placements = { + ...flowPlacements(), + 'i0': const DocumentCellPlacement.flow(group: 'g', index: 1, maxColumns: 3), + 'i1': const DocumentCellPlacement.flow(group: 'g', index: 0, maxColumns: 3), + }; + final harness = await _pumpEditor(tester, nodes: flowNodes(), cellLayout: cellLayout); + + var geometry = harness.geometry(); + expect(geometry.columnCount, 3); + expect(geometry.rowCount, 2); + expect(geometry.columnEdges, [0, 200, 400, 600]); + expect(geometry.slotRects['i1']!.topLeft, Offset.zero); + expect(geometry.slotRects['i0']!.topLeft, const Offset(200, 0)); + expect(geometry.slotRects['i3']!.left, 0); + expect(geometry.slotRects['i3']!.top, geometry.rowEdges[1]); + + // 400 px: three columns would be 133 px (< 160), so two. + await _pumpEditorWidget(tester, harness.editor, harness.layoutKey, cellLayout, 400); + geometry = harness.geometry(); + expect(geometry.columnCount, 2); + expect(geometry.rowCount, 3); + expect(geometry.columnEdges, [0, 200, 400]); + + // 150 px: one column. + await _pumpEditorWidget(tester, harness.editor, harness.layoutKey, cellLayout, 150); + geometry = harness.geometry(); + expect(geometry.columnCount, 1); + expect(geometry.rowCount, 5); + expect(harness.componentRect('i4').width, 150); + expect(tester.takeException(), isNull); + }); + + testWidgetsOnAllPlatforms('adds the largest spaceBefore of a flow row above it', (tester) async { + final cellLayout = _TestCellLayout(flowPlacements(spaceBeforeOfFourth: 30)); + final harness = await _pumpEditor(tester, nodes: flowNodes(), cellLayout: cellLayout); + + final geometry = harness.geometry(); + final rowHeight = harness.componentRect('i0').height; + expect(geometry.rowBottoms.first, rowHeight); + expect(geometry.rowEdges[1], rowHeight + 30); + expect(geometry.slotRects['i4']!.top, rowHeight + 30); + }); + }); + + group('geometry queries >', () { + Future<_Harness> pumpTwoCells(WidgetTester tester) { + return _pumpEditor( + tester, + nodes: [ + _paragraph('above', 'Above'), + _paragraph('a', 'A'), + _paragraph('b', _long), + _paragraph('c', 'C'), + _paragraph('d', 'D'), + _paragraph('below', 'Below'), + ], + cellLayout: _TestCellLayout({ + 'a': const DocumentCellPlacement.rows(group: 't', row: 0, column: 0, gap: 40), + 'b': const DocumentCellPlacement.rows(group: 't', row: 0, column: 1, gap: 40), + 'c': const DocumentCellPlacement.rows(group: 't', row: 1, column: 0, gap: 40), + 'd': const DocumentCellPlacement.rows(group: 't', row: 1, column: 1, gap: 40), + }), + ); + } + + testWidgetsOnAllPlatforms('positions and rects resolve inside cells', (tester) async { + final harness = await pumpTwoCells(tester); + final layout = harness.layout; + + final rectB = layout.getRectForPosition(const DocumentPosition( + nodeId: 'b', + nodePosition: TextNodePosition(offset: 0), + ))!; + expect(harness.slotRect('b').contains(rectB.center), isTrue); + + final atB = layout.getDocumentPositionAtOffset(rectB.center)!; + expect(atB.nodeId, 'b'); + + final rectD = layout.getRectForPosition(const DocumentPosition( + nodeId: 'd', + nodePosition: TextNodePosition(offset: 1), + ))!; + expect(layout.getDocumentPositionNearestToOffset(rectD.center)!.nodeId, 'd'); + }); + + testWidgetsOnAllPlatforms('a point below a short cell\'s text resolves to that cell', (tester) async { + final harness = await pumpTwoCells(tester); + final layout = harness.layout; + + final textA = harness.componentRect('a'); + final slotA = harness.slotRect('a'); + expect(slotA.bottom, greaterThan(textA.bottom + 20)); + final belowText = Offset(slotA.left + 60, slotA.bottom - 5); + + final nearest = layout.getDocumentPositionNearestToOffset(belowText)!; + expect(nearest.nodeId, 'a'); + expect((nearest.nodePosition as TextNodePosition).offset, 1); + expect(layout.getDocumentPositionAtOffset(belowText)!.nodeId, 'a'); + }); + + testWidgetsOnAllPlatforms('a point between two cells resolves to the nearer one', (tester) async { + final harness = await pumpTwoCells(tester); + final layout = harness.layout; + + final slotA = harness.slotRect('a'); + final slotB = harness.slotRect('b'); + expect(slotB.left - slotA.right, moreOrLessEquals(40)); + final y = slotA.top + 5; + + expect(layout.getDocumentPositionNearestToOffset(Offset(slotA.right + 10, y))!.nodeId, 'a'); + expect(layout.getDocumentPositionNearestToOffset(Offset(slotB.left - 10, y))!.nodeId, 'b'); + + // Between the rows, under the second column. + final slotD = harness.slotRect('d'); + expect(layout.getDocumentPositionNearestToOffset(Offset(slotD.left + 5, slotD.top - 2))!.nodeId, 'd'); + }); + + testWidgetsOnAllPlatforms('a region from a later cell of a row selects from that cell', (tester) async { + final harness = await pumpTwoCells(tester); + final layout = harness.layout; + + final inB = harness.componentRect('b').topLeft + const Offset(4, 4); + final inC = harness.componentRect('c').topLeft + const Offset(4, 4); + final inD = harness.componentRect('d').topLeft + const Offset(4, 4); + final inAbove = harness.componentRect('above').topLeft + const Offset(4, 4); + final inBelow = harness.componentRect('below').topLeft + const Offset(4, 4); + + // Down from the second cell of the first row to the first cell of the + // second row. + var selection = layout.getDocumentSelectionInRegion(inB, inC)!; + expect(selection.base.nodeId, 'b'); + expect(selection.extent.nodeId, 'c'); + + // Up. + selection = layout.getDocumentSelectionInRegion(inD, inB)!; + expect(selection.base.nodeId, 'd'); + expect(selection.extent.nodeId, 'b'); + + // Within a row, right to left. + selection = layout.getDocumentSelectionInRegion(inD, inC)!; + expect(selection.base.nodeId, 'd'); + expect(selection.extent.nodeId, 'c'); + + // From outside the group into it, and out of it. + selection = layout.getDocumentSelectionInRegion(inAbove, inB)!; + expect(selection.base.nodeId, 'above'); + expect(selection.extent.nodeId, 'b'); + selection = layout.getDocumentSelectionInRegion(inB, inBelow)!; + expect(selection.base.nodeId, 'b'); + expect(selection.extent.nodeId, 'below'); + selection = layout.getDocumentSelectionInRegion(inAbove, inBelow)!; + expect(selection.base.nodeId, 'above'); + expect(selection.extent.nodeId, 'below'); + }); + + testWidgetsOnMac('a mouse drag across the cells of a row selects them', (tester) async { + final harness = await pumpTwoCells(tester); + final layout = harness.layout; + + final start = layout.getGlobalOffsetFromDocumentOffset(harness.componentRect('b').topLeft + const Offset(4, 4)); + final end = layout.getGlobalOffsetFromDocumentOffset(harness.componentRect('d').topLeft + const Offset(4, 4)); + final gesture = await tester.startGesture(start, kind: PointerDeviceKind.mouse); + await tester.pump(); + for (int i = 1; i <= 5; i++) { + await gesture.moveTo(Offset.lerp(start, end, i / 5)!); + await tester.pump(); + } + await gesture.up(); + await tester.pump(kDoubleTapTimeout); + + final selection = harness.composer.selection!; + expect(selection.base.nodeId, 'b'); + expect(selection.extent.nodeId, 'd'); + }); + }); + + testWidgetsOnAllPlatforms('regroups when the cell layout notifies', (tester) async { + final cellLayout = _TestCellLayout(); + final harness = await _pumpEditor( + tester, + nodes: [_paragraph('a', 'A'), _paragraph('b', 'B'), _paragraph('p', 'P'), _paragraph('c', 'C')], + cellLayout: cellLayout, + ); + expect(find.byType(DocumentCellGroupView), findsNothing); + expect(harness.componentRect('b').top, harness.componentRect('a').bottom); + + cellLayout.placements = { + 'a': const DocumentCellPlacement.rows(group: 't', row: 0, column: 0), + 'b': const DocumentCellPlacement.rows(group: 't', row: 0, column: 1), + }; + await tester.pump(); + expect(find.byType(DocumentCellGroupView), findsOneWidget); + expect(harness.componentRect('b').top, harness.componentRect('a').top); + expect(harness.componentRect('b').left, 300); + // Components keep working after moving into the group. + expect(harness.layout.getDocumentPositionAtOffset(harness.componentRect('b').center)!.nodeId, 'b'); + + // A placement change moves the cell. + cellLayout.placements = { + 'a': const DocumentCellPlacement.rows(group: 't', row: 0, column: 0), + 'b': const DocumentCellPlacement.rows(group: 't', row: 1, column: 0), + }; + await tester.pump(); + expect(harness.componentRect('b').left, 0); + expect(harness.componentRect('b').top, harness.componentRect('a').bottom); + + // Runs with the same id that aren't consecutive are separate groups. + cellLayout.placements = { + 'a': const DocumentCellPlacement.rows(group: 't', row: 0, column: 0), + 'b': const DocumentCellPlacement.rows(group: 't', row: 0, column: 1), + 'c': const DocumentCellPlacement.rows(group: 't', row: 0, column: 0), + }; + await tester.pump(); + expect(find.byType(DocumentCellGroupView), findsNWidgets(2)); + expect(harness.group(1).group.nodeIds, ['c']); + expect(tester.takeException(), isNull); + }); + + testWidgetsOnAllPlatforms('SuperReader lays out cell groups', (tester) async { + final document = MutableDocument(nodes: [_paragraph('a', 'A'), _paragraph('b', 'B'), _paragraph('p', 'P')]); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + final layoutKey = GlobalKey(); + final cellLayout = _TestCellLayout({ + 'a': const DocumentCellPlacement.flow(group: 'g', index: 0, maxColumns: 2), + 'b': const DocumentCellPlacement.flow(group: 'g', index: 1, maxColumns: 2), + }); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 600, + height: 800, + child: SuperReader( + editor: editor, + documentLayoutKey: layoutKey, + stylesheet: _readerStylesheet, + cellLayout: cellLayout, + ), + ), + ), + ), + ), + ); + + final harness = _Harness(editor, composer, layoutKey); + expect(find.byType(DocumentCellGroupView), findsOneWidget); + expect(cellLayout.painted, isNotEmpty); + expect(harness.componentRect('b').top, harness.componentRect('a').top); + expect(harness.componentRect('b').left, 300); + expect(harness.componentRect('p').top, harness.componentRect('a').bottom); + expect(harness.layout.getDocumentPositionNearestToOffset(harness.componentRect('b').center)!.nodeId, 'b'); + }); + }); +} From d040f4789e2bac68816caa1303235d690d12b0b8 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Tue, 27 Oct 2026 23:58:18 +0200 Subject: [PATCH 05/16] feat: upgrade legacy table embeds on the server; cell keys in Write CellUpgrader commits the upgrade of legacy table and grid embeds as a system op on load and after commits that still hold one. Write handles Enter, Shift+Enter, Backspace, Delete, Tab, arrows and deleting selections in cells without merging or splitting them. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/write/ot/cell_upgrader.dart | 124 ++++ .../lib/src/write/ot/document_cache.dart | 35 +- wonky_server/lib/src/write/ot/ot_service.dart | 58 +- .../integration/write/cell_upgrader_test.dart | 346 ++++++++++ .../write/mcp/write_tools_test.dart | 117 ++++ .../controller/write_editor_controller.dart | 2 + .../features/editor/keyboard/cell_keys.dart | 146 ++++ .../editor/keyboard/editor_keyboard.dart | 4 + .../requests/cell_editing_handlers.dart | 624 ++++++++++++++++++ 9 files changed, 1439 insertions(+), 17 deletions(-) create mode 100644 wonky_server/lib/src/write/ot/cell_upgrader.dart create mode 100644 wonky_server/test/integration/write/cell_upgrader_test.dart create mode 100644 wonky_write/lib/features/editor/keyboard/cell_keys.dart create mode 100644 wonky_write/lib/features/editor/requests/cell_editing_handlers.dart diff --git a/wonky_server/lib/src/write/ot/cell_upgrader.dart b/wonky_server/lib/src/write/ot/cell_upgrader.dart new file mode 100644 index 00000000..5f6494cc --- /dev/null +++ b/wonky_server/lib/src/write/ot/cell_upgrader.dart @@ -0,0 +1,124 @@ +import 'dart:convert'; + +import 'package:serverpod/serverpod.dart'; +import 'package:wonky_core/wonky_core.dart' show Role; +import 'package:wonky_write_core/wonky_write_core.dart'; + +import '../../platform/common/actor.dart'; +import '../../platform/common/models.dart'; +import 'document_cache.dart'; +import 'ot_service.dart'; + +/// Upgrades the legacy `table` and `grid` embeds of a Write document to +/// cell and grid item lines (Write 12 §1.5). +/// +/// The upgrade is committed as a system op (`actorKind: system`, +/// `kind: system`, no author) through [OtService.submit], so it bumps the +/// revision, is broadcast to connected clients as an ordinary remote op and +/// shows in the history as the system's. It runs when a document's state +/// is loaded ([DocumentCache.of]) and after every committed op whose result +/// still holds a legacy embed (an old client, a restored version). +/// +/// The change is computed from the head under the document lock and must +/// commit at exactly that head (`atHead`): a concurrent op (or an upgrade on +/// another instance) makes it retry from the new head, so two upgrades of +/// the same embed never both commit. Once the head holds no legacy embed, +/// nothing is committed: loading again or committing again is a no-op. +abstract final class CellUpgrader { + /// What the upgrade is called in logs (Write 12 §1.5). + static const label = 'Upgrade tables'; + + /// Attempts before giving up when other ops keep landing in between. + static const maxAttempts = 5; + + /// Queues an upgrade of [document] after the earlier ones of [state]; + /// completes when it ran (committed or found nothing to do). Never + /// throws: failures are logged. + static Future schedule( + Session session, + Document document, + DocumentState state, { + DriveItem? item, + }) => state.runUpgrade(() => _run(session, document, state, item)); + + static Future _run( + Session session, + Document document, + DocumentState state, + DriveItem? item, + ) async { + if (state.upgradeBlocked) return; + try { + for (var attempt = 0; attempt < maxAttempts; attempt++) { + final fresh = await Document.db.findById(session, document.id!); + if (fresh == null) return; + final (change, base, legacy) = await state.lock(() async { + await state.catchUp(session, fresh.headRevision); + final content = state.content; + return ( + CellUpgrade.upgradeChange(content), + state.revision, + CellUpgrade.hasLegacy(content), + ); + }); + if (change == null) { + // Embeds that cannot be parsed stay; don't look again. + if (legacy) state.upgradeBlocked = true; + return; + } + item ??= await DriveItem.db.findById(session, document.itemId); + if (item == null) return; + final owner = await UserProfile.db.findById(session, item.ownerId); + if (owner == null) return; + try { + final committed = await OtService.submit( + session, + // The owner stands in for the system where a user is needed + // (overlay lifecycle); the op itself has no author. + actor: Actor( + userId: owner.id!, + authUserId: owner.authUserId, + kind: ActorKind.system, + ), + document: document, + item: item, + role: Role.owner, + clientOpId: const Uuid().v7obj(), + baseRevision: base, + deltaJson: jsonEncode(change.toJson()), + kind: DocumentOpKind.system, + atHead: true, + enforceOpLimits: false, + // Not settle(): it waits for this upgrade. + awaitSideEffects: false, + ); + await committed.sideEffects; + session.log( + '$label: ${document.uuid} r${committed.revision}', + level: LogLevel.info, + ); + return; + } on OpRejection catch (r) { + if (r.code == DocRejectCode.rebaseTooOld) continue; + state.upgradeBlocked = true; + session.log( + '$label of ${document.uuid} was rejected: ${r.message}', + level: LogLevel.warning, + ); + return; + } + } + session.log( + '$label of ${document.uuid}: the document kept changing, ' + 'gave up after $maxAttempts attempts.', + level: LogLevel.warning, + ); + } on Object catch (e, st) { + session.log( + '$label of ${document.uuid} failed: $e', + level: LogLevel.error, + stackTrace: st, + ); + } + } +} diff --git a/wonky_server/lib/src/write/ot/document_cache.dart b/wonky_server/lib/src/write/ot/document_cache.dart index d7f35e36..baf4843e 100644 --- a/wonky_server/lib/src/write/ot/document_cache.dart +++ b/wonky_server/lib/src/write/ot/document_cache.dart @@ -4,6 +4,7 @@ import 'package:serverpod/serverpod.dart'; import 'package:wonky_write_core/wonky_write_core.dart'; import '../../platform/common/models.dart'; +import 'cell_upgrader.dart'; import 'document_store.dart'; import 'edit_bookkeeping.dart'; @@ -51,6 +52,24 @@ class DocumentState { static const maxRecent = 1000; + /// Upgrades of legacy table and grid embeds ([CellUpgrader]), run one + /// after the other outside [lock] and outside the side effects (an + /// upgrade commits an op, which queues side effects of its own). + Future _upgrades = Future.value(); + + /// The legacy embeds of this document cannot be upgraded (the upgrade was + /// rejected): no further attempts while the state is cached. + bool upgradeBlocked = false; + + /// Queues [work] after the earlier upgrades of this document. The + /// returned future completes when [work] is done; its errors are passed + /// on to it but never break the queue. + Future runUpgrade(Future Function() work) { + final done = _upgrades.then((_) => work()); + _upgrades = done.catchError((Object _) {}); + return done; + } + /// Queues [work] after the side effects of earlier ops of this document. /// The returned future completes when [work] is done; its errors are /// passed on to it but never break the queue. @@ -60,11 +79,13 @@ class DocumentState { return done; } - /// Writes the pending edit bookkeeping now and completes when every side - /// effect queued so far has run. - Future settle() { + /// Writes the pending edit bookkeeping now and completes when every + /// upgrade and side effect queued so far has run (the side effects + /// queued by those upgrades included). + Future settle() async { unawaited(edits.flush()); - return _effects; + await _upgrades; + await _effects; } /// Runs [action] exclusively for this document on this instance. @@ -207,6 +228,12 @@ abstract final class DocumentCache { } _states[key] = state; _touch(state); + // Legacy table and grid embeds are upgraded before the state is + // handed out (Write 12 §1.5); the upgrade's own submit finds the + // state registered above. Never throws. + if (CellUpgrade.hasLegacy(content)) { + await CellUpgrader.schedule(session, doc, state); + } return state; }(); _loading[key] = future; diff --git a/wonky_server/lib/src/write/ot/ot_service.dart b/wonky_server/lib/src/write/ot/ot_service.dart index afbf8ad2..030c752c 100644 --- a/wonky_server/lib/src/write/ot/ot_service.dart +++ b/wonky_server/lib/src/write/ot/ot_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:serverpod/serverpod.dart'; @@ -9,6 +10,7 @@ import '../../platform/common/models.dart'; import '../../platform/common/time.dart'; import '../../platform/config/wonky_config.dart'; import '../collab/overlay_lifecycle.dart'; +import 'cell_upgrader.dart'; import 'document_bus.dart'; import 'document_cache.dart'; import 'document_store.dart'; @@ -71,7 +73,15 @@ abstract final class OtService { /// the bookkeeping was written; /// editing connections pass false and acknowledge right after the /// commit. [enforceOpLimits] applies [maxOpBytes] and [maxOpComponents] - /// (off for server-computed changes such as restores). + /// (off for server-computed changes such as restores). With [atHead] the + /// op must be based on the head: it is rejected with `rebaseTooOld` + /// instead of being transformed (server changes computed from the head + /// that must not be rebased, such as the table upgrade). + /// + /// An op of a system actor (`ActorKind.system`) is stored and broadcast + /// without an author and adds no edit bookkeeping. After an op whose + /// result still holds a legacy table or grid embed, the upgrade is queued + /// ([CellUpgrader], Write 12 §1.5). static Future submit( Session session, { required Actor actor, @@ -87,7 +97,9 @@ abstract final class OtService { String? onBehalfOfSessionId, bool awaitSideEffects = true, bool enforceOpLimits = true, + bool atHead = false, }) async { + final system = actor.kind == ActorKind.system; final duplicate = await _duplicate(session, document, clientOpId); if (duplicate != null) return duplicate; if (role < Role.commenter) { @@ -128,6 +140,7 @@ abstract final class OtService { } final state = await DocumentCache.of(session, document); + var legacy = false; final committed = await state.lock(() async { final now = nowUtc(); _Commit? commit; @@ -157,6 +170,12 @@ abstract final class OtService { transaction: tx, ); if (resent != null) return; + if (atHead && baseRevision != locked.headRevision) { + throw OpRejection( + DocRejectCode.rebaseTooOld, + 'The document changed since revision $baseRevision.', + ); + } await state.catchUp(session, locked.headRevision, transaction: tx); commit = await _apply( session, @@ -176,7 +195,7 @@ abstract final class OtService { documentId: document.id!, revision: commit!.revision, clientOpId: clientOpId, - authorId: actor.userId, + authorId: system ? null : actor.userId, actorKind: actor.kind, sessionId: sessionId, kind: kind, @@ -229,8 +248,8 @@ abstract final class OtService { op: DocRemoteOp( revision: c.revision, deltaJson: c.opJson, - authorId: actor.userId, - authorUuid: actor.authUserId, + authorId: system ? null : actor.userId, + authorUuid: system ? null : actor.authUserId, authorSessionId: sessionId, actorKind: actor.kind, kind: kind, @@ -248,15 +267,22 @@ abstract final class OtService { ); } // Bookkeeping (activity, versions, drive) is only counted here and - // written in batches; the per-op work is queued. - state.edits.add( - session, - item, - actor, - op: c.op, - revision: c.revision, - after: c.after, - ); + // written in batches; the per-op work is queued. System ops (the + // table upgrade) are nobody's edit. + if (!system) { + state.edits.add( + session, + item, + actor, + op: c.op, + revision: c.revision, + after: c.after, + ); + } + legacy = + kind != DocumentOpKind.system && + !state.upgradeBlocked && + CellUpgrade.hasLegacy(c.after); final snapshot = c.revision % WonkyConfig.current.snapshotEveryNOps == 0; final effects = snapshot || transitions.isNotEmpty ? state.runSideEffect( @@ -276,6 +302,12 @@ abstract final class OtService { : null; return CommittedOp(c.revision, c.op, sideEffects: effects); }); + // Old clients and restores can still write legacy embeds (Write 12 + // §1.5): upgrade them right after this op. [DocumentState.settle] + // waits for the upgrade. + if (legacy) { + unawaited(CellUpgrader.schedule(session, document, state, item: item)); + } if (awaitSideEffects) { await committed.sideEffects; await state.settle(); diff --git a/wonky_server/test/integration/write/cell_upgrader_test.dart b/wonky_server/test/integration/write/cell_upgrader_test.dart new file mode 100644 index 00000000..540cc5e7 --- /dev/null +++ b/wonky_server/test/integration/write/cell_upgrader_test.dart @@ -0,0 +1,346 @@ +@Tags(['integration']) +library; + +import 'dart:convert'; + +import 'package:clock/clock.dart'; +import 'package:serverpod/serverpod.dart'; +import 'package:test/test.dart'; +import 'package:wonky_core/wonky_core.dart' show Role; +import 'package:wonky_server/src/platform/common/actor.dart'; +import 'package:wonky_server/src/platform/common/models.dart' hide CellTable; +import 'package:wonky_server/src/write/ot/document_cache.dart'; +import 'package:wonky_server/src/write/ot/document_store.dart'; +import 'package:wonky_server/src/write/ot/ot_service.dart'; +import 'package:wonky_write_core/wonky_write_core.dart'; + +import '../../support/mcp_harness.dart'; +import '../../support/test_doc_client.dart'; +import '../../support/test_drive.dart'; +import '../../support/test_users.dart'; +import '../../support/wonky_serverpod.dart'; + +/// A table and a grid embed line inserted at the start, as documents +/// written before Write 12 hold them. +Delta _legacyInsert() => Delta() + ..insert( + const TableEmbed( + id: 't1', + align: [null, CellAlign.right], + rows: [ + ['Name', 'Qty'], + ['**Pens**', '3'], + ], + ).toInsert(), + ) + ..insert('\n') + ..insert(const GridEmbed(id: 'g1', items: ['Fast', 'Reliable']).toInsert()) + ..insert('\n'); + +/// Appends [change] to the op log of [item] like an old server did and +/// drops the cached state, so the next use loads the document. Returns +/// the new head revision. +Future _storeLegacy( + Session session, + DriveItemView item, + TestUser author, + Delta change, +) async { + final doc = (await DocumentStore.byUuid(session, item.contentRef!))!; + final before = await DocumentStore.head(session, doc); + final head = doc.headRevision; + await DocumentOp.db.insertRow( + session, + DocumentOp( + documentId: doc.id!, + revision: head + 1, + clientOpId: const Uuid().v7obj(), + authorId: author.id, + actorKind: ActorKind.user, + kind: DocumentOpKind.content, + baseRevision: head, + deltaJson: jsonEncode(change.toJson()), + inverseJson: jsonEncode(WonkyOt.invert(change, before).toJson()), + createdAt: clock.now().toUtc(), + ), + ); + await Document.db.updateWhere( + session, + columnValues: (t) => [t.headRevision(head + 1)], + where: (t) => t.id.equals(doc.id!), + ); + DocumentCache.evict(item.contentRef!); + return head + 1; +} + +Future _stored(Session session, DriveItemView item) async { + final doc = (await DocumentStore.byUuid(session, item.contentRef!))!; + return DocumentStore.head(session, doc); +} + +Future> _ops(Session session, DriveItemView item) async { + final doc = (await DocumentStore.byUuid(session, item.contentRef!))!; + return DocumentOp.db.find( + session, + where: (t) => t.documentId.equals(doc.id!), + orderBy: (t) => t.revision, + ); +} + +/// The tables and grids of [content] in document order, as +/// `table ` / `grid `. +List _runs(Delta content) => [ + for (final r in CellRuns.of(WonkyDocument(content))) + switch (r) { + CellTable(:final id) => 'table $id', + CellGrid(:final id) => 'grid $id', + _ => '?', + }, +]; + +void main() { + withWonkyServerpod('Given a document stored with legacy table and grid ' + 'embeds', (sessionBuilder, endpoints) { + late TestUser anna; + late DriveItemView item; + late int legacyHead; + late Delta legacy; + setUp(() async { + anna = await TestUsers.create(sessionBuilder, name: 'Anna'); + item = await TestDrive.document(endpoints, anna, markdown: 'Intro'); + final session = sessionBuilder.build(); + legacyHead = await _storeLegacy(session, item, anna, _legacyInsert()); + legacy = await _stored(session, item); + }); + + test( + 'when the document is loaded then one system op upgrades the embeds ' + 'to cell lines', + () async { + final client = await TestDocClient.connect( + endpoints, + anna, + item.contentRef!, + ); + await client.close(); + final session = sessionBuilder.build(); + final ops = await _ops(session, item); + final stored = await _stored(session, item); + + expect(client.init.revision, legacyHead + 1); + expect(CellUpgrade.hasLegacy(client.document), isFalse); + expect(_runs(client.document), ['table t1', 'grid g1']); + expect(DeltaUtils.equals(stored, client.document), isTrue); + expect( + ops.skip(legacyHead).map((o) => (o.actorKind, o.kind, o.authorId)), + [(ActorKind.system, DocumentOpKind.system, null)], + ); + }, + ); + + test( + 'when the document is upgraded then its Markdown export is unchanged', + () async { + await TestDocClient.connect( + endpoints, + anna, + item.contentRef!, + ).then((c) => c.close()); + final stored = await _stored(sessionBuilder.build(), item); + + expect( + const MarkdownCodec().exportMarkdown(stored), + const MarkdownCodec().exportMarkdown(legacy), + ); + }, + ); + + test( + 'when the document is loaded a second time then nothing more is ' + 'committed', + () async { + await TestDocClient.connect( + endpoints, + anna, + item.contentRef!, + ).then((c) => c.close()); + DocumentCache.evict(item.contentRef!); + + final again = await TestDocClient.connect( + endpoints, + anna, + item.contentRef!, + ); + await again.close(); + final ops = await _ops(sessionBuilder.build(), item); + + expect(again.init.revision, legacyHead + 1); + expect(ops.length, legacyHead + 1); + }, + ); + + test( + 'when an MCP tool reads the document first then it edits the upgraded ' + 'table by the embed id', + () async { + final mcp = await McpHarness.forUser(anna); + + final r = await mcp.call('write.update_table', { + 'document_uuid': docOf(item), + 'table': {'table_id': 't1'}, + 'operations': [ + {'op': 'set_cell', 'row': 1, 'col': 1, 'markdown': '4'}, + ], + }); + final stored = await _stored(sessionBuilder.build(), item); + + expect(r['revision'], legacyHead + 2); + expect(CellUpgrade.hasLegacy(stored), isFalse); + expect( + const MarkdownCodec().exportMarkdown(stored), + contains('| **Pens** | 4 |'), + ); + }, + ); + + test( + 'when a version from before the upgrade is restored then the restored ' + 'embeds are upgraded again', + () async { + // Captured from the stored head: the document is not loaded yet. + final version = await endpoints.version.nameCurrent( + anna.endpointSession, + item.uuid, + 'Legacy', + ); + await TestDocClient.connect( + endpoints, + anna, + item.contentRef!, + ).then((c) => c.close()); + + final result = await endpoints.version.restore( + anna.endpointSession, + item.uuid, + version.id, + ); + final session = sessionBuilder.build(); + final stored = await _stored(session, item); + final ops = await _ops(session, item); + + expect(version.revision, legacyHead); + expect(CellUpgrade.hasLegacy(stored), isFalse); + expect(_runs(stored), ['table t1', 'grid g1']); + expect(ops.map((o) => o.revision).last, result.revision! + 1); + expect(ops.last.kind, DocumentOpKind.system); + }, + ); + }); + + withWonkyServerpod('Given a connected client on a document without legacy ' + 'embeds', (sessionBuilder, endpoints) { + late TestUser anna; + late DriveItemView item; + late TestDocClient client; + setUp(() async { + anna = await TestUsers.create(sessionBuilder, name: 'Anna'); + item = await TestDrive.document(endpoints, anna, markdown: 'Intro'); + client = await TestDocClient.connect(endpoints, anna, item.contentRef!); + }); + tearDown(() => client.close()); + + test( + 'when an old client commits a table embed then the upgrade follows as ' + 'a system op that the client receives', + () async { + final head = client.revision; + + client.submitRaw(jsonEncode(_legacyInsert().toJson())); + // The ack and the upgrade op may arrive in either order. + DocAck? ack; + DocRemoteOp? upgrade; + while (ack == null || upgrade == null) { + switch (await client.nextAny()) { + case final DocAck m: + ack = m; + case final DocRemoteOp m: + upgrade = m; + default: + break; + } + } + await client.sideEffectsDone(); + final stored = await _stored(sessionBuilder.build(), item); + final ops = await _ops(sessionBuilder.build(), item); + + expect(ack.revision, head + 1); + expect( + ( + upgrade.revision, + upgrade.actorKind, + upgrade.kind, + upgrade.authorId, + ), + (head + 2, ActorKind.system, DocumentOpKind.system, null), + ); + expect(CellUpgrade.hasLegacy(stored), isFalse); + expect(_runs(stored), ['table t1', 'grid g1']); + expect(ops.length, head + 2); + }, + ); + + test( + 'when a change that must commit at the head is based on an older ' + 'revision then it is rejected instead of rebased', + () async { + client.edit(Delta()..insert('Hello ')); + await client.processUntilSynchronized(); + final session = sessionBuilder.build(); + final doc = (await DocumentStore.byUuid(session, item.contentRef!))!; + final driveItem = (await DriveItem.db.findById(session, doc.itemId))!; + final profile = (await UserProfile.db.findById(session, anna.id))!; + + final submit = OtService.submit( + session, + actor: Actor( + userId: anna.id, + authUserId: profile.authUserId, + kind: ActorKind.system, + ), + document: doc, + item: driveItem, + role: Role.owner, + clientOpId: const Uuid().v7obj(), + baseRevision: client.revision - 1, + deltaJson: TestDrive.insertAt(0, 'x'), + kind: DocumentOpKind.system, + atHead: true, + ); + + await expectLater( + submit, + throwsA( + isA().having( + (r) => r.code, + 'code', + DocRejectCode.rebaseTooOld, + ), + ), + ); + }, + ); + + test( + 'when a client commits ordinary text then no upgrade op is committed', + () async { + client.edit(Delta()..insert('Hello ')); + await client.processUntilSynchronized(); + + final ops = await _ops(sessionBuilder.build(), item); + + expect(ops.length, client.revision); + expect(ops.last.actorKind, ActorKind.user); + }, + ); + }); +} diff --git a/wonky_server/test/integration/write/mcp/write_tools_test.dart b/wonky_server/test/integration/write/mcp/write_tools_test.dart index 4e112e60..50801cb7 100644 --- a/wonky_server/test/integration/write/mcp/write_tools_test.dart +++ b/wonky_server/test/integration/write/mcp/write_tools_test.dart @@ -291,6 +291,123 @@ void main() { }, ); + test( + 'when inserting a table then it is made of cell lines (Write 12 §1.1)', + () async { + final r = await mcp.call('write.insert_table', { + 'document_uuid': docOf(doc), + 'at': {'type': 'end'}, + 'rows': [ + ['Name', 'Qty'], + ['**Pens**', '3'], + ], + 'align': [null, 'right'], + }); + + final content = await _content(endpoints, anna, doc); + final table = CellRuns.table( + WonkyDocument(content), + r['table_id'] as String, + ); + expect(r['table_index'], 0); + expect(CellUpgrade.hasLegacy(content), isFalse); + expect(table?.rows.map((row) => row.cells.length), [2, 2]); + expect( + CellAttr.of(table!.rows.first.cells.last.attributes)?.align, + CellAlign.right, + ); + expect( + await _markdown(endpoints, anna, doc), + contains('| **Pens** | 3 |'), + ); + }, + ); + + test( + 'when updating a table by index with operations then rows and columns ' + 'change and the result counts them', + () async { + await mcp.call('write.insert_table', { + 'document_uuid': docOf(doc), + 'at': {'type': 'end'}, + 'rows': [ + ['Name', 'Qty'], + ['Pens', '3'], + ], + }); + + final r = await mcp.call('write.update_table', { + 'document_uuid': docOf(doc), + 'table': {'table_index': 0}, + 'operations': [ + { + 'op': 'insert_row', + 'at': 2, + 'cells': ['Ink', '1'], + }, + {'op': 'insert_column', 'at': 2, 'header': 'Price'}, + {'op': 'delete_row', 'row': 1}, + ], + }); + + expect((r['rows'], r['columns']), (2, 3)); + final md = await _markdown(endpoints, anna, doc); + expect(md, contains('| Name | Qty | Price |')); + expect(md, contains('| Ink | 1 |')); + expect(md, isNot(contains('Pens'))); + }, + ); + + test( + 'when updating a table with rows then its rows are replaced and it ' + 'keeps its id', + () async { + final inserted = await mcp.call('write.insert_table', { + 'document_uuid': docOf(doc), + 'at': {'type': 'end'}, + 'rows': [ + ['Name', 'Qty'], + ['Pens', '3'], + ], + }); + + final r = await mcp.call('write.update_table', { + 'document_uuid': docOf(doc), + 'table': {'table_id': inserted['table_id']}, + 'rows': [ + ['Item'], + ['Paper'], + ['Glue'], + ], + }); + + final content = await _content(endpoints, anna, doc); + final table = CellRuns.table( + WonkyDocument(content), + inserted['table_id'] as String, + ); + expect(r['table_id'], inserted['table_id']); + expect((r['rows'], r['columns']), (3, 1)); + expect(table?.rows.length, 3); + expect(await _markdown(endpoints, anna, doc), contains('| Glue |')); + }, + ); + + test( + 'when updating a table that does not exist then not_found is returned', + () async { + final e = await mcp.error('write.update_table', { + 'document_uuid': docOf(doc), + 'table': {'table_index': 3}, + 'rows': [ + ['A'], + ], + }); + + expect(e['code'], 'not_found'); + }, + ); + test('when setting markdown then untouched anchors survive', () async { final c = await mcp.call('comments.create', { 'uuid': docOf(doc), diff --git a/wonky_write/lib/features/editor/controller/write_editor_controller.dart b/wonky_write/lib/features/editor/controller/write_editor_controller.dart index 0d1ef705..1d3520bf 100644 --- a/wonky_write/lib/features/editor/controller/write_editor_controller.dart +++ b/wonky_write/lib/features/editor/controller/write_editor_controller.dart @@ -16,6 +16,7 @@ import '../mapping/delta_ops.dart'; import '../mapping/document_mirror.dart'; import '../mapping/mirror_overlays.dart'; import '../requests/block_editing_handlers.dart'; +import '../requests/cell_editing_handlers.dart'; import '../setup/editor_factory.dart'; import '../components/box_frames.dart' show BoxFrameLayout; import '../reactions/pill_boundary.dart'; @@ -65,6 +66,7 @@ class WriteEditorController extends ChangeNotifier { _events.add(const EditorNotice(EditorNoticeKind.documentFull)), maxDocumentChars: maxDocumentChars, ), + ...cellEditingHandlers(), ...blockEditingHandlers( newEmbedId: this.newEmbedId, markdownShortcuts: () => _preferences.markdownShortcuts, diff --git a/wonky_write/lib/features/editor/keyboard/cell_keys.dart b/wonky_write/lib/features/editor/keyboard/cell_keys.dart new file mode 100644 index 00000000..7c7ba605 --- /dev/null +++ b/wonky_write/lib/features/editor/keyboard/cell_keys.dart @@ -0,0 +1,146 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/super_editor.dart'; + +import '../controller/editor_types.dart'; +import '../controller/write_editor_controller.dart'; +import '../requests/cell_editing_handlers.dart'; + +/// Tab, Shift+Tab, ↑ and ↓ in table cells and grid items (Write 12 §3.4): +/// Tab walks the cells (selecting the next cell's text) and appends a row +/// or item after the last; ↑/↓ go to the cell above or below in the laid +/// out table or grid, keeping the caret's x, and leave it at its edges. +/// Enter, Backspace and Delete are request handlers +/// ([cellEditingHandlers]). +bool cellKeys(KeyEvent event, WriteEditorController controller) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) return false; + final key = event.logicalKey; + final keyboard = HardwareKeyboard.instance; + if (keyboard.isAltPressed || + keyboard.isMetaPressed || + keyboard.isControlPressed) { + return false; + } + final selection = controller.composer.selection; + if (selection == null) return false; + final document = controller.document; + final node = document.getNodeById(selection.extent.nodeId); + final group = CellGroupNodes.of(document, node); + if (group == null || node is! TextNode) return false; + final shift = keyboard.isShiftPressed; + + if (key == LogicalKeyboardKey.tab) { + final i = group.nodes.indexOf(node); + final next = shift ? i - 1 : i + 1; + if (next < 0) return true; + if (next >= group.nodes.length) { + // Read-only: Tab walks the cells but appends nothing. + if (controller.mode == EditorMode.viewing) return true; + controller.editor.execute([AppendCellRequest(node.id)]); + return true; + } + final target = group.nodes[next] as TextNode; + _select( + controller, + DocumentSelection( + base: DocumentPosition( + nodeId: target.id, + nodePosition: const TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: target.id, + nodePosition: TextNodePosition(offset: target.text.length), + ), + ), + ); + return true; + } + + final up = key == LogicalKeyboardKey.arrowUp; + if (!up && key != LogicalKeyboardKey.arrowDown) return false; + if (shift) return false; + final layout = controller.documentLayout; + final position = selection.extent.nodePosition; + if (layout == null || position is! TextNodePosition) return false; + final component = layout.getComponentByNodeId(node.id); + if (component == null) return false; + // Within a cell's wrapped lines, the default moves the caret. + final within = up + ? component.movePositionUp(position) + : component.movePositionDown(position); + if (within != null) return false; + final caret = layout.getRectForPosition(selection.extent); + if (caret == null) return false; + final x = caret.center.dx; + + // The nearest cell whose box is above (below) the caret's cell. + final here = _boxOf(layout, node.id); + if (here == null) return false; + DocumentNode? best; + Rect? bestBox; + for (final n in group.nodes) { + if (n.id == node.id) continue; + final box = _boxOf(layout, n.id); + if (box == null) continue; + final isAbove = box.bottom <= here.top + 0.5; + final isBelow = box.top >= here.bottom - 0.5; + if (up ? !isAbove : !isBelow) continue; + if (bestBox == null) { + best = n; + bestBox = box; + continue; + } + final closer = up + ? box.bottom > bestBox.bottom + 0.5 + : box.top < bestBox.top - 0.5; + final sameRow = (box.top - bestBox.top).abs() <= 0.5; + if (closer || (sameRow && _dx(box, x) < _dx(bestBox, x))) { + best = n; + bestBox = box; + } + } + // At the table's edge: the block before or after it. + best ??= up + ? document.getNodeBeforeById(group.nodes.first.id) + : document.getNodeAfterById(group.nodes.last.id); + if (best == null) return true; + final target = layout.getComponentByNodeId(best.id); + final targetBox = _boxOf(layout, best.id); + if (target == null || targetBox == null) return false; + final local = x - targetBox.left; + _select( + controller, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: best.id, + nodePosition: up + ? target.getEndPositionNearX(local) + : target.getBeginningPositionNearX(local), + ), + ), + ); + return true; +} + +double _dx(Rect box, double x) => + x < box.left ? box.left - x : (x > box.right ? x - box.right : 0); + +/// The component box of [nodeId] in document coordinates. +Rect? _boxOf(DocumentLayout layout, String nodeId) { + final component = layout.getComponentByNodeId(nodeId); + if (component is! State) return null; + final box = (component as State).context.findRenderObject(); + if (box is! RenderBox || !box.hasSize) return null; + final topLeft = layout.getDocumentOffsetFromAncestorOffset(Offset.zero, box); + return topLeft & box.size; +} + +void _select(WriteEditorController controller, DocumentSelection selection) { + controller.editor.execute([ + ChangeSelectionRequest( + selection, + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); +} diff --git a/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart b/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart index 4940fe56..0ad7dfd4 100644 --- a/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart +++ b/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart @@ -6,6 +6,7 @@ import '../applier/delta_applier.dart'; import '../commands/write_editor_commands.dart'; import '../controller/editor_types.dart'; import '../controller/write_editor_controller.dart'; +import 'cell_keys.dart'; import '../mapping/wonky_schema.dart' show isQuotedListNode; import '../model/document_queries.dart'; import '../reactions/pill_boundary.dart'; @@ -73,6 +74,9 @@ List wonkyKeyboardActions({ if (popoverKeys != null && popoverKeys(keyEvent)) { return ExecutionInstruction.haltExecution; } + if (cellKeys(keyEvent, controller)) { + return ExecutionInstruction.haltExecution; + } if (keymap != null && keymap(keyEvent)) { return ExecutionInstruction.haltExecution; } diff --git a/wonky_write/lib/features/editor/requests/cell_editing_handlers.dart b/wonky_write/lib/features/editor/requests/cell_editing_handlers.dart new file mode 100644 index 00000000..cae3feb4 --- /dev/null +++ b/wonky_write/lib/features/editor/requests/cell_editing_handlers.dart @@ -0,0 +1,624 @@ +import 'package:flutter/services.dart' show TextPosition; +import 'package:super_editor/super_editor.dart'; +import 'package:wonky_write_core/wonky_write_core.dart' + show CellAttr, CellIds, GridEmbed; + +import '../mapping/wonky_schema.dart'; + +/// Request handlers for Enter, Shift+Enter, Backspace, Delete and deleting +/// selections in table cells and grid items (Write 12 §3.4). They run +/// before [blockEditingHandlers], so keyboard, IME and macOS selector paths +/// behave alike, and they never merge or split cells: the table keeps its +/// shape. +List cellEditingHandlers() => [ + (editor, request) { + if (request is! AppendCellRequest) return null; + final group = CellGroupNodes.of( + editor.document, + editor.document.getNodeById(request.nodeId), + ); + if (group == null) return null; + return group.isTable + ? _AppendRowCommand(group, caretColumn: 0) + : _AppendItemCommand(group); + }, + (editor, request) { + if (request is! InsertNewlineAtCaretRequest) return null; + return _newline(_ctxOf(editor), request.newNodeId); + }, + (editor, request) { + if (request is! InsertSoftNewlineAtCaretRequest) return null; + return _softNewline(_ctxOf(editor)); + }, + (editor, request) { + if (request is DeleteUpstreamRequest || + request is DeleteUpstreamAtBeginningOfNodeRequest) { + return _backspace(_ctxOf(editor)); + } + return null; + }, + (editor, request) { + if (request is! DeleteDownstreamRequest) return null; + return _delete(_ctxOf(editor)); + }, + (editor, request) { + final DocumentRange? range = switch (request) { + DeleteSelectionRequest() => editor.composer.selection, + DeleteContentRequest(:final documentRange) => documentRange, + _ => null, + }; + if (range == null || range.isCollapsed) return null; + return _deleteRange(editor.document, range); + }, +]; + +/// The document and composer the key handlers read. +typedef _Ctx = ({Document document, DocumentComposer composer}); + +_Ctx _ctxOf(Editor editor) => + (document: editor.document, composer: editor.composer); + +/// Appends a row to the table, or an item to the grid, of cell or item node +/// [nodeId] and puts the caret in its first cell (Tab in the last cell, +/// Write 12 §3.4). +class AppendCellRequest implements EditRequest { + const AppendCellRequest(this.nodeId); + + final String nodeId; +} + +// ----------------------------------------------------------------------------- +// The table or grid of a node in the super_editor document. + +/// The nodes of the table or grid that contains a cell or item node. +class CellGroupNodes { + CellGroupNodes._(this.nodes, this.rows); + + /// Every node of the group, in document order. + final List nodes; + + /// A table's rows (each a list of its cell nodes); a grid's items as one + /// row. + final List> rows; + + bool get isTable => cellOf(nodes.first) != null; + + /// The (row, column) of [nodeId]. + (int, int)? positionOf(String nodeId) { + for (var r = 0; r < rows.length; r++) { + final c = rows[r].indexWhere((n) => n.id == nodeId); + if (c >= 0) return (r, c); + } + return null; + } + + /// The group of [node], or `null` when it is not a cell or item. + static CellGroupNodes? of(Document document, DocumentNode? node) { + if (node == null) return null; + final key = _keyOf(node); + if (key == null) return null; + final nodes = [node]; + var before = document.getNodeBeforeById(node.id); + while (before != null && _keyOf(before) == key) { + nodes.insert(0, before); + before = document.getNodeBeforeById(before.id); + } + var after = document.getNodeAfterById(node.id); + while (after != null && _keyOf(after) == key) { + nodes.add(after); + after = document.getNodeAfterById(after.id); + } + final rows = >[]; + if (cellOf(node) == null) { + rows.add(nodes); + } else { + String? row; + for (final n in nodes) { + final r = cellOf(n)!.row; + if (r != row) rows.add([]); + rows.last.add(n); + row = r; + } + } + return CellGroupNodes._(nodes, rows); + } + + static String? _keyOf(DocumentNode node) { + final cell = cellOf(node); + if (cell != null) return 't:${cell.table}'; + final item = gridItemOf(node); + return item == null ? null : 'g:${item.id}'; + } +} + +// ----------------------------------------------------------------------------- +// Keys. + +/// The text node and caret offset of a collapsed selection in a cell or item. +(TextNode, int, CellGroupNodes)? _caretInCell(_Ctx editor) { + final selection = editor.composer.selection; + if (selection == null || !selection.isCollapsed) return null; + final node = editor.document.getNodeById(selection.extent.nodeId); + final position = selection.extent.nodePosition; + if (node is! TextNode || position is! TextNodePosition) return null; + final group = CellGroupNodes.of(editor.document, node); + if (group == null) return null; + return (node, position.offset, group); +} + +EditCommand? _newline(_Ctx editor, String newNodeId) { + final selection = editor.composer.selection; + if (selection == null) return null; + if (!selection.isCollapsed) { + // Enter over a selection in one cell replaces it like typing; across + // cells the deletion handler clears the cells first. + final base = editor.document.getNodeById(selection.base.nodeId); + if (!isCellNode(base) && + !isCellNode(editor.document.getNodeById(selection.extent.nodeId))) { + return null; + } + return _CompoundCommand([ + DeleteContentCommand(documentRange: selection, updateSelection: true), + _DeferredCommand((ctx) => _newline(ctx, newNodeId)), + ]); + } + final caret = _caretInCell(editor); + if (caret == null) return null; + final (node, offset, group) = caret; + if (group.isTable) { + // The caret goes to the cell below; the last row gets a row after it. + final (row, col) = group.positionOf(node.id)!; + if (row + 1 < group.rows.length) { + final below = group.rows[row + 1]; + final target = below[col < below.length ? col : below.length - 1]; + return _CaretCommand(target.id, const TextNodePosition(offset: 0)); + } + return _AppendRowCommand(group, caretColumn: col); + } + // Grid: Enter on an empty last item leaves the grid; otherwise the text + // after the caret moves into a new item (Write 12 §3.4). + final items = group.nodes; + if (node.text.isEmpty && identical(items.last, node) && items.length > 1) { + return _LeaveGridCommand(node.id); + } + if (items.length >= GridEmbed.maxItems) return const _NoOpCommand(); + return _CompoundCommand([ + SplitParagraphCommand( + nodeId: node.id, + splitPosition: TextPosition(offset: offset), + newNodeId: newNodeId, + replicateExistingMetadata: true, + ), + _CaretCommand(newNodeId, const TextNodePosition(offset: 0)), + ]); +} + +EditCommand? _softNewline(_Ctx editor) { + final caret = _caretInCell(editor); + if (caret == null) return null; + final (node, _, group) = caret; + if (!group.isTable) return const _NoOpCommand(); + final (row, col) = group.positionOf(node.id)!; + if (row == 0) return const _NoOpCommand(); + final above = group.rows[row - 1]; + final target = above[col < above.length ? col : above.length - 1]; + return _CaretCommand( + target.id, + TextNodePosition(offset: (target as TextNode).text.length), + ); +} + +EditCommand? _backspace(_Ctx editor) { + final selection = editor.composer.selection; + if (selection == null || !selection.isCollapsed) return null; + final document = editor.document; + final node = document.getNodeById(selection.extent.nodeId); + final position = selection.extent.nodePosition; + if (node is! TextNode || + position is! TextNodePosition || + position.offset != 0) { + return null; + } + final previous = document.getNodeBeforeById(node.id); + final group = CellGroupNodes.of(document, node); + if (group == null) { + // After a table or grid: an empty line is removed, a line with text + // stays; the caret goes to the end of the last cell (no merge). + if (previous is TextNode && isCellNode(previous)) { + return _CompoundCommand([ + if (node.text.isEmpty) DeleteNodeCommand(nodeId: node.id), + _CaretCommand( + previous.id, + TextNodePosition(offset: previous.text.length), + ), + ]); + } + return null; + } + final inGroup = previous != null && group.nodes.contains(previous); + if (!group.isTable && node.text.isEmpty) { + // An empty grid item is removed; the last one becomes a paragraph. + if (group.nodes.length == 1) return _LeaveGridCommand(node.id); + final target = inGroup ? previous : document.getNodeAfterById(node.id); + return _CompoundCommand([ + DeleteNodeCommand(nodeId: node.id), + if (target is TextNode) + _CaretCommand( + target.id, + TextNodePosition(offset: inGroup ? target.text.length : 0), + ), + ]); + } + if (inGroup && previous is TextNode) { + return _CaretCommand( + previous.id, + TextNodePosition(offset: previous.text.length), + ); + } + // At the start of a table or grid: nothing to merge with. + return const _NoOpCommand(); +} + +EditCommand? _delete(_Ctx editor) { + final selection = editor.composer.selection; + if (selection == null || !selection.isCollapsed) return null; + final document = editor.document; + final node = document.getNodeById(selection.extent.nodeId); + final position = selection.extent.nodePosition; + if (node is! TextNode || + position is! TextNodePosition || + position.offset != node.text.length) { + return null; + } + final next = document.getNodeAfterById(node.id); + final group = CellGroupNodes.of(document, node); + if (group == null) { + // Before a table or grid: an empty line is removed; the caret goes to + // the first cell (no merge). + if (next is TextNode && isCellNode(next)) { + return _CompoundCommand([ + if (node.text.isEmpty) DeleteNodeCommand(nodeId: node.id), + _CaretCommand(next.id, const TextNodePosition(offset: 0)), + ]); + } + return null; + } + final inGroup = next != null && group.nodes.contains(next); + if (!inGroup || next is! TextNode) return const _NoOpCommand(); + if (!group.isTable && next.text.isEmpty) { + return DeleteNodeCommand(nodeId: next.id); + } + return _CaretCommand(next.id, const TextNodePosition(offset: 0)); +} + +// ----------------------------------------------------------------------------- +// Deleting selections. + +/// Deleting [range] when it touches table cells or grid items: cells are +/// cleared, never merged or removed, unless their whole table or grid is in +/// the range; the lines outside lose their selected part (Write 12 §3.4). +/// `null` when super_editor's deletion is right (no cell involved, or one +/// node). +EditCommand? _deleteRange(Document document, DocumentRange range) { + final normalized = range.normalize(document); + final start = normalized.start; + final end = normalized.end; + if (start.nodeId == end.nodeId) return null; + final nodes = document.getNodesInside(start, end); + if (!nodes.any(isCellNode)) return null; + + int offsetOf(DocumentPosition p) => p.nodePosition is TextNodePosition + ? (p.nodePosition as TextNodePosition).offset + : 0; + bool fullyCovered(DocumentNode node) { + if (node.id == start.nodeId) { + return offsetOf(start) == 0 && + (node.id != end.nodeId || + node is! TextNode || + offsetOf(end) >= node.text.length); + } + if (node.id == end.nodeId) { + return node is! TextNode || offsetOf(end) >= node.text.length; + } + return true; + } + + // Groups whose every node is fully in the range are deleted whole. + final deletedGroups = {}; + final seen = {}; + for (final node in nodes) { + final group = CellGroupNodes.of(document, node); + if (group == null || !seen.add(group.nodes.first.id)) continue; + if (group.nodes.every((n) => nodes.contains(n) && fullyCovered(n))) { + deletedGroups.addAll(group.nodes.map((n) => n.id)); + } + } + final startIsCell = isCellNode(document.getNodeById(start.nodeId)); + final endIsCell = isCellNode(document.getNodeById(end.nodeId)); + final partialCells = nodes.any( + (n) => isCellNode(n) && !deletedGroups.contains(n.id), + ); + if (!partialCells && !startIsCell && !endIsCell) { + // Whole tables between two ordinary lines: super_editor deletes them + // and merges the two lines. + return null; + } + return _ClearRangeCommand( + nodeIds: [for (final n in nodes) n.id], + start: start, + end: end, + deletedNodes: { + ...deletedGroups, + for (final n in nodes) + if (!isCellNode(n) && n.id != start.nodeId && n.id != end.nodeId) n.id, + }, + ); +} + +/// Clears the selected text in each node of a range and deletes +/// [deletedNodes]; the caret goes to the range start (or the first node +/// left after it). +class _ClearRangeCommand extends EditCommand { + _ClearRangeCommand({ + required this.nodeIds, + required this.start, + required this.end, + required this.deletedNodes, + }); + + final List nodeIds; + final DocumentPosition start; + final DocumentPosition end; + final Set deletedNodes; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + String? caretNode; + var caretOffset = 0; + for (final id in nodeIds) { + final node = document.getNodeById(id); + if (node == null) continue; + if (deletedNodes.contains(id)) continue; + if (node is! TextNode) continue; + final from = id == start.nodeId + ? (start.nodePosition as TextNodePosition).offset + : 0; + final to = id == end.nodeId + ? (end.nodePosition as TextNodePosition).offset + : node.text.length; + caretNode ??= id; + if (caretNode == id) caretOffset = from; + if (to > from) { + executor.executeCommand( + DeleteContentCommand( + documentRange: DocumentRange( + start: DocumentPosition( + nodeId: id, + nodePosition: TextNodePosition(offset: from), + ), + end: DocumentPosition( + nodeId: id, + nodePosition: TextNodePosition(offset: to), + ), + ), + ), + ); + } + } + // The node after the range keeps the caret when everything before it + // goes; a document never ends up empty. + String? fallback; + if (caretNode == null) { + final after = document.getNodeAfterById(nodeIds.last); + if (after is TextNode && !deletedNodes.contains(after.id)) { + fallback = after.id; + } else { + fallback = Editor.createNodeId(); + executor.executeCommand( + InsertNodeAfterNodeCommand( + existingNodeId: nodeIds.last, + newNode: ParagraphNode(id: fallback, text: AttributedText()), + ), + ); + } + } + for (final id in nodeIds) { + if (deletedNodes.contains(id) && document.getNodeById(id) != null) { + executor.executeCommand(DeleteNodeCommand(nodeId: id)); + } + } + _placeCaret( + executor, + caretNode ?? fallback!, + TextNodePosition(offset: caretNode == null ? 0 : caretOffset), + ); + } +} + +// ----------------------------------------------------------------------------- +// Commands. + +void _placeCaret( + CommandExecutor executor, + String nodeId, + NodePosition position, +) { + executor.executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition(nodeId: nodeId, nodePosition: position), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ); +} + +/// Moves the caret (no document change). +class _CaretCommand extends EditCommand { + _CaretCommand(this.nodeId, this.position); + + final String nodeId; + final NodePosition position; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.nonHistorical; + + @override + void execute(EditContext context, CommandExecutor executor) => + _placeCaret(executor, nodeId, position); +} + +class _NoOpCommand extends EditCommand { + const _NoOpCommand(); + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.nonHistorical; + + @override + void execute(EditContext context, CommandExecutor executor) {} +} + +/// Runs [commands] in order. +class _CompoundCommand extends EditCommand { + _CompoundCommand(this.commands); + + final List commands; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + for (final c in commands) { + executor.executeCommand(c); + } + } +} + +/// A command chosen when it runs (after the commands before it). +class _DeferredCommand extends EditCommand { + _DeferredCommand(this.build); + + final EditCommand? Function(_Ctx ctx) build; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final command = build(( + document: context.document, + composer: context.composer, + )); + if (command != null) executor.executeCommand(command); + } +} + +/// Appends an empty row to a table (its cells in the table's containers, +/// with the header's alignment) and puts the caret in [caretColumn]. +class _AppendRowCommand extends EditCommand { + _AppendRowCommand(this.group, {required this.caretColumn}); + + final CellGroupNodes group; + final int caretColumn; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final header = group.rows.first; + final columns = group.rows.fold(0, (n, r) => r.length > n ? r.length : n); + final first = cellOf(group.nodes.first)!; + final rowId = CellIds.row(); + final box = group.nodes.first.getMetadataValue(WonkyNodeMetadata.box); + var after = group.nodes.last.id; + String? caret; + for (var c = 0; c < columns; c++) { + final id = Editor.createNodeId(); + final align = c < header.length ? cellOf(header[c])?.align : null; + executor.executeCommand( + InsertNodeAfterNodeCommand( + existingNodeId: after, + newNode: ParagraphNode( + id: id, + text: AttributedText(), + metadata: { + NodeMetadata.blockType: paragraphAttribution, + WonkyNodeMetadata.cell: CellAttr( + table: first.table, + row: rowId, + align: align, + ).toJson(), + if (box is List && box.isNotEmpty) WonkyNodeMetadata.box: box, + }, + ), + ), + ); + if (c == caretColumn.clamp(0, columns - 1)) caret = id; + after = id; + } + if (caret != null) { + _placeCaret(executor, caret, const TextNodePosition(offset: 0)); + } + } +} + +/// Turns grid item [nodeId] into an ordinary paragraph (keeping its +/// containers). +class _LeaveGridCommand extends EditCommand { + _LeaveGridCommand(this.nodeId); + + final String nodeId; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final node = context.document.getNodeById(nodeId); + if (node is! ParagraphNode) return; + final metadata = {...node.metadata}..remove(WonkyNodeMetadata.gridItem); + executor.executeCommand( + ReplaceNodeCommand( + existingNodeId: nodeId, + newNode: ParagraphNode(id: nodeId, text: node.text, metadata: metadata), + ), + ); + _placeCaret(executor, nodeId, const TextNodePosition(offset: 0)); + } +} + +/// Appends an empty item to a grid and puts the caret in it. +class _AppendItemCommand extends EditCommand { + _AppendItemCommand(this.group); + + final CellGroupNodes group; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + if (group.nodes.length >= GridEmbed.maxItems) return; + final last = group.nodes.last; + final id = Editor.createNodeId(); + executor.executeCommand( + InsertNodeAfterNodeCommand( + existingNodeId: last.id, + newNode: ParagraphNode( + id: id, + text: AttributedText(), + metadata: {...last.metadata} + ..remove(WonkyNodeMetadata.lineAttributes), + ), + ), + ); + _placeCaret(executor, id, const TextNodePosition(offset: 0)); + } +} From 295f63da65528188601a3fc9465fefaf373efa62 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Oct 2026 00:07:59 +0200 Subject: [PATCH 06/16] feat(write): lay out table cells and grid items natively WriteCellLayout places cell and item nodes in SE-15 groups with borders, header, bullets, container frames and chrome; CellStylePhase styles them; widget tests cover clicks, double clicks, Tab, arrows, Enter and deletion in cells. Co-Authored-By: Claude Opus 5.5 --- specs/wonky_write/12-native-cells.md | 25 +- .../editor/components/box_frames.dart | 38 +- .../editor/components/cell_layout.dart | 485 ++++++++++++++++++ .../lib/features/editor/ui/wonky_editor.dart | 44 ++ .../commands/write_commands_test.dart | 22 +- .../wonky_markdown_commands_test.dart | 38 +- .../reactions/wonky_shortcuts_test.dart | 26 +- .../editor/ui/editor_extras_test.dart | 374 ++++++++------ 8 files changed, 831 insertions(+), 221 deletions(-) create mode 100644 wonky_write/lib/features/editor/components/cell_layout.dart diff --git a/specs/wonky_write/12-native-cells.md b/specs/wonky_write/12-native-cells.md index 8d1bc5c0..678483bb 100644 --- a/specs/wonky_write/12-native-cells.md +++ b/specs/wonky_write/12-native-cells.md @@ -99,10 +99,17 @@ stay valid input everywhere, and `CellUpgrade` (`wonky_write_core`) converts the `table`/`grid` embed line with cell or item lines (inline Markdown parsed into inline attributes, the embed id kept as the table/grid id, new row ids, the embed line's `box` copied to each line, `cmt_*`/`sug_*` of the embed dropped), or `null` when there are none. -- **Server**: when a document's state is loaded, and after every committed op whose result still - contains a legacy embed (an old client, a restored version), the server commits the upgrade as a - system op ("Upgrade tables"), so stored documents converge to the new format. Clients receive it - as an ordinary remote op. +- **Server** (`CellUpgrader`, `wonky_server/lib/src/write/ot/cell_upgrader.dart`): when a + document's state is loaded (`DocumentCache.of`), and after every committed op whose result still + contains a legacy embed (an old client, a restored version; `OtService.submit`), the server + commits the upgrade as a system op (`kind: system`, a system actor, no author; "Upgrade tables" + in the logs), so stored documents converge to the new format. Clients receive it as an ordinary + remote op. The change is computed from the head and committed only at that head (`atHead`; + otherwise it retries from the new head), so two upgrades never both apply; once the head holds no + legacy embed nothing is committed. It bumps the document's revision and `updatedAt`, but adds no + activity, version or drive "modified" entry. Readers that bypass the document cache (snapshots, + exports, copies) see legacy embeds until the first load; they handle both forms. Comment threads + anchored on an embed become orphaned (text deleted). - **Client**: a legacy embed that is still in a local document (an offline cache from before the upgrade) renders read-only as before (`TableBlockNode`, the grid component without editing) until the server's upgrade op arrives. The editor never creates legacy embeds. @@ -112,7 +119,7 @@ stay valid input everywhere, and `CellUpgrade` (`wonky_write_core`) converts the ### 1.6 Markdown and other formats -Nothing changes in the formats: a table is a GFM table, a grid the `::::grid` directive (11 §4). +Nothing changes in the formats: a table is a GFM table, a grid the `:::grid` directive (11 §4). Import produces cell/item lines; export reads them through `DocTree`. A cell's inline content is written as inline Markdown (pipes escaped), so a cell with a comment or a mention round-trips as its text. @@ -272,10 +279,12 @@ delete is empty after the transform). document and a table/grid id (insert/delete/move row and column, alignment, set cell, insert table/grid, grid columns/bullets/items, replace a table from rows). `DocumentEdits` (MCP on the server) uses it; so do the editor commands. -- MCP tools keep their names and arguments (`write.table.*`, `write.grid.*`, 09 §3); cell - arguments are inline Markdown, as before. +- The server MCP tools keep their names and arguments (`write.insert_table`, + `write.update_table`, 09 §3); cell arguments are inline Markdown, as before. The editor's + `write.table.*` and `write.grid.*` commands (§3.5) are reachable through `ui.write`. Document + offsets now count a table's cell text and line ends (an image still counts 1). - The AI assistant reads documents as Markdown and writes Markdown: unchanged. -- `DocumentStore` upgrades legacy embeds on load and after commits (§1.5). +- `CellUpgrader` upgrades legacy embeds on load and after commits (§1.5). ## 5. Tests and acceptance diff --git a/wonky_write/lib/features/editor/components/box_frames.dart b/wonky_write/lib/features/editor/components/box_frames.dart index 5952279d..1296dc3d 100644 --- a/wonky_write/lib/features/editor/components/box_frames.dart +++ b/wonky_write/lib/features/editor/components/box_frames.dart @@ -247,7 +247,8 @@ class BoxFrameComponentBuilder implements ComponentBuilder { if (component == null) return null; final node = document.getNodeById(viewModel.nodeId); final frames = boxOf(node); - if (node == null || frames.isEmpty) return component; + // Table cells and grid items are framed as a group ([frameGroup]). + if (node == null || frames.isEmpty || isCellNode(node)) return component; // Collapsing a details container changes no view model: the frames // listen to the layout instead. // Nor does a frame changed in place (a callout's type or title): the @@ -268,7 +269,38 @@ class BoxFrameComponentBuilder implements ComponentBuilder { ); } - Widget _frames(DocumentNode node, List frames, Widget component) { + /// [child] (a table or grid, Write 12 §3.2) in the frames of the + /// containers of its [first] and [last] nodes, with the block spacing + /// inside them. + Widget frameGroup(DocumentNode first, DocumentNode last, Widget child) { + final frames = boxOf(first); + if (frames.isEmpty) return child; + // The outermost container's spacing (for other nodes their padding, + // [BoxStylePhase]). + final outer = theme.wonky.frameOf(containerFrameOf(document, first, 0)); + return Padding( + padding: EdgeInsets.only( + top: _isFirstAt(document, first, frames, 0) + ? outer.spaceBefore * zoom + : 0, + bottom: _isLastAt(document, last, frames, 0) + ? outer.spaceAfter * zoom + : 0, + ), + child: ListenableBuilder( + listenable: layout, + builder: (context, _) => _frames(first, frames, child, last: last), + ), + ); + } + + Widget _frames( + DocumentNode node, + List frames, + Widget component, { + DocumentNode? last, + }) { + final end = last ?? node; final style = theme.wonky; // The summary of a collapsed details ends its box: no block spacing // below it inside the frame, whose bottom padding follows directly. @@ -293,7 +325,7 @@ class BoxFrameComponentBuilder implements ComponentBuilder { first && layout.isCollapsed(frame.id); // A collapsed details ends with its summary: bottom border and padding. - final last = collapsedSummary || _isLastAt(document, node, frames, depth); + final last = collapsedSummary || _isLastAt(document, end, frames, depth); if (frame.type == BoxType.details && layout.isCollapsed(frame.id) && !(first && isDetailsSummaryNode(document, node, depth))) { diff --git a/wonky_write/lib/features/editor/components/cell_layout.dart b/wonky_write/lib/features/editor/components/cell_layout.dart new file mode 100644 index 00000000..a521d090 --- /dev/null +++ b/wonky_write/lib/features/editor/components/cell_layout.dart @@ -0,0 +1,485 @@ +import 'package:flutter/material.dart'; +import 'package:shad/shad.dart' show LucideIcons; +import 'package:super_editor/super_editor.dart'; +import 'package:wonky_write_core/wonky_write_core.dart' + show CellAlign, GridBullets; + +import '../../../l10n/write_l10n.dart'; +import '../mapping/wonky_schema.dart'; +import '../styles/editor_theme.dart'; +import 'box_frames.dart'; +import 'editor_services.dart'; +import 'wonky_components.dart'; + +/// Where a table cell or grid item node sits, read from the document +/// (Write 12 §3.2). +@immutable +class CellSlot { + const CellSlot.cell({ + required this.group, + required this.row, + required this.column, + required this.align, + }) : index = 0, + columns = 0, + bullets = GridBullets.none; + + const CellSlot.item({ + required this.group, + required this.index, + required this.columns, + required this.bullets, + }) : row = 0, + column = 0, + align = null; + + /// The table or grid id. + final String group; + final int row; + final int column; + final int index; + final int columns; + final GridBullets bullets; + + /// The column's alignment (the header cell's, Write 12 §1.1). + final CellAlign? align; + + bool get isCell => columns == 0; + + @override + bool operator ==(Object other) => + other is CellSlot && + other.group == group && + other.row == row && + other.column == column && + other.index == index && + other.columns == columns && + other.bullets == bullets && + other.align == align; + + @override + int get hashCode => + Object.hash(group, row, column, index, columns, bullets, align); +} + +/// The slots of every table cell and grid item of a document, kept current +/// as the document changes; notifies when one changes. +class CellSlots extends ChangeNotifier { + CellSlots(this.document) { + _rebuild(); + document.addListener(_onChange); + } + + final Document document; + var _slots = {}; + + /// The slot of [nodeId], or `null` when it is no cell or item. + CellSlot? operator [](String nodeId) => _slots[nodeId]; + + void _onChange(DocumentChangeLog _) { + if (_rebuild()) notifyListeners(); + } + + /// Recomputes the slots; whether any changed. + bool _rebuild() { + final next = {}; + String? table; + String? row; + var r = -1; + var c = 0; + // The header's alignments of the current table. + final header = []; + String? grid; + var item = 0; + int? columns; + GridBullets? bullets; + for (final node in document) { + final cell = cellOf(node); + if (cell != null) { + if (cell.table != table) { + table = cell.table; + row = null; + r = -1; + header.clear(); + } + if (cell.row != row) { + row = cell.row; + r++; + c = 0; + } + if (r == 0) header.add(cell.align); + next[node.id] = CellSlot.cell( + group: 't:${cell.table}', + row: r, + column: c, + align: c < header.length ? header[c] : null, + ); + c++; + grid = null; + continue; + } + table = null; + final g = gridItemOf(node); + if (g != null) { + if (g.id != grid) { + grid = g.id; + item = 0; + // The first item's values are the grid's (Write 12 §1.2). + columns = g.columns; + bullets = g.bullets; + } + next[node.id] = CellSlot.item( + group: 'g:${g.id}', + index: item++, + columns: columns!, + bullets: bullets!, + ); + continue; + } + grid = null; + } + final changed = !_sameMaps(next, _slots); + _slots = next; + return changed; + } + + static bool _sameMaps(Map a, Map b) { + if (a.length != b.length) return false; + for (final e in a.entries) { + if (b[e.key] != e.value) return false; + } + return true; + } + + @override + void dispose() { + document.removeListener(_onChange); + super.dispose(); + } +} + +/// Write's [DocumentCellLayout] (Write 12 §3.2): table cells in rows with +/// borders and a header background, grid items in a flow with their +/// bullets; the group sits in its containers' frames and has the table's +/// or grid's chrome. +class WriteCellLayout extends ChangeNotifier implements DocumentCellLayout { + WriteCellLayout(this.slots) { + slots.addListener(notifyListeners); + } + + final CellSlots slots; + + /// Set by the editor on every build. + EditorTheme theme = EditorTheme.fallback(Brightness.light); + double zoom = 1; + bool editable = true; + EditorBlockCallbacks callbacks = const EditorBlockCallbacks(); + + /// Frames the group in its containers (the editor's frame builder). + BoxFrameComponentBuilder? frames; + + /// Extra space above row `row` of the table or grid `group` (pagination, + /// Write 12 §3.8). + double Function(String group, int row)? rowSpaceBefore; + + /// Takes the editor's values. Called while building: a theme or zoom + /// change also changes the stylesheet, which rebuilds the layout. + void update({ + required EditorTheme theme, + required double zoom, + required bool editable, + required EditorBlockCallbacks callbacks, + required BoxFrameComponentBuilder frames, + }) { + this.theme = theme; + this.zoom = zoom; + this.editable = editable; + this.callbacks = callbacks; + this.frames = frames; + } + + double get _bulletSize => (theme.body.fontSize ?? 14.67) * zoom * 1.3; + + /// The left room of a grid item for its bullet. + double bulletRoom(GridBullets bullets) => + bullets == GridBullets.none ? 0 : _bulletSize * 1.45; + + /// The padding of a cell's text. + EdgeInsets get cellPadding => + EdgeInsets.symmetric(horizontal: 8 * zoom, vertical: 4 * zoom); + + @override + DocumentCellPlacement? placementOf(String nodeId) { + final slot = slots[nodeId]; + if (slot == null) return null; + if (slot.isCell) { + return DocumentCellPlacement.rows( + group: slot.group, + row: slot.row, + column: slot.column, + minColumnWidth: 48 * zoom, + spaceBefore: rowSpaceBefore?.call(slot.group, slot.row) ?? 0, + ); + } + return DocumentCellPlacement.flow( + group: slot.group, + index: slot.index, + maxColumns: slot.columns, + minColumnWidth: 160 * zoom, + gap: theme.wonky.gridColumnGap * zoom, + spaceBefore: rowSpaceBefore?.call(slot.group, slot.index) ?? 0, + ); + } + + @override + Widget wrapGroup( + BuildContext context, + DocumentCellGroup group, + Widget child, + ) { + final document = slots.document; + final first = document.getNodeById(group.nodeIds.first); + final last = document.getNodeById(group.nodeIds.last); + final l = writeL10n(context); + final isTable = group.kind == DocumentCellGroupKind.rows; + final id = group.id.substring(2); + final firstSlot = slots[group.nodeIds.first]; + Widget body; + if (isTable) { + final rows = group.nodeIds.isEmpty + ? 0 + : slots[group.nodeIds.last]!.row + 1; + final columns = group.nodeIds.fold(0, (n, nodeId) { + final c = slots[nodeId]?.column ?? 0; + return c + 1 > n ? c + 1 : n; + }); + body = Semantics( + container: true, + label: l.editorTableLabel(columns, rows), + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8 * zoom), + child: _chrome( + child, + !editable || callbacks.onEditTable == null + ? null + : EditorChromeButton( + key: ValueKey('${WonkyEditorKeys.tableEdit}.$id'), + icon: Icons.edit_outlined, + tooltip: l.editorEditTable, + onTap: (_) => callbacks.onEditTable!(id), + ), + ), + ), + ); + } else { + final gridCallbacks = editable ? callbacks.grid : null; + final columns = firstSlot?.columns ?? 3; + final bullets = firstSlot?.bullets ?? GridBullets.check; + body = Semantics( + container: true, + label: l.editorGridLabel(group.nodeIds.length, columns), + child: Padding( + padding: EdgeInsets.only( + top: theme.wonky.gridSpaceBefore * zoom, + bottom: theme.wonky.gridSpaceAfter * zoom, + ), + child: _chrome( + child, + gridCallbacks == null + ? null + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + EditorChromeButton( + key: ValueKey('write.editor.grid.columns.$id'), + label: l.editorGridColumns(columns), + tooltip: l.cmdGridSetColumns, + onTap: (rect) => gridCallbacks.onColumnsTap(id, rect), + ), + const SizedBox(width: 4), + EditorChromeButton( + key: ValueKey('write.editor.grid.bullets.$id'), + icon: switch (bullets) { + GridBullets.check => LucideIcons.check, + GridBullets.circle => LucideIcons.circleSmall, + GridBullets.none => LucideIcons.minus, + }, + tooltip: l.cmdGridSetBullets, + onTap: (rect) => gridCallbacks.onBulletsTap(id, rect), + ), + const SizedBox(width: 4), + EditorChromeButton( + key: ValueKey('write.editor.grid.add.$id'), + icon: LucideIcons.plus, + tooltip: l.cmdGridAddItem, + onTap: (_) => gridCallbacks.onAddItem(id), + ), + const SizedBox(width: 4), + EditorChromeButton( + key: ValueKey('write.editor.grid.menu.$id'), + icon: LucideIcons.ellipsis, + tooltip: l.editorGridMenu, + onTap: (rect) => gridCallbacks.onMenu(id, rect), + ), + ], + ), + ), + ), + ); + } + final frames = this.frames; + if (frames == null || first == null || last == null) return body; + return frames.frameGroup(first, last, body); + } + + Widget _chrome(Widget child, Widget? chrome) => chrome == null + ? child + : EditorHoverChrome(above: true, chrome: chrome, child: child); + + @override + void paintGroup( + Canvas canvas, + Offset offset, + DocumentCellGroupGeometry geometry, + ) { + if (geometry.group.kind == DocumentCellGroupKind.flow) { + _paintBullets(canvas, offset, geometry); + return; + } + if (geometry.rowCount == 0 || geometry.columnCount == 0) return; + final left = geometry.columnEdges.first; + final right = geometry.columnEdges.last; + // The header row's background. + canvas.drawRect( + Rect.fromLTRB( + left, + geometry.rowEdges.first, + right, + geometry.rowBottoms.first, + ).shift(offset), + Paint()..color = theme.tableHeaderBackground, + ); + final line = Paint() + ..color = theme.tableBorderColor + ..strokeWidth = 1 + ..style = PaintingStyle.stroke; + // Each row's box and column lines (rows may be apart: page breaks). + for (var r = 0; r < geometry.rowCount; r++) { + final top = geometry.rowEdges[r]; + final bottom = geometry.rowBottoms[r]; + canvas.drawRect( + Rect.fromLTRB(left + 0.5, top + 0.5, right - 0.5, bottom - 0.5) + .shift(offset), + line, + ); + for (var c = 1; c < geometry.columnCount; c++) { + final x = geometry.columnEdges[c]; + canvas.drawLine( + Offset(x, top) + offset, + Offset(x, bottom) + offset, + line, + ); + } + } + } + + void _paintBullets( + Canvas canvas, + Offset offset, + DocumentCellGroupGeometry geometry, + ) { + final first = slots[geometry.group.nodeIds.first]; + final bullets = first?.bullets ?? GridBullets.none; + final icon = switch (bullets) { + GridBullets.check => LucideIcons.check, + GridBullets.circle => LucideIcons.circleSmall, + GridBullets.none => null, + }; + if (icon == null) return; + final size = _bulletSize; + final painter = TextPainter( + text: TextSpan( + text: String.fromCharCode(icon.codePoint), + style: TextStyle( + fontFamily: icon.fontFamily, + package: icon.fontPackage, + fontSize: size, + height: 1, + color: theme.wonky.gridBulletColor, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + for (final rect in geometry.slotRects.values) { + painter.paint( + canvas, + rect.topLeft + offset + Offset(0, size * 0.05 + _textTopInset), + ); + } + painter.dispose(); + } + + /// The bullet's offset from the item's top (the first line's leading). + double get _textTopInset { + final fontSize = (theme.body.fontSize ?? 14.67) * zoom; + final height = theme.body.height ?? 1.4; + return ((fontSize * height) - _bulletSize).clamp(0, double.infinity) / 2; + } + + @override + void dispose() { + slots.removeListener(notifyListeners); + super.dispose(); + } +} + +/// Styles table cells and grid items (Write 12 §3.3): cells get their +/// padding, their column's alignment and, in the header row, bold text; +/// grid items room for their bullet. +class CellStylePhase extends SingleColumnLayoutStylePhase { + CellStylePhase(this.layout); + + WriteCellLayout layout; + + final _headerBuilders = Expando(); + + @override + SingleColumnLayoutViewModel style( + Document document, + SingleColumnLayoutViewModel viewModel, + ) { + var changed = false; + final models = []; + for (final model in viewModel.componentViewModels) { + final slot = layout.slots[model.nodeId]; + if (slot == null || model is! TextComponentViewModel) { + models.add(model); + continue; + } + final copy = model.copy(); + final text = copy as TextComponentViewModel; + if (slot.isCell) { + copy.padding = layout.cellPadding; + text.textAlignment = switch (slot.align) { + CellAlign.center => TextAlign.center, + CellAlign.right => TextAlign.right, + _ => TextAlign.left, + }; + if (slot.row == 0) { + final base = text.textStyleBuilder; + text.textStyleBuilder = _headerBuilders[base] ??= (attributions) => + base(attributions).copyWith(fontWeight: FontWeight.w600); + } + } else { + copy.padding = EdgeInsets.only(left: layout.bulletRoom(slot.bullets)); + } + changed = true; + models.add(copy); + } + if (!changed) return viewModel; + return SingleColumnLayoutViewModel( + padding: viewModel.padding, + componentViewModels: models, + ); + } +} diff --git a/wonky_write/lib/features/editor/ui/wonky_editor.dart b/wonky_write/lib/features/editor/ui/wonky_editor.dart index 790c4c39..c1f7b20a 100644 --- a/wonky_write/lib/features/editor/ui/wonky_editor.dart +++ b/wonky_write/lib/features/editor/ui/wonky_editor.dart @@ -13,6 +13,7 @@ import '../../../l10n/write_l10n.dart'; import '../applier/delta_applier.dart'; import '../commands/write_editor_commands.dart'; import '../components/box_frames.dart'; +import '../components/cell_layout.dart'; import '../components/editor_services.dart'; import '../components/grid_component.dart'; import '../components/youtube_component.dart'; @@ -173,6 +174,12 @@ class WonkyEditorState extends State { zoom: 1, ); final _direction = TextDirectionStylePhase(); + // Table cells and grid items laid out side by side (Write 12 §3.2), one + // layout per document shown (the document, a suggestion preview). + WriteCellLayout? _mainCells; + WriteCellLayout? _previewCells; + late final _cellPhase = CellStylePhase(_cellsFor(controller.document)); + WriteCellLayout? _cellsInBuild; final _pillSpacing = PillSpacingStylePhase(); final _slashPicker = PickerController(); final _mentionPicker = PickerController(); @@ -297,8 +304,29 @@ class WonkyEditorState extends State { } } + /// The cell layout of [document] (made when the document changes). + WriteCellLayout _cellsFor(Document document) { + final main = identical(document, controller.document); + var cells = main ? _mainCells : _previewCells; + if (cells == null || !identical(cells.slots.document, document)) { + cells?.slots.dispose(); + cells?.dispose(); + cells = WriteCellLayout(CellSlots(document)); + if (main) { + _mainCells = cells; + } else { + _previewCells = cells; + } + } + return cells; + } + @override void dispose() { + for (final cells in [_mainCells, _previewCells]) { + cells?.slots.dispose(); + cells?.dispose(); + } _detach(controller); _hoverTimer?.cancel(); _previewTimer?.cancel(); @@ -926,6 +954,18 @@ class WonkyEditorState extends State { callbacks: widget.blockCallbacks, editable: editable, ); + // Tables and grids are framed as a whole (Write 12 §3.2). + final cells = _cellsFor(doc) + ..update( + theme: theme, + zoom: zoom, + editable: editable, + callbacks: widget.blockCallbacks, + frames: framed(const ParagraphComponentBuilder()) + as BoxFrameComponentBuilder, + ); + _cellPhase.layout = cells; + _cellsInBuild = cells; return [ for (final builder in _unframedBuilders( theme, @@ -1036,6 +1076,7 @@ class WonkyEditorState extends State { selectionStyle: style.selectionStyles, customStylePhases: [ _boxPhase, + _cellPhase, _pillSpacing, if (pages) _paginationPhase, _codeHighlight, @@ -1043,6 +1084,7 @@ class WonkyEditorState extends State { ...widget.extraStylePhases, ], componentBuilders: _componentBuilders(theme, style.zoom, editable: true), + cellLayout: _cellsInBuild, contentDragDelegate: _textDrag, documentUnderlayBuilders: [ if (pages) @@ -1113,6 +1155,7 @@ class WonkyEditorState extends State { stylesheet: stylesheet, customStylePhases: [ _boxPhase, + _cellPhase, _pillSpacing, // Pages also for previews (Viewing mode shows one). if (pages) _paginationPhase, @@ -1126,6 +1169,7 @@ class WonkyEditorState extends State { editable: false, document: editor.document, ), + cellLayout: _cellsInBuild, documentUnderlayBuilders: [ if (pages) _ReaderPageChromeLayerBuilder( diff --git a/wonky_write/test/features/commands/write_commands_test.dart b/wonky_write/test/features/commands/write_commands_test.dart index c8dfa01b..ee3cf27d 100644 --- a/wonky_write/test/features/commands/write_commands_test.dart +++ b/wonky_write/test/features/commands/write_commands_test.dart @@ -149,21 +149,29 @@ void main() { }, ); - test('when inserting a 2×2 table then undo removes it', () async { + test('when inserting a 2×2 table then its cells are lines, the caret is ' + 'in the first cell and undo removes it', () async { doc.editor.select(DeltaSelection.collapsed(text().length - 1)); final r = await run('write.insert.table', {'rows': 2, 'cols': 2}); expect(r.isOk, isTrue); + final tables = CellRuns.of( + WonkyDocument(h.session.editor.delta), + ).whereType(); + expect(tables.single.rows.map((r) => r.cells.length), [2, 2]); + expect(doc.commands.caretCell(), (0, 0)); + + await run('write.table.insertRow', {'position': 'below'}); expect( - h.session.editor.delta.toList().any((op) => op.data is Map), - isTrue, + CellRuns.of( + WonkyDocument(h.session.editor.delta), + ).whereType().single.rows, + hasLength(3), ); await h.undo.undo(); - expect( - h.session.editor.delta.toList().any((op) => op.data is Map), - isFalse, - ); + await h.undo.undo(); + expect(CellRuns.of(WonkyDocument(h.session.editor.delta)), isEmpty); }); test( diff --git a/wonky_write/test/features/editor/commands/wonky_markdown_commands_test.dart b/wonky_write/test/features/editor/commands/wonky_markdown_commands_test.dart index 72d1fb2b..d173f66d 100644 --- a/wonky_write/test/features/editor/commands/wonky_markdown_commands_test.dart +++ b/wonky_write/test/features/editor/commands/wonky_markdown_commands_test.dart @@ -4,9 +4,14 @@ import 'package:wonky_write/features/editor/applier/delta_applier.dart'; import 'package:wonky_write/features/editor/commands/write_editor_commands.dart'; import 'package:wonky_write/features/editor/controller/editor_types.dart'; import 'package:wonky_write_core/wonky_write_core.dart' - show BoxType, CalloutVariant, GridBullets, MarkdownFlavor; - -import 'package:wonky_write/features/editor/grid/inline_grid_editing.dart'; + show + BoxType, + CalloutVariant, + CellGrid, + CellRuns, + GridBullets, + MarkdownFlavor, + WonkyDocument; import '../support/editor_harness.dart'; @@ -188,26 +193,23 @@ void main() { expect(h.controller.boxFrames.isCollapsed('any'), isFalse); }); - test('when inserting a grid then it has three items and can change', () { - final grids = h.controller.gridEditing = InlineGridEditing( - controller: h.controller, - commands: commands, - ); - addTearDown(grids.dispose); + test('when inserting a grid then it has three item lines, the caret is ' + 'in the first, and it can change', () { final id = commands.insertGrid()!; - expect(grids.active, GridItemRef(id, 0), reason: 'caret in item 1'); - grids.deactivate(); - Map grid() => - (h.delta.operations.first.data! as Map)['grid'] as Map; - expect(grid()['items'], ['', '', '']); + CellGrid grid() => CellRuns.grid(WonkyDocument(h.delta), id)!; + expect(grid().items.map((l) => l.text), ['', '', '']); + expect(commands.caretInCell, isTrue); + expect(commands.gridOf()?.id, id); commands.setGridColumns(2, nodeId: id); commands.setGridBullets(GridBullets.circle, nodeId: id); commands.addGridItem(nodeId: id); - expect(grid()['columns'], 2); - expect(grid()['bullets'], 'circle'); - expect((grid()['items'] as List).length, 4); + expect(grid().columns, 2); + expect(grid().bullets, GridBullets.circle); + expect(grid().items, hasLength(4)); commands.removeGridItem(0, nodeId: id); - expect((grid()['items'] as List).length, 3); + expect(grid().items, hasLength(3)); + commands.deleteGrid(nodeId: id); + expect(CellRuns.grid(WonkyDocument(h.delta), id), isNull); }); }); diff --git a/wonky_write/test/features/editor/reactions/wonky_shortcuts_test.dart b/wonky_write/test/features/editor/reactions/wonky_shortcuts_test.dart index 84d971d3..f2fe9cbb 100644 --- a/wonky_write/test/features/editor/reactions/wonky_shortcuts_test.dart +++ b/wonky_write/test/features/editor/reactions/wonky_shortcuts_test.dart @@ -1,9 +1,7 @@ import 'package:dart_quill_delta/dart_quill_delta.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:wonky_write_core/wonky_write_core.dart' show MarkdownFlavor; - -import 'package:wonky_write/features/editor/commands/write_editor_commands.dart'; -import 'package:wonky_write/features/editor/grid/inline_grid_editing.dart'; +import 'package:wonky_write_core/wonky_write_core.dart' + show CellGrid, CellRuns, MarkdownFlavor, WonkyDocument; import '../support/editor_harness.dart'; @@ -74,19 +72,19 @@ void main() { test('when typing ":::grid " then a grid is inserted before the line and ' 'its first item gets the caret', () async { h = _wonky(Delta()..insert('\n'))..select(0); - final grids = h.controller.gridEditing = InlineGridEditing( - controller: h.controller, - commands: WriteEditorCommands(h.controller), - ); - addTearDown(grids.dispose); h.type(':::grid '); await pumpEventQueue(); - final grid = h.delta.operations.first.data! as Map; - expect((grid['grid'] as Map)['columns'], 3); - expect((grid['grid'] as Map)['items'], ['', '', '']); + final grid = CellRuns.of(WonkyDocument(h.delta)).single as CellGrid; + expect(grid.columns, 3); + expect(grid.items.map((l) => l.text), ['', '', '']); + // The caret is in the first item; typing goes there. + h.type('Fast'); expect( - grids.active, - GridItemRef((grid['grid'] as Map)['id'] as String, 0), + (CellRuns.of(WonkyDocument(h.delta)).single as CellGrid) + .items + .first + .text, + 'Fast', ); }); diff --git a/wonky_write/test/features/editor/ui/editor_extras_test.dart b/wonky_write/test/features/editor/ui/editor_extras_test.dart index c9fdd7db..4bc1622e 100644 --- a/wonky_write/test/features/editor/ui/editor_extras_test.dart +++ b/wonky_write/test/features/editor/ui/editor_extras_test.dart @@ -12,6 +12,8 @@ import 'package:wonky_app/platform.dart'; import 'package:wonky_write/features/editor/keyboard/editor_keyboard.dart'; import 'package:wonky_write/features/editor/popovers/editor_popovers.dart'; import 'package:wonky_write/features/editor/ui/wonky_editor.dart'; +import 'package:wonky_write_core/wonky_write_core.dart' + show CellRuns, WonkyDocument; import '../support/editor_harness.dart'; @@ -84,219 +86,249 @@ Future<_Pumped> _pump( return _Pumped(h, key, drops); } -Delta _tableDoc() => Delta() - ..insert('Intro\n') - ..insert({ - 'table': { - 'id': 't1', - 'align': [null, null], - 'rows': [ - ['Name', 'Qty'], - ['Apples', '3'], - ], - }, - }) - ..insert('\n') - ..insert('Outro\n'); +/// A paragraph, a 2×2 table of cell lines and a paragraph. +Delta _tableDoc() { + Map cell(String row) => { + 'cell': {'table': 't1', 'row': row}, + }; + return Delta() + ..insert('Intro\n') + ..insert('Name') + ..insert('\n', cell('r0')) + ..insert('Qty') + ..insert('\n', cell('r0')) + ..insert('Apples') + ..insert('\n', cell('r1')) + ..insert('3') + ..insert('\n', cell('r1')) + ..insert('Outro\n'); +} +/// The table's cell texts, row by row. List> _rows(Delta delta) { - for (final op in delta.operations) { - final data = op.data; - if (data is Map && data['table'] is Map) { - return [ - for (final row in (data['table'] as Map)['rows'] as List) - [for (final c in row as List) '$c'], - ]; - } - } - return const []; + final t = CellRuns.table(WonkyDocument(delta), 't1'); + if (t == null) return const []; + return [ + for (final r in t.rows) [for (final c in r.cells) c.text], + ]; +} + +/// The text of the node with the caret and the caret's offset in it. +(String, int, int) _caret(EditorHarness h) { + final s = h.controller.composer.selection!; + final node = h.controller.document.getNodeById(s.extent.nodeId)! as TextNode; + return ( + node.text.toPlainText(), + (s.base.nodePosition as TextNodePosition).offset, + (s.extent.nodePosition as TextNodePosition).offset, + ); } Offset _center(WidgetTester tester, String text) => tester.getCenter(find.text(text, findRichText: true).first); void main() { - group('Given a table in the document (SE-4 in-place editing)', () { - testWidgets('when tapping a cell then it becomes a field with its ' - 'Markdown source', (tester) async { + group('Given a table of cell lines (Write 12)', () { + testWidgets('when it is laid out then the cells of a row are side by side ' + 'and the rows stacked', (tester) async { + await _pump(tester, _tableDoc()); + final name = _center(tester, 'Name'); + final qty = _center(tester, 'Qty'); + final apples = _center(tester, 'Apples'); + expect(qty.dy, closeTo(name.dy, 1)); + expect(qty.dx, greaterThan(name.dx + 50)); + expect(apples.dy, greaterThan(name.dy + 5)); + expect(apples.dx, closeTo(name.dx, 30)); + }); + + testWidgets('when clicking a word in a cell then the caret is there, and ' + 'double-clicking another word of that cell selects it (AC-12.1)', ( + tester, + ) async { final p = await _pump(tester, _tableDoc()); + await tester.tapAt( + _center(tester, 'Apples'), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 50)); + expect(_caret(p.h).$1, 'Apples'); - await tester.tapAt(_center(tester, 'Apples')); - await tester.pump(); + // Another word of the same cell, while the caret is in it. + p.h.controller.applyRemote( + Delta() + ..retain('Intro\nName\nQty\nApples'.length) + ..insert(' and pears'), + ); + await tester.pumpAndSettle(); + final s = p.h.controller.composer.selection!; + final layout = p.h.controller.documentLayout!; + final rect = layout.getRectForPosition( + DocumentPosition( + nodeId: s.extent.nodeId, + nodePosition: const TextNodePosition(offset: 13), + ), + )!; + final at = layout.getGlobalOffsetFromDocumentOffset(rect.center); + await tester.tapAt(at, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 40)); + await tester.tapAt(at, kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + expect(_caret(p.h), ('Apples and pears', 11, 16)); + }, variant: TargetPlatformVariant.only(TargetPlatform.macOS)); - final field = tester.widget(find.byType(TextField)); - expect(field.controller!.text, 'Apples'); - expect(p.state.tableEditing.active?.row, 1); - expect(p.state.tableEditing.active?.column, 0); + testWidgets('when clicking below a short cell then the caret goes into ' + 'that cell', (tester) async { + final p = await _pump( + tester, + _tableDoc().compose( + Delta() + ..retain('Intro\nName\n'.length) + ..insert( + 'A long header that wraps over several lines in its column ' + 'so the row is much taller than the first cell ', + ), + ), + ); + final name = tester.getRect(find.text('Name', findRichText: true).first); + await tester.tapAt(Offset(name.center.dx, name.bottom + 30)); + await tester.pumpAndSettle(); + expect(_caret(p.h).$1, 'Name'); }); - testWidgets( - 'when another cell is pressed while editing then editing moves there ' - 'without stopping in between', - (tester) async { - final p = await _pump(tester, _tableDoc()); - await tester.tapAt(_center(tester, 'Apples')); - await tester.pumpAndSettle(); - final editing = p.state.tableEditing; - final stops = []; - void record() { - if (editing.active == null) stops.add(null); - } - - editing.addListener(record); - addTearDown(() => editing.removeListener(record)); - - final gesture = await tester.startGesture( - _center(tester, '3'), - kind: PointerDeviceKind.mouse, - ); - await tester.pump(); - expect(editing.active?.column, 0); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(editing.active?.row, 1); - expect(editing.active?.column, 1); - expect(stops, isEmpty); - }, - variant: TargetPlatformVariant.only(TargetPlatform.macOS), - ); - - testWidgets( - 'when a word of a cell is double-clicked then editing starts with the ' - 'word selected', - (tester) async { - final p = await _pump(tester, _tableDoc()); - final at = _center(tester, 'Apples'); - final gesture = await tester.startGesture( - at, - kind: PointerDeviceKind.mouse, - ); - await gesture.up(); - await tester.pump(const Duration(milliseconds: 40)); - await gesture.down(at); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(p.state.tableEditing.active?.row, 1); - expect( - p.state.tableEditing.text.selection, - const TextSelection(baseOffset: 0, extentOffset: 6), - ); - }, - variant: TargetPlatformVariant.only(TargetPlatform.macOS), - ); - - testWidgets('when typing and pressing Tab then the cell is replaced in ' - 'one "Edit cell" step and the next cell is edited', (tester) async { + testWidgets('when typing in a cell then the text goes into that cell', ( + tester, + ) async { final p = await _pump(tester, _tableDoc()); await tester.tapAt(_center(tester, 'Apples')); - await tester.pump(); + await tester.pumpAndSettle(); + p.h.controller.select( + DeltaSelection.collapsed('Intro\nName\nQty\nApples'.length), + ); + await tester.typeImeText('!'); + await tester.pumpAndSettle(); + expect(_rows(p.h.delta), [ + ['Name', 'Qty'], + ['Apples!', '3'], + ]); + }); + + testWidgets('when pressing Tab then the next cell is selected and Tab in ' + 'the last cell appends a row (AC-12.2)', (tester) async { + final p = await _pump(tester, _tableDoc()); + await tester.tapAt(_center(tester, 'Apples')); + await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Pears **ripe**'); await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.pump(); + await tester.pumpAndSettle(); + expect(_caret(p.h), ('3', 0, 1)); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(_rows(p.h.delta), [ + ['Name', 'Qty'], + ['Apples', '3'], + ['', ''], + ]); + expect(_caret(p.h), ('', 0, 0)); - expect(_rows(p.h.delta)[1], ['Pears **ripe**', '3']); - expect(p.h.changes.where((c) => c.label == 'Edit cell'), hasLength(1)); - expect(p.state.tableEditing.active?.column, 1); - final field = tester.widget(find.byType(TextField)); - expect(field.controller!.text, '3'); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + expect(_caret(p.h), ('3', 0, 1)); }); - testWidgets('when pressing Tab in the last cell then a row is appended', ( + testWidgets('when pressing ↑ and ↓ then the caret keeps its column', ( tester, ) async { final p = await _pump(tester, _tableDoc()); await tester.tapAt(_center(tester, '3')); - await tester.pump(); - - await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.pump(); - - expect(_rows(p.h.delta), hasLength(3)); - expect(p.h.changes.last.label, 'Insert row'); - expect(p.state.tableEditing.active?.row, 2); - expect(p.state.tableEditing.active?.column, 0); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(_caret(p.h).$1, 'Qty'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(_caret(p.h).$1, 'Intro'); + + await tester.tapAt(_center(tester, 'Name')); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(_caret(p.h).$1, 'Apples'); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(_caret(p.h).$1, 'Outro'); }); - testWidgets('when pressing Enter in the last row then a row is added ' - 'below and Shift+Enter goes back up', (tester) async { + testWidgets('when pressing Enter in a cell then the caret goes to the cell ' + 'below, and in the last row a row is added', (tester) async { final p = await _pump(tester, _tableDoc()); - await tester.tapAt(_center(tester, '3')); - await tester.pump(); - - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pump(); + await tester.tapAt(_center(tester, 'Qty')); + await tester.pumpAndSettle(); + + p.h.controller.editor.execute([ + InsertNewlineAtCaretRequest(Editor.createNodeId()), + ]); + await tester.pumpAndSettle(); + expect(_caret(p.h).$1, '3'); + + p.h.controller.editor.execute([ + InsertNewlineAtCaretRequest(Editor.createNodeId()), + ]); + await tester.pumpAndSettle(); expect(_rows(p.h.delta), hasLength(3)); - expect(p.state.tableEditing.active?.row, 2); - expect(p.state.tableEditing.active?.column, 1); - - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - await tester.pump(); - expect(p.state.tableEditing.active?.row, 1); + expect(_rows(p.h.delta)[2], ['', '']); + expect(_caret(p.h), ('', 0, 0)); }); - testWidgets('when pressing Mod+B over the field selection then it is ' - 'wrapped in ** and Esc leaves the cell with the table selected', ( + testWidgets('when deleting a selection across cells then their text is ' + 'cleared, the shape stays and undo restores it (AC-12.3)', ( tester, ) async { final p = await _pump(tester, _tableDoc()); - await tester.tapAt(_center(tester, 'Apples')); - await tester.pump(); - final field = tester.widget(find.byType(TextField)); - field.controller!.selection = const TextSelection( - baseOffset: 0, - extentOffset: 6, + // From "Na|me" to "App|les". + p.h.controller.select( + DeltaSelection('Intro\nNa'.length, 'Intro\nName\nQty\nApp'.length), ); - - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyB); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pump(); - expect(field.controller!.text, '**Apples**'); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pump(); - expect(_rows(p.h.delta)[1][0], '**Apples**'); - expect(p.state.tableEditing.active, isNull); - expect(find.byType(TextField), findsNothing); - expect(p.h.selection, const DeltaSelection(6, 7)); + p.h.controller.editor.execute([ + const DeleteSelectionRequest(TextAffinity.downstream), + ]); + await tester.pumpAndSettle(); + expect(_rows(p.h.delta), [ + ['Na', ''], + ['les', '3'], + ]); + expect(_caret(p.h), ('Na', 2, 2)); + + expect(p.h.changes, hasLength(1)); + p.h.undo(p.h.changes.last); + await tester.pumpAndSettle(); + expect(_rows(p.h.delta), [ + ['Name', 'Qty'], + ['Apples', '3'], + ]); }); - testWidgets('when a collaborator deletes the table while editing then ' - 'editing stops', (tester) async { + testWidgets('when pressing Backspace at the start of a cell then the ' + 'caret goes to the previous cell without merging', (tester) async { final p = await _pump(tester, _tableDoc()); - await tester.tapAt(_center(tester, 'Apples')); - await tester.pump(); - - p.h.controller.applyRemote( - Delta() - ..retain(6) - ..delete(2), + p.h.controller.select( + DeltaSelection.collapsed('Intro\nName\nQty\n'.length), ); await tester.pump(); - expect(p.state.tableEditing.active, isNull); - expect(find.byType(TextField), findsNothing); - }); - - testWidgets('when undoing in a clean cell then the document undo runs', ( - tester, - ) async { - var undos = 0; - await _pump(tester, _tableDoc(), onUndo: () => undos += 1); - await tester.tapAt(_center(tester, 'Apples')); - await tester.pump(); - - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(); - - expect(undos, 1); + p.h.controller.editor.execute([const DeleteUpstreamRequest()]); + await tester.pumpAndSettle(); + expect(_rows(p.h.delta), [ + ['Name', 'Qty'], + ['Apples', '3'], + ]); + expect(_caret(p.h), ('Qty', 3, 3)); }); }); From 211bf5d881709444255c8165809107c9b3916d66 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Oct 2026 00:13:19 +0200 Subject: [PATCH 07/16] feat(write): paste and copy with cells; tests for native tables and grids Pasting into a cell joins the lines; a pasted table gets new ids; plain text copies rows as TSV. Table, grid and popover tests move to cell lines. Co-Authored-By: Claude Opus 5.5 --- .../wonky_write_core/lib/src/delta/cells.dart | 33 ++ .../commands/wonky_markdown_commands.dart | 2 +- .../commands/write_editor_commands.dart | 59 ++- .../editor/components/cell_layout.dart | 8 +- .../editor/paste/clipboard_codec.dart | 91 ++-- .../reactions/block_conversion_reactions.dart | 5 +- .../features/editor/setup/editor_factory.dart | 5 +- .../lib/features/editor/ui/wonky_editor.dart | 5 +- .../commands/write_editor_commands_test.dart | 124 +++-- .../editor/components/grid_editing_test.dart | 456 +++++------------- .../features/tables/table_editor_test.dart | 31 +- 11 files changed, 371 insertions(+), 448 deletions(-) diff --git a/packages/wonky_write_core/lib/src/delta/cells.dart b/packages/wonky_write_core/lib/src/delta/cells.dart index e4ef3bdd..59d960db 100644 --- a/packages/wonky_write_core/lib/src/delta/cells.dart +++ b/packages/wonky_write_core/lib/src/delta/cells.dart @@ -442,6 +442,39 @@ abstract final class CellLines { return d; } + /// [fragment] with new table, row and grid ids (paste and import, Write + /// 12 §1.3): each table and grid gets a new id, each row a new row id, so + /// a pasted copy never joins the original. + static Delta regenerateIds(Delta fragment) { + final groups = {}; + final rows = {}; + final out = Delta(); + for (final op in fragment.toList()) { + final attrs = op.attributes; + final cell = CellAttr.of(attrs); + final item = cell == null ? GridItemAttr.of(attrs) : null; + if (cell == null && item == null) { + out.push(op); + continue; + } + final next = {...attrs!}; + if (cell != null) { + final table = groups.putIfAbsent('t:${cell.table}', CellIds.group); + final row = rows.putIfAbsent( + '${cell.table}/${cell.row}', + () => CellIds.row(), + ); + next[WonkyAttr.cell] = cell.copyWith(table: table, row: row).toJson(); + } else { + next[WonkyAttr.grid] = item! + .copyWith(id: groups.putIfAbsent('g:${item.id}', CellIds.group)) + .toJson(); + } + out.insert(op.data, next); + } + return out; + } + static void _appendInline(Delta out, Delta inline) { for (final op in inline.toList()) { final data = op.data; diff --git a/wonky_write/lib/features/editor/commands/wonky_markdown_commands.dart b/wonky_write/lib/features/editor/commands/wonky_markdown_commands.dart index 127071f9..1b0e863a 100644 --- a/wonky_write/lib/features/editor/commands/wonky_markdown_commands.dart +++ b/wonky_write/lib/features/editor/commands/wonky_markdown_commands.dart @@ -429,7 +429,7 @@ extension WonkyMarkdownCommands on WriteEditorCommands { GridBullets bullets = GridBullets.check, }) { if (isSuggesting || caretInCell) return null; - final id = CellIds.group(); + final id = controller.newEmbedId(); final lines = CellLines.grid( id: id, items: [Delta(), Delta(), Delta()], diff --git a/wonky_write/lib/features/editor/commands/write_editor_commands.dart b/wonky_write/lib/features/editor/commands/write_editor_commands.dart index 709caed2..35291f3b 100644 --- a/wonky_write/lib/features/editor/commands/write_editor_commands.dart +++ b/wonky_write/lib/features/editor/commands/write_editor_commands.dart @@ -11,7 +11,6 @@ import 'package:wonky_write_core/wonky_write_core.dart' CellEditException, CellEdits, CellGrid, - CellIds, CellLines, DeltaUtils, GridBullets, @@ -1003,7 +1002,7 @@ class WriteEditorCommands { /// 12 §3.5), the caret in its first cell. Returns the table's id. String? insertTable({int rows = 3, int columns = 3}) { if (isSuggesting || caretInCell) return null; - final id = CellIds.group(); + final id = controller.newEmbedId(); final lines = CellLines.table( id: id, rows: List.generate( @@ -1704,10 +1703,23 @@ class WriteEditorCommands { int? end, }) { label ??= _l.undoPaste; - final s = selection; + var s = selection; + if (start == null && + s != null && + !s.isCollapsed && + _selectionTouchesCells(s) && + controller.mirror.blockAtOffset(s.start).id != + controller.mirror.blockAtOffset(s.end).id) { + // Pasting over a selection across cells clears the cells first and + // pastes into the first (Write 12 §3.4). + deleteSelection(); + s = selection; + } final from = start ?? s?.start; final to = end ?? s?.end ?? from; if (!canEdit || from == null || to == null || fragment.isEmpty) return null; + // A pasted table or grid is a new one (Write 12 §1.3). + fragment = CellLines.regenerateIds(fragment); var lines = splitDeltaIntoLines(fragment); if (lines.isEmpty) return null; @@ -1719,6 +1731,30 @@ class WriteEditorCommands { final blockAttributes = block.blockAttributes; final fragmentEndsWithNewline = _endsWithNewline(fragment); + if (isCellNode(block.node)) { + // A cell or grid item is one line: the pasted lines' text joined with + // spaces, inline formatting kept (Write 12 §3.4). + var first = true; + for (final line in lines) { + if (!first) change.insert(' '); + first = false; + for (final segment in line.segments) { + final data = segment.data; + if (data is! String) continue; + final text = data.replaceAll(DeltaKeys.softBreak, ' '); + if (text.isNotEmpty) { + change.insert(text, segment.attributes); + } + } + } + final inserted = _insertedLength(change); + return _apply( + change..trim(), + label, + selectionAfter: DeltaSelection.collapsed(from + inserted), + ); + } + if (block.isCode) { // Plain text; newlines stay code lines. final text = lines @@ -1778,6 +1814,15 @@ class WriteEditorCommands { final trailing = fragmentEndsWithNewline ? null : lines.last; for (var i = 0; i < complete.length; i += 1) { final line = complete[i]; + if (_isCellLine(line)) { + // Cell and item lines stay whole lines (Write 12 §3.4). + if (i == 0 && local > 0) change.insert('\n', blockAttributes); + for (final segment in line.segments) { + change.insert(segment.data, segment.attributes); + } + change.insert('\n', line.endAttributes); + continue; + } if (line.isEmbedLine) { if (i == 0 && local > 0) change.insert('\n', blockAttributes); change @@ -1813,6 +1858,14 @@ class WriteEditorCommands { ); } + static bool _isCellLine(DeltaLine line) => + line.endAttributes?[DeltaKeys.cell] != null || + line.endAttributes?[DeltaKeys.gridItem] != null; + + bool _selectionTouchesCells(DeltaSelection s) => controller.mirror + .blocksInSelection(s) + .any((block) => isCellNode(block.node)); + static Map? _mergeOverlays( Map? block, Map? end, diff --git a/wonky_write/lib/features/editor/components/cell_layout.dart b/wonky_write/lib/features/editor/components/cell_layout.dart index a521d090..23052e69 100644 --- a/wonky_write/lib/features/editor/components/cell_layout.dart +++ b/wonky_write/lib/features/editor/components/cell_layout.dart @@ -368,8 +368,12 @@ class WriteCellLayout extends ChangeNotifier implements DocumentCellLayout { final top = geometry.rowEdges[r]; final bottom = geometry.rowBottoms[r]; canvas.drawRect( - Rect.fromLTRB(left + 0.5, top + 0.5, right - 0.5, bottom - 0.5) - .shift(offset), + Rect.fromLTRB( + left + 0.5, + top + 0.5, + right - 0.5, + bottom - 0.5, + ).shift(offset), line, ); for (var c = 1; c < geometry.columnCount; c++) { diff --git a/wonky_write/lib/features/editor/paste/clipboard_codec.dart b/wonky_write/lib/features/editor/paste/clipboard_codec.dart index 6d5f6bbd..e32892dd 100644 --- a/wonky_write/lib/features/editor/paste/clipboard_codec.dart +++ b/wonky_write/lib/features/editor/paste/clipboard_codec.dart @@ -6,6 +6,7 @@ import 'package:dart_quill_delta/dart_quill_delta.dart'; import 'package:flutter/foundation.dart'; import 'package:wonky_write_core/wonky_write_core.dart' show + CellAttr, DocTreeBuilder, HtmlBodyWriter, HtmlImporter, @@ -14,7 +15,8 @@ import 'package:wonky_write_core/wonky_write_core.dart' SuggestionView, SuggestionViewMode, WonkyDeltaNormalizer, - WonkyDeltaValidator; + WonkyDeltaValidator, + WonkyDocument; import '../mapping/wonky_schema.dart'; @@ -94,61 +96,64 @@ class ClipboardCodec { } /// Readable plain text: bullets `• `, numbers, tasks `☐ `/`☑ `, tables as - /// TSV, images as `[image: alt]`. + /// TSV (cells of a row joined with tabs, Write 12 §3.4), images as + /// `[image: alt]`. static String readablePlainText(Delta document) { - final out = StringBuffer(); - final line = StringBuffer(); + final out = []; var ordinal = 0; - for (final op in document.operations) { - final data = op.data; - if (data is Map) { - final type = data.keys.first; - final value = data[type]; + CellAttr? previousCell; + for (final line in WonkyDocument(document).lines) { + final text = StringBuffer(); + for (final segment in line.segments) { + final data = segment.data; + if (data is String) { + text.write(data.replaceAll(DeltaKeys.softBreak, '\n')); + continue; + } + final map = data as Map; + final type = map.keys.first; + final value = map[type]; if (type == DeltaKeys.imageEmbed && value is Map) { - line.write('[image: ${value['alt'] ?? ''}]'); + text.write('[image: ${value['alt'] ?? ''}]'); } else if (type == DeltaKeys.dividerEmbed) { - line.write('---'); + text.write('---'); } else if (type == DeltaKeys.tableEmbed && value is Map) { final rows = (value['rows'] as List? ?? const []).map( (r) => (r as List).join('\t'), ); - line.write(rows.join('\n')); + text.write(rows.join('\n')); } - continue; } - final text = data as String; - var start = 0; - while (true) { - final nl = text.indexOf('\n', start); - if (nl < 0) { - line.write( - text.substring(start).replaceAll(DeltaKeys.softBreak, '\n'), - ); - break; + final attrs = line.attributes; + final cell = CellAttr.of(attrs); + if (cell != null) { + final sameRow = + previousCell != null && + previousCell.table == cell.table && + previousCell.row == cell.row; + if (sameRow) { + out.last = '${out.last}\t$text'; + } else { + out.add('$text'); } - line.write( - text.substring(start, nl).replaceAll(DeltaKeys.softBreak, '\n'), - ); - final attrs = op.attributes; - final list = attrs?[DeltaKeys.list]; - final indent = ' ' * ((attrs?[DeltaKeys.indent] as int?) ?? 0); - final prefix = switch (list) { - 'bullet' => '$indent• ', - 'ordered' => '$indent${++ordinal}. ', - 'checked' => '$indent☑ ', - 'unchecked' => '$indent☐ ', - _ => '', - }; - if (list != 'ordered') ordinal = 0; - out - ..write(prefix) - ..writeln(line); - line.clear(); - start = nl + 1; + previousCell = cell; + ordinal = 0; + continue; } + previousCell = null; + final list = attrs[DeltaKeys.list]; + final indent = ' ' * ((attrs[DeltaKeys.indent] as int?) ?? 0); + final prefix = switch (list) { + 'bullet' => '$indent• ', + 'ordered' => '$indent${++ordinal}. ', + 'checked' => '$indent☑ ', + 'unchecked' => '$indent☐ ', + _ => '', + }; + if (list != 'ordered') ordinal = 0; + out.add('$prefix$text'); } - if (line.isNotEmpty) out.write(line); - return out.toString().trimRight(); + return out.join('\n').trimRight(); } /// Decodes pasted content (§23.3): Wonky Delta → HTML → plain text diff --git a/wonky_write/lib/features/editor/reactions/block_conversion_reactions.dart b/wonky_write/lib/features/editor/reactions/block_conversion_reactions.dart index f13284d0..035fa740 100644 --- a/wonky_write/lib/features/editor/reactions/block_conversion_reactions.dart +++ b/wonky_write/lib/features/editor/reactions/block_conversion_reactions.dart @@ -14,10 +14,7 @@ import 'wonky_shortcuts.dart'; /// The prefix must be exactly the text before the caret, and the caret must be /// right after the space that was just typed. Never applies in code blocks. class BlockPrefixConversionReaction extends EditReaction { - BlockPrefixConversionReaction({ - required this.newEmbedId, - this.isWonky, - }); + BlockPrefixConversionReaction({required this.newEmbedId, this.isWonky}); /// Creates ids for new embeds (UUIDv7). final String Function() newEmbedId; diff --git a/wonky_write/lib/features/editor/setup/editor_factory.dart b/wonky_write/lib/features/editor/setup/editor_factory.dart index 8bd7b7e9..30062e81 100644 --- a/wonky_write/lib/features/editor/setup/editor_factory.dart +++ b/wonky_write/lib/features/editor/setup/editor_factory.dart @@ -146,10 +146,7 @@ List wonkyReactions( WonkyTypingAttributesReaction(boundary: pillBoundary), marked(const LinkifyReaction(), 'Autolink', autolink), marked( - BlockPrefixConversionReaction( - newEmbedId: newEmbedId, - isWonky: isWonky, - ), + BlockPrefixConversionReaction(newEmbedId: newEmbedId, isWonky: isWonky), 'Autoformat', markdown, ), diff --git a/wonky_write/lib/features/editor/ui/wonky_editor.dart b/wonky_write/lib/features/editor/ui/wonky_editor.dart index c1f7b20a..2fb19f2a 100644 --- a/wonky_write/lib/features/editor/ui/wonky_editor.dart +++ b/wonky_write/lib/features/editor/ui/wonky_editor.dart @@ -961,8 +961,9 @@ class WonkyEditorState extends State { zoom: zoom, editable: editable, callbacks: widget.blockCallbacks, - frames: framed(const ParagraphComponentBuilder()) - as BoxFrameComponentBuilder, + frames: + framed(const ParagraphComponentBuilder()) + as BoxFrameComponentBuilder, ); _cellPhase.layout = cells; _cellsInBuild = cells; diff --git a/wonky_write/test/features/editor/commands/write_editor_commands_test.dart b/wonky_write/test/features/editor/commands/write_editor_commands_test.dart index a5bf11ab..c3174e2d 100644 --- a/wonky_write/test/features/editor/commands/write_editor_commands_test.dart +++ b/wonky_write/test/features/editor/commands/write_editor_commands_test.dart @@ -5,6 +5,8 @@ import 'package:wonky_write/features/editor/commands/write_editor_commands.dart' import 'package:wonky_write/features/editor/controller/editor_types.dart'; import 'package:wonky_write/features/editor/model/document_queries.dart'; import 'package:wonky_write/features/editor/model/table_model.dart'; +import 'package:wonky_write_core/wonky_write_core.dart' + show CellAlign, CellEdits, CellLines; import '../support/editor_harness.dart'; @@ -299,15 +301,14 @@ void main() { }, ); - test( - 'when inserting a table then it is one step and the caret goes below', - () { - final id = commands.insertTable(rows: 2, columns: 2); - expect(id, 'embed-0'); - expect(h.changes.single.label, 'Insert table'); - expect(commands.tableOf(nodeId: id)!.rowCount, 2); - }, - ); + test('when inserting a table then it is one step and the caret goes into ' + 'its first cell', () { + final id = commands.insertTable(rows: 2, columns: 2); + expect(id, 'embed-0'); + expect(h.changes.single.label, 'Insert table'); + expect(commands.tableOf(nodeId: id)!.rowCount, 2); + expect(commands.caretCell(), (0, 0)); + }); }); group('Given a caret mid-paragraph', () { @@ -437,45 +438,59 @@ void main() { }); }); - group('Given a table', () { + group('Given a table of cell lines', () { + Map cell(String row) => { + 'cell': {'table': 't1', 'row': row}, + }; final initial = Delta() - ..insert({ - 'table': { - 'id': 't1', - 'align': [null, null], - 'rows': [ - ['A', 'B'], - ['1', '2'], - ], - }, - }) + ..insert('A') + ..insert('\n', cell('r0')) + ..insert('B') + ..insert('\n', cell('r0')) + ..insert('1') + ..insert('\n', cell('r1')) + ..insert('2') + ..insert('\n', cell('r1')) ..insert('\n'); - setUp(() => setUpWith(initial, base: 0, extent: 1)); - - test( - 'when editing a cell and adding a row then each is one undo step keeping the id', - () { - commands.editTable( - (t) => t.setCell(1, 1, 'a | b'), - label: 'Edit table', - ); - commands.editTable((t) => t.insertRow(2), label: 'Insert row'); - final table = commands.tableOf(nodeId: 't1')!; - expect(table.rows, [ - ['A', 'B'], - ['1', 'a | b'], - ['', ''], - ]); - expect(h.changes.map((c) => c.label), ['Edit table', 'Insert row']); - h.undo(h.changes.last); - h.undo(h.changes[0]); - expect(h.delta, initial); - }, - ); + setUp(() => setUpWith(initial, base: 0)); + + test('when editing a cell and adding a row then each is one undo step ' + 'keeping the id', () { + commands.editCells( + (run) => CellEdits.setCell( + run.doc, + run.id, + 1, + 1, + CellLines.markdown('a | b'), + ), + nodeId: 't1', + label: 'Edit table', + ); + commands.editCells( + (run) => CellEdits.insertRow(run.doc, run.id, 2), + nodeId: 't1', + label: 'Insert row', + ); + final table = commands.tableOf(nodeId: 't1')!; + expect(table.id, 't1'); + expect(table.rows, [ + ['A', 'B'], + ['1', r'a \| b'], + ['', ''], + ]); + expect(h.changes.map((c) => c.label), ['Edit table', 'Insert row']); + h.undo(h.changes.last); + h.undo(h.changes[0]); + expect(h.delta, initial); + }); - test('when setting a column alignment then the embed changes', () { - commands.editTable( - (t) => t.setColumnAlignment(1, TableAlign.right), + test('when setting a column alignment then every cell of the column has ' + 'it', () { + commands.editCells( + (run) => + CellEdits.setColumnAlignment(run.doc, run.id, 1, CellAlign.right), + nodeId: 't1', label: 'Align', ); expect(commands.tableOf(nodeId: 't1')!.align, [ @@ -483,6 +498,25 @@ void main() { TableAlign.right, ]); }); + + test('when replacing the table from the editor then unchanged cells keep ' + 'their lines', () { + final edited = TableModel( + id: 't1', + align: const [TableAlign.none, TableAlign.none], + rows: const [ + ['A', 'B'], + ['one', '2'], + ], + ); + expect(commands.replaceTable(edited, nodeId: 't1'), isTrue); + final deletes = h.changes.last.delta + .toList() + .where((op) => op.isDelete) + .fold(0, (n, op) => n + op.length!); + expect(deletes, 1); + expect(commands.tableOf(nodeId: 't1')!.rows[1], ['one', '2']); + }); }); group('Given an image', () { diff --git a/wonky_write/test/features/editor/components/grid_editing_test.dart b/wonky_write/test/features/editor/components/grid_editing_test.dart index 279ba1db..96e96fdd 100644 --- a/wonky_write/test/features/editor/components/grid_editing_test.dart +++ b/wonky_write/test/features/editor/components/grid_editing_test.dart @@ -1,349 +1,155 @@ import 'package:dart_quill_delta/dart_quill_delta.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/super_editor_test.dart'; -import 'package:wonky_write/features/editor/grid/inline_grid_editing.dart'; -import 'package:wonky_write/features/editor/styles/editor_theme.dart'; -import 'package:wonky_write_core/wonky_write_core.dart' show MarkdownFlavor; +import 'package:super_editor/super_editor.dart'; +import 'package:wonky_write/features/editor/applier/delta_applier.dart'; +import 'package:wonky_write_core/wonky_write_core.dart' + show CellGrid, CellRuns, DocumentLine, MarkdownFlavor, WonkyDocument; +import '../support/editor_harness.dart'; import '../ui/wonky_editor_test.dart' show pumpEditor; -Finder _item(int i) => find.byKey(ValueKey('write.editor.gridItem.g1.$i')); - -void main() { - testWidgets( - 'Given an item being edited when another item is pressed then editing ' - 'moves there without stopping in between', - (tester) async { - final h = await pumpEditor( - tester, - Delta() - ..insert('Above\n') - ..insert({ - 'grid': { - 'id': 'g1', - 'columns': 3, - 'items': ['One', 'Two', 'Three'], - }, - }) - ..insert('\nBelow\n'), - ); - h.controller.markdownFlavor = () => MarkdownFlavor.wonky; - final editing = h.controller.gridEditing!; - expect(editing.activate('g1', 0), isTrue); - await tester.pumpAndSettle(); - final selection = h.controller.selection; - - final states = []; - void record() => states.add(editing.active); - editing.addListener(record); - addTearDown(() => editing.removeListener(record)); - - final gesture = await tester.startGesture( - tester.getCenter(_item(1)), - kind: PointerDeviceKind.mouse, - ); - await tester.pump(); - // Pressed, not released: the first item is still being edited. - expect(editing.active, const GridItemRef('g1', 0)); - expect(h.controller.selection, selection); - - await gesture.up(); - await tester.pumpAndSettle(); - - expect(editing.active, const GridItemRef('g1', 1)); - expect(states, isNot(contains(null))); - expect(h.controller.selection, selection); - }, - variant: TargetPlatformVariant.only(TargetPlatform.macOS), - ); +/// A paragraph, a grid of three items in three columns and a paragraph. +Delta _gridDoc() { + const grid = { + 'grid': {'id': 'g1', 'columns': 3, 'bullets': 'check'}, + }; + return Delta() + ..insert('Above\n') + ..insert('One') + ..insert('\n', grid) + ..insert('Two') + ..insert('\n', grid) + ..insert('Three') + ..insert('\n', grid) + ..insert('Below\n'); +} - testWidgets( - 'Given an item being edited when a word is double-clicked then it is ' - 'selected', - (tester) async { - final h = await pumpEditor( - tester, - Delta() - ..insert({ - 'grid': { - 'id': 'g1', - 'columns': 1, - 'items': ['Alpha Beta Gamma'], - }, - }) - ..insert('\nBelow\n'), - ); - h.controller.markdownFlavor = () => MarkdownFlavor.wonky; - final editing = h.controller.gridEditing!; - expect(editing.activate('g1', 0), isTrue); - await tester.pumpAndSettle(); +List _items(EditorHarness h) { + final grid = CellRuns.grid(WonkyDocument(h.delta), 'g1'); + return [for (final l in grid?.items ?? const []) l.text]; +} - final field = find.byType(TextField); - final box = tester.getRect(field); - // "Beta": a little right of the field's middle third's start. - final at = Offset(box.left + box.width * 0.0 + 50, box.center.dy); - final gesture = await tester.startGesture( - at, - kind: PointerDeviceKind.mouse, - ); - await gesture.up(); - await tester.pump(const Duration(milliseconds: 50)); - await gesture.down(at); - await gesture.up(); - await tester.pumpAndSettle(); +String _caretText(EditorHarness h) { + final s = h.controller.composer.selection!; + final node = h.controller.document.getNodeById(s.extent.nodeId)! as TextNode; + return node.text.toPlainText(); +} - final s = editing.text.selection; - // ignore: avoid_print - print('selection $s of "${editing.text.text}"'); - expect(s.isCollapsed, isFalse); - }, - variant: TargetPlatformVariant.only(TargetPlatform.macOS), - ); +Offset _center(WidgetTester tester, String text) => + tester.getCenter(find.text(text, findRichText: true).first); - testWidgets( - 'Given a grid not being edited when a word of an item is double-clicked ' - 'then editing starts with that word selected', - (tester) async { - final h = await pumpEditor( - tester, - Delta() - ..insert({ - 'grid': { - 'id': 'g1', - 'columns': 1, - 'items': ['Alpha Beta Gamma'], - }, - }) - ..insert('\nBelow\n'), - ); +void main() { + group('Given a grid of item lines (Write 12 §1.2)', () { + testWidgets('when it is laid out then the items flow side by side', ( + tester, + ) async { + final h = await pumpEditor(tester, _gridDoc()); h.controller.markdownFlavor = () => MarkdownFlavor.wonky; - final editing = h.controller.gridEditing!; - final paragraph = tester.renderObject( - find.text('Alpha Beta Gamma', findRichText: true), - ); - // In "Beta" (offsets 6–10). - final at = paragraph.localToGlobal( - paragraph.getOffsetForCaret(const TextPosition(offset: 8), Rect.zero) + - Offset(0, paragraph.size.height / 2), - ); + final one = _center(tester, 'One'); + final two = _center(tester, 'Two'); + final three = _center(tester, 'Three'); + expect(two.dy, closeTo(one.dy, 1)); + expect(three.dy, closeTo(one.dy, 1)); + expect(two.dx, greaterThan(one.dx + 50)); + expect(three.dx, greaterThan(two.dx + 50)); + expect(_center(tester, 'Below').dy, greaterThan(one.dy + 5)); + }); + + testWidgets( + 'when clicking an item then the caret is there and double-clicking ' + 'selects its word', + (tester) async { + final h = await pumpEditor(tester, _gridDoc()); + await tester.tapAt( + _center(tester, 'Two'), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 50)); + expect(_caretText(h), 'Two'); + + // The middle of "Three"'s third character. + final three = h.controller.document.firstWhere( + (n) => n is TextNode && n.text.toPlainText() == 'Three', + ); + final layout = h.controller.documentLayout!; + final at = layout.getGlobalOffsetFromDocumentOffset( + layout + .getRectForPosition( + DocumentPosition( + nodeId: three.id, + nodePosition: const TextNodePosition(offset: 2), + ), + )! + .center, + ); + await tester.tapAt(at, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 40)); + await tester.tapAt(at, kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + final s = h.controller.composer.selection!; + expect(_caretText(h), 'Three'); + expect((s.base.nodePosition as TextNodePosition).offset, 0); + expect((s.extent.nodePosition as TextNodePosition).offset, 5); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + ); + + testWidgets('when pressing Enter in an item then the next item is added, ' + 'and Backspace in an empty item removes it (AC-12.7)', (tester) async { + final h = await pumpEditor(tester, _gridDoc()); + h.controller.select(DeltaSelection.collapsed('Above\nOne'.length)); + await tester.pump(); - final gesture = await tester.startGesture( - at, - kind: PointerDeviceKind.mouse, - ); - await gesture.up(); - await tester.pump(const Duration(milliseconds: 40)); - expect(editing.active, const GridItemRef('g1', 0)); - await gesture.down(at); - await gesture.up(); + h.controller.editor.execute([ + InsertNewlineAtCaretRequest(Editor.createNodeId()), + ]); await tester.pumpAndSettle(); + expect(_items(h), ['One', '', 'Two', 'Three']); + expect(_caretText(h), ''); - expect( - editing.text.selection, - const TextSelection(baseOffset: 6, extentOffset: 10), - ); - - // A fast double click: the second click lands before the field is - // built, on the rendered item (here: another item, then back). - editing.deactivate(); - await tester.pumpAndSettle(); - await tester.pump(const Duration(milliseconds: 400)); - // (Later than the clicks above: a new series.) - final fast = await tester.createGesture(kind: PointerDeviceKind.mouse); - await fast.down(at, timeStamp: const Duration(seconds: 10)); - await fast.up(timeStamp: const Duration(seconds: 10, milliseconds: 10)); - await fast.down( - at, - timeStamp: const Duration(seconds: 10, milliseconds: 20), - ); - await fast.up(timeStamp: const Duration(seconds: 10, milliseconds: 30)); + h.controller.editor.execute([const DeleteUpstreamRequest()]); await tester.pumpAndSettle(); + expect(_items(h), ['One', 'Two', 'Three']); + expect(_caretText(h), 'One'); + }); - expect( - editing.text.selection, - const TextSelection(baseOffset: 6, extentOffset: 10), - ); - // The editor's selection color, not Material's. - final theme = TextSelectionTheme.of( - tester.element(find.byType(EditableText)), + testWidgets('when pressing Enter in the empty last item then it leaves ' + 'the grid', (tester) async { + final h = await pumpEditor(tester, _gridDoc()); + h.controller.select( + DeltaSelection.collapsed('Above\nOne\nTwo\nThree'.length), ); - expect( - theme.selectionColor, - EditorTheme.fallback(Brightness.light).selectionColor, - ); - }, - variant: TargetPlatformVariant.only(TargetPlatform.macOS), - ); - - testWidgets( - 'Given the caret in the text when an item is clicked then the grid ' - 'becomes the selection only once its field has the focus', - (tester) async { - final h = await pumpEditor( - tester, - Delta() - ..insert('Above\n') - ..insert({ - 'grid': { - 'id': 'g1', - 'columns': 2, - 'items': ['Alpha', 'Beta'], - }, - }) - ..insert('\nBelow\n'), - ); - h.controller.markdownFlavor = () => MarkdownFlavor.wonky; - await tester.placeCaretInParagraph(h.controller.document.first.id, 2); - await tester.pump(const Duration(milliseconds: 400)); - expect(h.controller.focusNode.hasPrimaryFocus, isTrue); - - // Selecting the grid while the editor still had the focus let the - // editor take the text input back from the field. - final editorFocusedAtSelection = []; - void record() { - final s = h.controller.selection; - if (s != null && !s.isCollapsed) { - editorFocusedAtSelection.add(h.controller.focusNode.hasPrimaryFocus); - } - } - - h.controller.addListener(record); - addTearDown(() => h.controller.removeListener(record)); - - final gesture = await tester.startGesture( - tester.getCenter(_item(1)), - kind: PointerDeviceKind.mouse, - ); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(h.controller.gridEditing!.focusNode.hasFocus, isTrue); - expect(editorFocusedAtSelection, isNotEmpty); - expect(editorFocusedAtSelection, everyElement(isFalse)); - }, - variant: TargetPlatformVariant.only(TargetPlatform.macOS), - ); - - testWidgets( - 'Given an unsaved item when the editor is disposed then the edit is ' - 'saved without errors', - (tester) async { - final h = await pumpEditor( - tester, - Delta() - ..insert({ - 'grid': { - 'id': 'g1', - 'columns': 1, - 'items': ['Alpha'], - }, - }) - ..insert('\nBelow\n'), - ); - h.controller.markdownFlavor = () => MarkdownFlavor.wonky; - h.controller.gridEditing!.activate('g1', 0); - await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Alpha more'); await tester.pump(); - - await tester.pumpWidget(const SizedBox()); - - expect(tester.takeException(), isNull); - final grid = (h.delta.operations.first.data! as Map)['grid'] as Map; - expect(grid['items'], ['Alpha more']); - }, - ); - - testWidgets( - 'Given an item being edited when its field is clicked then the editor ' - 'neither moves its caret nor takes the focus', - (tester) async { - final h = await pumpEditor( - tester, - Delta() - ..insert('Above\n') - ..insert({ - 'grid': { - 'id': 'g1', - 'columns': 2, - 'items': ['Alpha beta', 'Gamma'], - }, - }) - ..insert('\nBelow\n'), - ); - h.controller.markdownFlavor = () => MarkdownFlavor.wonky; - final editing = h.controller.gridEditing!; - final first = await tester.startGesture( - tester.getCenter(_item(0)), - kind: PointerDeviceKind.mouse, - ); - await first.up(); + h.controller.editor.execute([ + InsertNewlineAtCaretRequest(Editor.createNodeId()), + ]); await tester.pumpAndSettle(); - await tester.pump(const Duration(milliseconds: 400)); - final selection = h.controller.selection; - expect(selection?.isCollapsed, isFalse); + expect(_items(h), ['One', 'Two', 'Three', '']); - // The left half of the grid: the editor would put its caret before it. - final field = tester.getRect(find.byType(TextField)); - final click = await tester.startGesture( - Offset(field.left + 4, field.center.dy), - kind: PointerDeviceKind.mouse, - ); - await click.up(); + h.controller.editor.execute([ + InsertNewlineAtCaretRequest(Editor.createNodeId()), + ]); await tester.pumpAndSettle(); - - expect(editing.active, const GridItemRef('g1', 0)); - expect(editing.focusNode.hasFocus, isTrue); - expect(h.controller.selection, selection); - expect(editing.text.selection.baseOffset, lessThan(3)); - }, - variant: TargetPlatformVariant.only(TargetPlatform.macOS), - ); - - testWidgets( - 'Given an item being edited when it is triple-clicked then its text is ' - 'selected', - (tester) async { - final h = await pumpEditor( - tester, - Delta() - ..insert({ - 'grid': { - 'id': 'g1', - 'columns': 1, - 'items': ['Alpha Beta Gamma'], - }, - }) - ..insert('\nBelow\n'), - ); - h.controller.markdownFlavor = () => MarkdownFlavor.wonky; - final editing = h.controller.gridEditing!; - expect(editing.activate('g1', 0), isTrue); + expect(_items(h), ['One', 'Two', 'Three']); + final grid = CellRuns.of(WonkyDocument(h.delta)).single as CellGrid; + expect(h.delta.toList().last.data, '\nBelow\n'); + expect(grid.items, hasLength(3)); + }); + + testWidgets('when pressing Tab then the next item is selected and Tab in ' + 'the last item appends one', (tester) async { + final h = await pumpEditor(tester, _gridDoc()); + await tester.tapAt(_center(tester, 'Two')); await tester.pumpAndSettle(); - await tester.pump(const Duration(milliseconds: 400)); - - final field = tester.getRect(find.byType(TextField)); - final at = Offset(field.left + 30, field.center.dy); - final gesture = await tester.startGesture( - at, - kind: PointerDeviceKind.mouse, - ); - await gesture.up(); - for (var i = 0; i < 2; i++) { - await tester.pump(const Duration(milliseconds: 50)); - await gesture.down(at); - await gesture.up(); - } + await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pumpAndSettle(); - - expect( - editing.text.selection, - const TextSelection(baseOffset: 0, extentOffset: 16), - ); - }, - variant: TargetPlatformVariant.only(TargetPlatform.macOS), - ); + expect(_caretText(h), 'Three'); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(_items(h), ['One', 'Two', 'Three', '']); + expect(_caretText(h), ''); + }); + }); } diff --git a/wonky_write/test/features/tables/table_editor_test.dart b/wonky_write/test/features/tables/table_editor_test.dart index 1481d82a..ec099637 100644 --- a/wonky_write/test/features/tables/table_editor_test.dart +++ b/wonky_write/test/features/tables/table_editor_test.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:wonky_write_core/wonky_write_core.dart' + show CellRuns, CellTable, WonkyDocument; import 'package:shad/shad.dart'; import 'package:wonky_core/wonky_core.dart' show CommandResult, CommandStatus, InvocationSource; @@ -478,25 +480,16 @@ void main() { }); group('Given a document with a table and write.table.edit', () { - String tableId(Delta delta) => delta - .toList() - .map((op) => op.data) - .whereType>() - .map((data) => data['table']) - .whereType>() - .map((table) => '${table['id']}') - .single; - - List rowsOf(Delta delta) => - delta - .toList() - .map((op) => op.data) - .whereType>() - .map((data) => data['table']) - .whereType>() - .map((table) => table['rows']) - .single - as List; + String tableId(Delta delta) => + CellRuns.of(WonkyDocument(delta)).whereType().single.id; + + List rowsOf(Delta delta) => [ + for (final row + in CellRuns.of( + WonkyDocument(delta), + ).whereType().single.rows) + [for (final c in row.cells) c.text], + ]; testWidgets('when editing a cell in the editor and pressing Done then ' 'the table changes in one undo step', (tester) async { From 3bcc4b5d82b5075f5d047a8ff78398069bd6be41 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Oct 2026 00:16:34 +0200 Subject: [PATCH 08/16] feat(write): table rows move to the next page as one; cell context menu Co-Authored-By: Claude Opus 5.5 --- .../editor/components/cell_layout.dart | 32 +++++++++++++++---- .../editor/pagination/pagination.dart | 32 +++++++++++++++++-- .../lib/features/editor/ui/wonky_editor.dart | 2 +- .../lib/features/screen/document_menus.dart | 32 +++++++++++++++++-- .../screen/write_document_controller.dart | 2 +- 5 files changed, 85 insertions(+), 15 deletions(-) diff --git a/wonky_write/lib/features/editor/components/cell_layout.dart b/wonky_write/lib/features/editor/components/cell_layout.dart index 23052e69..c1c2575f 100644 --- a/wonky_write/lib/features/editor/components/cell_layout.dart +++ b/wonky_write/lib/features/editor/components/cell_layout.dart @@ -6,6 +6,7 @@ import 'package:wonky_write_core/wonky_write_core.dart' import '../../../l10n/write_l10n.dart'; import '../mapping/wonky_schema.dart'; +import '../pagination/pagination.dart'; import '../styles/editor_theme.dart'; import 'box_frames.dart'; import 'editor_services.dart'; @@ -163,12 +164,32 @@ class CellSlots extends ChangeNotifier { /// bullets; the group sits in its containers' frames and has the table's /// or grid's chrome. class WriteCellLayout extends ChangeNotifier implements DocumentCellLayout { - WriteCellLayout(this.slots) { + WriteCellLayout(this.slots, {this.pagination}) { slots.addListener(notifyListeners); + pagination?.addListener(_onPagination); } final CellSlots slots; + /// The page breaks (Pages layout): a row pushed to the next page has its + /// gap as space before it (Write 12 §3.8). + final PaginationController? pagination; + var _gaps = const {}; + + void _onPagination() { + final gaps = { + for (final e in pagination!.gaps.entries) + if (slots[e.key] != null) e.key: e.value, + }; + if (_sameGaps(gaps, _gaps)) return; + _gaps = gaps; + notifyListeners(); + } + + static bool _sameGaps(Map a, Map b) => + a.length == b.length && + a.entries.every((e) => (b[e.key] ?? -1) == e.value); + /// Set by the editor on every build. EditorTheme theme = EditorTheme.fallback(Brightness.light); double zoom = 1; @@ -178,10 +199,6 @@ class WriteCellLayout extends ChangeNotifier implements DocumentCellLayout { /// Frames the group in its containers (the editor's frame builder). BoxFrameComponentBuilder? frames; - /// Extra space above row `row` of the table or grid `group` (pagination, - /// Write 12 §3.8). - double Function(String group, int row)? rowSpaceBefore; - /// Takes the editor's values. Called while building: a theme or zoom /// change also changes the stylesheet, which rebuilds the layout. void update({ @@ -218,7 +235,7 @@ class WriteCellLayout extends ChangeNotifier implements DocumentCellLayout { row: slot.row, column: slot.column, minColumnWidth: 48 * zoom, - spaceBefore: rowSpaceBefore?.call(slot.group, slot.row) ?? 0, + spaceBefore: _gaps[nodeId] ?? 0, ); } return DocumentCellPlacement.flow( @@ -227,7 +244,7 @@ class WriteCellLayout extends ChangeNotifier implements DocumentCellLayout { maxColumns: slot.columns, minColumnWidth: 160 * zoom, gap: theme.wonky.gridColumnGap * zoom, - spaceBefore: rowSpaceBefore?.call(slot.group, slot.index) ?? 0, + spaceBefore: _gaps[nodeId] ?? 0, ); } @@ -433,6 +450,7 @@ class WriteCellLayout extends ChangeNotifier implements DocumentCellLayout { @override void dispose() { slots.removeListener(notifyListeners); + pagination?.removeListener(_onPagination); super.dispose(); } } diff --git a/wonky_write/lib/features/editor/pagination/pagination.dart b/wonky_write/lib/features/editor/pagination/pagination.dart index 1ec2846d..aab727ca 100644 --- a/wonky_write/lib/features/editor/pagination/pagination.dart +++ b/wonky_write/lib/features/editor/pagination/pagination.dart @@ -166,9 +166,23 @@ class PaginationController extends ChangeNotifier { var page = 0; double? lastBottom; for (var i = 0; i < nodes.length; i += 1) { - final rect = natural[i]; - if (rect == null) continue; + final laidOut = natural[i]; + if (laidOut == null) continue; + var rect = laidOut; final node = nodes[i]; + // A table row (or a row of a flowed grid) moves as one: the cells + // side by side with this one (Write 12 §3.8). Its gap goes on its + // first cell (the cell layout's space before the row). + final group = _groupOf(node); + if (group != null) { + while (i + 1 < nodes.length && + _groupOf(nodes[i + 1]) == group && + natural[i + 1] != null && + (natural[i + 1]!.top - rect.top).abs() <= 0.5) { + i += 1; + rect = rect.expandToInclude(natural[i]!); + } + } final height = rect.height; var top = rect.top + shift; var bottom = rect.bottom + shift; @@ -244,6 +258,14 @@ class PaginationController extends ChangeNotifier { return box is RenderBox && box.hasSize ? box.size : null; } + /// The table or grid of a cell or item node (`null` for other nodes). + static String? _groupOf(DocumentNode node) { + final cell = cellOf(node); + if (cell != null) return 't:${cell.table}'; + final item = gridItemOf(node); + return item == null ? null : 'g:${item.id}'; + } + static int? _nextLaidOut(List rects, int index) { for (var j = index + 1; j < rects.length; j += 1) { if (rects[j] != null) return j; @@ -311,7 +333,11 @@ class PaginationStylePhase extends SingleColumnLayoutStylePhase { for (var i = 0; i < models.length; i += 1) _padded( models[i], - top: gaps[models[i].nodeId] ?? 0, + // A row's gap is the cell layout's space before it (Write 12 + // §3.8), not the cell's padding. + top: isCellNode(document.getNodeById(models[i].nodeId)) + ? 0 + : gaps[models[i].nodeId] ?? 0, bottom: i == models.length - 1 ? tail : 0, ), ], diff --git a/wonky_write/lib/features/editor/ui/wonky_editor.dart b/wonky_write/lib/features/editor/ui/wonky_editor.dart index 2fb19f2a..5b10b70d 100644 --- a/wonky_write/lib/features/editor/ui/wonky_editor.dart +++ b/wonky_write/lib/features/editor/ui/wonky_editor.dart @@ -311,7 +311,7 @@ class WonkyEditorState extends State { if (cells == null || !identical(cells.slots.document, document)) { cells?.slots.dispose(); cells?.dispose(); - cells = WriteCellLayout(CellSlots(document)); + cells = WriteCellLayout(CellSlots(document), pagination: _pagination); if (main) { _mainCells = cells; } else { diff --git a/wonky_write/lib/features/screen/document_menus.dart b/wonky_write/lib/features/screen/document_menus.dart index f3c80705..a6c82957 100644 --- a/wonky_write/lib/features/screen/document_menus.dart +++ b/wonky_write/lib/features/screen/document_menus.dart @@ -151,17 +151,43 @@ List contextMenuEntries( } if (doc.inTable) { + // The caret's row and column (Write 12 §3.7). return [ ...out, - ui.item('write.table.editCell', label: l.menuEditCells), + const WonkyMenuSeparator(), + ui.item( + 'write.table.insertRow', + args: const {'position': 'above'}, + label: l.tableEditorInsertRowAbove, + keyName: 'write.table.insertRow.above', + ), + ui.item( + 'write.table.insertRow', + args: const {'position': 'below'}, + label: l.tableEditorInsertRowBelow, + keyName: 'write.table.insertRow.below', + ), + ui.item( + 'write.table.insertColumn', + args: const {'position': 'left'}, + label: l.tableEditorInsertColumnLeft, + keyName: 'write.table.insertColumn.left', + ), + ui.item( + 'write.table.insertColumn', + args: const {'position': 'right'}, + label: l.tableEditorInsertColumnRight, + keyName: 'write.table.insertColumn.right', + ), + ui.item('write.table.deleteRow', label: l.tableEditorDeleteRow), + ui.item('write.table.deleteColumn', label: l.tableEditorDeleteColumn), + const WonkyMenuSeparator(), ui.item('write.table.edit'), const WonkyMenuSeparator(), ui.item('write.edit.copyAsMarkdown', label: l.docMenuCopyMarkdown), ui.item('write.table.copyAsTsv'), ui.item('shell.comments.create', label: l.menuComment), const WonkyMenuSeparator(), - ui.item('write.block.moveUp', label: l.docMenuMoveUp), - ui.item('write.block.moveDown', label: l.docMenuMoveDown), ui.item('write.table.delete'), ]; } diff --git a/wonky_write/lib/features/screen/write_document_controller.dart b/wonky_write/lib/features/screen/write_document_controller.dart index a690cfa2..3ab8cc44 100644 --- a/wonky_write/lib/features/screen/write_document_controller.dart +++ b/wonky_write/lib/features/screen/write_document_controller.dart @@ -684,7 +684,7 @@ class WriteDocumentController extends DocumentController { /// The caret block is an image / table / code block. bool get onImage => embedData('image') != null; - bool get inTable => embedData('table') != null; + bool get inTable => commands.tableRunAt() != null; bool get inCode => block()?.isCode ?? false; /// Global rect of the text at Delta [offset] (popover anchors). From 1cef45d00a1dd692c66516478d9927186f19943f Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Oct 2026 00:18:31 +0200 Subject: [PATCH 09/16] docs(specs): native table and grid cells in specs 02, 04, 11, 12 and the index Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- specs/wonky_write/02-document-model.md | 6 +- specs/wonky_write/04-editor.md | 70 +++++++--------------- specs/wonky_write/11-wonky-markdown.md | 4 ++ specs/wonky_write/12-native-cells.md | 80 ++++++++++++++++---------- specs/wonky_write/README.md | 1 + 6 files changed, 82 insertions(+), 81 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1b32bbb8..5c169104 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ ## Specs -The specs in `specs/` are the source of truth. Start with `specs/README.md` (reading order, conventions), then `specs/01-architecture.md` (layout, package rules, how the apps are served). App specs: `specs/wonky_write/`, `specs/wonky_sheets/`, `specs/wonky_drive/` and `specs/wonky_slides/` (v2 decisions: `specs/v2-review.md`). Cross-app features: `specs/16-bugs-and-improvements.md` (Bugs & improvements board, M12); the RevLog sync of Sheets and Slides: `specs/17-revlog-sync.md`. Blog submissions from Wonky Write (M14, built): `specs/18-publications-and-submissions.md`; the guide for blog owners is `docs/blog-integration.md`. Wonky Markdown, Write's second Markdown flavor (the blog's directives: callouts, details, cards, grids, buttons, chips, numbered headings; set per document, publications choose theirs): `specs/wonky_write/11-wonky-markdown.md`. First-party usage analytics for admins in Wonky Drive (`/drive/analytics`, built): `specs/19-usage-analytics.md`. When implementation reveals a better approach, update the spec in the same change. +The specs in `specs/` are the source of truth. Start with `specs/README.md` (reading order, conventions), then `specs/01-architecture.md` (layout, package rules, how the apps are served). App specs: `specs/wonky_write/`, `specs/wonky_sheets/`, `specs/wonky_drive/` and `specs/wonky_slides/` (v2 decisions: `specs/v2-review.md`). Cross-app features: `specs/16-bugs-and-improvements.md` (Bugs & improvements board, M12); the RevLog sync of Sheets and Slides: `specs/17-revlog-sync.md`. Blog submissions from Wonky Write (M14, built): `specs/18-publications-and-submissions.md`; the guide for blog owners is `docs/blog-integration.md`. Wonky Markdown, Write's second Markdown flavor (the blog's directives: callouts, details, cards, grids, buttons, chips, numbered headings; set per document, publications choose theirs): `specs/wonky_write/11-wonky-markdown.md`. Tables and grids in Write are native document lines (`cell`/`grid` line attributes, the 2D cell layout of super_editor SE-15, the server's upgrade of legacy embeds): `specs/wonky_write/12-native-cells.md`. First-party usage analytics for admins in Wonky Drive (`/drive/analytics`, built): `specs/19-usage-analytics.md`. When implementation reveals a better approach, update the spec in the same change. Key rules from the specs: every user-visible action is a command and every state change is undoable (`specs/08-commands-and-undo.md`); single window, never open new tabs or windows (`specs/03-app-shell-and-navigation.md`); UI is built with `shad` and custom widgets in `wonky_ui` (`specs/02-design-system.md`); everything works in light and dark. diff --git a/specs/wonky_write/02-document-model.md b/specs/wonky_write/02-document-model.md index 28b53bbb..91ef5623 100644 --- a/specs/wonky_write/02-document-model.md +++ b/specs/wonky_write/02-document-model.md @@ -96,7 +96,8 @@ attributes (suggested insertion/deletion of an image, divider or table; comments |-------|-------|----------|--------------| | `image` | `{"id","src","alt","title"?}` — `src` is `wonky-file://` (uploaded) or an `https://` URL | `![alt](src "title")` (export rewrites `wonky-file://` to a signed/public URL or to a relative path in zip exports) | `ImageNode` | | `divider` | `{"id"}` | `---` | `HorizontalRuleNode` | -| `table` | `{"id","align":[ "left"\|"center"\|"right"\|null, …], "rows":[[cellMarkdown,…],…]}` — row 0 is the header row; cells are **inline Markdown strings** | GFM table | `TableBlockNode` (read-only block; edited via the table editor, see editor spec) | +| `table` | **Legacy** ([12 §1.5](12-native-cells.md): read, upgraded by the server, never written) `{"id","align":[ "left"\|"center"\|"right"\|null, …], "rows":[[cellMarkdown,…],…]}` — row 0 is the header row; cells are **inline Markdown strings** | GFM table | `TableBlockNode` (read-only) | +| *(line attribute)* `cell` | `{"table", "row", "align"?}` on each cell's `"\n"`; a table is a run of cell lines, row 0 the header ([12 §1.1](12-native-cells.md)) | GFM table | `ParagraphNode` with `WonkyNodeMetadata.cell`, laid out in rows (SE-15) | ### 1.4 Constraints (validated on client before submit and on server after transform) @@ -258,7 +259,8 @@ Each Delta line maps to one node, except merged code lines: |------|--------------| | Paragraph/header/quote/list/task with text `t` | `len(t) + 1` | | Code `ParagraphNode` with text containing k `\n` | `len(text) + 1` (each inner `\n` is a code-block line end) | -| Image / HR / table node | `2` (embed + `\n`) | +| Image / HR / (legacy) table node | `2` (embed + `\n`) | +| Table cell / grid item node | its text length + `1` (a line) | `DeltaIndex` (in the Flutter mapper) maintains a prefix-sum structure (Fenwick tree keyed by node order) for O(log n) conversions `int deltaOffset ⇄ DocumentPosition(nodeId, offset)`, diff --git a/specs/wonky_write/04-editor.md b/specs/wonky_write/04-editor.md index 3e357093..83433b41 100644 --- a/specs/wonky_write/04-editor.md +++ b/specs/wonky_write/04-editor.md @@ -1070,20 +1070,19 @@ import within Wonky (07 handles external rewriting). ## 18. Tables ### 18.1 Model recap -The Delta `table` embed is `{id, align[], rows[][]}`. Row 0 is the header and cells are inline -Markdown. `super_editor` maps it to a **read-only** `TableBlockNode(cells: List>)`, -with cell alignment in the cell `TextNode` metadata `TextNodeMetadata.textAlign`. The node is -selectable only as a whole. - -### 18.2 Rendering (`WonkyTableComponentBuilder`) -- It wraps `MarkdownTableComponentBuilder` (which must also stay in the list, §2.2). Styling - comes from `TableStyles` in the theme (borders, header background, cell padding), with - light and dark variants. -- Wide tables scroll horizontally with the built-in scrollbar. Cells wrap text up to a minimum - column width of 64 px. -- **Affordances** on hover or selection: an **Edit table** button (pencil, top-right of the - table, opens the table editor popover). Clicking a cell edits it in place - (§18.4). +Tables are **native document text** ([12](12-native-cells.md)): each cell is a line with the +`cell` line attribute `{table, row, align?}`, row 0 is the header, and cells hold ordinary inline +content (formatting, links, mentions, chips, comments). Each cell is a `ParagraphNode` with +`WonkyNodeMetadata.cell`. Legacy `table` embeds still render read-only (`TableBlockNode`) until +the server upgrades them (12 §1.5). + +### 18.2 Rendering +- `WriteCellLayout` (super_editor SE-15, 12 §3.2) lays a table's cell components out in rows: + columns share the width, each row is as tall as its tallest cell, borders and the header + background are painted behind the cells (theme `tableBorderColor`, `tableHeaderBackground`). + `CellStylePhase` gives cells their padding, alignment and the header's weight (12 §3.3). +- **Affordances** on hover: an **Edit table** button (pencil, top-right of the table, opens the + table editor popover). The context menu in a cell has the row and column commands (12 §3.7). ### 18.3 Insert - `write.insert.table {rows, cols}`. Defaults 3×3 (rows include the header row). Bounds 1–2,000 @@ -1091,43 +1090,16 @@ selectable only as a whole. - UI: the toolbar table button opens `WonkyTableSizePicker` (10×10 growing to 20×20). Insert › Table ▸ has the same picker plus *Custom size…* (a dialog with two `WonkyNumberStepper`s). Slash `/table` inserts 3×3. -- Inserts the embed (empty cells, `align` all `null`) after the current block, or replaces an - empty paragraph. Then it starts **in-place editing** of cell (0,0) (the popover when in-place - editing is unavailable). The insertion is its own undo step, "Insert table". +- Inserts the cell lines (empty cells) after the current block, or replaces an empty paragraph, + and puts the caret in the first cell. The insertion is its own undo step, "Insert table". ### 18.4 Table editor -**In-place editing** (patch **SE-4**, `features/editor/tables/inline_table_editing.dart`, -`write.table.editCell {nodeId?, row?, col?}`): clicking a cell (or Enter / F2 on a selected -table) turns it into a text field holding the cell's **inline Markdown source**; other cells keep -their rendering. While a cell is edited the local selection (presence) is the table embed, set -once the field has the focus: set while the document editor still had it (the field is built a -frame later), the editor took the text input back, so typing went to the editor, which moved its -caret before or after the table. Cells (rendered and edited) opt out of the editor's own -gestures (`DocumentGestureOptOut`, super_editor SE-14): its interactor wraps the document, so -without that a click in a cell also took the editor's focus and put its caret before or after -the table. -The cells share the field's tap region group, so pressing another cell is not a tap outside the -field (which would end editing and show the editor's selection for a moment): editing moves -straight to it. The click puts the field's caret where it was (the rendered offset mapped to the -source, skipping markup: `FieldClicks`), a double click selects the word there and a triple -click the text, counted from raw pointer events on the rendered cell and the field alike (the -field misses the click that created it, and on the web its own double click doesn't fire), the -selection kept against the field's late single click; the field uses the editor's selection -color. -- Tab / Shift+Tab: next / previous cell (Tab selects the cell's text; Tab in the last cell appends - a row). Enter: cell below; in the last row it appends a row. Shift+Enter: cell above. ↑ ↓: row - above / below; ← → at the text edges: previous / next cell. Leaving the first or last cell (or - the top / bottom row) puts the caret before / after the table. -- Mod+B / I / E, Mod+Shift+X wrap the field selection in `**`, `*`, `` ` ``, `~~`; Mod+K wraps it - as `[text]()`. -- Esc leaves the cell with the table selected; Mod+Enter leaves it with the caret after the table. -- Commits: on leaving the cell, on focus loss, and after 1.5 s without typing (so collaborators - see the edit). Each commit is one `table` embed replacement and one undo step "Edit cell"; - appended rows are their own step "Insert row". Mod+Z in a field with draft history undoes the - draft; in a clean field it runs the document undo. -- Concurrency: a remote change to other cells updates the field when it is clean; a dirty draft - wins when committed (last writer wins, per cell). If the table or the cell disappears, editing - stops with a notice. +Cells are edited in place like any text: the caret, clicks, double and triple clicks, drags and +typing are the editor's (12 §3.4). Tab / Shift+Tab walk the cells (Tab in the last cell appends a +row), Enter goes to the cell below (appending a row in the last row), Shift+Enter to the cell +above, ↑ ↓ keep the column, Backspace and Delete never merge cells, and deleting a selection +across cells clears their text and keeps the table's shape (12 §3.4). Structural commands are in +12 §3.5. The **`TableEditorPopover`** (below) remains for bulk operations (structure, alignment, paste grid, sort) and narrow screens. diff --git a/specs/wonky_write/11-wonky-markdown.md b/specs/wonky_write/11-wonky-markdown.md index 56ea8267..15e09f8d 100644 --- a/specs/wonky_write/11-wonky-markdown.md +++ b/specs/wonky_write/11-wonky-markdown.md @@ -83,6 +83,10 @@ restart), like a quoted and an unquoted list (02 §1.2). ### 2.3 Block embeds `grid` and `youtube` +> **Grids are native lines now** ([12 §1.2](12-native-cells.md)): each item is a line with the +> `grid` line attribute `{id, columns, bullets}`. The `grid` embed below is the legacy form, +> still read (and upgraded by the server, 12 §1.5) but never written. + | Embed | Value | Markdown (Wonky) | |-------|-------|------------------| | `grid` | `{"id", "columns": 1–4, "bullets": "check"\|"circle"\|"none", "items": [cellMarkdown, …]}` | `:::grid{columns=3 bullets=check}` + one `- item` line per item + `:::` | diff --git a/specs/wonky_write/12-native-cells.md b/specs/wonky_write/12-native-cells.md index 678483bb..6e52e557 100644 --- a/specs/wonky_write/12-native-cells.md +++ b/specs/wonky_write/12-native-cells.md @@ -160,58 +160,77 @@ abstract class DocumentCellLayout implements Listenable { `DocumentCellGroupView` (a custom render object) instead of the column: either **rows** (each placement names its row and column) or **flow** (an index, a maximum column count and a minimum column width: the count drops while columns would be narrower). Columns share the width equally - (`minColumnWidth` 64 px for tables); each row is as tall as its tallest cell and every cell is - given that height, so borders line up. Missing cells of ragged rows are empty space. + (rows keep at least `minColumnWidth` and overflow beyond that); each row is as tall as its + tallest cell, and every cell's **slot** has that height, so borders line up (the cell's + component keeps its natural height; a click in the slot below its text goes to the cell). + Missing cells of ragged rows are empty space. A placement's `spaceBefore` adds space above its + row (pagination, §3.8). `DocumentCellGroupGeometry` gives the column edges, row tops and bottoms + and every node's slot. - `paintGroup` paints behind the cells with the cell rectangles (table borders, header background); `wrapGroup` wraps the whole group (container frames, chrome); both come from the app. - The layout rebuilds when `placementOf` changes (the cell layout notifies). - Geometry stays exact: components keep their keys and render boxes, so positions, selection rectangles, carets and hit tests work as for any component. The patch also makes the - "nearest component" fallback measure 2D distance (it measured only vertical distance, which - picked the first cell of a row for a click between cells). + "nearest component" fallback measure 2D distance for grouped components (it measured only + vertical distance, which picked the first cell of a row for a click between cells), resolves a + point in a slot outside the component to that cell, and makes drag selection find the cell at + each end of the drag (the top-to-bottom search treats a group as one block). Without a cell + layout nothing changes. - Mouse and touch interactors are unchanged: clicks, double/triple clicks and drags in cells are the editor's. -Write's `WriteCellLayout` provides placements from the node metadata, table borders and the header -background from `TableStyles`, grid gaps from the theme, container frames around a group (the -frame segments of the group's first and last node) and the chrome (§3.7). +Write's `WriteCellLayout` (`components/cell_layout.dart`) provides placements from `CellSlots` +(every cell's row and column and every item's index, recomputed in one pass when the document +changes; it notifies when a slot changes), table borders and the header background (theme +`tableBorderColor`, `tableHeaderBackground`), grid bullets (painted in `paintGroup` at each item +slot's top left), grid gaps from the theme (minimum column width 160 px at 100 %), container +frames around a group (`BoxFrameComponentBuilder.frameGroup`, using the group's first and last +node) and the chrome (§3.7). ### 3.3 Styling -`CellStylePhase` (after the stylesheet, before pagination) gives cell and item view models their -padding (table cells: `TableStyles.cellPadding`; grid items: the grid gap), the header row a bold -text style, a cell its `align` as text alignment, and removes block spacing. Grid items render -their bullet before the text (`GridItemComponentBuilder`). Containers no longer frame cell -components one by one; `WriteCellLayout.wrapGroup` frames the group. +`CellStylePhase` (after the container phase, before pagination) gives cell and item view models +their padding (table cells 8 × 4 px at 100 %; grid items room on the left for their bullet), the +header row a semi-bold text style and a cell its column's alignment as text alignment (block +spacing goes). Containers do not frame cell components one by one; `WriteCellLayout.wrapGroup` +frames the group. ### 3.4 Caret, selection and keys The caret is the document caret. Clicking, double click (word), triple click (line), dragging, Shift+arrows and Shift+click select as in paragraphs; a selection may span cells and continues in -document order (row by row). Keys in a cell or item (`CellKeyboard`, before the defaults): +document order (row by row). Enter, Shift+Enter, Backspace, Delete and deleting a selection are +request handlers (`requests/cell_editing_handlers.dart`, before Write's block handlers), so +hardware keys, IME and macOS selectors behave alike; Tab and ↑/↓ are a key handler +(`keyboard/cell_keys.dart`, before the keymap) that uses the laid-out boxes, so ↑/↓ work for +table rows and flowed grid rows alike: | Key | Table cell | Grid item | |-----|------------|-----------| -| Enter | caret to the cell below; in the last row, appends a row first | inserts an item after this one (text after the caret moves into it) | +| Enter | caret to the cell below; in the last row, appends a row first | inserts an item after this one (text after the caret moves into it); in an empty last item, leaves the grid (the item becomes a paragraph) | | Shift+Enter | caret to the cell above | — (ignored) | | Tab / Shift+Tab | next / previous cell, selecting its text; Tab in the last cell appends a row | next / previous item; Tab in the last item appends one | | ↑ / ↓ | same column, row above / below (keeping the x position); from the first / last row, to the block before / after the table | the item above / below in the flowed layout | | ← / → | across the cell edge to the previous / next cell (document order), out of the table at its ends | same, in item order | -| Backspace at the start | moves the caret to the end of the previous cell (never merges cells) | in an empty item, removes it (and the grid with its last item), caret to the previous item's end; otherwise like a cell | +| Backspace at the start | moves the caret to the end of the previous cell (never merges cells); in the first cell, nothing | in an empty item, removes it, caret to the previous item's end (the only item becomes a paragraph); otherwise like a cell | | Delete at the end | moves the caret to the start of the next cell | removes the next item if it is empty, else moves | | Mod+A | selects the document | selects the document | +Next to a table or grid: Backspace at the start of the line after it and Delete at the end of the +line before it move the caret into the table (removing that line when it is empty) instead of +merging it with a cell. + - **Deleting a selection that spans cells** clears the selected text in each cell; cells and rows - stay (`CellDeletionHandler`, a request handler before super_editor's). Typing or pasting over - such a selection clears it, then inserts into the first cell. + stay (a request handler for `DeleteSelectionRequest` and `DeleteContentRequest`). Typing or + pasting over such a selection clears it, then inserts into the first cell. - **Selections that start outside a table** and end inside it (or the reverse) delete whole lines outside and clear text inside. A selection covering a whole table (every cell, as from Mod+A or dragging across it) deletes the table. - **Paste into a cell** inserts plain text with line breaks as spaces; rich content keeps its inline formatting. Pasting cell lines (copied from a table) into a cell pastes their text joined with spaces. Pasting them into an ordinary line inserts a new table (new ids, §1.3). -- **Copy** of a selection inside one table writes its text as TSV (cells separated by tabs, rows by - newlines) as plain text, the Delta (with new ids on paste) and an HTML table. +- **Copy** writes the selection's plain text with a table's rows as TSV (cells separated by tabs, + rows by newlines), the Delta (new ids on paste, `CellLines.regenerateIds`) and HTML. - Inline formatting commands work on cell text. Block commands (headings, lists, quote, code block, alignment, indentation, numbered heading, insert table/grid/container) are disabled in a cell; the normalizer removes such attributes anyway (§1.4). @@ -228,10 +247,11 @@ tools use (§4). | `write.insert.table {rows, cols}` | inserts a table after the current block (or replacing an empty paragraph), caret in the first cell | | `write.table.insertRow {index?, position}` | inserts a row above/below (default: the caret's row) | | `write.table.insertColumn {index?, position}` | inserts a column left/right (default: the caret's column) | -| `write.table.deleteRow {index?}` / `deleteColumn` | deletes; deleting the last row or column deletes the table; the header row cannot be deleted while body rows exist (it can be emptied) | +| `write.table.deleteRow {index?}` / `deleteColumn` | deletes; deleting the last row or column deletes the table; in the editor the header row cannot be deleted while body rows exist (it can be emptied); MCP's `delete_row` keeps its old rule (any row but the only one) | | `write.table.moveRow {from, to}` / `moveColumn` | moves (the moved cells' text is deleted and re-inserted) | | `write.table.setColumnAlignment {index?, align}` | sets `align` on the column's cells | | `write.table.setCell {row, col, markdown}` | replaces a cell's content (MCP) | +| `write.table.editCell {row, col}` | selects the cell's text | | `write.table.delete` | deletes the table | | `write.table.edit` | opens the table editor popover (§3.6) | | `write.insert.grid` | inserts a grid of three empty items, caret in the first | @@ -243,26 +263,28 @@ Choosing a value the table or grid already has succeeds without an edit (04 radi ### 3.6 Table editor popover The popover (04 §18.4) stays for bulk work (reordering, sorting, pasting a grid of values). It -edits a `TableDraft` built from the table's lines; **Done** replaces the table's lines with the -draft's (one undo step "Edit table"), keeping the table id. Inline attributes that inline Markdown +edits a `TableModel` built from the table's lines (`TableModel.fromCells`, cells as inline +Markdown); **Done** applies `CellEdits.replaceTable` (one undo step "Edit table"), keeping the +table id; a cell whose Markdown did not change keeps its line. Inline attributes that inline Markdown cannot express (comments, suggestions) in changed cells are lost; unchanged cells keep their lines. Before any structural command or the popover, ragged rows are completed with empty cells (part of the same step). ### 3.7 Chrome and menus -- **Table**: on hover or with the caret inside, the chrome above the table's top-right corner has - *Edit table* (the popover) and ⋯ (insert row above/below, insert column left/right, delete row, - delete column, alignment, delete table). The context menu in a cell has the same row/column - items. *Format › Table ▸* lists them when the caret is in a table. +- **Table**: on hover, the chrome above the table's top-right corner has *Edit table* (the + popover). The context menu in a cell has insert row above/below, insert column left/right, + delete row, delete column, *Edit table*, copy as Markdown or TSV, comment and delete table. - **Grid**: the chrome of 11 §7.2 (columns, bullets, add item, ⋯ delete) sits on the group. - The chrome is in `WriteCellLayout.wrapGroup`, so it belongs to the group, not to one cell. ### 3.8 Pagination -In Pages mode a table row moves to the next page as a unit: `PaginationController` measures a row -as the union of its cells and gives every cell of the row the same gap. Grid rows likewise. A row -taller than a page overflows the seam (as an oversized block does). +In Pages mode a table row moves to the next page as a unit: `PaginationController` measures the +consecutive cells of a group that share a top as one block (a table row, a row of a flowed grid) +and records the gap on its first cell; the cell layout turns it into the row's `spaceBefore` +(the pagination phase pads only other nodes). A row taller than a page overflows the seam (as an +oversized block does). ### 3.9 Collaboration diff --git a/specs/wonky_write/README.md b/specs/wonky_write/README.md index 3c4e8720..e274e344 100644 --- a/specs/wonky_write/README.md +++ b/specs/wonky_write/README.md @@ -17,6 +17,7 @@ real-time sync via Quill Delta OT over typed Serverpod streams. Read the | 09 | [MCP & AI](09-mcp-and-ai.md) | Headless `write.*` tools, `ui.write.*` bridge tools, resources, AI assistant and inline AI | | 10 | [Implementation plan](10-implementation-plan.md) | Workstreams WS-W*, contracts, dependency graph, spikes, definition of done | | 11 | [Wonky Markdown](11-wonky-markdown.md) | The second Markdown flavor: Serverpod blog directives (callouts, details, cards, grids, buttons, arrow links, chips, numbered headings), the flavor setting, schema, codec, editing, themes and exports | +| 12 | [Native table and grid cells](12-native-cells.md) | Tables and grids as ordinary document text: `cell`/`grid` line attributes, the legacy embed upgrade, `CellEdits`, the 2D cell layout (super_editor SE-15), keys, commands, pagination. **Supersedes** the `table`/`grid` embed parts of 02, 04 §18 and 11 §2.3 | Authority order when specs disagree: suite specs for cross-cutting rules → 02/03 for content and sync semantics → feature specs. Fix the disagreement in the same change that finds it. From 7ceb5ee7bf58a66ecf30b4717c98767a711acac8 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Oct 2026 00:31:27 +0200 Subject: [PATCH 10/16] fix(write): no block types in cells; grids keep three columns from 480 px Adds a test for a mouse drag across cells. Co-Authored-By: Claude Opus 5.5 --- specs/wonky_write/12-native-cells.md | 2 +- .../commands/write_editor_commands.dart | 7 ++- .../editor/components/cell_layout.dart | 3 +- .../editor/ui/editor_extras_test.dart | 54 +++++++++++++++++++ .../features/tables/table_editor_test.dart | 7 ++- 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/specs/wonky_write/12-native-cells.md b/specs/wonky_write/12-native-cells.md index 6e52e557..e5025138 100644 --- a/specs/wonky_write/12-native-cells.md +++ b/specs/wonky_write/12-native-cells.md @@ -183,7 +183,7 @@ Write's `WriteCellLayout` (`components/cell_layout.dart`) provides placements fr (every cell's row and column and every item's index, recomputed in one pass when the document changes; it notifies when a slot changes), table borders and the header background (theme `tableBorderColor`, `tableHeaderBackground`), grid bullets (painted in `paintGroup` at each item -slot's top left), grid gaps from the theme (minimum column width 160 px at 100 %), container +slot's top left), grid gaps from the theme (minimum column width 140 px at 100 %: three columns from about 480 px), container frames around a group (`BoxFrameComponentBuilder.frameGroup`, using the group's first and last node) and the chrome (§3.7). diff --git a/wonky_write/lib/features/editor/commands/write_editor_commands.dart b/wonky_write/lib/features/editor/commands/write_editor_commands.dart index 35291f3b..8068fd83 100644 --- a/wonky_write/lib/features/editor/commands/write_editor_commands.dart +++ b/wonky_write/lib/features/editor/commands/write_editor_commands.dart @@ -402,8 +402,9 @@ class WriteEditorCommands { return blocks.isNotEmpty && blocks.every((b) => isInQuoteNode(b.node)); } + /// Table cells and grid items keep their type (Write 12 §3.4). bool canChangeBlockType() => - canEdit && _selectedBlocks().any((b) => b.isText); + canEdit && _selectedBlocks().any((b) => b.isText && !isCellNode(b.node)); /// `write.format.blockType {type}` and the list toggles. Quote, code and /// lists are toggles; headings are not. With [toggle] `false` @@ -417,7 +418,9 @@ class WriteEditorCommands { final blocks = nodeId != null ? [?controller.mirror.blockOf(nodeId)] : _selectedBlocks(); - final textBlocks = blocks.where((b) => b.isText).toList(); + final textBlocks = blocks + .where((b) => b.isText && !isCellNode(b.node)) + .toList(); if (!canEdit || textBlocks.isEmpty) return false; final isToggle = toggle && diff --git a/wonky_write/lib/features/editor/components/cell_layout.dart b/wonky_write/lib/features/editor/components/cell_layout.dart index c1c2575f..2a80697c 100644 --- a/wonky_write/lib/features/editor/components/cell_layout.dart +++ b/wonky_write/lib/features/editor/components/cell_layout.dart @@ -242,7 +242,8 @@ class WriteCellLayout extends ChangeNotifier implements DocumentCellLayout { group: slot.group, index: slot.index, maxColumns: slot.columns, - minColumnWidth: 160 * zoom, + // Three columns from about 480 px (Write 11 §7.2). + minColumnWidth: 140 * zoom, gap: theme.wonky.gridColumnGap * zoom, spaceBefore: _gaps[nodeId] ?? 0, ); diff --git a/wonky_write/test/features/editor/ui/editor_extras_test.dart b/wonky_write/test/features/editor/ui/editor_extras_test.dart index 4bc1622e..0e9d14a6 100644 --- a/wonky_write/test/features/editor/ui/editor_extras_test.dart +++ b/wonky_write/test/features/editor/ui/editor_extras_test.dart @@ -176,6 +176,60 @@ void main() { expect(_caret(p.h), ('Apples and pears', 11, 16)); }, variant: TargetPlatformVariant.only(TargetPlatform.macOS)); + testWidgets( + 'when dragging from inside one cell into the next then the selection ' + 'runs from the drag start to its end', + (tester) async { + final p = await _pump(tester, _tableDoc()); + final layout = p.h.controller.documentLayout!; + Offset at(String text, int offset) { + final node = p.h.controller.document.firstWhere( + (n) => n is TextNode && n.text.toPlainText() == text, + ); + return layout.getGlobalOffsetFromDocumentOffset( + layout + .getRectForPosition( + DocumentPosition( + nodeId: node.id, + nodePosition: TextNodePosition(offset: offset), + ), + )! + .center, + ); + } + + final from = at('Name', 2); + final to = at('Qty', 1); + final gesture = await tester.startGesture( + from, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + // A hand moves a few pixels first: the drag starts where it is + // recognized (beyond the 2 px mouse slop). + await gesture.moveTo(from + const Offset(0, 3)); + await tester.pump(const Duration(milliseconds: 16)); + for (var i = 1; i <= 10; i++) { + await gesture.moveTo(Offset.lerp(from, to, i / 10)!); + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + final s = p.h.controller.composer.selection!; + final doc = p.h.controller.document; + expect( + (doc.getNodeById(s.base.nodeId)! as TextNode).text.toPlainText(), + 'Name', + ); + expect((s.base.nodePosition as TextNodePosition).offset, 2); + expect( + (doc.getNodeById(s.extent.nodeId)! as TextNode).text.toPlainText(), + 'Qty', + ); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + ); + testWidgets('when clicking below a short cell then the caret goes into ' 'that cell', (tester) async { final p = await _pump( diff --git a/wonky_write/test/features/tables/table_editor_test.dart b/wonky_write/test/features/tables/table_editor_test.dart index ec099637..9940f9bd 100644 --- a/wonky_write/test/features/tables/table_editor_test.dart +++ b/wonky_write/test/features/tables/table_editor_test.dart @@ -484,10 +484,9 @@ void main() { CellRuns.of(WonkyDocument(delta)).whereType().single.id; List rowsOf(Delta delta) => [ - for (final row - in CellRuns.of( - WonkyDocument(delta), - ).whereType().single.rows) + for (final row in CellRuns.of( + WonkyDocument(delta), + ).whereType().single.rows) [for (final c in row.cells) c.text], ]; From 818ab04b92c743d782e28f9af47f6fdbf1479640 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Oct 2026 03:55:12 +0200 Subject: [PATCH 11/16] fix(spellcheck): skip words in a script the dictionary has no letters of Arabic or Hebrew words in an English document were all marked misspelled. The checker now accepts a word that shares no letter with the dictionary's alphabet (its words' letters and TRY, in either case). Co-Authored-By: Claude Opus 5.5 --- .../wonky_spellcheck/lib/src/checker.dart | 1 + .../wonky_spellcheck/lib/src/dictionary.dart | 32 ++++++++++++++++++ .../wonky_spellcheck/test/checker_test.dart | 16 +++++++++ specs/wonky_write/04-editor.md | 33 ++++++++++++------- 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/packages/wonky_spellcheck/lib/src/checker.dart b/packages/wonky_spellcheck/lib/src/checker.dart index 313d64b0..5b0f17b1 100644 --- a/packages/wonky_spellcheck/lib/src/checker.dart +++ b/packages/wonky_spellcheck/lib/src/checker.dart @@ -138,6 +138,7 @@ final class SpellChecker { return !_isForbidden(alt) && _checkCased(alt, true, compound); } if (_numberPattern.hasMatch(w)) return true; + if (!dictionary._hasLettersOf(w)) return true; if (_checkDots(w, forSuggest, compound)) return true; if (w.contains("'")) { return _checkDots(w.replaceAll("'", '’'), forSuggest, compound); diff --git a/packages/wonky_spellcheck/lib/src/dictionary.dart b/packages/wonky_spellcheck/lib/src/dictionary.dart index 825b097e..3a5da999 100644 --- a/packages/wonky_spellcheck/lib/src/dictionary.dart +++ b/packages/wonky_spellcheck/lib/src/dictionary.dart @@ -78,6 +78,7 @@ final class HunspellDictionary { // Lazily built suggestion helpers. List? _rootList; + Set? _alphabet; String? _effectiveTry; Map? _keyNeighbours; @@ -133,6 +134,37 @@ final class HunspellDictionary { List get _roots => _rootList ??= _words.keys.toList(growable: false); + static final RegExp _letter = RegExp(r'\p{L}', unicode: true); + + /// Whether [word] has a letter of this dictionary's alphabet (the letters + /// of its words and `TRY`, in either case). A word in another script + /// (Arabic in an English document) has none: it is not checked. + bool _hasLettersOf(String word) { + final alphabet = _alphabet ??= _buildAlphabet(); + // An empty dictionary knows no script: it checks (rejects) everything. + if (alphabet.isEmpty) return true; + for (final m in _letter.allMatches(word)) { + if (alphabet.contains(m[0]!.runes.first)) return true; + } + return false; + } + + Set _buildAlphabet() { + final letters = {}; + for (final w in [_try, ..._words.keys]) { + for (final m in _letter.allMatches(w)) { + letters.add(m[0]!); + } + } + return { + for (final l in letters) ...[ + l.runes.first, + ...l.toLowerCase().runes, + ...l.toUpperCase().runes, + ], + }; + } + /// TRY, or (when the `.aff` has none) the dictionary's letters by /// frequency. String get _tryForSuggest { diff --git a/packages/wonky_spellcheck/test/checker_test.dart b/packages/wonky_spellcheck/test/checker_test.dart index 761ad6ba..400f98c1 100644 --- a/packages/wonky_spellcheck/test/checker_test.dart +++ b/packages/wonky_spellcheck/test/checker_test.dart @@ -25,6 +25,22 @@ JavaScript '''; void main() { + group('Given words in a script the dictionary has no letters of', () { + late SpellChecker checker; + setUp(() => checker = checkerFor(_aff, _dic)); + + checkVectors(() => checker, [ + // Arabic, Hebrew and Cyrillic words in a Latin dictionary: unchecked. + ('مرحبا', true), + ('بالعالم', true), + ('שלום', true), + ('привет', true), + // A word with one of its letters is checked. + ('helloмир', false), + ('hallo', false), + ]); + }); + group('Given dictionary words with different capitalization', () { late SpellChecker checker; setUp(() => checker = checkerFor(_aff, _dic)); diff --git a/specs/wonky_write/04-editor.md b/specs/wonky_write/04-editor.md index 83433b41..ffb77fae 100644 --- a/specs/wonky_write/04-editor.md +++ b/specs/wonky_write/04-editor.md @@ -1624,7 +1624,8 @@ takes < 100 ms on a 100-page document. - **Scope**: - Visible blocks first, then the rest at idle. Re-checks changed nodes only. - Skipped: code blocks, inline code, links, mentions, URLs, emails, words with digits, - ALL-CAPS (setting), CamelCase, and words under 2 chars. + ALL-CAPS (setting), CamelCase, words under 2 chars, and words in a script the dictionary has + no letters of (Arabic or Hebrew in an English document: no red squiggles, §38). - Suggestions: a block is checked as it reads with its suggestions accepted — suggested deletions are left out, so `~~qi~~qu` + `ick` is checked as "quick", and struck text is never flagged. @@ -2113,21 +2114,31 @@ feature flag. ## 38. RTL and bidirectional text - Per-paragraph direction is detected by `TextDirectionStylePhase` from the first strong - character (`intl` `Bidi.estimateDirectionOfText`, falling back to the document language's - direction). It is applied as `textDirection` in component view models. `super_editor` - supports RTL layout per text component (see its `demo_rtl` example). Nothing is stored in - the Delta. -- For RTL blocks: list markers, checkboxes and indentation mirror to the right; quote borders - mirror. -- Caret movement: ←/→ move **visually**. Word jumps follow the visual direction. - Home/End use the logical line start/end. + character (`detectTextDirection`, falling back to the document's direction: left to right, as + documents have no language yet). It is applied as `textDirection` in component view models; + alignment stays `start` (the stylesheet's), so it follows. `super_editor` supports RTL layout + per text component. Nothing is stored in the Delta. +- For RTL blocks: list markers, checkboxes and indentation mirror to the right (super_editor's + list and task rows are in the block's `Directionality`); quote bars and their indent move to + the right (`quoteIndent` in the stylesheet, `quoteBar(rtl:)`); a numbered heading's step badge + is right of the text. A quote whose lines differ in direction has its bar per line. +- Caret movement: ←/→ move **visually** at the block level: in an RTL block ← moves downstream + and → upstream, also by word (⌥/Ctrl) and line (⌘), with Shift extending + (`editor_keyboard.dart`, before super_editor's logical handling and, on the web, the + browser's). Mixed-direction runs inside a line follow the block's direction (as in Google + Docs), not per-run visual order. ← at an RTL block's visual left end moves to the next block. + Home/End use the logical line start/end; ↑/↓ keep the x position. - Mixed-direction runs inside a line rely on Flutter's bidi layout. Selection highlights use the per-box rects (possibly several per visual line). -- Table cell alignment `null` means start-aligned per cell direction. +- Tables and grids have a direction for their layout (column order, flow, bullets) from their + first strong character; a cell without one takes its table's; columns without an alignment are + start-aligned ([12](12-native-cells.md) §3.10). - An RTL application chrome (mirrored menus and panels) is P2 and is not covered by this spec. **AC-38.1** A paragraph starting with Arabic text renders right-aligned with its bullet on the -right. → moves the caret visually right. +right. ← moves the caret visually left (to the next character), ⌥← to the word's visual left end. +**AC-38.2** An Arabic quote has its bar on the right; an Arabic task its checkbox on the right; an +Arabic numbered heading its badge on the right. ## 39. Robustness and edge cases From 43424cfe5cdfcf2e15f2ef8654f5dfc792bd7be2 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Oct 2026 03:55:12 +0200 Subject: [PATCH 12/16] fix(write): right-to-left tables, grids, quotes and arrow keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tables and grids take their direction from their first strong character: right to left, column 0 and the first grid item are on the right, bullets too; cells without strong characters follow the table and columns without an alignment are start-aligned (spec 12 §3.10). - SE-15: DocumentCellPlacement.textDirection mirrors a group; a group is no wider than its first component's maxWidth (tables and grids were as wide as the window, not the text column). - ←/→ move visually in right-to-left blocks, also by word and line. - SE-5: right-to-left line start/end (⌘←/⌘→ went to offset -1) and the task checkbox padding. - Quote bars, their indent and step badges sit on the right of right-to-left blocks. Co-Authored-By: Claude Opus 5.5 --- specs/wonky_write/12-native-cells.md | 39 ++- third_party/super_editor/PATCHES.md | 14 +- .../layout_single_column/_cell_layout.dart | 39 ++- .../layout_single_column/_layout.dart | 22 +- .../lib/src/default_editor/tasks.dart | 3 +- .../lib/src/default_editor/text.dart | 11 +- .../wonky_patches/se15_cell_groups_test.dart | 81 +++++ .../editor/components/cell_layout.dart | 72 ++++- .../editor/components/wonky_components.dart | 29 +- .../editor/keyboard/editor_keyboard.dart | 58 ++++ .../features/editor/styles/style_phases.dart | 37 ++- .../editor/styles/write_stylesheet.dart | 29 +- .../lib/features/editor/ui/wonky_editor.dart | 2 + .../test/features/editor/ui/rtl_test.dart | 301 ++++++++++++++++++ 14 files changed, 677 insertions(+), 60 deletions(-) create mode 100644 wonky_write/test/features/editor/ui/rtl_test.dart diff --git a/specs/wonky_write/12-native-cells.md b/specs/wonky_write/12-native-cells.md index e5025138..28885071 100644 --- a/specs/wonky_write/12-native-cells.md +++ b/specs/wonky_write/12-native-cells.md @@ -164,8 +164,11 @@ abstract class DocumentCellLayout implements Listenable { tallest cell, and every cell's **slot** has that height, so borders line up (the cell's component keeps its natural height; a click in the slot below its text goes to the cell). Missing cells of ragged rows are empty space. A placement's `spaceBefore` adds space above its - row (pagination, §3.8). `DocumentCellGroupGeometry` gives the column edges, row tops and bottoms - and every node's slot. + row (pagination, §3.8). A group is no wider than its first component's `maxWidth` (the text + column), like any component. A placement's `textDirection` (group-wide) mirrors the group: + right to left, column 0 and the first item are on the right (§3.10). + `DocumentCellGroupGeometry` gives the column edges (left to right), row tops and bottoms and + every node's slot. - `paintGroup` paints behind the cells with the cell rectangles (table borders, header background); `wrapGroup` wraps the whole group (container frames, chrome); both come from the app. - The layout rebuilds when `placementOf` changes (the cell layout notifies). @@ -183,15 +186,16 @@ Write's `WriteCellLayout` (`components/cell_layout.dart`) provides placements fr (every cell's row and column and every item's index, recomputed in one pass when the document changes; it notifies when a slot changes), table borders and the header background (theme `tableBorderColor`, `tableHeaderBackground`), grid bullets (painted in `paintGroup` at each item -slot's top left), grid gaps from the theme (minimum column width 140 px at 100 %: three columns from about 480 px), container +slot's top left, top right in a right-to-left grid), grid gaps from the theme (minimum column width 140 px at 100 %: three columns from about 480 px), container frames around a group (`BoxFrameComponentBuilder.frameGroup`, using the group's first and last node) and the chrome (§3.7). ### 3.3 Styling `CellStylePhase` (after the container phase, before pagination) gives cell and item view models -their padding (table cells 8 × 4 px at 100 %; grid items room on the left for their bullet), the -header row a semi-bold text style and a cell its column's alignment as text alignment (block +their padding (table cells 8 × 4 px at 100 %; grid items room for their bullet on the grid's +start side), the header row a semi-bold text style and a cell its column's alignment as text +alignment; a column without one is start-aligned, i.e. per the cell text's direction (block spacing goes). Containers do not frame cell components one by one; `WriteCellLayout.wrapGroup` frames the group. @@ -295,6 +299,22 @@ while another inserts a row: the new row lacks the new column's cell), shown wit completed by the next structural command. Two users deleting the same row converge (the second delete is empty after the transform). +### 3.10 Direction + +A table or grid has one direction for its layout: that of its **first strong character** in +document order (the first cell or item with one; Write 04 §38), else the document's (left to +right). Nothing is stored. `CellSlots` computes it in its pass and the placements carry it: + +- **Right to left**, column 0 is the rightmost column and a grid fills its rows from the right; + grid bullets and their room are on the right. Tab and Enter still walk the cells in document + order (Tab goes to the cell on the left); ↑/↓ keep the visual column. +- **Each cell's text** has its own direction (its first strong character). A cell without one + (empty, digits) takes the table's, so an empty cell of an Arabic table has its caret on the + right. Columns without an alignment are start-aligned; explicit `left`/`right` alignments + (`:---`, `---:`) are literal, as in GFM. +- Typing the first strong character into a table flips its layout at once; Markdown and the + other exports are unchanged (their readers apply their own bidi rules). + ## 4. Server, MCP and AI - `CellEdits` (`wonky_write_core/edit/cell_edits.dart`) builds every structural change from a @@ -318,10 +338,12 @@ delete is empty after the transform). line table (golden text where present), OT convergence fuzz with tables and grids (`EditGen`/`DeltaGen` generate cell and grid lines). - **super_editor (SE-15)**: rows and flow layout geometry, nearest-component in 2D, hit testing, - caret and selection rectangles in a group, rebuild on placement change, `SuperReader`. + caret and selection rectangles in a group, rebuild on placement change, `SuperReader`, right to + left rows and flows, the group's maximum width. - **Editor**: typing, click and double click inside a cell (the caret and word land in that cell), every key in §3.4, deletion across cells, paste and copy, every command (one undo step each), - container frame around a table, pagination of rows, the legacy read-only path. + container frame around a table, pagination of rows, the legacy read-only path, right-to-left + tables and grids (`test/features/editor/ui/rtl_test.dart`). - **Server**: upgrade on load and after a legacy commit (one system op, idempotent), MCP table and grid tools on line tables. @@ -340,6 +362,9 @@ editable; its Markdown export is unchanged by the upgrade. **AC-12.6** Bold, links, chips and mentions in a cell export to Markdown, HTML, PDF and DOCX. **AC-12.7** A grid item: Enter adds the next item, Backspace in an empty item removes it, the chrome changes columns and bullets. +**AC-12.8** A table whose first cell is Arabic has its first column on the right, right-aligned +cells (also empty ones) and Tab moving left; an Arabic grid fills from the right with its +bullets on the right. A table is never wider than the text column. ## 6. Removed diff --git a/third_party/super_editor/PATCHES.md b/third_party/super_editor/PATCHES.md index 8a29acb9..cafe5805 100644 --- a/third_party/super_editor/PATCHES.md +++ b/third_party/super_editor/PATCHES.md @@ -97,10 +97,18 @@ Every change is marked with `// WONKY PATCH ` in the code. Tests of our patc `default_editor/text.dart`. 5. **Undo/redo keyboard actions exported** (`lib/src/undo_redo.dart`) so apps can remove them from the default keyboard action lists. `lib/super_editor.dart`. +6. **Right-to-left task checkbox**: its padding (16 before, 4 after) was fixed left/right, so a + right-to-left task had 4 px between the checkbox and its text; now `EdgeInsetsDirectional` + (the row is in the task's `Directionality`). `default_editor/tasks.dart`. +7. **Right-to-left line start and end**: `TextComponent.getPositionAtStartOfLine` / + `getPositionAtEndOfLine` took super_text_layout's positions at the line's left and right + edges, so in right-to-left text ⌘←/⌘→ (and Home/End) went to the wrong end, and the + auto-wrap adjustment made the line end offset −1. Swapped for right-to-left components. + `default_editor/text.dart`. - **Tests**: `test/wonky_patches/se5_fixes_test.dart`, `se3_node_change_events_test.dart`, `se2_document_patch_test.dart` -- **Upstream**: 1–5 are general fixes; to be filed upstream. +- **Upstream**: 1–7 are general fixes; to be filed upstream. ## SE-6 — Content drag delegate (drag and drop of the selection) @@ -288,6 +296,10 @@ Every change is marked with `// WONKY PATCH ` in the code. Tests of our patc from a later cell of a row starts there). - `SuperEditor(cellLayout:)`, `SuperReader(cellLayout:)` → `DocumentScaffold` → layout. Keyboard caret movement (document order) is unchanged. + - A group is no wider than its first component's `maxWidth` (like `_Component`). + - `DocumentCellPlacement(textDirection:)` (group-wide): right to left, column 0 (rows) and + the first item (flows) are on the right. `columnEdges` still run left to right (visual + columns); `slotRects`, hit testing and region selection follow the mirrored slots. - **Files**: `lib/src/default_editor/layout_single_column/{_cell_layout.dart (new),_layout.dart,layout_single_column.dart}`, `lib/src/infrastructure/documents/document_scaffold.dart`, `lib/src/default_editor/super_editor.dart`, `lib/src/super_reader/super_reader.dart`; test: `test/wonky_patches/se15_cell_groups_test.dart` (new). diff --git a/third_party/super_editor/lib/src/default_editor/layout_single_column/_cell_layout.dart b/third_party/super_editor/lib/src/default_editor/layout_single_column/_cell_layout.dart index 15567ed7..f5d9cc31 100644 --- a/third_party/super_editor/lib/src/default_editor/layout_single_column/_cell_layout.dart +++ b/third_party/super_editor/lib/src/default_editor/layout_single_column/_cell_layout.dart @@ -12,14 +12,15 @@ enum DocumentCellGroupKind { /// Every placement names its row and column (a table). rows, - /// Items fill rows left to right in index order (a grid). + /// Items fill rows in index order (a grid), from the start side of the + /// group's text direction. flow, } /// Where a node's component goes inside a cell group. /// -/// The group-wide values ([minColumnWidth], [gap] and, for flows, -/// [maxColumns]) are taken from the group's first placement. +/// The group-wide values ([minColumnWidth], [gap], [textDirection] and, for +/// flows, [maxColumns]) are taken from the group's first placement. @immutable class DocumentCellPlacement { /// A table cell: [row] and [column] inside group [group]. @@ -30,6 +31,7 @@ class DocumentCellPlacement { this.minColumnWidth = 64, this.gap = 0, this.spaceBefore = 0, + this.textDirection = TextDirection.ltr, }) : kind = DocumentCellGroupKind.rows, index = 0, maxColumns = 0; @@ -43,6 +45,7 @@ class DocumentCellPlacement { this.minColumnWidth = 160, this.gap = 0, this.spaceBefore = 0, + this.textDirection = TextDirection.ltr, }) : kind = DocumentCellGroupKind.flow, row = 0, column = 0; @@ -75,6 +78,10 @@ class DocumentCellPlacement { /// placements is used), e.g. to push a row to the next page. final double spaceBefore; + /// The group's direction: [TextDirection.rtl] puts column 0 (rows) or the + /// first item (flows) on the right. + final TextDirection textDirection; + @override bool operator ==(Object other) => identical(this, other) || @@ -87,10 +94,12 @@ class DocumentCellPlacement { other.maxColumns == maxColumns && other.minColumnWidth == minColumnWidth && other.gap == gap && - other.spaceBefore == spaceBefore; + other.spaceBefore == spaceBefore && + other.textDirection == textDirection; @override - int get hashCode => Object.hash(group, kind, row, column, index, maxColumns, minColumnWidth, gap, spaceBefore); + int get hashCode => + Object.hash(group, kind, row, column, index, maxColumns, minColumnWidth, gap, spaceBefore, textDirection); @override String toString() => kind == DocumentCellGroupKind.rows @@ -146,9 +155,10 @@ class DocumentCellGroupGeometry { final int rowCount; final int columnCount; - /// `columnCount + 1` x positions: the left edge of every column, then the - /// right edge of the last column (with a gap, a column ends `gap` before the - /// next one starts). + /// `columnCount + 1` x positions from left to right: the left edge of every + /// visual column, then the right edge of the rightmost one (with a gap, a + /// column ends `gap` before the next one starts). In a right-to-left group + /// column 0 is the rightmost. final List columnEdges; /// `rowCount + 1` y positions: the top of every row, then the bottom of the @@ -298,6 +308,8 @@ class RenderDocumentCellGroup extends RenderBox double get _minColumnWidth => max(0.0, _first?.minColumnWidth ?? 0.0); + bool get _isRtl => _first?.textDirection == TextDirection.rtl; + /// Assigns every child (by index) a row and a column, and returns the /// column count. _CellGrid _grid(double width) { @@ -371,9 +383,13 @@ class RenderDocumentCellGroup extends RenderBox rowSpaceBefore[row] = max(rowSpaceBefore[row], max(0.0, _placements[i].spaceBefore)); } + final contentWidth = grid.columnCount > 0 ? grid.columnCount * columnWidth + (grid.columnCount - 1) * gap : 0.0; + // Right to left, the columns end at the right edge (or, when they + // overflow, start at 0 like left to right). + final start = _isRtl && width.isFinite ? max(0.0, width - contentWidth) : 0.0; final columnEdges = [ - for (int c = 0; c < grid.columnCount; c++) c * (columnWidth + gap), - if (grid.columnCount > 0) grid.columnCount * columnWidth + (grid.columnCount - 1) * gap else 0, + for (int c = 0; c < grid.columnCount; c++) start + c * (columnWidth + gap), + start + contentWidth, ]; final rowTops = []; @@ -390,7 +406,7 @@ class RenderDocumentCellGroup extends RenderBox final slotRects = {}; for (int i = 0; i < grid.rows.length; i++) { final row = grid.rows[i]; - final column = grid.columns[i]; + final column = _isRtl ? grid.columnCount - 1 - grid.columns[i] : grid.columns[i]; final slot = Rect.fromLTWH(columnEdges[column], rowTops[row], columnWidth, rowHeights[row]); if (i < _group.nodeIds.length) { slotRects[_group.nodeIds[i]] = slot; @@ -398,7 +414,6 @@ class RenderDocumentCellGroup extends RenderBox childOffsets?.add(slot.topLeft); } - final contentWidth = columnEdges.last; return DocumentCellGroupGeometry( group: _group, size: Size(width.isFinite ? width : contentWidth, y), diff --git a/third_party/super_editor/lib/src/default_editor/layout_single_column/_layout.dart b/third_party/super_editor/lib/src/default_editor/layout_single_column/_layout.dart index df212dc6..ae604c1d 100644 --- a/third_party/super_editor/lib/src/default_editor/layout_single_column/_layout.dart +++ b/third_party/super_editor/lib/src/default_editor/layout_single_column/_layout.dart @@ -1117,17 +1117,23 @@ class _SingleColumnDocumentLayoutState extends State nodeIds: [for (int j = i; j <= end; j++) _componentKeysToNodeIds[_topToBottomComponentKeys[j]]!], ); final occurrence = occurrences[placement.group] = (occurrences[placement.group] ?? -1) + 1; + // The group is as wide as its first component would be ([_Component]). + final viewModels = widget.presenter.viewModel.componentViewModels; + final maxWidth = i < viewModels.length ? viewModels[i].maxWidth : null; children.add( KeyedSubtree( key: ValueKey('wonky-cell-group:${placement.group}#$occurrence'), - child: cellLayout.wrapGroup( - context, - group, - DocumentCellGroupView( - group: group, - placements: [for (int j = i; j <= end; j++) placements[j]!], - cellLayout: cellLayout, - children: components.sublist(i, end + 1), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), + child: cellLayout.wrapGroup( + context, + group, + DocumentCellGroupView( + group: group, + placements: [for (int j = i; j <= end; j++) placements[j]!], + cellLayout: cellLayout, + children: components.sublist(i, end + 1), + ), ), ), ), diff --git a/third_party/super_editor/lib/src/default_editor/tasks.dart b/third_party/super_editor/lib/src/default_editor/tasks.dart index aa7fd432..108bf0d6 100644 --- a/third_party/super_editor/lib/src/default_editor/tasks.dart +++ b/third_party/super_editor/lib/src/default_editor/tasks.dart @@ -380,7 +380,8 @@ class _TaskComponentState extends State with ProxyDocumentCompone ), ), Padding( - padding: const EdgeInsets.only(left: 16, right: 4), + // WONKY PATCH SE-5: mirrored for right-to-left tasks. + padding: const EdgeInsetsDirectional.only(start: 16, end: 4), child: Checkbox( visualDensity: Theme.of(context).visualDensity, value: widget.viewModel.isComplete, diff --git a/third_party/super_editor/lib/src/default_editor/text.dart b/third_party/super_editor/lib/src/default_editor/text.dart index ecfc30a0..985ee91e 100644 --- a/third_party/super_editor/lib/src/default_editor/text.dart +++ b/third_party/super_editor/lib/src/default_editor/text.dart @@ -1348,17 +1348,24 @@ class TextComponentState extends State with DocumentComponent imp return TextNodePosition.fromTextPosition(positionOneLineDown); } + // WONKY PATCH SE-5: the text layout's line start and end are the + // positions at the line's left and right edges, which right-to-left text + // has the other way around. + bool get _isRtl => widget.textDirection == TextDirection.rtl; + @override TextNodePosition getPositionAtEndOfLine(TextNodePosition textPosition) { return TextNodePosition.fromTextPosition( - textLayout.getPositionAtEndOfLine(textPosition), + _isRtl ? textLayout.getPositionAtStartOfLine(textPosition) : textLayout.getPositionAtEndOfLine(textPosition), ); } @override TextNodePosition getPositionAtStartOfLine(TextNodePosition textNodePosition) { return TextNodePosition.fromTextPosition( - textLayout.getPositionAtStartOfLine(textNodePosition), + _isRtl + ? textLayout.getPositionAtEndOfLine(textNodePosition) + : textLayout.getPositionAtStartOfLine(textNodePosition), ); } diff --git a/third_party/super_editor/test/wonky_patches/se15_cell_groups_test.dart b/third_party/super_editor/test/wonky_patches/se15_cell_groups_test.dart index 95d8ee36..b9df4f87 100644 --- a/third_party/super_editor/test/wonky_patches/se15_cell_groups_test.dart +++ b/third_party/super_editor/test/wonky_patches/se15_cell_groups_test.dart @@ -227,6 +227,87 @@ void main() { }); }); + group('right to left >', () { + testWidgetsOnAllPlatforms('puts column 0 on the right and resolves points there', (tester) async { + const rtl = TextDirection.rtl; + final harness = await _pumpEditor( + tester, + nodes: [ + for (final id in ['a', 'b', 'c', 'd']) _paragraph(id, id.toUpperCase()) + ], + cellLayout: _TestCellLayout({ + 'a': const DocumentCellPlacement.rows(group: 't', row: 0, column: 0, gap: 10, textDirection: rtl), + 'b': const DocumentCellPlacement.rows(group: 't', row: 0, column: 1, gap: 10, textDirection: rtl), + 'c': const DocumentCellPlacement.rows(group: 't', row: 0, column: 2, gap: 10, textDirection: rtl), + 'd': const DocumentCellPlacement.rows(group: 't', row: 1, column: 0, gap: 10, textDirection: rtl), + }), + ); + + final geometry = harness.geometry(); + const columnWidth = (600 - 2 * 10) / 3; + // The edges still run left to right. + expect(geometry.columnEdges, [0, columnWidth + 10, 2 * columnWidth + 20, 3 * columnWidth + 20]); + expect(geometry.slotRects['a']!.left, 2 * columnWidth + 20); + expect(geometry.slotRects['b']!.left, columnWidth + 10); + expect(geometry.slotRects['c']!.left, 0); + expect(geometry.slotRects['d']!.left, 2 * columnWidth + 20); + expect(harness.componentRect('a').topLeft, harness.slotRect('a').topLeft); + expect(harness.componentRect('c').left, 0); + + // A point in the rightmost slot is in column 0. + final layout = harness.layout; + final right = harness.slotRect('a').centerRight - const Offset(2, 0); + expect(layout.getDocumentPositionAtOffset(right)!.nodeId, 'a'); + expect(layout.getDocumentPositionNearestToOffset(right + const Offset(0, 1))!.nodeId, 'a'); + }); + + testWidgetsOnAllPlatforms('fills a flow from the right', (tester) async { + final harness = await _pumpEditor( + tester, + nodes: [for (int i = 0; i < 4; i++) _paragraph('i$i', 'Item $i')], + cellLayout: _TestCellLayout({ + for (int i = 0; i < 4; i++) + 'i$i': DocumentCellPlacement.flow( + group: 'g', + index: i, + maxColumns: 3, + textDirection: TextDirection.rtl, + ), + }), + ); + + final geometry = harness.geometry(); + expect(geometry.columnEdges, [0, 200, 400, 600]); + expect(geometry.slotRects['i0']!.left, 400); + expect(geometry.slotRects['i1']!.left, 200); + expect(geometry.slotRects['i2']!.left, 0); + expect(geometry.slotRects['i3']!.left, 400); + expect(geometry.slotRects['i3']!.top, geometry.rowEdges[1]); + }); + }); + + testWidgetsOnAllPlatforms('a group is no wider than its first component may be', (tester) async { + // The default stylesheet's maxWidth is 640; the paragraphs are centered. + final harness = await _pumpEditor( + tester, + width: 800, + nodes: [ + _paragraph('above', 'Above'), + _paragraph('a', 'A'), + _paragraph('b', 'B'), + ], + cellLayout: _TestCellLayout({ + 'a': const DocumentCellPlacement.rows(group: 't', row: 0, column: 0), + 'b': const DocumentCellPlacement.rows(group: 't', row: 0, column: 1), + }), + ); + + expect(harness.componentRect('above').width, 640); + expect(harness.group().size.width, 640); + expect(harness.slotRect('a').left, harness.componentRect('above').left); + expect(harness.slotRect('b').right, harness.componentRect('above').right); + }); + group('flow >', () { Map flowPlacements({double spaceBeforeOfFourth = 0}) => { for (int i = 0; i < 5; i++) diff --git a/wonky_write/lib/features/editor/components/cell_layout.dart b/wonky_write/lib/features/editor/components/cell_layout.dart index 2a80697c..477dc1ee 100644 --- a/wonky_write/lib/features/editor/components/cell_layout.dart +++ b/wonky_write/lib/features/editor/components/cell_layout.dart @@ -8,6 +8,7 @@ import '../../../l10n/write_l10n.dart'; import '../mapping/wonky_schema.dart'; import '../pagination/pagination.dart'; import '../styles/editor_theme.dart'; +import '../styles/write_stylesheet.dart' show strongTextDirection; import 'box_frames.dart'; import 'editor_services.dart'; import 'wonky_components.dart'; @@ -21,6 +22,7 @@ class CellSlot { required this.row, required this.column, required this.align, + this.direction = TextDirection.ltr, }) : index = 0, columns = 0, bullets = GridBullets.none; @@ -30,6 +32,7 @@ class CellSlot { required this.index, required this.columns, required this.bullets, + this.direction = TextDirection.ltr, }) : row = 0, column = 0, align = null; @@ -45,6 +48,28 @@ class CellSlot { /// The column's alignment (the header cell's, Write 12 §1.1). final CellAlign? align; + /// The table's or grid's direction (Write 12 §3.10): its first strong + /// character's, in document order. + final TextDirection direction; + + bool get isRtl => direction == TextDirection.rtl; + + CellSlot withDirection(TextDirection direction) => isCell + ? CellSlot.cell( + group: group, + row: row, + column: column, + align: align, + direction: direction, + ) + : CellSlot.item( + group: group, + index: index, + columns: columns, + bullets: bullets, + direction: direction, + ); + bool get isCell => columns == 0; @override @@ -56,11 +81,20 @@ class CellSlot { other.index == index && other.columns == columns && other.bullets == bullets && - other.align == align; + other.align == align && + other.direction == direction; @override - int get hashCode => - Object.hash(group, row, column, index, columns, bullets, align); + int get hashCode => Object.hash( + group, + row, + column, + index, + columns, + bullets, + align, + direction, + ); } /// The slots of every table cell and grid item of a document, kept current @@ -84,6 +118,13 @@ class CellSlots extends ChangeNotifier { /// Recomputes the slots; whether any changed. bool _rebuild() { final next = {}; + // Group → its direction, from its first strong character. + final directions = {}; + void detect(String group, DocumentNode node) { + if (directions[group] != null || node is! TextNode) return; + directions[group] = strongTextDirection(node.text.toPlainText()); + } + String? table; String? row; var r = -1; @@ -109,6 +150,7 @@ class CellSlots extends ChangeNotifier { c = 0; } if (r == 0) header.add(cell.align); + detect('t:${cell.table}', node); next[node.id] = CellSlot.cell( group: 't:${cell.table}', row: r, @@ -129,6 +171,7 @@ class CellSlots extends ChangeNotifier { columns = g.columns; bullets = g.bullets; } + detect('g:${g.id}', node); next[node.id] = CellSlot.item( group: 'g:${g.id}', index: item++, @@ -139,6 +182,11 @@ class CellSlots extends ChangeNotifier { } grid = null; } + for (final e in next.entries) { + if (directions[e.value.group] == TextDirection.rtl) { + next[e.key] = e.value.withDirection(TextDirection.rtl); + } + } final changed = !_sameMaps(next, _slots); _slots = next; return changed; @@ -217,7 +265,7 @@ class WriteCellLayout extends ChangeNotifier implements DocumentCellLayout { double get _bulletSize => (theme.body.fontSize ?? 14.67) * zoom * 1.3; - /// The left room of a grid item for its bullet. + /// The room of a grid item for its bullet (on its start side). double bulletRoom(GridBullets bullets) => bullets == GridBullets.none ? 0 : _bulletSize * 1.45; @@ -236,6 +284,7 @@ class WriteCellLayout extends ChangeNotifier implements DocumentCellLayout { column: slot.column, minColumnWidth: 48 * zoom, spaceBefore: _gaps[nodeId] ?? 0, + textDirection: slot.direction, ); } return DocumentCellPlacement.flow( @@ -246,6 +295,7 @@ class WriteCellLayout extends ChangeNotifier implements DocumentCellLayout { minColumnWidth: 140 * zoom, gap: theme.wonky.gridColumnGap * zoom, spaceBefore: _gaps[nodeId] ?? 0, + textDirection: slot.direction, ); } @@ -432,10 +482,13 @@ class WriteCellLayout extends ChangeNotifier implements DocumentCellLayout { ), textDirection: TextDirection.ltr, )..layout(); + final rtl = first?.isRtl ?? false; for (final rect in geometry.slotRects.values) { painter.paint( canvas, - rect.topLeft + offset + Offset(0, size * 0.05 + _textTopInset), + (rtl ? rect.topRight - Offset(painter.width, 0) : rect.topLeft) + + offset + + Offset(0, size * 0.05 + _textTopInset), ); } painter.dispose(); @@ -483,10 +536,12 @@ class CellStylePhase extends SingleColumnLayoutStylePhase { final text = copy as TextComponentViewModel; if (slot.isCell) { copy.padding = layout.cellPadding; + // No alignment: the start of the cell's own text (Write 04 §38). text.textAlignment = switch (slot.align) { + CellAlign.left => TextAlign.left, CellAlign.center => TextAlign.center, CellAlign.right => TextAlign.right, - _ => TextAlign.left, + null => TextAlign.start, }; if (slot.row == 0) { final base = text.textStyleBuilder; @@ -494,7 +549,10 @@ class CellStylePhase extends SingleColumnLayoutStylePhase { base(attributions).copyWith(fontWeight: FontWeight.w600); } } else { - copy.padding = EdgeInsets.only(left: layout.bulletRoom(slot.bullets)); + final room = layout.bulletRoom(slot.bullets); + copy.padding = slot.isRtl + ? EdgeInsets.only(right: room) + : EdgeInsets.only(left: room); } changed = true; models.add(copy); diff --git a/wonky_write/lib/features/editor/components/wonky_components.dart b/wonky_write/lib/features/editor/components/wonky_components.dart index 5c7c7964..fe666467 100644 --- a/wonky_write/lib/features/editor/components/wonky_components.dart +++ b/wonky_write/lib/features/editor/components/wonky_components.dart @@ -705,14 +705,16 @@ Widget quoteBar( required double zoom, required double spacing, required Widget child, + bool rtl = false, }) => Semantics( label: writeL10n(context).editorQuoteLabel, child: CustomPaint( painter: _QuoteBarPainter( color: theme.quoteBarColor, width: theme.quoteBarWidth * zoom, + rtl: rtl, // The stylesheet indents quotes by bar width + 12 px; the bar sits in - // that indent, left of the text. + // that indent, before the text (right of right-to-left text). gap: 12 * zoom, ), child: Padding( @@ -722,6 +724,11 @@ Widget quoteBar( ), ); +/// Whether [viewModel]'s text is laid out right to left (Write 04 §38). +bool _isRtl(SingleColumnLayoutComponentViewModel viewModel) => + viewModel is TextComponentViewModel && + viewModel.textDirection == TextDirection.rtl; + /// super_editor's list items; inside a quote with the quote bar. class WonkyListItemComponentBuilder implements ComponentBuilder { const WonkyListItemComponentBuilder({ @@ -756,6 +763,7 @@ class WonkyListItemComponentBuilder implements ComponentBuilder { theme: theme, zoom: zoom, spacing: theme.listItemSpacing, + rtl: _isRtl(viewModel), child: component, ); } @@ -813,6 +821,7 @@ class WonkyParagraphComponentBuilder implements ComponentBuilder { theme: theme, zoom: zoom, spacing: quoteLineSpacing, + rtl: _isRtl(viewModel), child: component, ); } @@ -852,6 +861,7 @@ class WonkyParagraphComponentBuilder implements ComponentBuilder { fontSize: (theme.headings[level - 1].fontSize ?? 20) * zoom, theme: theme, onTap: editable ? callbacks.onStepTap : null, + textDirection: viewModel.textDirection, child: component, ); }, @@ -871,11 +881,13 @@ class _StepHeading extends StatelessWidget { required this.fontSize, required this.theme, required this.onTap, + required this.textDirection, required this.child, }); final String nodeId; final int step; + final TextDirection textDirection; final double fontSize; final EditorTheme theme; final void Function(String nodeId, Rect globalRect)? onTap; @@ -925,7 +937,9 @@ class _StepHeading extends StatelessWidget { ), ); } + // The badge before the heading: right of a right-to-left one. return Row( + textDirection: textDirection, crossAxisAlignment: CrossAxisAlignment.center, children: [ badge, @@ -941,16 +955,23 @@ class _QuoteBarPainter extends CustomPainter { required this.color, required this.width, required this.gap, + required this.rtl, }); final Color color; final double width; final double gap; + final bool rtl; @override void paint(Canvas canvas, Size size) { canvas.drawRect( - Rect.fromLTWH(-(width + gap), 0, width, size.height), + Rect.fromLTWH( + rtl ? size.width + gap : -(width + gap), + 0, + width, + size.height, + ), Paint()..color = color, ); } @@ -966,7 +987,8 @@ class _QuoteBarPainter extends CustomPainter { bool shouldRepaint(_QuoteBarPainter oldDelegate) => oldDelegate.color != color || oldDelegate.width != width || - oldDelegate.gap != gap; + oldDelegate.gap != gap || + oldDelegate.rtl != rtl; } class _CodeBlockChrome extends StatelessWidget { @@ -1431,6 +1453,7 @@ class WonkyTaskComponentBuilder implements ComponentBuilder { theme: theme, zoom: zoom, spacing: theme.listItemSpacing, + rtl: _isRtl(viewModel), child: task, ); } diff --git a/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart b/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart index 0ad7dfd4..da4dddf0 100644 --- a/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart +++ b/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart @@ -66,6 +66,7 @@ List wonkyKeyboardActions({ bool builtInShortcuts = true, EditorShortcutCallbacks callbacks = const EditorShortcutCallbacks(), bool ime = true, + TextDirection Function(String nodeId)? directionOf, }) { ExecutionInstruction handler({ required SuperEditorContext editContext, @@ -80,6 +81,10 @@ List wonkyKeyboardActions({ if (keymap != null && keymap(keyEvent)) { return ExecutionInstruction.haltExecution; } + if (directionOf != null && + _rightToLeftArrow(keyEvent, editContext, directionOf)) { + return ExecutionInstruction.haltExecution; + } if (_pillBoundaryArrow(keyEvent, controller)) { return ExecutionInstruction.haltExecution; } @@ -113,6 +118,59 @@ List wonkyKeyboardActions({ ]; } +/// ← and → move the caret visually (Write 04 §38): in a right-to-left block +/// ← moves downstream and → upstream, with super_editor's modifiers (word, +/// line). The caret's block decides, so mixed runs inside a line follow the +/// block's direction. super_editor's own handling (and, on the web, the +/// browser's) moves logically. +bool _rightToLeftArrow( + KeyEvent event, + SuperEditorContext editContext, + TextDirection Function(String nodeId) directionOf, +) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) return false; + final key = event.logicalKey; + if (key != LogicalKeyboardKey.arrowLeft && + key != LogicalKeyboardKey.arrowRight) { + return false; + } + final selection = editContext.composer.selection; + if (selection == null || + directionOf(selection.extent.nodeId) != TextDirection.rtl) { + return false; + } + // Composing on the web: the browser's candidate list takes the keys. + if (kIsWeb && editContext.composer.composingRegion.value != null) { + return false; + } + final keyboard = HardwareKeyboard.instance; + final windows = defaultTargetPlatform == TargetPlatform.windows; + if (windows && keyboard.isAltPressed) return false; + MovementModifier? modifier; + if ((windows || defaultTargetPlatform == TargetPlatform.linux) && + keyboard.isControlPressed) { + modifier = MovementModifier.word; + } else if (_isApple && keyboard.isMetaPressed) { + modifier = MovementModifier.line; + } else if (_isApple && keyboard.isAltPressed) { + modifier = MovementModifier.word; + } + final ops = editContext.commonOps; + if (key == LogicalKeyboardKey.arrowLeft) { + ops.moveCaretDownstream( + expand: keyboard.isShiftPressed, + movementModifier: modifier, + ); + } else { + ops.moveCaretUpstream( + expand: keyboard.isShiftPressed, + movementModifier: modifier, + ); + } + // Also at the document's end: the key must not move the other way. + return true; +} + /// → at the end of a chip, button or arrow link moves the caret out of it /// without moving it (typing goes after the element); ← there moves it back /// in (Write 11 §7.4). diff --git a/wonky_write/lib/features/editor/styles/style_phases.dart b/wonky_write/lib/features/editor/styles/style_phases.dart index f07a3cf7..b1bf7f7b 100644 --- a/wonky_write/lib/features/editor/styles/style_phases.dart +++ b/wonky_write/lib/features/editor/styles/style_phases.dart @@ -5,6 +5,7 @@ import 'package:super_editor/super_editor.dart'; import 'package:wonky_code/wonky_code.dart'; import '../attributions/wonky_attributions.dart'; +import '../components/cell_layout.dart' show CellSlots; import '../mapping/wonky_schema.dart'; import 'write_stylesheet.dart'; @@ -250,42 +251,60 @@ class PillSpacingStylePhase extends SingleColumnLayoutStylePhase { } /// Lays out right-to-left paragraphs right-to-left (Write 04 §38): direction -/// from the first strong character; nothing is stored in the Delta. +/// from the first strong character; nothing is stored in the Delta. A block +/// without one takes [fallback], a table cell or grid item its table's or +/// grid's direction (Write 12 §3.10). Alignment is left to the other phases: +/// `start` (the stylesheet's) follows the direction. class TextDirectionStylePhase extends SingleColumnLayoutStylePhase { TextDirectionStylePhase({this.fallback = TextDirection.ltr}); /// Direction of the document language (for blocks without strong chars). TextDirection fallback; + /// The cell slots of the document being styled, for the direction of + /// cells without strong characters. They are rebuilt on every document + /// change before the layout restyles (they listen first). + CellSlots? cells; + + var _directions = const {}; + + /// The direction of [nodeId]'s block in the last styling pass, e.g. for + /// the arrow keys. + TextDirection directionOf(String nodeId) => _directions[nodeId] ?? fallback; + @override SingleColumnLayoutViewModel style( Document document, SingleColumnLayoutViewModel viewModel, ) { - return SingleColumnLayoutViewModel( + final directions = {}; + final result = SingleColumnLayoutViewModel( padding: viewModel.padding, componentViewModels: [ for (final model in viewModel.componentViewModels) - if (model is TextComponentViewModel) _withDirection(model) else model, + if (model is TextComponentViewModel) + _withDirection(model, directions) + else + model, ], ); + _directions = directions; + return result; } SingleColumnLayoutComponentViewModel _withDirection( SingleColumnLayoutComponentViewModel model, + Map directions, ) { final textModel = model as TextComponentViewModel; final direction = detectTextDirection( textModel.text.toPlainText(), - fallback: fallback, + fallback: cells?[model.nodeId]?.direction ?? fallback, ); + directions[model.nodeId] = direction; if (direction == textModel.textDirection) return model; final copy = model.copy(); - (copy as TextComponentViewModel) - ..textDirection = direction - ..textAlignment = direction == TextDirection.rtl - ? TextAlign.right - : TextAlign.left; + (copy as TextComponentViewModel).textDirection = direction; return copy; } } diff --git a/wonky_write/lib/features/editor/styles/write_stylesheet.dart b/wonky_write/lib/features/editor/styles/write_stylesheet.dart index 266bfcca..62107dd2 100644 --- a/wonky_write/lib/features/editor/styles/write_stylesheet.dart +++ b/wonky_write/lib/features/editor/styles/write_stylesheet.dart @@ -129,13 +129,22 @@ class WriteStylesheetBuilder { double px(double value) => value * scale; // List and task items; inside a quote they sit in the quote's indent // and their spacing is inside the bar (WonkyParagraphComponentBuilder). - CascadingPadding listItemPadding(DocumentNode node) => - isQuotedListNode(node) + // The indent of a quote's bar, on the start side of the block (Write 04 + // §38). + CascadingPadding quoteIndent(DocumentNode node) => isRtlNode(node) ? CascadingPadding.only( - left: px(theme.quoteBarWidth + 12), + right: px(theme.quoteBarWidth + 12), top: 0, bottom: 0, ) + : CascadingPadding.only( + left: px(theme.quoteBarWidth + 12), + top: 0, + bottom: 0, + ); + CascadingPadding listItemPadding(DocumentNode node) => + isQuotedListNode(node) + ? quoteIndent(node) : CascadingPadding.only( top: px(theme.listItemSpacing), bottom: px(theme.listItemSpacing), @@ -179,11 +188,7 @@ class WriteStylesheetBuilder { // The space above and below each quote line is inside the bar // (WonkyParagraphComponentBuilder), so consecutive lines and // paragraphs of one quote share one unbroken bar (Write 04 §14). - Styles.padding: CascadingPadding.only( - left: px(theme.quoteBarWidth + 12), - top: 0, - bottom: 0, - ), + Styles.padding: quoteIndent(node), }; }), // Space around the block; the box itself (background, corners, border, @@ -465,12 +470,16 @@ class WriteInlineStyler { TextDirection detectTextDirection( String text, { TextDirection fallback = TextDirection.ltr, -}) { +}) => strongTextDirection(text) ?? fallback; + +/// The direction of [text]'s first strong character, `null` when it has +/// none (digits, punctuation and spaces are neutral). +TextDirection? strongTextDirection(String text) { for (final rune in text.runes) { if (_isRtl(rune)) return TextDirection.rtl; if (_isStrongLtr(rune)) return TextDirection.ltr; } - return fallback; + return null; } bool _isRtl(int rune) => diff --git a/wonky_write/lib/features/editor/ui/wonky_editor.dart b/wonky_write/lib/features/editor/ui/wonky_editor.dart index 5b10b70d..b0cbfe58 100644 --- a/wonky_write/lib/features/editor/ui/wonky_editor.dart +++ b/wonky_write/lib/features/editor/ui/wonky_editor.dart @@ -966,6 +966,7 @@ class WonkyEditorState extends State { as BoxFrameComponentBuilder, ); _cellPhase.layout = cells; + _direction.cells = cells.slots; _cellsInBuild = cells; return [ for (final builder in _unframedBuilders( @@ -1121,6 +1122,7 @@ class WonkyEditorState extends State { popoverKeys: _popoverKeys, builtInShortcuts: widget.builtInShortcuts, callbacks: callbacks, + directionOf: _direction.directionOf, ), contentTapDelegateFactories: [ (editContext) => WonkyLinkTapHandler( diff --git a/wonky_write/test/features/editor/ui/rtl_test.dart b/wonky_write/test/features/editor/ui/rtl_test.dart new file mode 100644 index 00000000..4adede46 --- /dev/null +++ b/wonky_write/test/features/editor/ui/rtl_test.dart @@ -0,0 +1,301 @@ +import 'package:dart_quill_delta/dart_quill_delta.dart'; +import 'package:flutter/gestures.dart' show kDoubleTapTimeout; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:wonky_write/features/editor/applier/delta_applier.dart'; + +import '../support/editor_harness.dart'; +import 'wonky_editor_test.dart' show pumpEditor; + +Map _cell(String row) => { + 'cell': {'table': 't1', 'row': row}, +}; + +const _grid = { + 'grid': {'id': 'g1', 'columns': 3, 'bullets': 'check'}, +}; + +/// Arabic blocks of every kind, a table, a grid and an English quote. +Delta _doc() => Delta() + ..insert('مرحبا بالعالم\n') + ..insert('عنوان') + ..insert('\n', {'header': 2, 'step': 3}) + ..insert('بند أول') + ..insert('\n', {'list': 'bullet'}) + ..insert('مهمة') + ..insert('\n', {'list': 'unchecked'}) + ..insert('اقتباس') + ..insert('\n', {'blockquote': true}) + ..insert('English quote') + ..insert('\n', {'blockquote': true}) + ..insert('الاسم') + ..insert('\n', _cell('r0')) + ..insert('الكمية') + ..insert('\n', _cell('r0')) + ..insert('تفاح') + ..insert('\n', _cell('r1')) + ..insert('\n', _cell('r1')) + ..insert('واحد') + ..insert('\n', _grid) + ..insert('اثنان') + ..insert('\n', _grid) + ..insert('ثلاثة') + ..insert('\n', _grid) + ..insert('English line\n'); + +/// The node of the [index]th text block. +TextNode _node(EditorHarness h, int index) => + h.controller.document.toList()[index] as TextNode; + +/// The global bounds of [node]'s component. +Rect _box(EditorHarness h, DocumentNode node) { + final component = h.controller.documentLayout!.getComponentByNodeId(node.id)!; + final box = component.context.findRenderObject()! as RenderBox; + return box.localToGlobal(Offset.zero) & box.size; +} + +/// The global caret rect at [offset] in [node]. +Rect _caret(EditorHarness h, DocumentNode node, int offset) { + final layout = h.controller.documentLayout!; + final rect = layout.getRectForPosition( + DocumentPosition( + nodeId: node.id, + nodePosition: TextNodePosition(offset: offset), + ), + )!; + return rect.shift(layout.getGlobalOffsetFromDocumentOffset(Offset.zero)); +} + +TextNodePosition _extent(EditorHarness h) => + h.controller.composer.selection!.extent.nodePosition as TextNodePosition; + +Future _tapAt( + WidgetTester tester, + EditorHarness h, + DocumentNode node, + int offset, +) async { + await tester.tapAt(_caret(h, node, offset).center); + await tester.pumpAndSettle(); + // Not a double click with the next tap. + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 50)); +} + +void main() { + group('Given right-to-left blocks (Write 04 §38)', () { + testWidgets('when laid out then text, list markers, checkboxes, quote ' + 'bars and step badges are on the right', (tester) async { + final h = await pumpEditor(tester, _doc()); + final paragraph = _node(h, 0); + final column = _box(h, paragraph); + + // The paragraph starts at the right edge. + expect(_caret(h, paragraph, 0).left, closeTo(column.right, 2)); + + // The heading's text starts left of its step badge. + final heading = _node(h, 1); + final badge = tester.getRect( + find.byKey(ValueKey('write.editor.step.${heading.id}')), + ); + expect(badge.right, closeTo(column.right, 2)); + expect(_caret(h, heading, 0).left, lessThan(badge.left)); + + // The bullet item's text starts left of its marker. + final item = _node(h, 2); + expect(_caret(h, item, 0).left, lessThan(column.right - 10)); + + // The checkbox is right of the task's text. + final task = _node(h, 3); + final checkbox = tester.getRect(find.byType(Checkbox)); + expect(checkbox.center.dx, greaterThan(_caret(h, task, 0).left)); + expect(checkbox.right, closeTo(column.right, 18)); + + // The Arabic quote is indented from the right, the English one from + // the left. + final quote = _node(h, 4); + expect(_box(h, quote).right, lessThan(column.right - 8)); + expect(_box(h, quote).left, closeTo(column.left, 1)); + final english = _node(h, 5); + expect(_box(h, english).left, greaterThan(column.left + 8)); + expect(_box(h, english).right, closeTo(column.right, 1)); + + // The English line is unaffected. + final last = h.controller.document.last; + expect(_caret(h, last, 0).left, closeTo(column.left, 2)); + }); + + testWidgets( + 'when pressing ← and → then the caret moves visually, also by word ' + 'and with Shift', + (tester) async { + final h = await pumpEditor(tester, _doc()); + final paragraph = _node(h, 0); + await _tapAt(tester, h, paragraph, 2); + expect(_extent(h).offset, 2); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(_extent(h).offset, 3); + expect( + _caret(h, paragraph, 3).left, + lessThan(_caret(h, paragraph, 2).left), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(_extent(h).offset, 1); + + // ⌥← to the end of the word (visually left). + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + expect(_extent(h).offset, 'مرحبا'.length); + + // ⇧← extends the selection forward. + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + final s = h.controller.composer.selection!; + expect((s.base.nodePosition as TextNodePosition).offset, 5); + expect(_extent(h).offset, 6); + + // ⌘← to the line's visual left end (its logical end), ⌘→ back. + await _tapAt(tester, h, paragraph, 2); + await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft); + await tester.pump(); + expect(_extent(h).offset, paragraph.text.length); + await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft); + await tester.pump(); + expect(_extent(h).offset, 0); + + // In the English line ← still moves back. + final last = h.controller.document.last; + await _tapAt(tester, h, last, 3); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(_extent(h).offset, 2); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + ); + + testWidgets('when pressing ← at the visual left end then the caret moves ' + 'to the next block', (tester) async { + final h = await pumpEditor(tester, _doc()); + final paragraph = _node(h, 0); + final length = paragraph.text.length; + await _tapAt(tester, h, paragraph, length); + expect(_extent(h).offset, length); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + final s = h.controller.composer.selection!; + expect(s.extent.nodeId, _node(h, 1).id); + expect(_extent(h).offset, 0); + }); + }); + + group('Given a right-to-left table and grid (Write 12 §3.10)', () { + testWidgets('when laid out then column 0 is on the right and cells are ' + 'right aligned, also an empty one', (tester) async { + final h = await pumpEditor(tester, _doc()); + final name = _node(h, 6); + final qty = _node(h, 7); + final apples = _node(h, 8); + final empty = _node(h, 9); + expect(_box(h, name).left, greaterThan(_box(h, qty).right - 1)); + expect(_box(h, apples).left, closeTo(_box(h, name).left, 1)); + // Text starts at the cell's right (the box is inside its padding). + expect(_caret(h, name, 0).left, closeTo(_box(h, name).right, 2)); + expect(_caret(h, empty, 0).left, closeTo(_box(h, empty).right, 2)); + // The table is as wide as the text column. + final column = _box(h, _node(h, 0)); + expect(_box(h, name).right, closeTo(column.right - 8, 1)); + expect(_box(h, qty).left, closeTo(column.left + 8, 1)); + }); + + testWidgets('when pressing Tab then the next cell (on the left) is ' + 'selected', (tester) async { + final h = await pumpEditor(tester, _doc()); + final name = _node(h, 6); + await _tapAt(tester, h, name, 1); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + final s = h.controller.composer.selection!; + expect(s.extent.nodeId, _node(h, 7).id); + }); + + testWidgets('when the grid is laid out then it flows from the right with ' + 'its bullets on the right', (tester) async { + final h = await pumpEditor(tester, _doc()); + final one = _node(h, 10); + final two = _node(h, 11); + final three = _node(h, 12); + expect(_box(h, one).left, greaterThan(_box(h, two).right)); + expect(_box(h, two).left, greaterThan(_box(h, three).right)); + // The bullet's room is right of the text. + final column = _box(h, _node(h, 0)); + expect(_caret(h, one, 0).left, lessThan(column.right - 15)); + expect(_caret(h, one, 0).left, greaterThan(column.right - 40)); + }); + + testWidgets('when its first cell gets Arabic text then the table is laid ' + 'out right to left', (tester) async { + final h = await pumpEditor( + tester, + Delta() + ..insert('مرحبا\n') + ..insert('\n', _cell('r0')) + ..insert('\n', _cell('r0')) + ..insert('End\n'), + ); + final a = _node(h, 1); + final b = _node(h, 2); + // No strong characters: the document's direction (left to right). + expect(_box(h, a).right, lessThan(_box(h, b).left + 1)); + + h.controller.select(DeltaSelection.collapsed('مرحبا\n'.length)); + await tester.pump(); + h.controller.editor.execute([ + InsertTextRequest( + documentPosition: DocumentPosition( + nodeId: a.id, + nodePosition: const TextNodePosition(offset: 0), + ), + textToInsert: 'نص', + attributions: const {}, + ), + ]); + await tester.pumpAndSettle(); + expect(_box(h, a).left, greaterThan(_box(h, b).right - 1)); + // The empty cell follows the table. + expect(_caret(h, b, 0).left, closeTo(_box(h, b).right, 2)); + }); + }); + + testWidgets('Given a table in a wide window then it is no wider than the ' + 'text column', (tester) async { + final h = await pumpEditor( + tester, + Delta() + ..insert('Para\n') + ..insert('A') + ..insert('\n', _cell('r0')) + ..insert('B') + ..insert('\n', _cell('r0')) + ..insert('End\n'), + ); + final column = _box(h, _node(h, 0)); + expect(column.width, lessThan(1100)); + // The cells' boxes are inside their 8 px padding. + expect(_box(h, _node(h, 1)).left, closeTo(column.left + 8, 1)); + expect(_box(h, _node(h, 2)).right, closeTo(column.right - 8, 1)); + }); +} From c202aea8d4434bcba6144e996eca6eac16bd86b9 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Oct 2026 06:59:18 +0200 Subject: [PATCH 13/16] fix(super_editor): paint right-to-left selection highlights on the text The layers beneath a TextComponent's text are zero-size painters in a Stack aligned top start, which in the right-to-left Directionality of a paragraph, list item or task is the top right: selections and underlines were drawn a block's width to the right. Aligned top left (SE-5 item 8). Co-Authored-By: Claude Opus 5.5 --- specs/wonky_write/04-editor.md | 6 ++- third_party/super_editor/PATCHES.md | 6 ++- .../lib/src/default_editor/text.dart | 4 ++ .../test/wonky_patches/se5_fixes_test.dart | 47 +++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/specs/wonky_write/04-editor.md b/specs/wonky_write/04-editor.md index ffb77fae..587e069a 100644 --- a/specs/wonky_write/04-editor.md +++ b/specs/wonky_write/04-editor.md @@ -2128,8 +2128,11 @@ feature flag. browser's). Mixed-direction runs inside a line follow the block's direction (as in Google Docs), not per-run visual order. ← at an RTL block's visual left end moves to the next block. Home/End use the logical line start/end; ↑/↓ keep the x position. + At the end of a chip, button or arrow link, ← moves out of it and → back in + ([11](11-wonky-markdown.md) §7.4). - Mixed-direction runs inside a line rely on Flutter's bidi layout. Selection highlights use - the per-box rects (possibly several per visual line). + the per-box rects (possibly several per visual line), painted from the text's top left also in + an RTL block (super_editor SE-5), like spelling and comment underlines. - Tables and grids have a direction for their layout (column order, flow, bullets) from their first strong character; a cell without one takes its table's; columns without an alignment are start-aligned ([12](12-native-cells.md) §3.10). @@ -2139,6 +2142,7 @@ feature flag. right. ← moves the caret visually left (to the next character), ⌥← to the word's visual left end. **AC-38.2** An Arabic quote has its bar on the right; an Arabic task its checkbox on the right; an Arabic numbered heading its badge on the right. +**AC-38.3** Selecting a word of an Arabic paragraph highlights that word. ## 39. Robustness and edge cases diff --git a/third_party/super_editor/PATCHES.md b/third_party/super_editor/PATCHES.md index cafe5805..e0317a8d 100644 --- a/third_party/super_editor/PATCHES.md +++ b/third_party/super_editor/PATCHES.md @@ -105,10 +105,14 @@ Every change is marked with `// WONKY PATCH ` in the code. Tests of our patc edges, so in right-to-left text ⌘←/⌘→ (and Home/End) went to the wrong end, and the auto-wrap adjustment made the line end offset −1. Swapped for right-to-left components. `default_editor/text.dart`. +8. **Right-to-left selection highlight**: the layers beneath a `TextComponent`'s text (selection + highlight, underlines) are zero-size painters in a `Stack`, aligned top start; in the + right-to-left `Directionality` of a paragraph, list item or task that is the top right, so + they painted a block's width to the right of the text. Aligned top left. `default_editor/text.dart`. - **Tests**: `test/wonky_patches/se5_fixes_test.dart`, `se3_node_change_events_test.dart`, `se2_document_patch_test.dart` -- **Upstream**: 1–7 are general fixes; to be filed upstream. +- **Upstream**: 1–8 are general fixes; to be filed upstream. ## SE-6 — Content drag delegate (drag and drop of the selection) diff --git a/third_party/super_editor/lib/src/default_editor/text.dart b/third_party/super_editor/lib/src/default_editor/text.dart index 985ee91e..32c6e5ee 100644 --- a/third_party/super_editor/lib/src/default_editor/text.dart +++ b/third_party/super_editor/lib/src/default_editor/text.dart @@ -1410,6 +1410,10 @@ class TextComponentState extends State with DocumentComponent imp overflow: widget.overflow, layerBeneathBuilder: (context, textLayout) { return Stack( + // WONKY PATCH SE-5: the layers paint in the text layout's + // coordinates from its top left, also in a right-to-left + // Directionality (where topStart is the top right). + alignment: Alignment.topLeft, children: [ // Selection highlight beneath the text. if (widget.text.length > 0) diff --git a/third_party/super_editor/test/wonky_patches/se5_fixes_test.dart b/third_party/super_editor/test/wonky_patches/se5_fixes_test.dart index 5ac74549..92ce32d0 100644 --- a/third_party/super_editor/test/wonky_patches/se5_fixes_test.dart +++ b/third_party/super_editor/test/wonky_patches/se5_fixes_test.dart @@ -1,6 +1,9 @@ // Tests for WONKY PATCH SE-5 fixes found by the Write test suites. +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/default_editor/selection_highlight.dart'; import 'package:super_editor/super_editor.dart'; +import 'package:super_text_layout/super_text_layout.dart'; void main() { group('SE-5 fixes >', () { @@ -46,5 +49,49 @@ void main() { expect(item.indent, 3); expect(item.getMetadataValue('custom'), 1); }); + + testWidgets( + 'the selection of right-to-left text is painted on its characters', + (tester) async { + final document = MutableDocument(nodes: [ + ParagraphNode(id: 'p', text: AttributedText('مرحبا بالعالم')), + ]); + final composer = MutableDocumentComposer( + initialSelection: const DocumentSelection( + base: DocumentPosition( + nodeId: 'p', nodePosition: TextNodePosition(offset: 0)), + extent: DocumentPosition( + nodeId: 'p', nodePosition: TextNodePosition(offset: 5)), + ), + ); + final editor = + createDefaultDocumentEditor(document: document, composer: composer); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + gestureMode: DocumentGestureMode.mouse, + ), + ), + )); + await tester.pumpAndSettle(); + + final paint = find.descendant( + of: find.byType(NonOverlappingSelectionHighlight), + matching: find.byType(CustomPaint), + ); + final painter = tester.widget(paint).painter! + as NonOverlappingSelectionPainter; + final paintOrigin = + tester.renderObject(paint).localToGlobal(Offset.zero); + final text = tester.renderObject( + find.byType(LayoutAwareRichText), + ); + expect(paintOrigin, text.localToGlobal(Offset.zero)); + // The first word is at the right end of the line. + final bounds = painter.selectionPath()!.getBounds(); + expect(bounds.right, closeTo(text.size.width, 1)); + expect(bounds.left, greaterThan(text.size.width / 2)); + }); }); } From 59fa20fbcbfbd95f29c0aae3ba8043c78fd0e324 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Oct 2026 06:59:18 +0200 Subject: [PATCH 14/16] fix(write): a click past a line-end chip, button or link puts the caret outside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A click beyond the last character's box of a chip, button or arrow link leaves the caret outside the element, like →; a click on its end puts it back inside. In right-to-left blocks the pill boundary keys swap (← out, → in) instead of being taken by the visual arrow handling. Co-Authored-By: Claude Opus 5.5 --- specs/wonky_write/11-wonky-markdown.md | 5 +- .../editor/keyboard/editor_keyboard.dart | 22 +++-- .../editor/reactions/pill_boundary.dart | 82 ++++++++++++++++++- .../lib/features/editor/ui/wonky_editor.dart | 6 ++ .../editor/ui/editor_clicks_test.dart | 67 +++++++++++++++ .../test/features/editor/ui/rtl_test.dart | 58 +++++++++++++ 6 files changed, 230 insertions(+), 10 deletions(-) diff --git a/specs/wonky_write/11-wonky-markdown.md b/specs/wonky_write/11-wonky-markdown.md index 15e09f8d..47c2c989 100644 --- a/specs/wonky_write/11-wonky-markdown.md +++ b/specs/wonky_write/11-wonky-markdown.md @@ -354,7 +354,10 @@ and exports show details expanded (HTML: `
`). last glyph, not after the room for the pill's padding or the arrow (the last character's letter spacing; `PillBoundary.caretRect` through super_editor SE-12). → moves the caret *outside* without moving it (typing follows the element; the caret is drawn past the pill's - edge or the arrow), ← moves it back inside; a click splits the last character at its glyph's + edge or the arrow), ← moves it back inside (in a right-to-left block ← moves out and → back + in); a click past the element's end, beyond its last character's box (right of it; left of it + in a right-to-left block), puts the caret outside too (`PillBoundaryTapHandler`), and a click + on the element's end puts it inside; a click splits the last character at its glyph's middle, not its box's, which includes that room (super_editor SE-13), and a click on what is under the pointer (a link to open, the link card) takes the character whose box holds the point (`DocumentGeometry.characterAt`), so the arrow belongs to its link; the link card also diff --git a/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart b/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart index da4dddf0..a6a4af0d 100644 --- a/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart +++ b/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart @@ -81,11 +81,11 @@ List wonkyKeyboardActions({ if (keymap != null && keymap(keyEvent)) { return ExecutionInstruction.haltExecution; } - if (directionOf != null && - _rightToLeftArrow(keyEvent, editContext, directionOf)) { + if (_pillBoundaryArrow(keyEvent, controller, directionOf)) { return ExecutionInstruction.haltExecution; } - if (_pillBoundaryArrow(keyEvent, controller)) { + if (directionOf != null && + _rightToLeftArrow(keyEvent, editContext, directionOf)) { return ExecutionInstruction.haltExecution; } if (builtInShortcuts && @@ -173,8 +173,12 @@ bool _rightToLeftArrow( /// → at the end of a chip, button or arrow link moves the caret out of it /// without moving it (typing goes after the element); ← there moves it back -/// in (Write 11 §7.4). -bool _pillBoundaryArrow(KeyEvent event, WriteEditorController controller) { +/// in (Write 11 §7.4). In a right-to-left block the keys swap. +bool _pillBoundaryArrow( + KeyEvent event, + WriteEditorController controller, + TextDirection Function(String nodeId)? directionOf, +) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) return false; final key = event.logicalKey; if (key != LogicalKeyboardKey.arrowRight && @@ -198,12 +202,16 @@ bool _pillBoundaryArrow(KeyEvent event, WriteEditorController controller) { final boundary = controller.pillBoundary; final outside = boundary.isOutsideAt(selection.extent); final preferences = controller.composer.preferences; - if (key == LogicalKeyboardKey.arrowRight && !outside) { + final rtl = directionOf?.call(node.id) == TextDirection.rtl; + final forward = rtl + ? LogicalKeyboardKey.arrowLeft + : LogicalKeyboardKey.arrowRight; + if (key == forward && !outside) { boundary.leaveAt(selection.extent); preferences.removeStyles(pill); return true; } - if (key == LogicalKeyboardKey.arrowLeft && outside) { + if (key != forward && outside) { boundary.clear(); preferences.addStyles(pill); return true; diff --git a/wonky_write/lib/features/editor/reactions/pill_boundary.dart b/wonky_write/lib/features/editor/reactions/pill_boundary.dart index 31cd41d4..71a01043 100644 --- a/wonky_write/lib/features/editor/reactions/pill_boundary.dart +++ b/wonky_write/lib/features/editor/reactions/pill_boundary.dart @@ -1,12 +1,14 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/painting.dart' show Rect; +import 'package:flutter/painting.dart' show Rect, TextDirection; +import 'package:flutter/services.dart' show HardwareKeyboard; import 'package:super_editor/super_editor.dart'; import '../attributions/wonky_attributions.dart'; /// The caret at the end of a chip, button or arrow link (Write 11 §7.4): /// inside by default (typed text extends the element), outside after → (typed -/// text follows it). ← at the same place goes back inside. The state belongs +/// text follows it) or a click past the element. ← at the same place goes +/// back inside (in right-to-left blocks the keys swap). The state belongs /// to one caret position and ends when the caret moves. class PillBoundary extends ChangeNotifier { DocumentPosition? _outside; @@ -14,6 +16,16 @@ class PillBoundary extends ChangeNotifier { /// Whether a caret at [position] types after the element ending there. bool isOutsideAt(DocumentPosition position) => _outside == position; + /// Like [isOutsideAt], whatever the caret's affinity. + bool _outsideAtOffset(String nodeId, int offset) { + final outside = _outside; + final p = outside?.nodePosition; + return outside != null && + outside.nodeId == nodeId && + p is TextNodePosition && + p.offset == offset; + } + /// Puts a caret at [position] outside the element ending there. void leaveAt(DocumentPosition position) { if (_outside == position) return; @@ -111,3 +123,69 @@ class PillBoundary extends ChangeNotifier { }; } } + +/// A click past the end of a chip, button or arrow link, beyond its last +/// character's box (right of it; left in a right-to-left block), puts the +/// caret outside the element, like → does; a click on the element's end +/// puts it inside (Write 11 §7.4). The caret itself is placed by +/// super_editor after this delegate. +class PillBoundaryTapHandler extends ContentTapDelegate { + PillBoundaryTapHandler({ + required this.document, + required this.composer, + required this.boundary, + this.directionOf, + }); + + final Document document; + final DocumentComposer composer; + final PillBoundary boundary; + final TextDirection Function(String nodeId)? directionOf; + + @override + TapHandlingInstruction onTap(DocumentTapDetails details) { + // Shift+click extends the selection. + if (HardwareKeyboard.instance.isShiftPressed) { + return TapHandlingInstruction.continueHandling; + } + final layout = details.documentLayout; + final position = layout.getDocumentPositionNearestToOffset( + details.layoutOffset, + ); + final p = position?.nodePosition; + final node = position == null + ? null + : document.getNodeById(position.nodeId); + if (position == null || p is! TextNodePosition || node is! TextNode) { + return TapHandlingInstruction.continueHandling; + } + final pill = PillBoundary.endingAt(node.text, p.offset); + if (pill.isEmpty) return TapHandlingInstruction.continueHandling; + final last = layout.getRectForSelection( + DocumentPosition( + nodeId: position.nodeId, + nodePosition: TextNodePosition(offset: p.offset - 1), + ), + position, + ); + if (last == null) return TapHandlingInstruction.continueHandling; + // A click on the edge itself (where the caret is drawn) is on the + // element. + const edge = 2.0; + final x = details.layoutOffset.dx; + final past = directionOf?.call(position.nodeId) == TextDirection.rtl + ? x < last.left - edge + : x > last.right + edge; + // The typing attributes follow when the caret moves; set here too, for a + // click at the caret's own position. + final preferences = composer.preferences; + if (past) { + boundary.leaveAt(position); + preferences.removeStyles(pill); + } else if (boundary._outsideAtOffset(position.nodeId, p.offset)) { + boundary.clear(); + preferences.addStyles(pill); + } + return TapHandlingInstruction.continueHandling; + } +} diff --git a/wonky_write/lib/features/editor/ui/wonky_editor.dart b/wonky_write/lib/features/editor/ui/wonky_editor.dart index b0cbfe58..e34b00cc 100644 --- a/wonky_write/lib/features/editor/ui/wonky_editor.dart +++ b/wonky_write/lib/features/editor/ui/wonky_editor.dart @@ -1131,6 +1131,12 @@ class WonkyEditorState extends State { requireModifier: true, onOpenLink: widget.onOpenLink, ), + (editContext) => PillBoundaryTapHandler( + document: controller.document, + composer: controller.composer, + boundary: controller.pillBoundary, + directionOf: _direction.directionOf, + ), superEditorAddEmptyParagraphTapHandlerFactory, ], ); diff --git a/wonky_write/test/features/editor/ui/editor_clicks_test.dart b/wonky_write/test/features/editor/ui/editor_clicks_test.dart index 97296ad7..aadca160 100644 --- a/wonky_write/test/features/editor/ui/editor_clicks_test.dart +++ b/wonky_write/test/features/editor/ui/editor_clicks_test.dart @@ -4,9 +4,27 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; +import 'package:wonky_write/features/editor/attributions/wonky_attributions.dart'; +import '../support/editor_harness.dart'; import 'wonky_editor_test.dart' show pumpEditor; +/// The global box of the character at [offset] in [nodeId]. +Rect characterBox(EditorHarness h, String nodeId, int offset) { + final layout = h.controller.documentLayout!; + final rect = layout.getRectForSelection( + DocumentPosition( + nodeId: nodeId, + nodePosition: TextNodePosition(offset: offset), + ), + DocumentPosition( + nodeId: nodeId, + nodePosition: TextNodePosition(offset: offset + 1), + ), + )!; + return rect.shift(layout.getGlobalOffsetFromDocumentOffset(Offset.zero)); +} + /// Clicks reach the editor's interactor, which lies below the document /// content: nothing a component paints may be a hit target of its own. void main() { @@ -85,4 +103,53 @@ void main() { }, variant: TargetPlatformVariant.only(TargetPlatform.macOS), ); + + for (final (name, attributes, attribution) in [ + ('a chip', {'chip': true}, ChipAttribution.plain as Attribution), + ( + 'a button', + {'link': 'https://example.com', 'linkStyle': 'button'}, + LinkStyleAttribution.button, + ), + ( + 'an arrow link', + {'link': 'https://example.com', 'linkStyle': 'arrow'}, + LinkStyleAttribution.arrow, + ), + ]) { + testWidgets( + 'Given $name at the end of a line when clicking right of it then the ' + 'caret is outside it, and on its last glyph inside it (Write 11 §7.4)', + (tester) async { + final h = await pumpEditor( + tester, + Delta() + ..insert('See ') + ..insert('Docs', attributes) + ..insert('\nNext\n'), + ); + final id = h.controller.document.first.id; + final end = DocumentPosition( + nodeId: id, + nodePosition: const TextNodePosition(offset: 8), + ); + final last = characterBox(h, id, 7); + DocumentPosition caret() => h.controller.composer.selection!.extent; + bool inside() => h.controller.composer.preferences.currentAttributions + .contains(attribution); + + await click(tester, Offset(last.right + 30, last.center.dy)); + expect(caret().isEquivalentTo(end), isTrue); + expect(h.controller.pillBoundary.isOutsideAt(caret()), isTrue); + expect(inside(), isFalse); + + // On the last glyph's right half, at the same position: inside. + await click(tester, Offset(last.right - 2, last.center.dy)); + expect(caret().isEquivalentTo(end), isTrue); + expect(h.controller.pillBoundary.isOutsideAt(caret()), isFalse); + expect(inside(), isTrue); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + ); + } } diff --git a/wonky_write/test/features/editor/ui/rtl_test.dart b/wonky_write/test/features/editor/ui/rtl_test.dart index 4adede46..5d7170f4 100644 --- a/wonky_write/test/features/editor/ui/rtl_test.dart +++ b/wonky_write/test/features/editor/ui/rtl_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:super_editor/super_editor.dart'; import 'package:wonky_write/features/editor/applier/delta_applier.dart'; +import 'package:wonky_write/features/editor/attributions/wonky_attributions.dart'; import '../support/editor_harness.dart'; import 'wonky_editor_test.dart' show pumpEditor; @@ -202,6 +203,63 @@ void main() { }); }); + group('Given a chip at the end of a right-to-left line (Write 11 §7.4)', () { + Future pumpChip(WidgetTester tester) => pumpEditor( + tester, + Delta() + ..insert('انظر ') + ..insert('وثائق', {'chip': true}) + ..insert('\nEnglish line\n'), + ); + const end = 'انظر وثائق'.length; + bool inside(EditorHarness h) => h + .controller + .composer + .preferences + .currentAttributions + .contains(ChipAttribution.plain); + bool outside(EditorHarness h) => h.controller.pillBoundary.isOutsideAt( + h.controller.composer.selection!.extent, + ); + + testWidgets('when clicking left of it then the caret is outside it', ( + tester, + ) async { + final h = await pumpChip(tester); + final line = _node(h, 0); + final last = _caret(h, line, end); + await tester.tapAt(Offset(last.left - 30, last.center.dy)); + await tester.pumpAndSettle(); + expect(_extent(h).offset, end); + expect(outside(h), isTrue); + expect(inside(h), isFalse); + }); + + testWidgets( + 'when pressing ← at its end then the caret moves out of it, → back in', + (tester) async { + final h = await pumpChip(tester); + h.controller.focusNode.requestFocus(); + h.controller.select(DeltaSelection.collapsed(end)); + await tester.pump(); + expect(inside(h), isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(_extent(h).offset, end); + expect(outside(h), isTrue); + expect(inside(h), isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(_extent(h).offset, end); + expect(outside(h), isFalse); + expect(inside(h), isTrue); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + ); + }); + group('Given a right-to-left table and grid (Write 12 §3.10)', () { testWidgets('when laid out then column 0 is on the right and cells are ' 'right aligned, also an empty one', (tester) async { From 8589abf80b5b3f1b4d34db4ceb8614e9b5e23e29 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Oct 2026 08:15:39 +0200 Subject: [PATCH 15/16] fix(write): no link card when the pointer rests right of a line-end link The hover took the nearest caret position, and linkAt also looks at the character before it, so the pointer anywhere right of a line ending in a link or button showed its card (also after a click there, which leaves the caret outside). The hover now needs a character under the pointer (DocumentGeometry.characterUnder). Co-Authored-By: Claude Opus 5.5 --- specs/wonky_write/11-wonky-markdown.md | 4 ++- .../editor/layers/document_geometry.dart | 34 +++++++++++++++++++ .../lib/features/editor/ui/wonky_editor.dart | 7 ++-- .../editor/ui/editor_clicks_test.dart | 30 ++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/specs/wonky_write/11-wonky-markdown.md b/specs/wonky_write/11-wonky-markdown.md index 47c2c989..7955fdd1 100644 --- a/specs/wonky_write/11-wonky-markdown.md +++ b/specs/wonky_write/11-wonky-markdown.md @@ -360,7 +360,9 @@ and exports show details expanded (HTML: `
`). on the element's end puts it inside; a click splits the last character at its glyph's middle, not its box's, which includes that room (super_editor SE-13), and a click on what is under the pointer (a link to open, the link card) takes the character whose box holds the - point (`DocumentGeometry.characterAt`), so the arrow belongs to its link; the link card also + point (`DocumentGeometry.characterAt`), so the arrow belongs to its link; the hover card + needs a character under the pointer (`DocumentGeometry.characterUnder`), so the pointer + right of a line ending in a link or button shows none; the link card also shows at the end of a button or arrow link while the caret is inside it (a one-character button can be edited); at the *start* of a chip or button after a space the caret (typed text goes before the element) is drawn before the pill, not on its first glyph; any other caret move ends the state. An element made by an diff --git a/wonky_write/lib/features/editor/layers/document_geometry.dart b/wonky_write/lib/features/editor/layers/document_geometry.dart index 5ead470f..52c5dc08 100644 --- a/wonky_write/lib/features/editor/layers/document_geometry.dart +++ b/wonky_write/lib/features/editor/layers/document_geometry.dart @@ -24,6 +24,40 @@ abstract final class DocumentGeometry { return box != null && box.contains(offset) ? before : position; } + /// The position before the character whose box holds [offset] (document + /// coordinates), or null when the point is over no character: past a + /// line's end, in a margin. For what the pointer hovers. + static DocumentPosition? characterUnder( + DocumentLayout layout, + Document document, + Offset offset, + ) { + final position = layout.getDocumentPositionNearestToOffset(offset); + final p = position?.nodePosition; + final node = position == null + ? null + : document.getNodeById(position.nodeId); + if (position == null || p is! TextNodePosition || node is! TextNode) { + return null; + } + for (final start in [p.offset - 1, p.offset]) { + if (start < 0 || start >= node.text.length) continue; + final from = DocumentPosition( + nodeId: position.nodeId, + nodePosition: TextNodePosition(offset: start), + ); + final box = layout.getRectForSelection( + from, + DocumentPosition( + nodeId: position.nodeId, + nodePosition: TextNodePosition(offset: start + 1), + ), + ); + if (box != null && box.contains(offset)) return from; + } + return null; + } + /// Per-line rectangles (document coordinates) covering Delta `[start, end)`. /// Embeds contribute their whole box. Limited to [maxRects]. static List rectsForRange( diff --git a/wonky_write/lib/features/editor/ui/wonky_editor.dart b/wonky_write/lib/features/editor/ui/wonky_editor.dart index e34b00cc..5092533c 100644 --- a/wonky_write/lib/features/editor/ui/wonky_editor.dart +++ b/wonky_write/lib/features/editor/ui/wonky_editor.dart @@ -612,13 +612,16 @@ class WonkyEditorState extends State { final layout = controller.documentLayout; if (layout == null) return; final local = layout.getDocumentOffsetFromAncestorOffset(event.position); + // The character under the pointer: right of a line ending in a link + // (or button) is not on the link. final position = layout.getDocumentPositionAtOffset(local) == null ? null - : DocumentGeometry.characterAt(layout, local); + : DocumentGeometry.characterUnder(layout, controller.document, local); final offset = position == null ? null : controller.mirror.offsetOf(position); - final run = offset == null ? null : _commands.linkAt(offset); + var run = offset == null ? null : _commands.linkAt(offset); + if (run != null && (offset! < run.start || offset >= run.end)) run = null; _hoverTimer?.cancel(); if (run == null) { _hoverTimer = Timer(const Duration(milliseconds: 200), () { diff --git a/wonky_write/test/features/editor/ui/editor_clicks_test.dart b/wonky_write/test/features/editor/ui/editor_clicks_test.dart index aadca160..1f352fe6 100644 --- a/wonky_write/test/features/editor/ui/editor_clicks_test.dart +++ b/wonky_write/test/features/editor/ui/editor_clicks_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; import 'package:wonky_write/features/editor/attributions/wonky_attributions.dart'; +import 'package:wonky_write/features/editor/popovers/editor_popovers.dart'; import '../support/editor_harness.dart'; import 'wonky_editor_test.dart' show pumpEditor; @@ -152,4 +153,33 @@ void main() { variant: TargetPlatformVariant.only(TargetPlatform.macOS), ); } + + testWidgets( + 'Given a button at the end of a line when the pointer rests right of it ' + 'then no link card shows, and over the button it does', + (tester) async { + final h = await pumpEditor( + tester, + Delta() + ..insert('See ') + ..insert('Docs', { + 'link': 'https://example.com', + 'linkStyle': 'button', + }) + ..insert('\nNext\n'), + ); + final last = characterBox(h, h.controller.document.first.id, 7); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset(last.right + 30, last.center.dy)); + await mouse.moveTo(Offset(last.right + 40, last.center.dy)); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byKey(LinkHoverCard.cardKey), findsNothing); + + await mouse.moveTo(last.center); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byKey(LinkHoverCard.cardKey), findsOneWidget); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + ); } From ecb994446510aebf48133e94807045d446dcd1ab Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Oct 2026 08:41:35 +0200 Subject: [PATCH 16/16] chore: regenerate the spec index Co-Authored-By: Claude Opus 5.5 --- .../src/platform/board/ai/spec_index.g.dart | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/wonky_server/lib/src/platform/board/ai/spec_index.g.dart b/wonky_server/lib/src/platform/board/ai/spec_index.g.dart index 0773d008..b1348025 100644 --- a/wonky_server/lib/src/platform/board/ai/spec_index.g.dart +++ b/wonky_server/lib/src/platform/board/ai/spec_index.g.dart @@ -659,7 +659,7 @@ const specIndexApps = { ' ### 17.4 Image interactions\n' ' ## 18. Tables\n' ' ### 18.1 Model recap\n' - ' ### 18.2 Rendering (`WonkyTableComponentBuilder`)\n' + ' ### 18.2 Rendering\n' ' ### 18.3 Insert\n' ' ### 18.4 Table editor\n' ' ### 18.5 Paste and copy\n' @@ -1015,6 +1015,32 @@ const specIndexApps = { ' ## 10. MCP and AI (09)\n' ' ## 11. Acceptance criteria\n' ' ## 12. Tests\n' + 'specs/wonky_write/12-native-cells.md\n' + ' # Wonky Write — Native table and grid cells\n' + ' ## 1. Document model\n' + ' ### 1.1 Table cells\n' + ' ### 1.2 Grid items\n' + ' ### 1.3 Ids\n' + ' ### 1.4 Validator and normalizer\n' + ' ### 1.5 Legacy embeds and the upgrade\n' + ' ### 1.6 Markdown and other formats\n' + ' ## 2. DocTree\n' + ' ## 3. Editor\n' + ' ### 3.1 Nodes\n' + ' ### 3.2 Layout (super_editor patch SE-15)\n' + ' ### 3.3 Styling\n' + ' ### 3.4 Caret, selection and keys\n' + ' ### 3.5 Commands\n' + ' ### 3.6 Table editor popover\n' + ' ### 3.7 Chrome and menus\n' + ' ### 3.8 Pagination\n' + ' ### 3.9 Collaboration\n' + ' ### 3.10 Direction\n' + ' ## 4. Server, MCP and AI\n' + ' ## 5. Tests and acceptance\n' + ' ### 5.1 Tests\n' + ' ### 5.2 Acceptance criteria\n' + ' ## 6. Removed\n' 'specs/wonky_write/README.md\n' ' # Wonky Write — Specifications', 'sheets':