refactor(learn): the editor's general parts move to halkyon_editor (ADR 0033, D37)

Todo stores editor documents too, so the document, the editor, its view,
the format bar, links, the clipboard, scripts and EditorBlocks move, with
their strings and tests, to a shared package on the same appflowy_editor
fork. What is Learn's alone stays in Learn on top of it, and what the
editor took from it directly is inverted: EditorBlocks gains textMarks,
formatActions and onEditorClosed, RichContentEditor a wrapEditor, and an
InsertableBlock's name takes the BuildContext. Learn's screens use
LearnContentEditor and LearnContentView, which add Learn's blocks and the
grammar check. Learn behaves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-06 21:25:44 +01:00
commit 648dfe4051
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
195 changed files with 1676 additions and 1366 deletions

View file

@ -29,8 +29,8 @@ 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` (giving tables the decimal sizes the editor casts to: JSON from imports and archives may hold whole numbers, or none; our fork of the editor fits a read-only table's rows to their cells, which upstream's leaves at their stored height) and holds the one editing widget (`RichContentEditor`) and the one read-only renderer (`RichContentView`). Copying and pasting keep every format between the app's editors (`EditorClipboard`, decided 2026-10-11): the system's clipboard holds plain text only, so the blocks copied last stay in memory beside it and a paste takes them while it still holds their text, plain text otherwise; the editor's own copy, cut and paste are replaced (Ctrl+C, X, V), and on phones a bar over selected text (the editor's `MobileFloatingToolbar`, over text only) copies, cuts, pastes and selects all. Links come from the format bar's link button and Ctrl+K (`link.dart`, in place of the editor's own link menu, English only); an address without its scheme leads to the web. Text above other text (ruby, `ruby.dart`, decided 2026-10-12) is named in the content's language, furigana over Japanese, pinyin over Chinese, else text above (`ContentLanguages`, in `core/widget/`, which every screen of a studyset sets through its language scope); read-only it shows only under the pointer or after a long press, its room kept so lines don't move, and while editing over the pointer; with the caret inside such text, the format bar's button edits all of it.
- **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.
- **Rich text is `RichContent`** (`halkyon_editor`, ADR 0033, D37: the editor's general parts moved there on 6 Nov 2026, when Todo took editor documents too; Learn's own stay in `lib/src/core/editor/` on top of it: callout, spoiler, formula, file and YouTube blocks, ruby, Markdown, `core_blocks.dart`, `rich_text_screen.dart`, `save_status.dart`, and `learn_content.dart`, the `LearnContentEditor` and `LearnContentView` every screen uses, which are the package's widgets with Learn's blocks and the check of the spelling): the editor's document JSON plus a `formatVersion`, pure Dart, so models can use it. The package 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; our fork of the editor fits a read-only table's rows to their cells, which upstream's leaves at their stored height) and holds the one editing widget (`RichContentEditor`) and the one read-only renderer (`RichContentView`). Copying and pasting keep every format between the app's editors (`EditorClipboard`, decided 2026-10-11): the system's clipboard holds plain text only, so the blocks copied last stay in memory beside it and a paste takes them while it still holds their text, plain text otherwise; the editor's own copy, cut and paste are replaced (Ctrl+C, X, V), and on phones a bar over selected text (the editor's `MobileFloatingToolbar`, over text only) copies, cuts, pastes and selects all. Links come from the format bar's link button and Ctrl+K (the package's `link.dart`, in place of the editor's own link menu, English only); an address without its scheme leads to the web. Text above other text (ruby, `ruby.dart`, decided 2026-10-12) is named in the content's language, furigana over Japanese, pinyin over Chinese, else text above (`ContentLanguages`, in `core/widget/`, which every screen of a studyset sets through its language scope); read-only it shows only under the pointer or after a long press, its room kept so lines don't move, and while editing over the pointer; with the caret inside such text, the format bar's button edits all of it.
- **Blocks** beyond the editor's own (callout, spoiler, formula, image, sound, YouTube video, study button) keep their type and attributes as named in the package's `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, and since D37 the marks within lines the package can't know (ruby, formulas), their format bar buttons (`rubyFormatAction`, `inlineMathFormatAction`, after subscript) and what an editor going takes along; the core's are `coreBlocks`, on the package's `basicBlocks`, 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.
- **Build configuration comes from `--dart-define`.** The client holds no secrets, ever.
@ -54,7 +54,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- Shared fakes live in `test/support/`, those of halkyon_app's boundaries in its `package:halkyon_app/testing.dart` (`FakeAuthGateway`, `FakeInstanceChecker`, `MemorySignInAddresses`, `MemoryInstanceStore`, `MemoryPendingChangesStore`), which `testDependencies` fills in; fakes pre-stub their streams so an incidental subscription doesn't fail the test.
- **Editor in widget tests**: host `AppFlowyEditor` in a scrollable body with a fixed-height slot (an over-constrained `Expanded` hangs the test), and `pump(const Duration(seconds: 1))` after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests.
- **No `Stream.first` on a repository's stream in code that widget tests run**: `first` waits for the subscription's cancel future, which for a broadcast stream is a completed future of the root zone, so under the widget test's fake clock it never completes. Read once with the repository's `all()` instead. For the same reason a repository never keeps a completed future to chain later work on (a `Future.value()` made in `setUp` runs what waits for it in the real zone): a queue of writes starts empty and empties again when idle (`SerialQueue`, halkyon_core), and a cache keeps values, not futures (`GeoMapRepository`). Assets are read for real, which the fake clock doesn't advance: a widget test showing a map gets the maps read beforehand (`loadedMaps(tester)` into `testDependencies(maps: ...)`), and one showing how a character is written its strokes (`loadedStrokes(tester, characters)` into `testDependencies(strokes: ...)`). Nor is a subscription that has finished cancelled with `await` there (`await subscription.cancel()` on a stream done): its cancel future never completes under the fake clock either, which hung everything after it (found 2026-10-26 with the flashcards an assistant proposes); cancel it unawaited (`CardProposalsCubit`), or forget it in `onDone` and `onError`, so only one still running is awaited (`ConversationCubit`).
- **Text inside the editor's views** (`RichContentView`, `RichContentEditor`) is rich text: find it with `findRichText: true`.
- **Text inside the editor's views** (`LearnContentView`, `LearnContentEditor`) is rich text: find it with `findRichText: true`.
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
- **A menu item acts in the frame after its menu closes** (`MenuAnchor`): pump twice after tapping one before expecting what it does.
- **Server tests of what writes synced rows** run on the sync engine's database, as the server does (the generated test tools build the pod without it): pass the interceptor to `withServerpod`, call `initializeOfflineSync` in `setUp`, and write as a person through `transactionForUser`. Its writes take transactions of their own, so these tests turn rollback off and delete what they made (`test/integration/asset/asset_test.dart`).

View file

