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
211 changed files with 1676 additions and 1366 deletions

View file

@ -11,6 +11,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of (the floating bar and rail, `FoldingTabs`' tab row folding into the title), with texts of its own (English, German).
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), every app's Profile (`HalkyonProfile`, decided 2026-11-02: the person's own tab, the same in every app, with their account, the ways they sign in and their public profile, one for all apps, then the app's own tabs) and people's public profiles (`PublicProfileRepository`, `PublicProfileSection`, `showProfileOf`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), About with the instance's source link (`showAppAbout`), the device's own speech recognition (`SpeechInput`, `DeviceSpeechInput` on `speech_to_text`, `FakeSpeechInput` in `testing.dart`; none on Linux) and dictation into a text at its cursor, listening again after each pause (`Dictation`, Meditate's ADR 0038, D15; an app that listens declares the microphone and its usage texts itself), what's new after an update (`WhatsNew`, `WhatsNewOnUpdate`, `WhatsNewTile`, from the app's `assets/whats_new/`, Train's ADR 0030, D61), the device's appearance and language, chosen in Settings and applied before anyone signs in (`Look`, `FileLookStore`, `LookCubit`, `DeviceLookCard`; Learn keeps them among its own device settings and shows `LookSettingsCard`; ADR 0014's amendment of 4 Nov 2026), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
- `halkyon_editor` (ADR 0033, D37, 6 Nov 2026): rich text, the general parts of Learn's editor on our fork of `appflowy_editor`: `RichContent` (the editor's document JSON, which Learn and Todo store), `RichContentEditor`, `RichContentView`, the format bar, links, the clipboard keeping formats, superscript and subscript, and `EditorBlocks`, through which an app adds blocks, marks within lines and their buttons (`basicBlocks` without any), with texts of its own (English, German). What is Learn's alone stays in Learn on top of it.
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
- `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed, row IDs keyed by the account (`keyedRowId`). SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`.
- `halkyon_llm` (pure Dart, Halkyon Learn's ADR 0021, D15 and D46): language models' APIs streamed over plain HTTP, no SDKs: Anthropic's Messages API, OpenAI's chat completions (each known host's fields, D45) and its Responses API, behind one interface (`LlmClient`), with `FakeLlmClient` for tests; tested on replies written from the providers' documentation and on those `tool/record_llm.dart` records with a key (D36). The module forwards the operator's assistants with it, and the apps call a person's own provider. BSD-3-Clause as `halkyon_oidc`, `publish_to: none`.

View file

@ -11,7 +11,7 @@ The repository's packages carry different licences, each package's full text in
| `apps/learn/halkyon_learn_curator`, turning dictionary downloads into dictionary files | GNU Affero General Public License, version 3 or any later version | `AGPL-3.0-or-later` |
| `packages/halkyon_base_server`, the Serverpod module every Halkyon server includes | Server Side Public License, version 1 | `SSPL-1.0` |
| `packages/halkyon_base_client`, generated from the module's models, with SRP's client half | GNU Affero General Public License, version 3 or any later version | `AGPL-3.0-or-later` |
| `packages/halkyon_app`, `packages/halkyon_ui` | GNU Affero General Public License, version 3 or any later version | `AGPL-3.0-or-later` |
| `packages/halkyon_app`, `packages/halkyon_editor`, `packages/halkyon_ui` | GNU Affero General Public License, version 3 or any later version | `AGPL-3.0-or-later` |
| `packages/halkyon_keys`, an account's keys on the device | GNU Affero General Public License, version 3 or any later version | `AGPL-3.0-or-later` |
| `packages/halkyon_core`, `packages/halkyon_interop`; BIP-39's English word list in halkyon_core under the MIT License (`BIP39-LICENSE`) | GNU Affero General Public License, version 3 or any later version, as halkyon_shared, where they began, licensed them | `AGPL-3.0-or-later` |
| `apps/todo/halkyon_todo_flutter`, Todo's app | GNU Affero General Public License, version 3 or any later version | `AGPL-3.0-or-later` |

View file

@ -23,6 +23,8 @@ Halkyon Meditate is to follow.
[`halkyon_base`](packages/halkyon_base_server/README.md) (what every server has, as a
Serverpod module with its generated client `halkyon_base_client`: signing in, the
account and its deletion, the pruning, the instance's basics),
[`halkyon_editor`](packages/halkyon_editor/README.md) (rich text: the editor, its view
and the document they keep),
[`halkyon_core`](packages/halkyon_core/README.md) (low-level primitives) and
[`halkyon_interop`](packages/halkyon_interop/README.md) (contracts between apps).
- `deploy/`: what runs an instance (Docker Compose: Caddy, the server, PostgreSQL),

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

@ -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

@ -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

@ -0,0 +1,84 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_host.dart';
import 'package:material_ui/material_ui.dart';
/// halkyon_editor's [RichContentEditor] with Learn's blocks ([blocksOf]) and,
/// with [grammar], spelling and grammar checked as the text is written, in
/// that language ([GrammarHost], ADR 0024).
class const LearnContentEditor({
/// The content the editor opens with; later changes to it are ignored.
required final RichContent initialContent,
/// Called with the whole content after every change to the document.
required final ValueChanged<RichContent> onChanged,
/// Whether the editor takes the focus as it opens, its cursor at the end.
final bool autofocus = false,
/// The editor's focus, to follow or move it; one of its own otherwise.
final FocusNode? focusNode,
/// Whether a [FormatBar] shows below the editor while it has the focus.
final bool formatBar = false,
/// The room around the text, within the editor's scrolling.
final EdgeInsets padding = EdgeInsets.zero,
/// The language the text is checked in as it is written; null: it isn't.
final GrammarLanguage? grammar,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => RichContentEditor(
initialContent: initialContent,
onChanged: onChanged,
autofocus: autofocus,
focusNode: focusNode,
formatBar: formatBar,
padding: padding,
blocks: blocksOf(context),
wrapEditor: grammar == null ? null : _checked,
);
Widget _checked(
BuildContext context,
EditorState editorState,
FocusNode focusNode,
Widget editor,
) => switch (grammar) {
// Checked for the person signed in; nobody is outside their app.
final language? when PersonDependencies.maybeOf(context) != null =>
GrammarHost(
editorState: editorState,
focusNode: focusNode,
language: language,
child: editor,
),
_ => editor,
};
}
/// halkyon_editor's [RichContentView] with Learn's blocks ([blocksOf]).
class const LearnContentView({
/// The content to show.
required final RichContent content,
/// The text's style; the theme's `bodyLarge` without.
final TextStyle? textStyle,
/// How paragraphs and headings align where the editor gave them no
/// alignment of their own; at the start without.
final TextAlign? textAlign,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => RichContentView(
content: content,
textStyle: textStyle,
textAlign: textAlign,
blocks: blocksOf(context),
);
}

View file

@ -1,8 +1,6 @@
import 'package:appflowy_editor/appflowy_editor.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/atomic_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/formula_screen.dart';
import 'package:halkyon_learn_flutter/src/core/editor/math_view.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -12,7 +10,7 @@ import 'package:material_ui/material_ui.dart';
/// ([editFormula]) and inserted once saved; a tap on it in the editor opens
/// it again.
final mathBlock = InsertableBlock(
name: (l10n) => l10n.blockFormula,
name: (context) => AppLocalizations.of(context).blockFormula,
icon: LucideIcons.sigma,
group: InsertGroup.blocks,
keywords: const ['formula', 'math', 'equation', 'latex', 'tex'],
@ -38,6 +36,18 @@ final Map<String, BlockComponentBuilder> mathBlockBuilders = {
mathBlockType: _MathBlockBuilder(),
};
/// The format bar's button for formulas within lines (Σ).
final inlineMathFormatAction = FormatAction(
icon: LucideIcons.sigma,
tooltip: (context) => AppLocalizations.of(context).formatInlineMath,
active: (editorState) =>
editorState.selection != null &&
(editorState.getDeltaAttributeValueInSelection<bool>(inlineMathKey) ??
false),
run: toggleInlineMath,
refocus: true,
);
/// Makes the selection of [editorState] a formula within its line, or
/// plain text again if it is one; at a caret, writes a new formula on its
/// own screen and puts it there. A formula within a line is text marked

View file

@ -1,7 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.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/editor/rich_content_document.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
/// What Markdown has no words of its own for, in the person's language: a
/// sound's link, a video's link without a caption, a spoiler's summary, a

View file

@ -1,9 +1,9 @@
import 'dart:math' as math;
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/asset/widget/studyset_files.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/learn_content.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_host.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -84,7 +84,7 @@ class _RichTextEditorState() extends State<_RichTextEditor> {
children: [
SizedBox(
height: math.max(320, constraints.maxHeight - 32),
child: RichContentEditor(
child: LearnContentEditor(
initialContent: widget.initial,
onChanged: (text) => _text = text,
formatBar: true,

View file

@ -1,9 +1,10 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/gestures.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/widget/content_languages.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
// Text above (ruby): text set small above text within a line, kept on the
@ -49,6 +50,16 @@ String rubyButtonName(BuildContext context) {
};
}
/// The format bar's button for text above.
final rubyFormatAction = FormatAction(
icon: LucideIcons.languages,
tooltip: rubyButtonName,
active: (editorState) =>
editorState.selection != null &&
editorState.getDeltaAttributeValueInSelection<String>(rubyKey) != null,
run: editRuby,
);
/// Gives the text selected in [editorState] text above, asked for starting
/// from what it has; empty takes it away. With the caret in text that has
/// text above (or right after it), that whole text is selected and edited,

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