fix(editor): tables with whole-number sizes, or none, show

The editor casts a table's sizes (its column width, row height, height,
and each cell's width and height) to decimals, and finds a row's default
height with a null check. JSON written elsewhere may hold whole numbers,
40 where the editor writes 40.0, or no sizes at all: such a table showed
Flutter's error box instead. The Writing System paths in an archive
brought over from elsewhere hold whole numbers. Opening content now gives
its tables their sizes as the editor reads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-11 19:31:39 +02:00
commit 277994d31b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 102 additions and 1 deletions

View file

@ -89,7 +89,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`.
- **State: `flutter_bloc`, uniformly.** Blocs fold repository streams into immutable states with value equality, are provided as close to their consumer as possible, and log through one global `BlocObserver`. Why: the data layer is stream-shaped, event → state machines test without widgets, and one pattern everywhere is itself the point.
- **Value equality comes from `equatable`**, mixed in (`with Equatable`), for models and states alike; `leancode_lint`'s `missing_equatable_props` catches a field left out of `props`.
- **Rich text is `RichContent`** (`lib/src/core/content/`): the editor's document JSON plus a `formatVersion`, pure Dart, so models and the server can use it. `lib/src/core/editor/` converts it to and from the editor's `Document` and holds the one editing widget (`RichContentEditor`) and the one read-only renderer (`RichContentView`).
- **Rich text is `RichContent`** (`lib/src/core/content/`): the editor's document JSON plus a `formatVersion`, pure Dart, so models and the server can use it. `lib/src/core/editor/` converts it to and from the editor's `Document` (giving tables the decimal sizes the editor casts to: JSON from imports and archives may hold whole numbers, or none) and holds the one editing widget (`RichContentEditor`) and the one read-only renderer (`RichContentView`).
- **Blocks** beyond the editor's own (callout, spoiler, formula, image, sound, YouTube video, study button) keep their type and attributes as named in `core/content/content_blocks.dart`, which `RichContent` reads too (their text counts in `plainText`, and a video or study button isn't blank). An `EditorBlocks` lists how each type is built, which can be inserted (the format bar's Insert sheet and the desktop's `/` menu show the same list) and its typing rules; the core's are `coreBlocks`, and a feature's (the study button, `feature/study/`) reach the editors through `EditorBlocksScope`, which `PersonApp` fills, since the core can't know features. One builder serves editor and view: a block asks `editorState.editable`, and read-only shows what it is for (a spoiler hidden until revealed, a formula set, a video's play). A block taken whole mixes in `AtomicBlock` (selection, the remove button); unknown types show "needs a newer version" and stay as they are. New block types keep `formatVersion` 1: raising it would hide whole studysets and paths in older apps.
- **Time comes from `clock.now()`** (`package:clock`), never `DateTime.now()`, in server and app. Why: scheduling, streaks and expiry are deterministic under test (`withClock`, `fakeAsync`), and because Serverpod itself reads `clock.now()` (e.g. session expiry), `withClock` controls its behaviour too.
- **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries.

View file

@ -18,10 +18,56 @@ extension RichContentToDocument on RichContent {
// A mutable deep copy: the editor keeps parts of the JSON it is given and
// may change them in place, while RichContent's JSON is unmodifiable.
final json = jsonDecode(jsonEncode(editorJson)) as Map<String, Object?>;
_sizeTables(json['document']);
return Document.fromJson(json);
}
}
/// The sizes a table keeps, with what it takes where it has none.
final _tableSizes = {
TableBlockKeys.colDefaultWidth: TableDefaults.colWidth,
TableBlockKeys.rowDefaultHeight: TableDefaults.rowHeight,
TableBlockKeys.colMinimumWidth: TableDefaults.colMinimumWidth,
};
/// Gives the tables in [node] their sizes as the editor reads them, which
/// casts them to decimals and fails on whole numbers or none (checked with
/// our fork of v6.2): JSON written elsewhere, such as imports and archives,
/// may hold `40` where the editor itself writes `40.0`.
void _sizeTables(Object? node) {
if (node is! Map<String, Object?>) {
return;
}
double? decimal(Object? size) => switch (size) {
final num size => size.toDouble(),
final String size => double.tryParse(size),
_ => null,
};
if (node['data'] case final Map<String, Object?> data) {
switch (node['type']) {
case TableBlockKeys.type:
for (final MapEntry(:key, value: fallback) in _tableSizes.entries) {
data[key] = decimal(data[key]) ?? fallback;
}
if (decimal(data[TableBlockKeys.colsHeight]) case final height?) {
data[TableBlockKeys.colsHeight] = height;
}
case TableCellBlockKeys.type:
for (final key in [
TableCellBlockKeys.width,
TableCellBlockKeys.height,
]) {
if (decimal(data[key]) case final size?) {
data[key] = size;
}
}
}
}
if (node['children'] case final List<Object?> children) {
children.forEach(_sizeTables);
}
}
/// Stores the editor's document as [RichContent].
extension DocumentToRichContent on Document {
/// This document's content, in the current format.

View file

@ -37,6 +37,61 @@ void main() {
await tester.pump(const Duration(seconds: 1));
}
testWidgets('shows a table whose sizes are whole numbers, or missing, as '
'JSON written elsewhere may hold them', (tester) async {
Map<String, Object?> cell(int column, int row, Map<String, Object?> data) =>
{
'type': 'table/cell',
'data': {'colPosition': column, 'rowPosition': row, ...data},
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': '$column$row'},
],
},
},
],
};
for (final (table, cells) in [
// As an archive from elsewhere brought it: whole numbers.
(
{
'colsLen': 2,
'rowsLen': 1,
'colsHeight': 82,
'colDefaultWidth': 160,
'rowDefaultHeight': 40,
'colMinimumWidth': 40,
},
{'width': 80, 'height': 40},
),
// Without the editor's sizes.
(<String, Object?>{'colsLen': 2, 'rowsLen': 1}, <String, Object?>{}),
]) {
await show(
tester,
RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'table',
'data': table,
'children': [cell(0, 0, cells), cell(1, 0, cells)],
},
],
},
}),
);
expect(tester.takeException(), isNull);
expect(find.textContaining('00', findRichText: true), findsOneWidget);
expect(find.textContaining('10', findRichText: true), findsOneWidget);
}
});
testWidgets('shows the content', (tester) async {
await show(tester, paragraph('猫は動物です。'));