@ -37,8 +37,8 @@ other apps and the packages they share ([the repository's README](../../README.m
| `halkyon_learn_curator` | Turns dictionary sources into the files the server loads | AGPL-3.0-or-later |
It builds on the shared packages in `packages/`: `halkyon_base` (sign-in, accounts, the
instance's basics; a Serverpod module), `halkyon_app` (instances, sign-in, sync) and
`halkyon_ui` (the look).
instance's basics; a Serverpod module), `halkyon_app` (instances, sign-in, sync),
`halkyon_editor` (rich text) and `halkyon_ui` (the look).
## Setting up

View file

@ -775,24 +775,9 @@
"composerDiscardConfirm": "Verwerfen",
"composerConflict": "Diese Karteikarte wurde auf zwei Arten zugleich geändert: auf zwei deiner Geräte oder von dir und durch eine Korrektur im Wörterbuch oder eine neue Veröffentlichung dessen, wovon es kopiert ist. Wähle unten für jeden Teil die Version, die du behalten möchtest.",
"composerKeepThis": "Diese behalten",
"formatAlignLeft": "Linksbündig",
"formatAlignCenter": "Zentriert",
"formatAlignRight": "Rechtsbündig",
"formatBold": "Fett",
"formatItalic": "Kursiv",
"formatUnderline": "Unterstrichen",
"formatSuperscript": "Hochgestellt",
"formatSubscript": "Tiefgestellt",
"formatRuby": "Text darüber (Aussprache, Übersetzung)",
"formatRubyFurigana": "Furigana (eine Lesung über dem Text)",
"formatRubyPinyin": "Pinyin (über den Schriftzeichen)",
"formatLink": "Link",
"linkTitle": "Link",
"linkText": "Text",
"linkAddress": "Adresse",
"linkAddressHint": "https://…",
"linkSave": "Speichern",
"linkRemove": "Link entfernen",
"rubyTitle": "Text darüber",
"rubyTitleFurigana": "Furigana",
"rubyTitlePinyin": "Pinyin",
@ -1100,22 +1085,6 @@
"fileNotKept": "Die Datei konnte nicht auf diesem Gerät gespeichert werden.",
"fileRemoveImage": "Bild entfernen",
"fileRemoveSound": "Audio entfernen",
"editorInsert": "Einfügen",
"blockGroupText": "Text",
"blockGroupBlocks": "Blöcke",
"blockGroupMedia": "Medien",
"blockHeading": "Überschrift {level}",
"blockBulletedList": "Aufzählung",
"blockNumberedList": "Nummerierte Liste",
"blockChecklist": "Checkliste",
"blockQuote": "Zitat",
"blockListItem": "Listenpunkt",
"blockChecklistItem": "Aufgabe",
"editorSlashHint": "Tippe / für einen Block oder schreib einfach los",
"blockDivider": "Trennlinie",
"blockTable": "Tabelle",
"blockRemove": "Entfernen",
"blockUnknown": "Dieser Teil braucht eine neuere Version der App.",
"blockCallout": "Hinweisbox",
"blockSpoiler": "Spoiler",
"calloutNote": "Notiz",

View file

@ -885,24 +885,9 @@
"composerDiscardConfirm": "Discard",
"composerConflict": "This flashcard was changed in two ways at once: on two of your devices, or by you and by a correction from the dictionary or a new release of what it was copied from. Choose the version to keep of each part below.",
"composerKeepThis": "Keep this",
"formatAlignLeft": "Align left",
"formatAlignCenter": "Centre",
"formatAlignRight": "Align right",
"formatBold": "Bold",
"formatItalic": "Italic",
"formatUnderline": "Underline",
"formatSuperscript": "Superscript",
"formatSubscript": "Subscript",
"formatRuby": "Text above (a pronunciation, a translation)",
"formatRubyFurigana": "Furigana (a reading above the text)",
"formatRubyPinyin": "Pinyin (above the characters)",
"formatLink": "Link",
"linkTitle": "Link",
"linkText": "Text",
"linkAddress": "Address",
"linkAddressHint": "https://…",
"linkSave": "Save",
"linkRemove": "Remove link",
"rubyTitle": "Text above",
"rubyTitleFurigana": "Furigana",
"rubyTitlePinyin": "Pinyin",
@ -1253,27 +1238,6 @@
"fileNotKept": "The file couldn't be kept on this device.",
"fileRemoveImage": "Remove image",
"fileRemoveSound": "Remove sound",
"editorInsert": "Insert",
"blockGroupText": "Text",
"blockGroupBlocks": "Blocks",
"blockGroupMedia": "Media",
"blockHeading": "Heading {level}",
"@blockHeading": {
"placeholders": {
"level": {"type": "int"}
}
},
"blockBulletedList": "Bulleted list",
"blockNumberedList": "Numbered list",
"blockChecklist": "Checklist",
"blockQuote": "Quote",
"blockListItem": "List item",
"blockChecklistItem": "To-do",
"editorSlashHint": "Type / to insert a block, or just start typing",
"blockDivider": "Divider",
"blockTable": "Table",
"blockRemove": "Remove",
"blockUnknown": "This part needs a newer version of the app.",
"blockCallout": "Callout",
"blockSpoiler": "Spoiler",
"calloutNote": "Note",

View file

@ -3,7 +3,6 @@
"studysetSheetColor": "Color",
"tagSheetColor": "Color",
"formatAlignCenter": "Center",
"homeCustomise": "Customize",
"exploreLicense": "License",
"exploreLicenseChosen": "License: {count}",

View file

@ -4564,54 +4564,6 @@ abstract class AppLocalizations {
/// **'Keep this'**
String get composerKeepThis;
/// No description provided for @formatAlignLeft.
///
/// In en, this message translates to:
/// **'Align left'**
String get formatAlignLeft;
/// No description provided for @formatAlignCenter.
///
/// In en, this message translates to:
/// **'Centre'**
String get formatAlignCenter;
/// No description provided for @formatAlignRight.
///
/// In en, this message translates to:
/// **'Align right'**
String get formatAlignRight;
/// No description provided for @formatBold.
///
/// In en, this message translates to:
/// **'Bold'**
String get formatBold;
/// No description provided for @formatItalic.
///
/// In en, this message translates to:
/// **'Italic'**
String get formatItalic;
/// No description provided for @formatUnderline.
///
/// In en, this message translates to:
/// **'Underline'**
String get formatUnderline;
/// No description provided for @formatSuperscript.
///
/// In en, this message translates to:
/// **'Superscript'**
String get formatSuperscript;
/// No description provided for @formatSubscript.
///
/// In en, this message translates to:
/// **'Subscript'**
String get formatSubscript;
/// No description provided for @formatRuby.
///
/// In en, this message translates to:
@ -4630,48 +4582,6 @@ abstract class AppLocalizations {
/// **'Pinyin (above the characters)'**
String get formatRubyPinyin;
/// No description provided for @formatLink.
///
/// In en, this message translates to:
/// **'Link'**
String get formatLink;
/// No description provided for @linkTitle.
///
/// In en, this message translates to:
/// **'Link'**
String get linkTitle;
/// No description provided for @linkText.
///
/// In en, this message translates to:
/// **'Text'**
String get linkText;
/// No description provided for @linkAddress.
///
/// In en, this message translates to:
/// **'Address'**
String get linkAddress;
/// No description provided for @linkAddressHint.
///
/// In en, this message translates to:
/// **'https://…'**
String get linkAddressHint;
/// No description provided for @linkSave.
///
/// In en, this message translates to:
/// **'Save'**
String get linkSave;
/// No description provided for @linkRemove.
///
/// In en, this message translates to:
/// **'Remove link'**
String get linkRemove;
/// No description provided for @rubyTitle.
///
/// In en, this message translates to:
@ -6478,102 +6388,6 @@ abstract class AppLocalizations {
/// **'Remove sound'**
String get fileRemoveSound;
/// No description provided for @editorInsert.
///
/// In en, this message translates to:
/// **'Insert'**
String get editorInsert;
/// No description provided for @blockGroupText.
///
/// In en, this message translates to:
/// **'Text'**
String get blockGroupText;
/// No description provided for @blockGroupBlocks.
///
/// In en, this message translates to:
/// **'Blocks'**
String get blockGroupBlocks;
/// No description provided for @blockGroupMedia.
///
/// In en, this message translates to:
/// **'Media'**
String get blockGroupMedia;
/// No description provided for @blockHeading.
///
/// In en, this message translates to:
/// **'Heading {level}'**
String blockHeading(int level);
/// No description provided for @blockBulletedList.
///
/// In en, this message translates to:
/// **'Bulleted list'**
String get blockBulletedList;
/// No description provided for @blockNumberedList.
///
/// In en, this message translates to:
/// **'Numbered list'**
String get blockNumberedList;
/// No description provided for @blockChecklist.
///
/// In en, this message translates to:
/// **'Checklist'**
String get blockChecklist;
/// No description provided for @blockQuote.
///
/// In en, this message translates to:
/// **'Quote'**
String get blockQuote;
/// No description provided for @blockListItem.
///
/// In en, this message translates to:
/// **'List item'**
String get blockListItem;
/// No description provided for @blockChecklistItem.
///
/// In en, this message translates to:
/// **'To-do'**
String get blockChecklistItem;
/// No description provided for @editorSlashHint.
///
/// In en, this message translates to:
/// **'Type / to insert a block, or just start typing'**
String get editorSlashHint;
/// No description provided for @blockDivider.
///
/// In en, this message translates to:
/// **'Divider'**
String get blockDivider;
/// No description provided for @blockTable.
///
/// In en, this message translates to:
/// **'Table'**
String get blockTable;
/// No description provided for @blockRemove.
///
/// In en, this message translates to:
/// **'Remove'**
String get blockRemove;
/// No description provided for @blockUnknown.
///
/// In en, this message translates to:
/// **'This part needs a newer version of the app.'**
String get blockUnknown;
/// No description provided for @blockCallout.
///
/// In en, this message translates to:

View file

@ -2837,30 +2837,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get composerKeepThis => 'Diese behalten';
@override
String get formatAlignLeft => 'Linksbündig';
@override
String get formatAlignCenter => 'Zentriert';
@override
String get formatAlignRight => 'Rechtsbündig';
@override
String get formatBold => 'Fett';
@override
String get formatItalic => 'Kursiv';
@override
String get formatUnderline => 'Unterstrichen';
@override
String get formatSuperscript => 'Hochgestellt';
@override
String get formatSubscript => 'Tiefgestellt';
@override
String get formatRuby => 'Text darüber (Aussprache, Übersetzung)';
@ -2870,27 +2846,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get formatRubyPinyin => 'Pinyin (über den Schriftzeichen)';
@override
String get formatLink => 'Link';
@override
String get linkTitle => 'Link';
@override
String get linkText => 'Text';
@override
String get linkAddress => 'Adresse';
@override
String get linkAddressHint => 'https://…';
@override
String get linkSave => 'Speichern';
@override
String get linkRemove => 'Link entfernen';
@override
String get rubyTitle => 'Text darüber';
@ -4045,57 +4000,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get fileRemoveSound => 'Audio entfernen';
@override
String get editorInsert => 'Einfügen';
@override
String get blockGroupText => 'Text';
@override
String get blockGroupBlocks => 'Blöcke';
@override
String get blockGroupMedia => 'Medien';
@override
String blockHeading(int level) {
return 'Überschrift $level';
}
@override
String get blockBulletedList => 'Aufzählung';
@override
String get blockNumberedList => 'Nummerierte Liste';
@override
String get blockChecklist => 'Checkliste';
@override
String get blockQuote => 'Zitat';
@override
String get blockListItem => 'Listenpunkt';
@override
String get blockChecklistItem => 'Aufgabe';
@override
String get editorSlashHint =>
'Tippe / für einen Block oder schreib einfach los';
@override
String get blockDivider => 'Trennlinie';
@override
String get blockTable => 'Tabelle';
@override
String get blockRemove => 'Entfernen';
@override
String get blockUnknown => 'Dieser Teil braucht eine neuere Version der App.';
@override
String get blockCallout => 'Hinweisbox';

View file

@ -2813,30 +2813,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get composerKeepThis => 'Keep this';
@override
String get formatAlignLeft => 'Align left';
@override
String get formatAlignCenter => 'Centre';
@override
String get formatAlignRight => 'Align right';
@override
String get formatBold => 'Bold';
@override
String get formatItalic => 'Italic';
@override
String get formatUnderline => 'Underline';
@override
String get formatSuperscript => 'Superscript';
@override
String get formatSubscript => 'Subscript';
@override
String get formatRuby => 'Text above (a pronunciation, a translation)';
@ -2846,27 +2822,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get formatRubyPinyin => 'Pinyin (above the characters)';
@override
String get formatLink => 'Link';
@override
String get linkTitle => 'Link';
@override
String get linkText => 'Text';
@override
String get linkAddress => 'Address';
@override
String get linkAddressHint => 'https://…';
@override
String get linkSave => 'Save';
@override
String get linkRemove => 'Remove link';
@override
String get rubyTitle => 'Text above';
@ -4009,57 +3964,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get fileRemoveSound => 'Remove sound';
@override
String get editorInsert => 'Insert';
@override
String get blockGroupText => 'Text';
@override
String get blockGroupBlocks => 'Blocks';
@override
String get blockGroupMedia => 'Media';
@override
String blockHeading(int level) {
return 'Heading $level';
}
@override
String get blockBulletedList => 'Bulleted list';
@override
String get blockNumberedList => 'Numbered list';
@override
String get blockChecklist => 'Checklist';
@override
String get blockQuote => 'Quote';
@override
String get blockListItem => 'List item';
@override
String get blockChecklistItem => 'To-do';
@override
String get editorSlashHint =>
'Type / to insert a block, or just start typing';
@override
String get blockDivider => 'Divider';
@override
String get blockTable => 'Table';
@override
String get blockRemove => 'Remove';
@override
String get blockUnknown => 'This part needs a newer version of the app.';
@override
String get blockCallout => 'Callout';
@ -8046,9 +7950,6 @@ class AppLocalizationsEnUs extends AppLocalizationsEn {
@override
String get tagSheetColor => 'Color';
@override
String get formatAlignCenter => 'Center';
@override
String get homeCustomise => 'Customize';

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/app/app_router.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
@ -10,7 +11,6 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/widget/device_settings_scope.dart';
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/theme/learn_theme.dart';

View file

@ -1,91 +0,0 @@
// The blocks of ours in rich text, beyond the editor's own: their types and
// the attributes they keep, in the editor's document JSON. Pure Dart, so
// `RichContent` and the server read them as the editor writes them.
//
// Every block keeps its type for good: content with a type an app doesn't
// know stays as it is there, shown as needing a newer app.
/// A sound: the person's file at `url` (`assetUrl`), played on a tap.
const audioBlockType = 'audio';
/// On text within a line: its furigana (ruby), the reading of the text it
/// is on (`かんじ` on `漢字`), shown above it.
const rubyKey = 'ruby';
/// On the editor's own `image` block: the id of the PDF a screenshot was
/// taken from; missing for any other image.
const imagePdfKey = 'pdf';
/// On the editor's own `image` block: the page of that PDF, from 1.
const imagePageKey = 'page';
/// A boxed aside of a kind (a note, a tip, a warning…), holding blocks of
/// its own as children.
const calloutBlockType = 'callout';
/// The callout's kind, one of [calloutKinds]; any other reads as `note`.
const calloutKindKey = 'kind';
/// The callout's title; empty or missing for its kind's name.
const calloutTitleKey = 'title';
/// Whether the callout starts folded, its blocks hidden until opened.
const calloutFoldedKey = 'folded';
/// The kinds of callouts, in the order they are offered.
const calloutKinds = [
'note',
'info',
'tip',
'success',
'question',
'warning',
'danger',
'quote',
];
/// Text hidden until it is revealed, in the block's own `delta`.
const spoilerBlockType = 'spoiler';
/// A formula set on its own line.
const mathBlockType = 'math';
/// The formula in LaTeX, of a [mathBlockType] block.
const mathFormulaKey = 'formula';
/// The text mark of a formula within a line: its text is the LaTeX.
const inlineMathKey = 'math';
/// A YouTube video, played only once someone asks for it.
const youtubeBlockType = 'youtube';
/// The video's id: the 11 characters YouTube names it by.
const youtubeVideoKey = 'video';
/// Where the video starts, in whole seconds; missing for its beginning.
const youtubeStartKey = 'start';
/// The caption shown in place of the video; optional.
const youtubeCaptionKey = 'caption';
/// A button starting a study session of a studyset's cards.
const studyButtonBlockType = 'study_button';
/// The id of the studyset studied.
const studyButtonStudysetKey = 'studyset';
/// The id of the tag whose cards alone are studied; missing for all.
const studyButtonTagKey = 'tag';
/// The name of that tag, to find it by once its id is gone (a tag folded
/// into another of its name).
const studyButtonTagNameKey = 'tagName';
/// The button's label; empty or missing for a plain "Study".
const studyButtonLabelKey = 'label';
/// Whether the studyset's topics are studied too; true when missing.
const studyButtonTopicsKey = 'topics';
/// Whether front/back and vocabulary cards ask their back first.
const studyButtonBackFirstKey = 'backFirst';

View file

@ -1,275 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
/// Rich text as the editor stores it: the editor's document JSON plus the
/// version of that format, so stored content can be migrated when it changes.
///
/// Pure Dart on purpose: models hold it, the data layer encodes it into the
/// content payload, and the server can read it without Flutter. The editor
/// converts it to and from its own document type in `core/editor`.
///
/// Made from the JSON the editor writes for a document, which it keeps as a
/// deeply unmodifiable copy.
final class RichContent(
Map<String, Object?> editorJson, {
/// The version of the format [editorJson] is in.
final int formatVersion = currentFormatVersion,
}) with Equatable {
/// A document without any blocks.
factory empty() => RichContent(const {
'document': {'type': 'page'},
});
/// [text] as a document, a paragraph per line.
factory plain(String text) => RichContent({
'document': {
'type': 'page',
'children': [
for (final line in text.split('\n'))
{
'type': 'paragraph',
'data': {
'delta': [
if (line.isNotEmpty) {'insert': line},
],
},
},
],
},
});
/// Reads the JSON written by [toJson].
///
/// Throws a [FormatException] for anything else, including content in a
/// newer format than this app knows.
factory fromJson(Map<String, Object?> json) {
final formatVersion = json['formatVersion'];
final editorJson = json['editor'];
if (formatVersion is! int || editorJson is! Map<String, Object?>) {
throw FormatException('Not rich content.', json);
}
if (formatVersion > currentFormatVersion) {
throw FormatException(
'Rich content in format $formatVersion, but this app only knows '
'formats up to $currentFormatVersion.',
json,
);
}
return RichContent(editorJson, formatVersion: formatVersion);
}
/// The format this app writes.
static const currentFormatVersion = 1;
/// The editor's document JSON, deeply unmodifiable.
final Map<String, Object?> editorJson = _unmodifiableMap(editorJson);
@override
List<Object?> get props => [formatVersion, editorJson];
/// The text without its formatting, one line per block; for previews.
/// A block's text kept beside its blocks counts too: a formula, a
/// callout's title, a video's caption, a study button's label.
String get plainText {
final blocks = <String>[];
void visit(Object? node) {
if (node case {'data': final Map<String, Object?> data}) {
if (data[_textKeys[node['type']]] case final String text) {
blocks.add(text);
}
if (data case {'delta': final List<Object?> delta}) {
blocks.add(
[
for (final operation in delta)
if (operation case {'insert': final String text}) text,
].join(),
);
}
}
if (node case {'children': final List<Object?> children}) {
children.forEach(visit);
}
}
visit(editorJson['document']);
return blocks.where((block) => block.isNotEmpty).join('\n');
}
/// Whether it holds nothing: no text, and no block that shows something
/// without text (an image, a sound, a video, a study button).
bool get isBlank =>
plainText.trim().isEmpty &&
!_nodes.any((node) => _somethingWithoutText.contains(node['type']));
/// Whether it shows an image, the person's or another.
bool get hasImages => _hasBlock('image');
/// Whether it plays a sound.
bool get hasSounds => _hasBlock(audioBlockType);
/// Whether it plays a video.
bool get hasVideos => _hasBlock(youtubeBlockType);
/// The person's files it shows or plays, by their asset rows' ids.
Set<String> get assetIds => {
for (final node in _nodes)
if (node case {'data': {'url': final url}}) ?assetIdOf(url),
};
/// The person's images it shows, by their asset rows' ids, in order and
/// each once: what an assistant that reads images may be sent of it (ADR
/// 0021, D12).
List<String> get imageIds => {
for (final node in _nodes)
if (node case {'type': 'image', 'data': {'url': final url}})
?assetIdOf(url),
}.toList();
bool _hasBlock(String type) => _nodes.any((node) => node['type'] == type);
/// This content without its first block when that is a heading reading
/// [text], whatever its case and spaces (decided 2026-10-16): a heading
/// that repeats the title shown above it, as imported lessons often
/// open. Unchanged otherwise.
RichContent withoutHeading(String text) {
String tidy(String text) =>
text.trim().replaceAll(RegExp(r'\s+'), ' ').toLowerCase();
if (editorJson['document']
case {
'children': [
{
'type': 'heading',
'data': {'delta': final List<Object?> delta},
},
...final rest,
],
} &&
final Map<String, Object?> document
when tidy(text).isNotEmpty) {
final heading = [
for (final operation in delta)
if (operation case {'insert': final String text}) text,
].join();
if (tidy(heading) == tidy(text)) {
return RichContent({
...editorJson,
'document': {...document, 'children': rest},
}, formatVersion: formatVersion);
}
}
return this;
}
/// Every block, depth first.
Iterable<Map<String, Object?>> get _nodes sync* {
Iterable<Map<String, Object?>> visit(Object? node) sync* {
if (node is Map<String, Object?>) {
yield node;
if (node case {'children': final List<Object?> children}) {
for (final child in children) {
yield* visit(child);
}
}
}
}
yield* visit(editorJson['document']);
}
/// This content with [text] after it, a paragraph per line: as text
/// taken from a PDF joins what is written. An empty last line takes the
/// text rather than staying above it.
RichContent withParagraphs(String text) {
final document =
editorJson['document'] as Map<String, Object?>? ??
const {'type': 'page'};
final children = [...?document['children'] as List<Object?>?];
if (children.lastOrNull
case {
'type': 'paragraph',
'data': {'delta': final List<Object?> delta},
}
when delta.isEmpty) {
children.removeLast();
}
return RichContent({
'document': {
...document,
'children': [
...children,
for (final line in text.split('\n'))
{
'type': 'paragraph',
'data': {
'delta': [
if (line.isNotEmpty) {'insert': line},
],
},
},
],
},
}, formatVersion: formatVersion);
}
/// The JSON stored in the content payload.
Map<String, Object?> toJson() => {
'formatVersion': formatVersion,
'editor': editorJson,
};
}
/// The URL naming the person's file [assetId] in content: an image's or a
/// sound's (ADR 0004, rule 5).
String assetUrl(String assetId) => '$_assetScheme$assetId';
/// The asset row [url] names, if it names one.
String? assetIdOf(Object? url) =>
url is String &&
url.startsWith(_assetScheme) &&
url.length > _assetScheme.length
? url.substring(_assetScheme.length)
: null;
const _assetScheme = 'asset:';
/// The person's files [json] (a payload's JSON, or a part of it) names: the
/// `url` of every block that shows or plays one, as [RichContent.assetIds]
/// finds them, at any depth.
Set<String> assetIdsIn(Object? json) => switch (json) {
final Map<String, Object?> map => {
?assetIdOf(map['url']),
for (final value in map.values) ...assetIdsIn(value),
},
final List<Object?> list => {for (final value in list) ...assetIdsIn(value)},
_ => const {},
};
/// The attribute holding the text of a block that keeps it beside its
/// blocks, by the block's type.
const _textKeys = {
mathBlockType: mathFormulaKey,
calloutBlockType: calloutTitleKey,
youtubeBlockType: youtubeCaptionKey,
studyButtonBlockType: studyButtonLabelKey,
};
/// The blocks that show something without any text.
const _somethingWithoutText = {
'image',
audioBlockType,
youtubeBlockType,
studyButtonBlockType,
};
Map<String, Object?> _unmodifiableMap(Map<String, Object?> map) =>
Map.unmodifiable({
for (final MapEntry(:key, :value) in map.entries)
key: _unmodifiableValue(value),
});
Object? _unmodifiableValue(Object? value) => switch (value) {
Map<String, Object?>() => _unmodifiableMap(value),
List<Object?>() => List<Object?>.unmodifiable(value.map(_unmodifiableValue)),
_ => value,
};

View file

@ -1,186 +0,0 @@
import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A block taken as a whole, like the editor's own image block: selected,
/// copied and deleted as one, with no caret inside it. Its state mixes this
/// in after [SelectableMixin] and [BlockComponentConfigurable], and builds
/// with [buildAtomic].
///
/// In an editor it has a remove button, top right: on hover with a mouse,
/// always on touch screens, which have no hover. Read-only, it is only what
/// it shows.
mixin AtomicBlock<T extends BlockComponentStatefulWidget>
on State<T>, SelectableMixin<T>, BlockComponentConfigurable<T> {
final _contentKey = GlobalKey();
var _hovered = false;
/// The editor it is in.
late final EditorState editorState = context.read<EditorState>();
/// Whether it is in an editor rather than shown read-only.
bool get editing => editorState.editable;
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
/// Removes the block from the document.
void remove() {
final transaction = editorState.transaction..deleteNode(node);
unawaited(editorState.apply(transaction));
}
/// The block around [child]: selectable as a whole, with the remove button
/// labelled [removeLabel] while [editing].
Widget buildAtomic({required Widget child, required String removeLabel}) {
final touch = switch (defaultTargetPlatform) {
TargetPlatform.android || TargetPlatform.iOS => true,
_ => false,
};
final content = Padding(key: _contentKey, padding: padding, child: child);
return Padding(
padding: margin,
child: BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
remoteSelection: editorState.remoteSelections,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [BlockSelectionType.block],
child: !editing
? content
: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Stack(
children: [
// Room beside the button, so it covers nothing.
Padding(
padding: const EdgeInsetsDirectional.only(end: 40),
child: content,
),
PositionedDirectional(
top: 0,
end: 0,
child: AnimatedOpacity(
opacity: touch || _hovered ? 1 : 0,
duration: const Duration(milliseconds: 120),
child: IconButton.filledTonal(
icon: const Icon(LucideIcons.x, size: 16),
tooltip: removeLabel,
visualDensity: VisualDensity.compact,
onPressed: remove,
),
),
),
],
),
),
),
);
}
@override
Position start() => Position(path: node.path);
@override
Position end() => Position(path: node.path, offset: 1);
@override
Position getPositionInOffset(Offset start) => end();
@override
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.cover;
@override
Rect getBlockRect({bool shiftWithBaseOffset = false}) {
final box = _contentKey.currentContext?.findRenderObject();
return box is RenderBox ? Offset.zero & box.size : Rect.zero;
}
@override
Rect? getCursorRectInPosition(
Position position, {
bool shiftWithBaseOffset = false,
}) {
final size = _renderBox?.size;
return size == null
? null
: Rect.fromLTWH(-size.width / 2, 0, size.width, size.height);
}
@override
List<Rect> getRectsInSelection(
Selection selection, {
bool shiftWithBaseOffset = false,
}) {
final parent = _renderBox;
if (parent == null) {
return [];
}
final box = _contentKey.currentContext?.findRenderObject();
return [
if (box is RenderBox)
box.localToGlobal(Offset.zero, ancestor: parent) & box.size
else
Offset.zero & parent.size,
];
}
@override
Selection getSelectionInRange(Offset start, Offset end) =>
Selection.single(path: node.path, startOffset: 0, endOffset: 1);
@override
Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) =>
_renderBox!.localToGlobal(offset);
}
/// A builder of atomic blocks: valid without text or children of their own.
abstract class AtomicBlockBuilder() extends BlockComponentBuilder {
@override
BlockComponentValidate get validate =>
(node) => node.delta == null && node.children.isEmpty;
}
/// The box an atomic block shows when it holds nothing yet, or what it holds
/// can't be shown: [icon] and [text], and [action] if there is something to
/// do about it.
class const BlockNotice({
required final IconData icon,
required final String text,
final Widget? action,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = theme.colorScheme.onSurfaceVariant;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(icon, color: color),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(color: color),
),
),
?action,
],
),
);
}
}

