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/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_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_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/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..59d960db --- /dev/null +++ b/packages/wonky_write_core/lib/src/delta/cells.dart @@ -0,0 +1,490 @@ +/// 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; + } + + /// [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; + 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/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/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_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/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/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/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/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/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/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/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..587e069a 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. @@ -1652,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. @@ -2141,21 +2114,35 @@ 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. + 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). -- Table cell alignment `null` means start-aligned per cell direction. + 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). - 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. +**AC-38.3** Selecting a word of an Arabic paragraph highlights that word. ## 39. Robustness and edge cases diff --git a/specs/wonky_write/11-wonky-markdown.md b/specs/wonky_write/11-wonky-markdown.md index 56ea8267..7955fdd1 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 + `:::` | @@ -350,10 +354,15 @@ 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 + 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/specs/wonky_write/12-native-cells.md b/specs/wonky_write/12-native-cells.md new file mode 100644 index 00000000..28885071 --- /dev/null +++ b/specs/wonky_write/12-native-cells.md @@ -0,0 +1,374 @@ +# 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`. 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 (`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`. + +### 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. + +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 + +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** (`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. +- **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 + (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). 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). +- 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 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` (`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, 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 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. + +### 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). 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); 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 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 (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** 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). + +### 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; 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 | +| `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 `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, 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 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 + +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). + +### 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 + 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. +- 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. +- `CellUpgrader` 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`, 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, 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. + +### 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. +**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 + +`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. 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. diff --git a/third_party/super_editor/PATCHES.md b/third_party/super_editor/PATCHES.md index 108d2a2a..e0317a8d 100644 --- a/third_party/super_editor/PATCHES.md +++ b/third_party/super_editor/PATCHES.md @@ -97,10 +97,22 @@ 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`. +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–5 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) @@ -253,6 +265,52 @@ 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. + - 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). +- **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..f5d9cc31 --- /dev/null +++ b/third_party/super_editor/lib/src/default_editor/layout_single_column/_cell_layout.dart @@ -0,0 +1,534 @@ +// 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 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], [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]. + const DocumentCellPlacement.rows({ + required this.group, + required this.row, + required this.column, + this.minColumnWidth = 64, + this.gap = 0, + this.spaceBefore = 0, + this.textDirection = TextDirection.ltr, + }) : 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, + this.textDirection = TextDirection.ltr, + }) : 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; + + /// 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) || + 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 && + other.textDirection == textDirection; + + @override + int get hashCode => + Object.hash(group, kind, row, column, index, maxColumns, minColumnWidth, gap, spaceBefore, textDirection); + + @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 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 + /// 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); + + 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) { + 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 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++) start + c * (columnWidth + gap), + start + contentWidth, + ]; + + 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 = _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; + } + childOffsets?.add(slot.topLeft); + } + + 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..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 @@ -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,74 @@ 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; + // 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: 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), + ), + ), + ), + ), + ); + i = end + 1; + } + return children; + } + /// Obtains a `GlobalKey` that should be attached to the component /// that represents the given [nodeId]. /// @@ -829,7 +1168,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 +1223,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 +1240,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/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..32c6e5ee 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), ); } @@ -1403,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/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..b9df4f87 --- /dev/null +++ b/third_party/super_editor/test/wonky_patches/se15_cell_groups_test.dart @@ -0,0 +1,583 @@ +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('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++) + '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'); + }); + }); +} 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)); + }); }); } 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': 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/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..1b0e863a 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 = controller.newEmbedId(); + 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..8068fd83 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,17 @@ import 'package:wonky_write_core/wonky_write_core.dart' BoxType, Boxes, CalloutVariant, + CellAlign, + CellEditException, + CellEdits, + CellGrid, + CellLines, DeltaUtils, GridBullets, - GridEmbed, MarkdownFlavor, WonkyAttr, WonkyDeltaNormalizer, + WonkyDocument, WonkyEmbed, WonkyToGfm, YouTubeEmbed; @@ -25,6 +30,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'; @@ -396,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` @@ -411,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 && @@ -888,7 +897,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 +1001,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 = controller.newEmbedId(); + 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 +1484,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; + } + + /// 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 selected one). + /// 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)), ); } @@ -1573,10 +1706,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; @@ -1588,6 +1734,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 @@ -1647,6 +1817,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 @@ -1682,6 +1861,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/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..477dc1ee --- /dev/null +++ b/wonky_write/lib/features/editor/components/cell_layout.dart @@ -0,0 +1,566 @@ +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 '../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'; + +/// 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, + this.direction = TextDirection.ltr, + }) : index = 0, + columns = 0, + bullets = GridBullets.none; + + const CellSlot.item({ + required this.group, + required this.index, + required this.columns, + required this.bullets, + this.direction = TextDirection.ltr, + }) : 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; + + /// 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 + 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 && + other.direction == direction; + + @override + 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 +/// 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 = {}; + // 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; + 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); + detect('t:${cell.table}', node); + 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; + } + detect('g:${g.id}', node); + next[node.id] = CellSlot.item( + group: 'g:${g.id}', + index: item++, + columns: columns!, + bullets: bullets!, + ); + continue; + } + 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; + } + + 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, {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; + bool editable = true; + EditorBlockCallbacks callbacks = const EditorBlockCallbacks(); + + /// Frames the group in its containers (the editor's frame builder). + BoxFrameComponentBuilder? frames; + + /// 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 room of a grid item for its bullet (on its start side). + 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: _gaps[nodeId] ?? 0, + textDirection: slot.direction, + ); + } + return DocumentCellPlacement.flow( + group: slot.group, + index: slot.index, + maxColumns: slot.columns, + // Three columns from about 480 px (Write 11 §7.2). + minColumnWidth: 140 * zoom, + gap: theme.wonky.gridColumnGap * zoom, + spaceBefore: _gaps[nodeId] ?? 0, + textDirection: slot.direction, + ); + } + + @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(); + final rtl = first?.isRtl ?? false; + for (final rect in geometry.slotRects.values) { + painter.paint( + canvas, + (rtl ? rect.topRight - Offset(painter.width, 0) : 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); + pagination?.removeListener(_onPagination); + 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; + // 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, + null => TextAlign.start, + }; + if (slot.row == 0) { + final base = text.textStyleBuilder; + text.textStyleBuilder = _headerBuilders[base] ??= (attributions) => + base(attributions).copyWith(fontWeight: FontWeight.w600); + } + } else { + final room = layout.bulletRoom(slot.bullets); + copy.padding = slot.isRtl + ? EdgeInsets.only(right: room) + : EdgeInsets.only(left: room); + } + changed = true; + models.add(copy); + } + if (!changed) return viewModel; + return SingleColumnLayoutViewModel( + padding: viewModel.padding, + componentViewModels: models, + ); + } +} 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..fe666467 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, ), ); } @@ -752,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( @@ -769,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({ @@ -803,6 +763,7 @@ class WonkyListItemComponentBuilder implements ComponentBuilder { theme: theme, zoom: zoom, spacing: theme.listItemSpacing, + rtl: _isRtl(viewModel), child: component, ); } @@ -860,6 +821,7 @@ class WonkyParagraphComponentBuilder implements ComponentBuilder { theme: theme, zoom: zoom, spacing: quoteLineSpacing, + rtl: _isRtl(viewModel), child: component, ); } @@ -899,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, ); }, @@ -918,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; @@ -972,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, @@ -988,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, ); } @@ -1013,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 { @@ -1478,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/controller/write_editor_controller.dart b/wonky_write/lib/features/editor/controller/write_editor_controller.dart index 717300c6..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,9 +16,9 @@ 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 '../grid/inline_grid_editing.dart' show InlineGridEditing; import '../reactions/pill_boundary.dart'; import '../view/editor_view_state.dart'; import 'editor_pipeline.dart'; @@ -66,6 +66,7 @@ class WriteEditorController extends ChangeNotifier { _events.add(const EditorNotice(EditorNoticeKind.documentFull)), maxDocumentChars: maxDocumentChars, ), + ...cellEditingHandlers(), ...blockEditingHandlers( newEmbedId: this.newEmbedId, markdownShortcuts: () => _preferences.markdownShortcuts, @@ -77,8 +78,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 +157,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 +772,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/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..a6a4af0d 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'; @@ -65,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, @@ -73,10 +75,17 @@ 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; } - if (_pillBoundaryArrow(keyEvent, controller)) { + if (_pillBoundaryArrow(keyEvent, controller, directionOf)) { + return ExecutionInstruction.haltExecution; + } + if (directionOf != null && + _rightToLeftArrow(keyEvent, editContext, directionOf)) { return ExecutionInstruction.haltExecution; } if (builtInShortcuts && @@ -109,10 +118,67 @@ 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). -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 && @@ -136,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/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/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/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/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 3737de20..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,11 +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, - this.onGridInserted, - }); + BlockPrefixConversionReaction({required this.newEmbedId, this.isWonky}); /// Creates ids for new embeds (UUIDv7). final String Function() newEmbedId; @@ -28,7 +24,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 +234,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 +243,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/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/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/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)); + } +} diff --git a/wonky_write/lib/features/editor/setup/editor_factory.dart b/wonky_write/lib/features/editor/setup/editor_factory.dart index a3e418c0..30062e81 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; @@ -147,11 +146,7 @@ List wonkyReactions( WonkyTypingAttributesReaction(boundary: pillBoundary), marked(const LinkifyReaction(), 'Autolink', autolink), marked( - BlockPrefixConversionReaction( - newEmbedId: newEmbedId, - isWonky: isWonky, - onGridInserted: onGridInserted, - ), + BlockPrefixConversionReaction(newEmbedId: newEmbedId, isWonky: isWonky), 'Autoformat', markdown, ), 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/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..5092533c 100644 --- a/wonky_write/lib/features/editor/ui/wonky_editor.dart +++ b/wonky_write/lib/features/editor/ui/wonky_editor.dart @@ -13,12 +13,11 @@ 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 '../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 +41,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 +74,6 @@ class WonkyEditor extends StatefulWidget { this.extraStylePhases = const [], this.emojiSuggestions, this.onDropFiles, - this.inlineTableEditing = true, this.externalDrops, this.endInset = 0, this.compactPadding, @@ -141,9 +138,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. @@ -180,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(); @@ -191,8 +191,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 +227,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 +270,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 +283,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 +291,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(); } @@ -329,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), pagination: _pagination); + 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(); @@ -481,7 +477,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 +506,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). @@ -648,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), () { @@ -781,12 +748,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 @@ -996,6 +957,20 @@ 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; + _direction.cells = cells.slots; + _cellsInBuild = cells; return [ for (final builder in _unframedBuilders( theme, @@ -1034,12 +1009,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 +1031,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, @@ -1117,6 +1081,7 @@ class WonkyEditorState extends State { selectionStyle: style.selectionStyles, customStylePhases: [ _boxPhase, + _cellPhase, _pillSpacing, if (pages) _paginationPhase, _codeHighlight, @@ -1124,6 +1089,7 @@ class WonkyEditorState extends State { ...widget.extraStylePhases, ], componentBuilders: _componentBuilders(theme, style.zoom, editable: true), + cellLayout: _cellsInBuild, contentDragDelegate: _textDrag, documentUnderlayBuilders: [ if (pages) @@ -1159,6 +1125,7 @@ class WonkyEditorState extends State { popoverKeys: _popoverKeys, builtInShortcuts: widget.builtInShortcuts, callbacks: callbacks, + directionOf: _direction.directionOf, ), contentTapDelegateFactories: [ (editContext) => WonkyLinkTapHandler( @@ -1167,6 +1134,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, ], ); @@ -1194,6 +1167,7 @@ class WonkyEditorState extends State { stylesheet: stylesheet, customStylePhases: [ _boxPhase, + _cellPhase, _pillSpacing, // Pages also for previews (Viewing mode shows one). if (pages) _paginationPhase, @@ -1207,6 +1181,7 @@ class WonkyEditorState extends State { editable: false, document: editor.document, ), + cellLayout: _cellsInBuild, documentUnderlayBuilders: [ if (pages) _ReaderPageChromeLayerBuilder( 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/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/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). 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/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/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)); - }); - }); -} 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_clicks_test.dart b/wonky_write/test/features/editor/ui/editor_clicks_test.dart index 97296ad7..1f352fe6 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,28 @@ 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 'package:wonky_write/features/editor/popovers/editor_popovers.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 +104,82 @@ 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), + ); + } + + 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), + ); } 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..0e9d14a6 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,303 @@ 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 { - final p = await _pump(tester, _tableDoc()); + 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)); + }); - await tester.tapAt(_center(tester, 'Apples')); - await tester.pump(); + 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'); - 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); - }); + // 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)); testWidgets( - 'when another cell is pressed while editing then editing moves there ' - 'without stopping in between', + '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()); - 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); + 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, + ); } - editing.addListener(record); - addTearDown(() => editing.removeListener(record)); - + final from = at('Name', 2); + final to = at('Qty', 1); final gesture = await tester.startGesture( - _center(tester, '3'), + from, kind: PointerDeviceKind.mouse, ); await tester.pump(); - expect(editing.active?.column, 0); + // 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(); - - 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, + final s = p.h.controller.composer.selection!; + final doc = p.h.controller.document; + expect( + (doc.getNodeById(s.base.nodeId)! as TextNode).text.toPlainText(), + 'Name', ); - 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((s.base.nodePosition as TextNodePosition).offset, 2); expect( - p.state.tableEditing.text.selection, - const TextSelection(baseOffset: 0, extentOffset: 6), + (doc.getNodeById(s.extent.nodeId)! as TextNode).text.toPlainText(), + 'Qty', ); }, 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 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 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)); - 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.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(_rows(p.h.delta), [ + ['Name', 'Qty'], + ['Apples', '3'], + ['', ''], + ]); + expect(_caret(p.h), ('', 0, 0)); + + 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)); }); }); 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..5d7170f4 --- /dev/null +++ b/wonky_write/test/features/editor/ui/rtl_test.dart @@ -0,0 +1,359 @@ +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 'package:wonky_write/features/editor/attributions/wonky_attributions.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 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 { + 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)); + }); +} diff --git a/wonky_write/test/features/tables/table_editor_test.dart b/wonky_write/test/features/tables/table_editor_test.dart index 1481d82a..9940f9bd 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,15 @@ 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 {