Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7d61c04
feat(write-core): native table cells and grid items as lines
vlidholt Oct 27, 2026
8b16e99
feat(write-core): MCP table edits on CellEdits; cells in generators a…
vlidholt Oct 27, 2026
2712236
refactor(write): table and grid commands on CellEdits; drop overlay e…
vlidholt Oct 27, 2026
b515287
feat(super_editor): SE-15 cell groups lay out components side by side
vlidholt Oct 27, 2026
d040f47
feat: upgrade legacy table embeds on the server; cell keys in Write
vlidholt Oct 27, 2026
295f63d
feat(write): lay out table cells and grid items natively
vlidholt Oct 27, 2026
211bf5d
feat(write): paste and copy with cells; tests for native tables and g…
vlidholt Oct 27, 2026
3bcc4b5
feat(write): table rows move to the next page as one; cell context menu
vlidholt Oct 27, 2026
1cef45d
docs(specs): native table and grid cells in specs 02, 04, 11, 12 and …
vlidholt Oct 27, 2026
7ceb5ee
fix(write): no block types in cells; grids keep three columns from 48…
vlidholt Oct 27, 2026
818ab04
fix(spellcheck): skip words in a script the dictionary has no letters of
vlidholt Oct 28, 2026
43424cf
fix(write): right-to-left tables, grids, quotes and arrow keys
vlidholt Oct 28, 2026
c202aea
fix(super_editor): paint right-to-left selection highlights on the text
vlidholt Oct 28, 2026
59fa20f
fix(write): a click past a line-end chip, button or link puts the car…
vlidholt Oct 28, 2026
8589abf
fix(write): no link card when the pointer rests right of a line-end link
vlidholt Oct 28, 2026
ecb9944
chore: regenerate the spec index
vlidholt Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 12 additions & 2 deletions packages/wonky_rich_text/lib/src/attributes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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_<threadUuid>: true`.
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion packages/wonky_rich_text/lib/src/markdown/inline_writer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 `&#96;`).
String? lastWritten;
var recent = '';
for (var i = from; i < to; i++) {
final t = toks[i];
Expand All @@ -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('<br>');
prevOut = '>';
lastWritten = prevOut;
recent = '';
case _Char(:final ch):
final atStart = i == from;
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/wonky_spellcheck/lib/src/checker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions packages/wonky_spellcheck/lib/src/dictionary.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ final class HunspellDictionary {

// Lazily built suggestion helpers.
List<String>? _rootList;
Set<int>? _alphabet;
String? _effectiveTry;
Map<int, String>? _keyNeighbours;

Expand Down Expand Up @@ -133,6 +134,37 @@ final class HunspellDictionary {

List<String> 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<int> _buildAlphabet() {
final letters = <String>{};
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 {
Expand Down
16 changes: 16 additions & 0 deletions packages/wonky_spellcheck/test/checker_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
93 changes: 93 additions & 0 deletions packages/wonky_write_core/lib/src/delta/cell_upgrade.dart
Original file line number Diff line number Diff line change
@@ -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,
);
}
Loading
Loading