View file

@ -1,54 +0,0 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// Lets the person choose one of [blocks] to insert, listed by their
/// [InsertGroup]; completes with it, or with null when they close the menu.
Future<InsertableBlock?> chooseBlock(
BuildContext context,
List<InsertableBlock> blocks,
) => showAdaptiveSheet<InsertableBlock>(
context,
builder: (context) => _BlockMenu(blocks: blocks),
);
class const _BlockMenu({required final List<InsertableBlock> blocks})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return SheetScaffold(
title: l10n.editorInsert,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final group in InsertGroup.values)
if (blocks.where((block) => block.group == group).toList()
case final inGroup when inGroup.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
switch (group) {
InsertGroup.text => l10n.blockGroupText,
InsertGroup.blocks => l10n.blockGroupBlocks,
InsertGroup.media => l10n.blockGroupMedia,
},
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
),
for (final block in inGroup)
ListTile(
leading: Icon(block.icon),
title: Text(block.name(l10n)),
onTap: () => Navigator.of(context).pop(block),
),
],
],
),
);
}
}

View file

@ -2,9 +2,8 @@ import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -17,7 +16,7 @@ import 'package:material_ui/material_ui.dart';
/// one opens and closes with the header's button, for that view alone.
/// A callout can't go inside another.
final calloutBlock = InsertableBlock(
name: (l10n) => l10n.blockCallout,
name: (context) => AppLocalizations.of(context).blockCallout,
icon: LucideIcons.messageSquareWarning,
group: InsertGroup.blocks,
keywords: const ['callout', 'note', 'info', 'tip', 'warning', 'admonition'],

View file

@ -1,18 +1,16 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/callout_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/file_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/math_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
import 'package:halkyon_learn_flutter/src/core/editor/spoiler_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/youtube_block.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The blocks every editor knows: the editor's own, and the core's.
/// The blocks every editor of Learn knows: halkyon_editor's, and the core's.
final coreBlocks = EditorBlocks(
builders: {
...standardBlockComponentBuilderMap,
...basicBlocks.builders,
...fileBlockBuilders,
...calloutBlockBuilders,
...spoilerBlockBuilders,
@ -20,68 +18,7 @@ final coreBlocks = EditorBlocks(
...youtubeBlockBuilders,
},
insertable: [
for (final level in [1, 2, 3])
_textBlock(
name: (l10n) => l10n.blockHeading(level),
icon: const [
LucideIcons.heading1,
LucideIcons.heading2,
LucideIcons.heading3,
][level - 1],
keywords: ['heading', 'h$level', 'title'],
node: () => headingNode(level: level),
),
_textBlock(
name: (l10n) => l10n.blockBulletedList,
icon: LucideIcons.list,
keywords: const ['bulleted list', 'bullet', 'unordered'],
node: bulletedListNode,
),
_textBlock(
name: (l10n) => l10n.blockNumberedList,
icon: LucideIcons.listOrdered,
keywords: const ['numbered list', 'ordered', 'number'],
node: numberedListNode,
),
_textBlock(
name: (l10n) => l10n.blockChecklist,
icon: LucideIcons.listTodo,
keywords: const ['checklist', 'todo', 'checkbox', 'task'],
node: () => todoListNode(checked: false),
),
_textBlock(
name: (l10n) => l10n.blockQuote,
icon: LucideIcons.textQuote,
keywords: const ['quote', 'citation'],
node: quoteNode,
),
InsertableBlock(
name: (l10n) => l10n.blockDivider,
icon: LucideIcons.separatorHorizontal,
group: InsertGroup.blocks,
keywords: const ['divider', 'horizontal rule', 'line', 'separator'],
insert: (context, editorState, at) =>
insertBlock(editorState, dividerNode(), at: at),
),
InsertableBlock(
name: (l10n) => l10n.blockTable,
icon: LucideIcons.table,
group: InsertGroup.blocks,
keywords: const ['table', 'grid', 'rows', 'columns'],
insideCallouts: false,
// Two rows of two cells, the caret in the first.
insert: (context, editorState, at) => insertBlock(
editorState,
TableNode.fromList([
['', ''],
['', ''],
]).node,
at: at,
caretIn: const [0, 0],
lineAfter: true,
insideCallouts: false,
),
),
...basicBlocks.insertable,
calloutBlock,
spoilerBlock,
mathBlock,
@ -90,24 +27,12 @@ final coreBlocks = EditorBlocks(
youtubeBlock,
],
shortcuts: [leaveCalloutOnEnter],
textMarks: [withRuby, withInlineMath],
formatActions: [rubyFormatAction, inlineMathFormatAction],
onEditorClosed: [hideHoverReading],
);
/// The blocks of the editors below [context]: the core's, and the
/// features' too where the app gives them ([EditorBlocksScope]).
EditorBlocks blocksOf(BuildContext context) =>
EditorBlocksScope.maybeOf(context) ?? coreBlocks;
/// A line of text of a kind, which the caret goes into.
InsertableBlock _textBlock({
required String Function(AppLocalizations l10n) name,
required IconData icon,
required List<String> keywords,
required Node Function() node,
}) => InsertableBlock(
name: name,
icon: icon,
group: InsertGroup.text,
keywords: keywords,
insert: (context, editorState, at) =>
insertBlock(editorState, node(), at: at, caretIn: const []),
);

View file

@ -1,338 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/atomic_block.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Where a block is listed in the Insert menu.
enum InsertGroup() {
/// Lines of text of a kind: headings, lists, quotes.
text,
/// Blocks laying out or adding to the text: tables, callouts, formulas.
blocks,
/// Files and videos.
media,
}
/// A block people can insert, as the Insert menu and the `/` menu offer it.
final class const InsertableBlock({
/// Its name in the menus.
required final String Function(AppLocalizations l10n) name,
/// Its icon in the menus.
required final IconData icon,
/// Where the Insert menu lists it.
required final InsertGroup group,
/// Words besides its name that find it after a `/`.
required final List<String> keywords,
/// Inserts it into the editor's state at the caret (null when there is
/// none), with the editor's context, for what the block asks first.
required final Future<void> Function(
BuildContext context,
EditorState editorState,
Selection? at,
)
insert,
/// Whether it may go inside a callout; else it goes after the callout.
final bool insideCallouts = true,
/// Whether the format bar has a button of its own for it too, at its
/// end: for what the editor's place is for (a screenshot beside a PDF).
final bool inFormatBar = false,
});
/// The blocks an editor knows: how each type is built, which of them
/// people can insert, in the menus' order, and what typing does in them
/// besides what the editor does anyway.
final class const EditorBlocks({
required final Map<String, BlockComponentBuilder> builders,
required final List<InsertableBlock> insertable,
/// Tried before the editor's own, so they may take a key from it.
final List<CharacterShortcutEvent> shortcuts = const [],
}) {
/// These blocks and [more]'s, [more]'s after these.
EditorBlocks plus(EditorBlocks more) => EditorBlocks(
builders: {...builders, ...more.builders},
insertable: [...insertable, ...more.insertable],
shortcuts: [...shortcuts, ...more.shortcuts],
);
}
/// Gives the editors below the blocks of the features too (a study button,
/// say), which the core can't know: the core's blocks plus theirs.
class const EditorBlocksScope({
required final EditorBlocks blocks,
required super.child,
super.key,
}) extends InheritedWidget {
/// The blocks the scope above [context] gives, if there is one.
static EditorBlocks? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<EditorBlocksScope>()?.blocks;
@override
bool updateShouldNotify(EditorBlocksScope oldWidget) =>
blocks != oldWidget.blocks;
}
/// The builders of [blocks] with a box for any type they don't know, as
/// content from a newer app may hold: it says so, and the block stays.
///
/// The editor's own text blocks say what they are in the app's language
/// ([l10n]) while empty: the editor's own placeholders follow a locale of
/// their own, set for the whole process, in few languages and not British
/// English.
Map<String, BlockComponentBuilder> buildersWithUnknown(
EditorBlocks blocks,
AppLocalizations l10n,
) => {
for (final MapEntry(key: type, value: builder) in blocks.builders.entries)
type: _withPlaceholder(builder, l10n),
errorBlockComponentBuilderKey: _UnknownBlockBuilder(),
};
/// [builder] alike, but saying what its block is in [l10n]'s language while
/// empty, for the editor's own text blocks; others as they are.
BlockComponentBuilder _withPlaceholder(
BlockComponentBuilder builder,
AppLocalizations l10n,
) {
BlockComponentConfiguration saying(String Function(Node node) text) =>
builder.configuration.copyWith(placeholderText: text);
return switch (builder) {
final ParagraphBlockComponentBuilder paragraph =>
ParagraphBlockComponentBuilder(
// A space, not nothing, keeps an empty line its height.
configuration: saying(
(_) => switch (defaultTargetPlatform) {
// Phones insert through the Insert button, not a `/` menu.
TargetPlatform.android || TargetPlatform.iOS => ' ',
_ => l10n.editorSlashHint,
},
),
showPlaceholder: paragraph.showPlaceholder,
),
final TodoListBlockComponentBuilder todo => TodoListBlockComponentBuilder(
configuration: saying((_) => l10n.blockChecklistItem),
textStyleBuilder: todo.textStyleBuilder,
iconBuilder: todo.iconBuilder,
toggleChildrenTriggers: todo.toggleChildrenTriggers,
),
final BulletedListBlockComponentBuilder bulleted =>
BulletedListBlockComponentBuilder(
configuration: saying((_) => l10n.blockListItem),
iconBuilder: bulleted.iconBuilder,
),
final NumberedListBlockComponentBuilder numbered =>
NumberedListBlockComponentBuilder(
configuration: saying((_) => l10n.blockListItem),
iconBuilder: numbered.iconBuilder,
),
final QuoteBlockComponentBuilder quote => QuoteBlockComponentBuilder(
configuration: saying((_) => l10n.blockQuote),
iconBuilder: quote.iconBuilder,
),
final HeadingBlockComponentBuilder heading => HeadingBlockComponentBuilder(
configuration: saying(
(node) => l10n.blockHeading(
node.attributes[HeadingBlockKeys.level] as int? ?? 1,
),
),
textStyleBuilder: heading.textStyleBuilder,
),
_ => builder,
};
}
/// The `/` menu of [blocks] in place of the editor's own, whose items can't
/// be translated and whose Image item makes images that never show. Desktop
/// only, as the editor's (phones use the Insert button).
CharacterShortcutEvent slashMenu(BuildContext context, EditorBlocks blocks) {
final l10n = AppLocalizations.of(context);
return customSlashCommand(
[
for (final block in blocks.insertable)
SelectionMenuItem(
getName: () => block.name(l10n),
icon: (editorState, selected, style) => Icon(
block.icon,
size: 18,
color: selected
? style.selectionMenuItemSelectedIconColor
: style.selectionMenuItemIconColor,
),
keywords: block.keywords,
// The menu's context is in the overlay; the editor's is the one
// with its surroundings. The menu goes first: it would stay above
// everything until the block is in, a dialog asking for it too.
handler: (editorState, menuService, menuContext) {
menuService.dismiss();
return block.insert(context, editorState, editorState.selection);
},
),
],
style: _slashMenuStyle(Theme.of(context).colorScheme),
);
}
/// The `/` menu in the theme's colours rather than the editor's own: on the
/// surface Material gives menus, as the app's other menus are, the item the
/// arrow keys reach tinted as a focused menu item is.
SelectionMenuStyle _slashMenuStyle(ColorScheme colors) {
// Material's state layer for a focused menu item: the text colour at 10%.
final focused = Color.alphaBlend(
colors.onSurface.withValues(alpha: 0.1),
colors.surfaceContainer,
);
return SelectionMenuStyle(
selectionMenuBackgroundColor: colors.surfaceContainer,
selectionMenuItemTextColor: colors.onSurface,
selectionMenuItemIconColor: colors.onSurfaceVariant,
selectionMenuItemSelectedTextColor: colors.onSurface,
selectionMenuItemSelectedIconColor: colors.onSurface,
selectionMenuItemSelectedColor: focused,
selectionMenuUnselectedLabelColor: colors.onSurfaceVariant,
selectionMenuDividerColor: colors.outlineVariant,
selectionMenuLinkBorderColor: colors.outline,
selectionMenuInvalidLinkColor: colors.error,
selectionMenuButtonColor: colors.primary,
selectionMenuButtonTextColor: colors.onPrimary,
selectionMenuButtonIconColor: colors.onPrimary,
selectionMenuButtonBorderColor: colors.primary,
selectionMenuTabIndicatorColor: colors.primary,
);
}
/// The character shortcuts of [blocks], then the editor's own, its `/`
/// menu replaced by [slash].
List<CharacterShortcutEvent> characterShortcutsOf(
EditorBlocks blocks,
CharacterShortcutEvent slash,
) => [
...blocks.shortcuts,
for (final event in standardCharacterShortcutEvents)
if (event.key != slash.key) event,
slash,
];
/// Inserts [block] at [at] (the caret; the end of the document without
/// one): in place of the empty line the caret is on, else after the block
/// the caret is in. A block may not go inside a callout unless
/// [insideCallouts], and never inside a table: then it goes after them.
///
/// The caret then goes to [caretIn], a path inside the block (`[]` for the
/// block itself, a heading say), or else to an empty line after the block.
/// That line is added unless one follows already; with a [caretIn] only if
/// [lineAfter], for a block that is typed into but can't be left by typing,
/// such as a table.
Future<void> insertBlock(
EditorState editorState,
Node block, {
Selection? at,
Path? caretIn,
bool lineAfter = false,
bool insideCallouts = true,
}) async {
final root = editorState.document.root;
var current = at == null ? null : editorState.getNodeAtPath(at.end.path);
// Up to a block directly in the page, or in a callout if allowed.
while (current != null &&
current.parent != null &&
current.parent != root &&
!(insideCallouts && current.parent!.type == calloutBlockType)) {
current = current.parent;
}
final transaction = editorState.transaction;
final Path path;
final Node? after;
if (current == null) {
path = [root.children.length];
after = null;
} else if (isEmptyLine(current)) {
path = current.path;
after = current.next;
transaction
..insertNode(path, block)
..deleteNode(current);
} else {
path = current.path.next;
after = current.next;
}
if (current == null || !isEmptyLine(current)) {
transaction.insertNode(path, block);
}
if ((caretIn == null || lineAfter) &&
(after == null || !isEmptyLine(after))) {
transaction.insertNode(path.next, paragraphNode());
}
transaction.afterSelection = Selection.collapsed(
Position(path: caretIn == null ? path.next : [...path, ...caretIn]),
);
await editorState.apply(transaction);
}
/// Whether [node] is an empty paragraph, nothing in it.
bool isEmptyLine(Node node) =>
node.type == ParagraphBlockKeys.type &&
(node.delta?.isEmpty ?? false) &&
node.children.isEmpty;
class _UnknownBlockBuilder() extends BlockComponentBuilder {
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return _UnknownBlock(
key: node.key,
node: node,
configuration: configuration,
);
}
@override
BlockComponentValidate get validate =>
(_) => true;
}
/// A block this app can't show, from a newer one: it says so, and stays as
/// it is unless the person removes it.
class const _UnknownBlock({
required super.node,
required super.configuration,
super.key,
}) extends BlockComponentStatefulWidget {
@override
State<_UnknownBlock> createState() => _UnknownBlockState();
}
class _UnknownBlockState()
extends State<_UnknownBlock>
with
SelectableMixin,
BlockComponentConfigurable,
AtomicBlock<_UnknownBlock> {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return buildAtomic(
removeLabel: l10n.blockRemove,
child: BlockNotice(
icon: LucideIcons.circleFadingArrowUp,
text: l10n.blockUnknown,
),
);
}
}

View file

@ -1,138 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
/// Copying and pasting between the app's editors with every format kept
/// (decided 2026-10-11): bold, links, furigana, formulas, callouts,
/// spoilers… The system's clipboard holds plain text only (the editor's own
/// copy drops what it makes of the formats), so the blocks copied last stay
/// here beside it; a paste takes them while the system's clipboard still
/// holds their text, and plain text otherwise, as from another app. Other
/// apps get the plain text.
///
/// The blocks stay in memory for as long as the app runs, whoever is signed
/// in, as the text on the system's clipboard does.
abstract final class EditorClipboard() {
/// What was copied last: its text, and its blocks as the editor's JSON.
static ({String text, List<Map<String, Object?>> blocks})? _copied;
/// Copies the selection of [editorState]; false without text selected.
static bool copy(EditorState editorState) {
final selection = editorState.selection?.normalized;
if (selection == null || selection.isCollapsed) {
return false;
}
final text = editorState.getTextInSelection(selection).join('\n');
final blocks = [
for (final node in editorState.getSelectedNodes(selection: selection))
// A copy of its own: later edits mustn't reach what was copied.
jsonDecode(jsonEncode(node.toJson())) as Map<String, Object?>,
];
if (blocks.isEmpty) {
return false;
}
_copied = (text: text, blocks: blocks);
unawaited(Clipboard.setData(ClipboardData(text: text)));
return true;
}
/// Copies the selection of [editorState] and takes it out.
static Future<void> cut(EditorState editorState) async {
if (copy(editorState)) {
await editorState.deleteSelectionIfNeeded();
}
}
/// Pastes at the selection of [editorState]: what was copied here, with
/// its formats, while the system's clipboard holds its text; whatever the
/// clipboard holds otherwise, as the editor pastes it.
static Future<void> paste(EditorState editorState) async {
final text = (await Clipboard.getData(Clipboard.kTextPlain))?.text;
final copied = _copied;
if (copied == null || text == null || _lines(text) != _lines(copied.text)) {
pasteCommand.execute(editorState);
return;
}
final blocks = [
for (final json in copied.blocks)
Node.fromJson(Map<String, Object>.from(json)),
];
switch (blocks) {
// A block without text (a formula, an image…) goes after the one at
// the caret, which the editor's paste of one block leaves out.
case [final block] when block.delta == null:
final selection = await editorState.deleteSelectionIfNeeded();
if (selection == null) {
return;
}
final transaction = editorState.transaction
..insertNode(selection.end.path.next, block)
..afterSelection = Selection.collapsed(
Position(path: selection.end.path.next),
);
await editorState.apply(transaction);
case [final block]:
await editorState.pasteSingleLineNode(block);
default:
await editorState.pasteMultiLineNodes(blocks);
}
}
/// [text] as compared: the system may end lines its own way.
static String _lines(String text) => text.replaceAll('\r\n', '\n');
/// Forgets what was copied; for tests.
@visibleForTesting
static void clear() => _copied = null;
}
/// The editor's shortcuts, copying, cutting and pasting through
/// [EditorClipboard] and with [link] for Ctrl+K in place of the editor's
/// own link menu; the rest as the editor has them.
List<CommandShortcutEvent> clipboardShortcuts({
required CommandShortcutEvent link,
}) => [
for (final event in standardCommandShortcutEvents)
if (!_replaced.contains(event)) event,
link,
CommandShortcutEvent(
key: copyCommand.key,
getDescription: () => copyCommand.description ?? '',
command: 'ctrl+c',
macOSCommand: 'cmd+c',
handler: (editorState) => EditorClipboard.copy(editorState)
? KeyEventResult.handled
: KeyEventResult.ignored,
),
CommandShortcutEvent(
key: cutCommand.key,
getDescription: () => cutCommand.description ?? '',
command: 'ctrl+x',
macOSCommand: 'cmd+x',
handler: (editorState) {
if (editorState.selection == null) {
return KeyEventResult.ignored;
}
unawaited(EditorClipboard.cut(editorState));
return KeyEventResult.handled;
},
),
CommandShortcutEvent(
key: pasteCommand.key,
getDescription: () => pasteCommand.description ?? '',
command: 'ctrl+v',
macOSCommand: 'cmd+v',
handler: (editorState) {
if (editorState.selection == null) {
return KeyEventResult.ignored;
}
unawaited(EditorClipboard.paste(editorState));
return KeyEventResult.handled;
},
),
];
final _replaced = {showLinkMenuCommand, copyCommand, cutCommand, pasteCommand};

View file

@ -1,69 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:halkyon_learn_flutter/src/core/editor/math_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart';
import 'package:material_ui/material_ui.dart';
/// The rich text's look at [context], editing or showing it: the theme's
/// text and colours, in light and dark mode alike, and no padding of its
/// own, as what holds the text pads it. The editor's defaults are black text
/// and 100 dp beside it on desktops.
///
/// Phones get the touch variant, with handles to drag the selection. Both
/// draw superscript and subscript text (`withScripts`), furigana
/// (`withRuby`) and formulas within lines (`withInlineMath`): marked or as
/// their source while [editing], else set.
/// The text is the theme's `bodyLarge` unless [textStyle] says otherwise.
EditorStyle editorStyleOf(
BuildContext context, {
bool editing = true,
TextStyle? textStyle,
}) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final text = TextStyleConfiguration(
text: (textStyle ?? theme.textTheme.bodyLarge!).copyWith(
color: colors.onSurface,
),
href: TextStyle(
color: colors.primary,
decoration: TextDecoration.underline,
),
code: TextStyle(
fontFamily: 'monospace',
color: colors.onSurfaceVariant,
backgroundColor: colors.surfaceContainerHighest,
),
);
final selection = colors.primary.withValues(alpha: 0.25);
return switch (defaultTargetPlatform) {
TargetPlatform.android || TargetPlatform.iOS => EditorStyle.mobile(
padding: EdgeInsets.zero,
cursorColor: colors.primary,
selectionColor: selection,
dragHandleColor: colors.primary,
textStyleConfiguration: text,
textSpanDecorator: withInlineMath(
withRuby(
withScripts(mobileTextSpanDecoratorForAttribute),
editing: editing,
),
editing: editing,
),
),
_ => EditorStyle.desktop(
padding: EdgeInsets.zero,
cursorColor: colors.primary,
selectionColor: selection,
textStyleConfiguration: text,
textSpanDecorator: withInlineMath(
withRuby(
withScripts(defaultTextSpanDecoratorForAttribute),
editing: editing,
),
editing: editing,
),
),
};
}

View file

@ -3,18 +3,15 @@ import 'dart:developer';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/asset/bloc/sound_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/media_type.dart';
import 'package:halkyon_learn_flutter/src/core/asset/widget/stored_file_image.dart';
import 'package:halkyon_learn_flutter/src/core/asset/widget/studyset_files.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/editor/atomic_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -60,7 +57,7 @@ final Map<String, BlockComponentBuilder> fileBlockBuilders = {
/// An image from the person's device, kept as a file of theirs.
final imageBlock = InsertableBlock(
name: (l10n) => l10n.fileImage,
name: (context) => AppLocalizations.of(context).fileImage,
icon: LucideIcons.image,
group: InsertGroup.media,
keywords: const ['image', 'picture', 'photo'],
@ -70,7 +67,7 @@ final imageBlock = InsertableBlock(
/// A sound from the person's device, kept as a file of theirs.
final soundBlock = InsertableBlock(
name: (l10n) => l10n.fileSound,
name: (context) => AppLocalizations.of(context).fileSound,
icon: LucideIcons.volume2,
group: InsertGroup.media,
keywords: const ['sound', 'audio', 'pronunciation'],

View file

@ -1,290 +0,0 @@
import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/link.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Formats the selection of [editorState]: alignment, bold, italic,
/// underline, a link, superscript and subscript, text above (furigana),
/// and with [onFormula] formulas
/// within the line. Each button shows whether the selection has its format
/// already. With [onInsert], an Insert button comes first, for blocks; the
/// [pinned] blocks have buttons at the end, outside the scrolling, so they
/// are always at hand.
///
/// The bar takes no focus, so tapping a button leaves the editor focused and
/// its selection where it was; on phones, where there are no shortcut keys,
/// it is the way to format at all. It scrolls sideways when narrow.
class const FormatBar({
required final EditorState editorState,
/// Called by the Insert button, which shows only with it.
final VoidCallback? onInsert,
/// Called by the formula button (Σ), which shows only with it.
final VoidCallback? onFormula,
/// Blocks with a button of their own at the end.
final List<InsertableBlock> pinned = const [],
/// Called with the pinned block whose button was pressed.
final ValueChanged<InsertableBlock>? onPinned,
super.key,
}) extends StatefulWidget {
/// How tall the bar is, so what docks it can make room for it.
static const height = 44.0;
@override
State<FormatBar> createState() => _FormatBarState();
}
class _FormatBarState() extends State<FormatBar> {
late StreamSubscription<EditorTransactionValue> _changes;
@override
void initState() {
super.initState();
_follow(widget.editorState);
}
@override
void didUpdateWidget(FormatBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.editorState != widget.editorState) {
_unfollow(oldWidget.editorState);
_follow(widget.editorState);
}
}
@override
void dispose() {
_unfollow(widget.editorState);
super.dispose();
}
// The buttons show the formats of the selection: anew whenever it moves
// or the document changes.
void _follow(EditorState editorState) {
editorState.selectionNotifier.addListener(_changed);
_changes = editorState.transactionStream.listen((_) => _changed());
}
void _unfollow(EditorState editorState) {
editorState.selectionNotifier.removeListener(_changed);
unawaited(_changes.cancel());
}
void _changed() {
if (mounted) {
setState(() {});
}
}
/// Whether every block in the selection is aligned [align]; no alignment
/// is left.
bool _isAligned(String align) {
final editorState = widget.editorState;
final selection = editorState.selection;
if (selection == null) {
return false;
}
final nodes = editorState.getNodesInSelection(selection);
return nodes.isNotEmpty &&
nodes.every(
(node) =>
((node.attributes[blockComponentAlign] as String?) ?? 'left') ==
align,
);
}
void _align(String align) {
final editorState = widget.editorState;
final selection = editorState.selection;
if (selection == null) {
return;
}
unawaited(
editorState.updateNode(
selection,
(node) => node.copyWith(
attributes: {...node.attributes, blockComponentAlign: align},
),
),
);
}
/// Whether all of the selection has the mark [key]; at a caret, whether
/// the next text typed takes it.
bool _has(String key) =>
widget.editorState.selection != null &&
(widget.editorState.getDeltaAttributeValueInSelection<bool>(key) ??
false);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final editorState = widget.editorState;
final formats = SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
children: [
if (widget.onInsert case final onInsert?) ...[
_FormatButton(
icon: LucideIcons.plus,
tooltip: l10n.editorInsert,
active: false,
onPressed: onInsert,
),
const _Divider(),
],
_FormatButton(
icon: LucideIcons.textAlignStart,
tooltip: l10n.formatAlignLeft,
active: _isAligned('left'),
onPressed: () => _align('left'),
),
_FormatButton(
icon: LucideIcons.textAlignCenter,
tooltip: l10n.formatAlignCenter,
active: _isAligned('center'),
onPressed: () => _align('center'),
),
_FormatButton(
icon: LucideIcons.textAlignEnd,
tooltip: l10n.formatAlignRight,
active: _isAligned('right'),
onPressed: () => _align('right'),
),
const _Divider(),
_FormatButton(
icon: LucideIcons.bold,
tooltip: l10n.formatBold,
active: _has(AppFlowyRichTextKeys.bold),
onPressed: () =>
editorState.toggleAttribute(AppFlowyRichTextKeys.bold),
),
_FormatButton(
icon: LucideIcons.italic,
tooltip: l10n.formatItalic,
active: _has(AppFlowyRichTextKeys.italic),
onPressed: () =>
editorState.toggleAttribute(AppFlowyRichTextKeys.italic),
),
_FormatButton(
icon: LucideIcons.underline,
tooltip: l10n.formatUnderline,
active: _has(AppFlowyRichTextKeys.underline),
onPressed: () =>
editorState.toggleAttribute(AppFlowyRichTextKeys.underline),
),
_FormatButton(
icon: LucideIcons.link,
tooltip: l10n.formatLink,
active: hasLink(editorState),
onPressed: () => editLink(context, editorState),
),
const _Divider(),
_FormatButton(
icon: LucideIcons.superscript,
tooltip: l10n.formatSuperscript,
active: _has(superscriptKey),
onPressed: () => toggleScript(editorState, superscriptKey),
),
_FormatButton(
icon: LucideIcons.subscript,
tooltip: l10n.formatSubscript,
active: _has(subscriptKey),
onPressed: () => toggleScript(editorState, subscriptKey),
),
_FormatButton(
icon: LucideIcons.languages,
tooltip: rubyButtonName(context),
active:
editorState.selection != null &&
editorState.getDeltaAttributeValueInSelection<String>(
rubyKey,
) !=
null,
onPressed: () => editRuby(context, editorState),
),
if (widget.onFormula case final onFormula?)
_FormatButton(
icon: LucideIcons.sigma,
tooltip: l10n.formatInlineMath,
active: _has(inlineMathKey),
onPressed: onFormula,
),
],
),
);
return ExcludeFocus(
child: SizedBox(
height: FormatBar.height,
child: Row(
children: [
Expanded(child: formats),
if (widget.pinned.isNotEmpty) ...[
const _Divider(),
for (final block in widget.pinned)
_FormatButton(
icon: block.icon,
tooltip: block.name(l10n),
active: false,
onPressed: () => widget.onPinned?.call(block),
),
const SizedBox(width: 4),
],
],
),
),
);
}
}
/// A format's button: its icon tinted, on a faint fill, while [active].
class const _FormatButton({
required final IconData icon,
required final String tooltip,
required final bool active,
required final VoidCallback onPressed,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return IconButton(
icon: Icon(
icon,
size: 18,
color: active ? colors.primary : colors.onSurfaceVariant,
),
tooltip: tooltip,
isSelected: active,
onPressed: onPressed,
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
backgroundColor: active
? colors.primary.withValues(alpha: 0.12)
: Colors.transparent,
),
);
}
}
/// Between groups of buttons.
class const _Divider() extends StatelessWidget {
@override
Widget build(BuildContext context) => SizedBox(
height: 24,
child: VerticalDivider(
width: 12,
color: Theme.of(context).colorScheme.outlineVariant,
),
);
}

Some files were not shown because too many files have changed in this diff Show more