feat(app): editor blocks: callout, spoiler, formulas, files, video, study button
Blocks beyond the editor's own, each built once for editor and view: - callout: eight kinds in theme colours, kind, title and "folded at first" from its header; written in place, left with Enter on its empty last line; never inside another - spoiler: written in plain sight, hidden behind Reveal read-only - formula on its own line, written on its own screen with a preview and snippet buttons, and formulas within a line (Σ in the format bar); set by flutter_math_fork behind MathView, LaTeX's numbered environments read as their aligned forms - image and sound picked from the device, kept as synced files - YouTube video from its link, with its start and a caption: a card until played, nothing fetched before; played in place where a web view exists (youtube_player_iframe, privacy-enhanced), else opened in the browser, behind YoutubeVideos in Dependencies - study button: a studyset, a tag or all cards, with or without its topics, back first; its StudyFocus rides on the study route and narrows that session only - tables, already in the editor, now insertable everywhere An Insert button opens on the format bar, so blocks can be inserted on phones too; the desktop's / menu lists the same blocks, translated. Card faces now dock the format bar while written, so cards get blocks and formatting on phones as well. The blocks are listed in EditorBlocks: the core's, plus the features' through EditorBlocksScope. Blocks of a type the app doesn't know say they need a newer version and stay as they are, so formatVersion stays 1. Their types and keys are in content_blocks.dart, which RichContent reads: a block's text counts as text, and a video or study button isn't blank. The path import now makes formulas real ones. integration_test/youtube_player_test.dart plays a real video through the app's player on a device, which no widget test can: YouTube refuses embeds that don't say where they are embedded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
9324432f61
commit
ec34bb2334
53 changed files with 5602 additions and 284 deletions
|
|
@ -84,11 +84,12 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
|
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
|
||||||
- **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonScope.of(context)`.
|
- **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonScope.of(context)`.
|
||||||
- **Every person has an app of their own** (`PersonApp`, `lib/src/app/`), which `SessionGate` builds inside the keyed subtree: a `MaterialApp.router` with its own `GoRouter`, and `PersonScope` around the router's navigator (`MaterialApp.builder`). So every screen and dialog is inside the person's scope and reads their repositories from `PersonScope.of(context)` wherever it is built, and a change of person disposes the router with every screen on it and starts again at home. Until someone is signed in, `SessionGate` shows a plain `MaterialApp`: the spinner, or the failure with a way to try again.
|
- **Every person has an app of their own** (`PersonApp`, `lib/src/app/`), which `SessionGate` builds inside the keyed subtree: a `MaterialApp.router` with its own `GoRouter`, and `PersonScope` around the router's navigator (`MaterialApp.builder`). So every screen and dialog is inside the person's scope and reads their repositories from `PersonScope.of(context)` wherever it is built, and a change of person disposes the router with every screen on it and starts again at home. Until someone is signed in, `SessionGate` shows a plain `MaterialApp`: the spinner, or the failure with a way to try again.
|
||||||
- **Platform plugins sit behind small interfaces in `Dependencies`**, with fakes in tests: picking files (`FilePicker`, `file_selector`), playing sounds (`SoundPlayer`, `audioplayers`), reading Anki packages on another isolate (`AnkiPackageReader`; a widget test's fake clock can't wait for one).
|
- **Platform plugins sit behind small interfaces in `Dependencies`**, with fakes in tests: picking files (`FilePicker`, `file_selector`), playing sounds (`SoundPlayer`, `audioplayers`), playing YouTube videos (`YoutubeVideos`, `youtube_player_iframe` where a web view exists, else the browser), reading Anki packages on another isolate (`AnkiPackageReader`; a widget test's fake clock can't wait for one).
|
||||||
- **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`.
|
- **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.
|
- **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`.
|
- **Value equality comes from `equatable`**, mixed in (`with Equatable`), for models and states alike; `leancode_lint`'s `missing_equatable_props` catches a field left out of `props`.
|
||||||
- **Rich text is `RichContent`** (`lib/src/core/content/`): the editor's document JSON plus a `formatVersion`, pure Dart, so models and the server can use it. `lib/src/core/editor/` converts it to and from the editor's `Document` and holds the one editing widget (`RichContentEditor`) and the one read-only renderer (`RichContentView`).
|
- **Rich text is `RichContent`** (`lib/src/core/content/`): the editor's document JSON plus a `formatVersion`, pure Dart, so models and the server can use it. `lib/src/core/editor/` converts it to and from the editor's `Document` and holds the one editing widget (`RichContentEditor`) and the one read-only renderer (`RichContentView`).
|
||||||
|
- **Blocks** beyond the editor's own (callout, spoiler, formula, image, sound, YouTube video, study button) keep their type and attributes as named in `core/content/content_blocks.dart`, which `RichContent` reads too (their text counts in `plainText`, and a video or study button isn't blank). An `EditorBlocks` lists how each type is built, which can be inserted (the format bar's Insert sheet and the desktop's `/` menu show the same list) and its typing rules; the core's are `coreBlocks`, and a feature's (the study button, `feature/study/`) reach the editors through `EditorBlocksScope`, which `PersonApp` fills, since the core can't know features. One builder serves editor and view: a block asks `editorState.editable`, and read-only shows what it is for (a spoiler hidden until revealed, a formula set, a video's play). A block taken whole mixes in `AtomicBlock` (selection, the remove button); unknown types show "needs a newer version" and stay as they are. New block types keep `formatVersion` 1: raising it would hide whole studysets and paths in older apps.
|
||||||
- **Time comes from `clock.now()`** (`package:clock`), never `DateTime.now()`, in server and app. Why: scheduling, streaks and expiry are deterministic under test (`withClock`, `fakeAsync`), and because Serverpod itself reads `clock.now()` (e.g. session expiry), `withClock` controls its behaviour too.
|
- **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.
|
- **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.
|
- **Build configuration comes from `--dart-define`.** The client holds no secrets, ever.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
import 'package:youtube_player_iframe/youtube_player_iframe.dart';
|
||||||
|
|
||||||
|
// Plays a real YouTube video through the app's player, on a device where
|
||||||
|
// videos play inside the app, online:
|
||||||
|
//
|
||||||
|
// flutter test integration_test/youtube_player_test.dart -d <device>
|
||||||
|
//
|
||||||
|
// YouTube refuses embeds that don't say where they are embedded (its error
|
||||||
|
// 153), which shows only in a real web view; such a player may also never
|
||||||
|
// start, without an error. So this passes only once the video plays.
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
testWidgets('a YouTube video plays inside the app', (tester) async {
|
||||||
|
final videos = IframeYoutubeVideos();
|
||||||
|
expect(videos.playInApp, isTrue, reason: 'Run it where videos play.');
|
||||||
|
// YouTube's own sample video for its embedded player.
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(child: videos.player((id: 'M7lc1UVf-VE', start: 10))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final controller = tester
|
||||||
|
.widget<YoutubePlayer>(find.byType(YoutubePlayer))
|
||||||
|
.controller;
|
||||||
|
|
||||||
|
final waited = Stopwatch()..start();
|
||||||
|
while (waited.elapsed < const Duration(minutes: 1)) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||||
|
await tester.pump();
|
||||||
|
final value = controller.value;
|
||||||
|
expect(value.error, YoutubeError.none, reason: 'YouTube refused it.');
|
||||||
|
if (value.playerState == PlayerState.playing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail(
|
||||||
|
'The video did not start within a minute; the player was '
|
||||||
|
'${controller.value.playerState.name}.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -529,6 +529,7 @@
|
||||||
"@heatmapDay": {"placeholders": {"count": {"type": "int"}, "date": {"type": "String"}}},
|
"@heatmapDay": {"placeholders": {"count": {"type": "int"}, "date": {"type": "String"}}},
|
||||||
|
|
||||||
"commonCancel": "Cancel",
|
"commonCancel": "Cancel",
|
||||||
|
"commonSave": "Save",
|
||||||
"commonClose": "Close",
|
"commonClose": "Close",
|
||||||
"commonUndo": "Undo",
|
"commonUndo": "Undo",
|
||||||
"commonTryAgain": "Try again",
|
"commonTryAgain": "Try again",
|
||||||
|
|
@ -543,6 +544,117 @@
|
||||||
"fileStopSound": "Stop sound",
|
"fileStopSound": "Stop sound",
|
||||||
"fileSoundUnavailable": "This sound isn't on this device yet. It plays once it's uploaded and you're online.",
|
"fileSoundUnavailable": "This sound isn't on this device yet. It plays once it's uploaded and you're online.",
|
||||||
"fileSoundUnplayable": "This sound can't be played. Its file may be damaged, or in a format this device can't play.",
|
"fileSoundUnplayable": "This sound can't be played. Its file may be damaged, or in a format this device can't play.",
|
||||||
|
"fileImages": "Images",
|
||||||
|
"fileSounds": "Sounds",
|
||||||
|
"fileNotAnImage": "This file isn't an image the app can show.",
|
||||||
|
"fileNotASound": "This file isn't a sound the app can play.",
|
||||||
|
"fileTooLarge": "This file is larger than {megabytes} MB.",
|
||||||
|
"@fileTooLarge": {
|
||||||
|
"placeholders": {
|
||||||
|
"megabytes": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"blockDivider": "Divider",
|
||||||
|
"blockTable": "Table",
|
||||||
|
"blockRemove": "Remove",
|
||||||
|
"blockUnknown": "This part needs a newer version of the app.",
|
||||||
|
"blockCallout": "Callout",
|
||||||
|
"blockSpoiler": "Spoiler",
|
||||||
|
"calloutNote": "Note",
|
||||||
|
"calloutInfo": "Info",
|
||||||
|
"calloutTip": "Tip",
|
||||||
|
"calloutSuccess": "Success",
|
||||||
|
"calloutQuestion": "Question",
|
||||||
|
"calloutWarning": "Warning",
|
||||||
|
"calloutDanger": "Danger",
|
||||||
|
"calloutQuote": "Quote",
|
||||||
|
"calloutOptions": "Callout options",
|
||||||
|
"calloutEditTitle": "Edit title…",
|
||||||
|
"calloutFoldedAtFirst": "Folded at first",
|
||||||
|
"calloutRemove": "Remove callout",
|
||||||
|
"calloutShow": "Show",
|
||||||
|
"calloutHide": "Hide",
|
||||||
|
"calloutWriteInside": "Write inside",
|
||||||
|
"calloutTitleDialog": "Callout title",
|
||||||
|
"calloutTitleLabel": "Title",
|
||||||
|
"calloutTitleHelper": "Leave it empty to show the kind's name.",
|
||||||
|
"spoilerReveal": "Reveal",
|
||||||
|
"spoilerPlaceholder": "Hidden text, revealed on a tap",
|
||||||
|
"blockFormula": "Formula",
|
||||||
|
"formatInlineMath": "Formula in the line",
|
||||||
|
"formulaTitle": "Formula",
|
||||||
|
"formulaInLineTitle": "Formula in the line",
|
||||||
|
"formulaPreview": "Preview",
|
||||||
|
"formulaPreviewEmpty": "The formula shows here",
|
||||||
|
"formulaLatex": "LaTeX",
|
||||||
|
"formulaLatexHelper": "\\\\ starts a new line in aligned, cases or a matrix.",
|
||||||
|
"formulaEmpty": "An empty formula. Tap to write it.",
|
||||||
|
"formulaEdit": "Edit formula",
|
||||||
|
"formulaRemove": "Remove formula",
|
||||||
|
"blockYoutube": "YouTube video",
|
||||||
|
"youtubeLink": "YouTube link",
|
||||||
|
"youtubeLinkHelper": "Paste a link from YouTube.",
|
||||||
|
"youtubeNotFound": "No YouTube video in this link.",
|
||||||
|
"youtubeFound": "Found the video.",
|
||||||
|
"youtubeFoundFrom": "Found the video, starting at {time}.",
|
||||||
|
"@youtubeFoundFrom": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"youtubeCaption": "Caption",
|
||||||
|
"youtubeCaptionHelper": "Shown in place of the video until it plays.",
|
||||||
|
"youtubeVideo": "YouTube video",
|
||||||
|
"youtubeSource": "YouTube",
|
||||||
|
"youtubeFrom": "YouTube · from {time}",
|
||||||
|
"@youtubeFrom": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"youtubeEdit": "Edit video",
|
||||||
|
"youtubePlay": "Play video",
|
||||||
|
"youtubeOpen": "Open on YouTube",
|
||||||
|
"youtubeRemove": "Remove video",
|
||||||
|
"youtubeBroken": "This video's link can't be read.",
|
||||||
|
"youtubeNotOpened": "Couldn't open YouTube.",
|
||||||
|
"blockStudyButton": "Study button",
|
||||||
|
"studyButtonDefaultLabel": "Study",
|
||||||
|
"studyButtonStudyset": "Studyset to study",
|
||||||
|
"studyButtonTag": "Cards",
|
||||||
|
"studyButtonAllCards": "All cards",
|
||||||
|
"studyButtonLabel": "Label",
|
||||||
|
"studyButtonTopics": "With its topics",
|
||||||
|
"studyButtonBackFirst": "Back first",
|
||||||
|
"studyButtonBackFirstHelper": "Front/back and vocabulary cards ask their back.",
|
||||||
|
"studyButtonWithTag": "Tagged {tag}",
|
||||||
|
"@studyButtonWithTag": {
|
||||||
|
"placeholders": {
|
||||||
|
"tag": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"studyButtonAlone": "Without its topics",
|
||||||
|
"studyButtonEdit": "Edit study button",
|
||||||
|
"studyButtonRemove": "Remove study button",
|
||||||
|
"studyButtonBroken": "This study button doesn't say what to study.",
|
||||||
|
"studyButtonGone": "That studyset no longer exists.",
|
||||||
"ankiImportMenu": "Import from Anki",
|
"ankiImportMenu": "Import from Anki",
|
||||||
"ankiImportFileType": "Anki decks",
|
"ankiImportFileType": "Anki decks",
|
||||||
"ankiImportTitle": "Import Anki deck",
|
"ankiImportTitle": "Import Anki deck",
|
||||||
|
|
|
||||||
|
|
@ -2842,6 +2842,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Cancel'**
|
/// **'Cancel'**
|
||||||
String get commonCancel;
|
String get commonCancel;
|
||||||
|
|
||||||
|
/// No description provided for @commonSave.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save'**
|
||||||
|
String get commonSave;
|
||||||
|
|
||||||
/// No description provided for @commonClose.
|
/// No description provided for @commonClose.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -2926,6 +2932,522 @@ abstract class AppLocalizations {
|
||||||
/// **'This sound can\'t be played. Its file may be damaged, or in a format this device can\'t play.'**
|
/// **'This sound can\'t be played. Its file may be damaged, or in a format this device can\'t play.'**
|
||||||
String get fileSoundUnplayable;
|
String get fileSoundUnplayable;
|
||||||
|
|
||||||
|
/// No description provided for @fileImages.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Images'**
|
||||||
|
String get fileImages;
|
||||||
|
|
||||||
|
/// No description provided for @fileSounds.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sounds'**
|
||||||
|
String get fileSounds;
|
||||||
|
|
||||||
|
/// No description provided for @fileNotAnImage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This file isn\'t an image the app can show.'**
|
||||||
|
String get fileNotAnImage;
|
||||||
|
|
||||||
|
/// No description provided for @fileNotASound.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This file isn\'t a sound the app can play.'**
|
||||||
|
String get fileNotASound;
|
||||||
|
|
||||||
|
/// No description provided for @fileTooLarge.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This file is larger than {megabytes} MB.'**
|
||||||
|
String fileTooLarge(int megabytes);
|
||||||
|
|
||||||
|
/// No description provided for @fileNotKept.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The file couldn\'t be kept on this device.'**
|
||||||
|
String get fileNotKept;
|
||||||
|
|
||||||
|
/// No description provided for @fileRemoveImage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove image'**
|
||||||
|
String get fileRemoveImage;
|
||||||
|
|
||||||
|
/// No description provided for @fileRemoveSound.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'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 @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:
|
||||||
|
/// **'Callout'**
|
||||||
|
String get blockCallout;
|
||||||
|
|
||||||
|
/// No description provided for @blockSpoiler.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Spoiler'**
|
||||||
|
String get blockSpoiler;
|
||||||
|
|
||||||
|
/// No description provided for @calloutNote.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Note'**
|
||||||
|
String get calloutNote;
|
||||||
|
|
||||||
|
/// No description provided for @calloutInfo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Info'**
|
||||||
|
String get calloutInfo;
|
||||||
|
|
||||||
|
/// No description provided for @calloutTip.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tip'**
|
||||||
|
String get calloutTip;
|
||||||
|
|
||||||
|
/// No description provided for @calloutSuccess.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Success'**
|
||||||
|
String get calloutSuccess;
|
||||||
|
|
||||||
|
/// No description provided for @calloutQuestion.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Question'**
|
||||||
|
String get calloutQuestion;
|
||||||
|
|
||||||
|
/// No description provided for @calloutWarning.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Warning'**
|
||||||
|
String get calloutWarning;
|
||||||
|
|
||||||
|
/// No description provided for @calloutDanger.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Danger'**
|
||||||
|
String get calloutDanger;
|
||||||
|
|
||||||
|
/// No description provided for @calloutQuote.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Quote'**
|
||||||
|
String get calloutQuote;
|
||||||
|
|
||||||
|
/// No description provided for @calloutOptions.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Callout options'**
|
||||||
|
String get calloutOptions;
|
||||||
|
|
||||||
|
/// No description provided for @calloutEditTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Edit title…'**
|
||||||
|
String get calloutEditTitle;
|
||||||
|
|
||||||
|
/// No description provided for @calloutFoldedAtFirst.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Folded at first'**
|
||||||
|
String get calloutFoldedAtFirst;
|
||||||
|
|
||||||
|
/// No description provided for @calloutRemove.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove callout'**
|
||||||
|
String get calloutRemove;
|
||||||
|
|
||||||
|
/// No description provided for @calloutShow.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show'**
|
||||||
|
String get calloutShow;
|
||||||
|
|
||||||
|
/// No description provided for @calloutHide.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hide'**
|
||||||
|
String get calloutHide;
|
||||||
|
|
||||||
|
/// No description provided for @calloutWriteInside.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Write inside'**
|
||||||
|
String get calloutWriteInside;
|
||||||
|
|
||||||
|
/// No description provided for @calloutTitleDialog.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Callout title'**
|
||||||
|
String get calloutTitleDialog;
|
||||||
|
|
||||||
|
/// No description provided for @calloutTitleLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Title'**
|
||||||
|
String get calloutTitleLabel;
|
||||||
|
|
||||||
|
/// No description provided for @calloutTitleHelper.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Leave it empty to show the kind\'s name.'**
|
||||||
|
String get calloutTitleHelper;
|
||||||
|
|
||||||
|
/// No description provided for @spoilerReveal.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Reveal'**
|
||||||
|
String get spoilerReveal;
|
||||||
|
|
||||||
|
/// No description provided for @spoilerPlaceholder.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hidden text, revealed on a tap'**
|
||||||
|
String get spoilerPlaceholder;
|
||||||
|
|
||||||
|
/// No description provided for @blockFormula.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Formula'**
|
||||||
|
String get blockFormula;
|
||||||
|
|
||||||
|
/// No description provided for @formatInlineMath.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Formula in the line'**
|
||||||
|
String get formatInlineMath;
|
||||||
|
|
||||||
|
/// No description provided for @formulaTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Formula'**
|
||||||
|
String get formulaTitle;
|
||||||
|
|
||||||
|
/// No description provided for @formulaInLineTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Formula in the line'**
|
||||||
|
String get formulaInLineTitle;
|
||||||
|
|
||||||
|
/// No description provided for @formulaPreview.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Preview'**
|
||||||
|
String get formulaPreview;
|
||||||
|
|
||||||
|
/// No description provided for @formulaPreviewEmpty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The formula shows here'**
|
||||||
|
String get formulaPreviewEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @formulaLatex.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'LaTeX'**
|
||||||
|
String get formulaLatex;
|
||||||
|
|
||||||
|
/// No description provided for @formulaLatexHelper.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'\\\\ starts a new line in aligned, cases or a matrix.'**
|
||||||
|
String get formulaLatexHelper;
|
||||||
|
|
||||||
|
/// No description provided for @formulaEmpty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'An empty formula. Tap to write it.'**
|
||||||
|
String get formulaEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @formulaEdit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Edit formula'**
|
||||||
|
String get formulaEdit;
|
||||||
|
|
||||||
|
/// No description provided for @formulaRemove.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove formula'**
|
||||||
|
String get formulaRemove;
|
||||||
|
|
||||||
|
/// No description provided for @blockYoutube.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'YouTube video'**
|
||||||
|
String get blockYoutube;
|
||||||
|
|
||||||
|
/// No description provided for @youtubeLink.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'YouTube link'**
|
||||||
|
String get youtubeLink;
|
||||||
|
|
||||||
|
/// No description provided for @youtubeLinkHelper.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Paste a link from YouTube.'**
|
||||||
|
String get youtubeLinkHelper;
|
||||||
|
|
||||||
|
/// No description provided for @youtubeNotFound.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No YouTube video in this link.'**
|
||||||
|
String get youtubeNotFound;
|
||||||
|
|
||||||
|
/// No description provided for @youtubeFound.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Found the video.'**
|
||||||
|
String get youtubeFound;
|
||||||
|
|
||||||
|
/// No description provided for @youtubeFoundFrom.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Found the video, starting at {time}.'**
|
||||||
|
String youtubeFoundFrom(String time);
|
||||||
|
|
||||||
|
/// No description provided for @youtubeCaption.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Caption'**
|
||||||
|
String get youtubeCaption;
|
||||||
|
|
||||||
|
/// No description provided for @youtubeCaptionHelper.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Shown in place of the video until it plays.'**
|
||||||
|
String get youtubeCaptionHelper;
|
||||||
|
|
||||||
|
/// No description provided for @youtubeVideo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'YouTube video'**
|
||||||
|
String get youtubeVideo;
|
||||||
|
|
||||||
|
/// No description provided for @youtubeSource.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'YouTube'**
|
||||||
|
String get youtubeSource;
|
||||||
|
|
||||||
|
/// No description provided for @youtubeFrom.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'YouTube · from {time}'**
|
||||||
|
String youtubeFrom(String time);
|
||||||
|
|
||||||
|
/// No description provided for @youtubeEdit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Edit video'**
|
||||||
|
String get youtubeEdit;
|
||||||
|
|
||||||
|
/// No description provided for @youtubePlay.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Play video'**
|
||||||
|
String get youtubePlay;
|
||||||
|
|
||||||
|
/// No description provided for @youtubeOpen.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Open on YouTube'**
|
||||||
|
String get youtubeOpen;
|
||||||
|
|
||||||
|
/// No description provided for @youtubeRemove.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove video'**
|
||||||
|
String get youtubeRemove;
|
||||||
|
|
||||||
|
/// No description provided for @youtubeBroken.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This video\'s link can\'t be read.'**
|
||||||
|
String get youtubeBroken;
|
||||||
|
|
||||||
|
/// No description provided for @youtubeNotOpened.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Couldn\'t open YouTube.'**
|
||||||
|
String get youtubeNotOpened;
|
||||||
|
|
||||||
|
/// No description provided for @blockStudyButton.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Study button'**
|
||||||
|
String get blockStudyButton;
|
||||||
|
|
||||||
|
/// No description provided for @studyButtonDefaultLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Study'**
|
||||||
|
String get studyButtonDefaultLabel;
|
||||||
|
|
||||||
|
/// No description provided for @studyButtonStudyset.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Studyset to study'**
|
||||||
|
String get studyButtonStudyset;
|
||||||
|
|
||||||
|
/// No description provided for @studyButtonTag.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cards'**
|
||||||
|
String get studyButtonTag;
|
||||||
|
|
||||||
|
/// No description provided for @studyButtonAllCards.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All cards'**
|
||||||
|
String get studyButtonAllCards;
|
||||||
|
|
||||||
|
/// No description provided for @studyButtonLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Label'**
|
||||||
|
String get studyButtonLabel;
|
||||||
|
|
||||||
|
/// No description provided for @studyButtonTopics.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'With its topics'**
|
||||||
|
String get studyButtonTopics;
|
||||||
|
|
||||||
|
/// No description provided for @studyButtonBackFirst.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Back first'**
|
||||||
|
String get studyButtonBackFirst;
|
||||||
|
|
||||||
|
/// No description provided for @studyButtonBackFirstHelper.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Front/back and vocabulary cards ask their back.'**
|
||||||
|
String get studyButtonBackFirstHelper;
|
||||||
|
|
||||||
|
/// No description provided for @studyButtonWithTag.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tagged {tag}'**
|
||||||
|
String studyButtonWithTag(String tag);
|
||||||
|
|
||||||
|
/// No description provided for @studyButtonAlone.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Without its topics'**
|
||||||
|
String get studyButtonAlone;
|
||||||
|
|
||||||
|
/// No description provided for @studyButtonEdit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Edit study button'**
|
||||||
|
String get studyButtonEdit;
|
||||||
|
|
||||||
|
/// No description provided for @studyButtonRemove.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove study button'**
|
||||||
|
String get studyButtonRemove;
|
||||||
|
|
||||||
|
/// No description provided for @studyButtonBroken.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This study button doesn\'t say what to study.'**
|
||||||
|
String get studyButtonBroken;
|
||||||
|
|
||||||
|
/// No description provided for @studyButtonGone.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'That studyset no longer exists.'**
|
||||||
|
String get studyButtonGone;
|
||||||
|
|
||||||
/// No description provided for @ankiImportMenu.
|
/// No description provided for @ankiImportMenu.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1683,6 +1683,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get commonCancel => 'Cancel';
|
String get commonCancel => 'Cancel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get commonSave => 'Save';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get commonClose => 'Close';
|
String get commonClose => 'Close';
|
||||||
|
|
||||||
|
|
@ -1730,6 +1733,278 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get fileSoundUnplayable =>
|
String get fileSoundUnplayable =>
|
||||||
'This sound can\'t be played. Its file may be damaged, or in a format this device can\'t play.';
|
'This sound can\'t be played. Its file may be damaged, or in a format this device can\'t play.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fileImages => 'Images';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fileSounds => 'Sounds';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fileNotAnImage => 'This file isn\'t an image the app can show.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fileNotASound => 'This file isn\'t a sound the app can play.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String fileTooLarge(int megabytes) {
|
||||||
|
return 'This file is larger than $megabytes MB.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fileNotKept => 'The file couldn\'t be kept on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fileRemoveImage => 'Remove image';
|
||||||
|
|
||||||
|
@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 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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get blockSpoiler => 'Spoiler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutNote => 'Note';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutInfo => 'Info';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutTip => 'Tip';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutSuccess => 'Success';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutQuestion => 'Question';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutWarning => 'Warning';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutDanger => 'Danger';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutQuote => 'Quote';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutOptions => 'Callout options';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutEditTitle => 'Edit title…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutFoldedAtFirst => 'Folded at first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutRemove => 'Remove callout';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutShow => 'Show';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutHide => 'Hide';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutWriteInside => 'Write inside';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutTitleDialog => 'Callout title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutTitleLabel => 'Title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calloutTitleHelper => 'Leave it empty to show the kind\'s name.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get spoilerReveal => 'Reveal';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get spoilerPlaceholder => 'Hidden text, revealed on a tap';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get blockFormula => 'Formula';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get formatInlineMath => 'Formula in the line';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get formulaTitle => 'Formula';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get formulaInLineTitle => 'Formula in the line';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get formulaPreview => 'Preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get formulaPreviewEmpty => 'The formula shows here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get formulaLatex => 'LaTeX';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get formulaLatexHelper =>
|
||||||
|
'\\\\ starts a new line in aligned, cases or a matrix.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get formulaEmpty => 'An empty formula. Tap to write it.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get formulaEdit => 'Edit formula';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get formulaRemove => 'Remove formula';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get blockYoutube => 'YouTube video';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeLink => 'YouTube link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeLinkHelper => 'Paste a link from YouTube.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeNotFound => 'No YouTube video in this link.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeFound => 'Found the video.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String youtubeFoundFrom(String time) {
|
||||||
|
return 'Found the video, starting at $time.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeCaption => 'Caption';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeCaptionHelper =>
|
||||||
|
'Shown in place of the video until it plays.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeVideo => 'YouTube video';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeSource => 'YouTube';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String youtubeFrom(String time) {
|
||||||
|
return 'YouTube · from $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeEdit => 'Edit video';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubePlay => 'Play video';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeOpen => 'Open on YouTube';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeRemove => 'Remove video';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeBroken => 'This video\'s link can\'t be read.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeNotOpened => 'Couldn\'t open YouTube.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get blockStudyButton => 'Study button';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyButtonDefaultLabel => 'Study';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyButtonStudyset => 'Studyset to study';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyButtonTag => 'Cards';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyButtonAllCards => 'All cards';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyButtonLabel => 'Label';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyButtonTopics => 'With its topics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyButtonBackFirst => 'Back first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyButtonBackFirstHelper =>
|
||||||
|
'Front/back and vocabulary cards ask their back.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String studyButtonWithTag(String tag) {
|
||||||
|
return 'Tagged $tag';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyButtonAlone => 'Without its topics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyButtonEdit => 'Edit study button';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyButtonRemove => 'Remove study button';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyButtonBroken =>
|
||||||
|
'This study button doesn\'t say what to study.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyButtonGone => 'That studyset no longer exists.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ankiImportMenu => 'Import from Anki';
|
String get ankiImportMenu => 'Import from Anki';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_editor_bloc.dar
|
||||||
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_editor_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_editor_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
||||||
|
|
@ -107,6 +108,7 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
||||||
person.tags,
|
person.tags,
|
||||||
person.settings,
|
person.settings,
|
||||||
studysetId,
|
studysetId,
|
||||||
|
focus: StudyFocus.fromQuery(state.uri.queryParameters),
|
||||||
)..add(const StudySessionEventStarted()),
|
)..add(const StudySessionEventStarted()),
|
||||||
child: StudyScreen(
|
child: StudyScreen(
|
||||||
studysetId: studysetId,
|
studysetId: studysetId,
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,17 @@ import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.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/app/app_router.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/person_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/router/route_paths.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
|
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_button_block.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// The blocks of the person's editors: the core's and the features'.
|
||||||
|
final _editorBlocks = coreBlocks.plus(studyEditorBlocks);
|
||||||
|
|
||||||
/// The app of one signed-in [person]: its own router, around the person's data
|
/// The app of one signed-in [person]: its own router, around the person's data
|
||||||
/// ([PersonScope]).
|
/// ([PersonScope]).
|
||||||
///
|
///
|
||||||
|
|
@ -55,7 +61,11 @@ class _PersonAppState() extends State<PersonApp> {
|
||||||
// reminder: remove it once they have moved.
|
// reminder: remove it once they have moved.
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
builder: (context, child) => MaterialUiCompatibilityBridge(
|
builder: (context, child) => MaterialUiCompatibilityBridge(
|
||||||
child: PersonScope(person: widget.person, child: child!),
|
child: PersonScope(
|
||||||
|
person: widget.person,
|
||||||
|
// The editors know the features' blocks too.
|
||||||
|
child: EditorBlocksScope(blocks: _editorBlocks, child: child!),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/// The media type of a file named [name], by its extension: the images and
|
||||||
|
/// sounds the app shows and plays; null for any other.
|
||||||
|
String? mediaTypeOf(String name) {
|
||||||
|
final dot = name.lastIndexOf('.');
|
||||||
|
return dot < 0 ? null : _mediaTypes[name.substring(dot + 1).toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The extensions of the images the app shows.
|
||||||
|
final imageExtensions = [
|
||||||
|
for (final MapEntry(:key, :value) in _mediaTypes.entries)
|
||||||
|
if (value.startsWith('image/')) key,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The extensions of the sounds the app plays.
|
||||||
|
final soundExtensions = [
|
||||||
|
for (final MapEntry(:key, :value) in _mediaTypes.entries)
|
||||||
|
if (value.startsWith('audio/')) key,
|
||||||
|
];
|
||||||
|
|
||||||
|
const _mediaTypes = {
|
||||||
|
'png': 'image/png',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'webp': 'image/webp',
|
||||||
|
'bmp': 'image/bmp',
|
||||||
|
'mp3': 'audio/mpeg',
|
||||||
|
'm4a': 'audio/mp4',
|
||||||
|
'aac': 'audio/aac',
|
||||||
|
'ogg': 'audio/ogg',
|
||||||
|
'oga': 'audio/ogg',
|
||||||
|
'opus': 'audio/ogg',
|
||||||
|
'wav': 'audio/wav',
|
||||||
|
'flac': 'audio/flac',
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
// 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';
|
||||||
|
|
||||||
|
/// 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';
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:equatable/equatable.dart';
|
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
|
/// 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.
|
/// version of that format, so stored content can be migrated when it changes.
|
||||||
|
|
@ -68,16 +69,23 @@ final class RichContent(
|
||||||
List<Object?> get props => [formatVersion, editorJson];
|
List<Object?> get props => [formatVersion, editorJson];
|
||||||
|
|
||||||
/// The text without its formatting, one line per block; for previews.
|
/// 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 {
|
String get plainText {
|
||||||
final blocks = <String>[];
|
final blocks = <String>[];
|
||||||
void visit(Object? node) {
|
void visit(Object? node) {
|
||||||
if (node case {'data': {'delta': final List<Object?> delta}}) {
|
if (node case {'data': final Map<String, Object?> data}) {
|
||||||
blocks.add(
|
if (data[_textKeys[node['type']]] case final String text) {
|
||||||
[
|
blocks.add(text);
|
||||||
for (final operation in delta)
|
}
|
||||||
if (operation case {'insert': final String text}) text,
|
if (data case {'delta': final List<Object?> delta}) {
|
||||||
].join(),
|
blocks.add(
|
||||||
);
|
[
|
||||||
|
for (final operation in delta)
|
||||||
|
if (operation case {'insert': final String text}) text,
|
||||||
|
].join(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (node case {'children': final List<Object?> children}) {
|
if (node case {'children': final List<Object?> children}) {
|
||||||
children.forEach(visit);
|
children.forEach(visit);
|
||||||
|
|
@ -88,14 +96,20 @@ final class RichContent(
|
||||||
return blocks.where((block) => block.isNotEmpty).join('\n');
|
return blocks.where((block) => block.isNotEmpty).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether it holds nothing: no text, no image and no sound.
|
/// Whether it holds nothing: no text, and no block that shows something
|
||||||
bool get isBlank => plainText.trim().isEmpty && !hasImages && !hasSounds;
|
/// 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.
|
/// Whether it shows an image, the person's or another.
|
||||||
bool get hasImages => _hasBlock('image');
|
bool get hasImages => _hasBlock('image');
|
||||||
|
|
||||||
/// Whether it plays a sound.
|
/// Whether it plays a sound.
|
||||||
bool get hasSounds => _hasBlock('audio');
|
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.
|
/// The person's files it shows or plays, by their asset rows' ids.
|
||||||
Set<String> get assetIds => {
|
Set<String> get assetIds => {
|
||||||
|
|
@ -142,6 +156,23 @@ String? assetIdOf(Object? url) =>
|
||||||
|
|
||||||
const _assetScheme = 'asset:';
|
const _assetScheme = 'asset:';
|
||||||
|
|
||||||
|
/// 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<String, Object?> _unmodifiableMap(Map<String, Object?> map) =>
|
||||||
Map.unmodifiable({
|
Map.unmodifiable({
|
||||||
for (final MapEntry(:key, :value) in map.entries)
|
for (final MapEntry(:key, :value) in map.entries)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_folders.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/person_folders.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
|
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
|
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
|
||||||
|
|
@ -71,6 +72,9 @@ class Dependencies({
|
||||||
/// Lets the person pick files to import.
|
/// Lets the person pick files to import.
|
||||||
required final FilePicker files,
|
required final FilePicker files,
|
||||||
|
|
||||||
|
/// Plays the YouTube videos in content.
|
||||||
|
required final YoutubeVideos videos,
|
||||||
|
|
||||||
/// Reads Anki packages to import.
|
/// Reads Anki packages to import.
|
||||||
required final AnkiPackageReader ankiPackages,
|
required final AnkiPackageReader ankiPackages,
|
||||||
final Future<void> Function()? _onDispose,
|
final Future<void> Function()? _onDispose,
|
||||||
|
|
@ -104,6 +108,7 @@ class Dependencies({
|
||||||
maps: GeoMapRepository(),
|
maps: GeoMapRepository(),
|
||||||
sounds: sounds,
|
sounds: sounds,
|
||||||
files: FileSelectorFilePicker(),
|
files: FileSelectorFilePicker(),
|
||||||
|
videos: IframeYoutubeVideos(),
|
||||||
ankiPackages: IsolateAnkiPackageReader(),
|
ankiPackages: IsolateAnkiPackageReader(),
|
||||||
openPerson: (person) async {
|
openPerson: (person) async {
|
||||||
await previousClosed.timeout(
|
await previousClosed.timeout(
|
||||||
|
|
|
||||||
186
halkyon_learn_flutter/lib/src/core/editor/atomic_block.dart
Normal file
186
halkyon_learn_flutter/lib/src/core/editor/atomic_block.dart
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
halkyon_learn_flutter/lib/src/core/editor/block_menu.dart
Normal file
54
halkyon_learn_flutter/lib/src/core/editor/block_menu.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
427
halkyon_learn_flutter/lib/src/core/editor/callout_block.dart
Normal file
427
halkyon_learn_flutter/lib/src/core/editor/callout_block.dart
Normal file
|
|
@ -0,0 +1,427 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.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/theme/theme_extras.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// A callout: a box of a kind (a note, a tip, a warning…) whose blocks are
|
||||||
|
/// written in place below its header, as a quote's text is. Its kind, title
|
||||||
|
/// and whether it starts folded are chosen from the header's menu.
|
||||||
|
///
|
||||||
|
/// In the editor it always shows what it holds; shown read-only, a folded
|
||||||
|
/// 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,
|
||||||
|
icon: LucideIcons.messageSquareWarning,
|
||||||
|
group: InsertGroup.blocks,
|
||||||
|
keywords: const ['callout', 'note', 'info', 'tip', 'warning', 'admonition'],
|
||||||
|
insideCallouts: false,
|
||||||
|
insert: (context, editorState, at) => insertBlock(
|
||||||
|
editorState,
|
||||||
|
Node(
|
||||||
|
type: calloutBlockType,
|
||||||
|
attributes: {calloutKindKey: calloutKinds.first},
|
||||||
|
children: [paragraphNode()],
|
||||||
|
),
|
||||||
|
at: at,
|
||||||
|
caretIn: const [0],
|
||||||
|
lineAfter: true,
|
||||||
|
insideCallouts: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// How callouts are built.
|
||||||
|
final Map<String, BlockComponentBuilder> calloutBlockBuilders = {
|
||||||
|
calloutBlockType: _CalloutBuilder(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Enter on an empty last line of a callout leaves it, as on an empty item
|
||||||
|
/// of a list: the line moves out after the callout, the caret with it.
|
||||||
|
final leaveCalloutOnEnter = CharacterShortcutEvent(
|
||||||
|
key: 'leave a callout',
|
||||||
|
character: '\n',
|
||||||
|
handler: (editorState) async {
|
||||||
|
final selection = editorState.selection;
|
||||||
|
final node = selection == null || !selection.isCollapsed
|
||||||
|
? null
|
||||||
|
: editorState.getNodeAtPath(selection.end.path);
|
||||||
|
final callout = node?.parent;
|
||||||
|
if (node == null ||
|
||||||
|
callout == null ||
|
||||||
|
callout.type != calloutBlockType ||
|
||||||
|
node.next != null ||
|
||||||
|
callout.children.length < 2 ||
|
||||||
|
!isEmptyLine(node)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final after = callout.path.next;
|
||||||
|
final transaction = editorState.transaction..deleteNode(node);
|
||||||
|
if (!(callout.next != null && isEmptyLine(callout.next!))) {
|
||||||
|
transaction.insertNode(after, paragraphNode());
|
||||||
|
}
|
||||||
|
transaction.afterSelection = Selection.collapsed(Position(path: after));
|
||||||
|
await editorState.apply(transaction);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// A callout's kind: its icon, name and colour.
|
||||||
|
enum _Kind(final IconData icon) {
|
||||||
|
note(LucideIcons.pin),
|
||||||
|
info(LucideIcons.info),
|
||||||
|
tip(LucideIcons.lightbulb),
|
||||||
|
success(LucideIcons.circleCheck),
|
||||||
|
question(LucideIcons.circleQuestionMark),
|
||||||
|
warning(LucideIcons.triangleAlert),
|
||||||
|
danger(LucideIcons.octagonAlert),
|
||||||
|
quote(LucideIcons.quote);
|
||||||
|
|
||||||
|
/// The kind [name] names; a note for any other.
|
||||||
|
static _Kind of(Object? name) =>
|
||||||
|
_Kind.values.where((kind) => kind.name == name).firstOrNull ?? note;
|
||||||
|
|
||||||
|
String label(AppLocalizations l10n) => switch (this) {
|
||||||
|
note => l10n.calloutNote,
|
||||||
|
info => l10n.calloutInfo,
|
||||||
|
tip => l10n.calloutTip,
|
||||||
|
success => l10n.calloutSuccess,
|
||||||
|
question => l10n.calloutQuestion,
|
||||||
|
warning => l10n.calloutWarning,
|
||||||
|
danger => l10n.calloutDanger,
|
||||||
|
quote => l10n.calloutQuote,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The colour of its icon, title and bar: theme colours that keep their
|
||||||
|
/// contrast on the surface in light and dark alike.
|
||||||
|
Color color(ThemeData theme) {
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
final extras = theme.extras;
|
||||||
|
return switch (this) {
|
||||||
|
note => colors.primary,
|
||||||
|
info => extras.info,
|
||||||
|
tip => colors.tertiary,
|
||||||
|
success => extras.success,
|
||||||
|
question => colors.secondary,
|
||||||
|
warning => extras.warning,
|
||||||
|
danger => colors.error,
|
||||||
|
quote => colors.onSurfaceVariant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CalloutBuilder() extends BlockComponentBuilder {
|
||||||
|
@override
|
||||||
|
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||||
|
final node = blockComponentContext.node;
|
||||||
|
return _Callout(key: node.key, node: node, configuration: configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
BlockComponentValidate get validate =>
|
||||||
|
(node) => node.delta == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class const _Callout({
|
||||||
|
required super.node,
|
||||||
|
required super.configuration,
|
||||||
|
super.key,
|
||||||
|
}) extends BlockComponentStatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_Callout> createState() => _CalloutState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CalloutState()
|
||||||
|
extends State<_Callout>
|
||||||
|
with SelectableMixin, BlockComponentConfigurable {
|
||||||
|
final _boxKey = GlobalKey();
|
||||||
|
|
||||||
|
late final _editorState = context.read<EditorState>();
|
||||||
|
|
||||||
|
/// Whether it shows its blocks, read-only.
|
||||||
|
late var _open = widget.node.attributes[calloutFoldedKey] != true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
BlockComponentConfiguration get configuration => widget.configuration;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Node get node => widget.node;
|
||||||
|
|
||||||
|
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
|
||||||
|
|
||||||
|
void _update(Map<String, Object?> attributes) {
|
||||||
|
final transaction = _editorState.transaction..updateNode(node, attributes);
|
||||||
|
unawaited(_editorState.apply(transaction));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _remove() {
|
||||||
|
final transaction = _editorState.transaction..deleteNode(node);
|
||||||
|
unawaited(_editorState.apply(transaction));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts a line into an emptied callout, the caret on it.
|
||||||
|
void _writeInside() {
|
||||||
|
final transaction = _editorState.transaction
|
||||||
|
..insertNode([...node.path, 0], paragraphNode())
|
||||||
|
..afterSelection = Selection.collapsed(Position(path: [...node.path, 0]));
|
||||||
|
unawaited(_editorState.apply(transaction));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editTitle(_Kind kind) async {
|
||||||
|
final title = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _TitleDialog(
|
||||||
|
title: node.attributes[calloutTitleKey] as String? ?? '',
|
||||||
|
kindLabel: kind.label(AppLocalizations.of(context)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (title != null && mounted) {
|
||||||
|
_update({calloutTitleKey: title.trim()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final editing = _editorState.editable;
|
||||||
|
final kind = _Kind.of(node.attributes[calloutKindKey]);
|
||||||
|
final accent = kind.color(theme);
|
||||||
|
final title = (node.attributes[calloutTitleKey] as String? ?? '').trim();
|
||||||
|
final folded = node.attributes[calloutFoldedKey] == true;
|
||||||
|
final open = editing || _open;
|
||||||
|
|
||||||
|
final header = Row(
|
||||||
|
children: [
|
||||||
|
Icon(kind.icon, size: 20, color: accent),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title.isEmpty ? kind.label(l10n) : title,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
color: accent,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (editing)
|
||||||
|
_CalloutMenu(
|
||||||
|
kind: kind,
|
||||||
|
folded: folded,
|
||||||
|
onKind: (kind) => _update({calloutKindKey: kind.name}),
|
||||||
|
onEditTitle: () => _editTitle(kind),
|
||||||
|
onFolded: (folded) => _update({calloutFoldedKey: folded}),
|
||||||
|
onRemove: _remove,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
open ? LucideIcons.chevronDown : LucideIcons.chevronRight,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
tooltip: open ? l10n.calloutHide : l10n.calloutShow,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () => setState(() => _open = !_open),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: margin,
|
||||||
|
child: ClipRRect(
|
||||||
|
key: _boxKey,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: ColoredBox(
|
||||||
|
color: accent.withValues(alpha: 0.10),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 8, 8),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
header,
|
||||||
|
if (open)
|
||||||
|
for (final child in node.children)
|
||||||
|
_editorState.renderer.build(context, child),
|
||||||
|
if (editing && node.children.isEmpty)
|
||||||
|
Align(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _writeInside,
|
||||||
|
child: Text(l10n.calloutWriteInside),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// The bar along its start.
|
||||||
|
PositionedDirectional(
|
||||||
|
start: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: SizedBox(width: 4, child: ColoredBox(color: accent)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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}) =>
|
||||||
|
getRectsInSelection(Selection.invalid()).first;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Rect? getCursorRectInPosition(
|
||||||
|
Position position, {
|
||||||
|
bool shiftWithBaseOffset = false,
|
||||||
|
}) => getRectsInSelection(Selection.collapsed(position)).firstOrNull;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Rect> getRectsInSelection(
|
||||||
|
Selection selection, {
|
||||||
|
bool shiftWithBaseOffset = false,
|
||||||
|
}) {
|
||||||
|
final parent = _renderBox;
|
||||||
|
if (parent == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
final box = _boxKey.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The header's menu, in the editor: the kind, the title, whether it starts
|
||||||
|
/// folded, and removing it.
|
||||||
|
class const _CalloutMenu({
|
||||||
|
required final _Kind kind,
|
||||||
|
required final bool folded,
|
||||||
|
required final ValueChanged<_Kind> onKind,
|
||||||
|
required final VoidCallback onEditTitle,
|
||||||
|
required final ValueChanged<bool> onFolded,
|
||||||
|
required final VoidCallback onRemove,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return MenuAnchor(
|
||||||
|
menuChildren: [
|
||||||
|
for (final option in _Kind.values)
|
||||||
|
MenuItemButton(
|
||||||
|
leadingIcon: Icon(option.icon, color: option.color(theme)),
|
||||||
|
trailingIcon: option == kind
|
||||||
|
? const Icon(LucideIcons.check, size: 18)
|
||||||
|
: null,
|
||||||
|
onPressed: () => onKind(option),
|
||||||
|
child: Text(option.label(l10n)),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
MenuItemButton(
|
||||||
|
leadingIcon: const Icon(LucideIcons.pencil),
|
||||||
|
onPressed: onEditTitle,
|
||||||
|
child: Text(l10n.calloutEditTitle),
|
||||||
|
),
|
||||||
|
CheckboxMenuButton(
|
||||||
|
value: folded,
|
||||||
|
onChanged: (value) => onFolded(value ?? false),
|
||||||
|
child: Text(l10n.calloutFoldedAtFirst),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
MenuItemButton(
|
||||||
|
leadingIcon: Icon(LucideIcons.trash2, color: theme.colorScheme.error),
|
||||||
|
onPressed: onRemove,
|
||||||
|
child: Text(l10n.calloutRemove),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
builder: (context, controller, child) => IconButton(
|
||||||
|
icon: const Icon(LucideIcons.ellipsis, size: 20),
|
||||||
|
tooltip: l10n.calloutOptions,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () =>
|
||||||
|
controller.isOpen ? controller.close() : controller.open(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks for a callout's title; empty for its kind's name.
|
||||||
|
class const _TitleDialog({
|
||||||
|
required final String title,
|
||||||
|
required final String kindLabel,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_TitleDialog> createState() => _TitleDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TitleDialogState() extends State<_TitleDialog> {
|
||||||
|
late final _field = TextEditingController(text: widget.title);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_field.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
void save() => Navigator.of(context).pop(_field.text);
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(l10n.calloutTitleDialog),
|
||||||
|
content: TextField(
|
||||||
|
controller: _field,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.calloutTitleLabel,
|
||||||
|
hintText: widget.kindLabel,
|
||||||
|
helperText: l10n.calloutTitleHelper,
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => save(),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(l10n.commonCancel),
|
||||||
|
),
|
||||||
|
FilledButton(onPressed: save, child: Text(l10n.commonSave)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
halkyon_learn_flutter/lib/src/core/editor/core_blocks.dart
Normal file
113
halkyon_learn_flutter/lib/src/core/editor/core_blocks.dart
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.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/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.
|
||||||
|
final coreBlocks = EditorBlocks(
|
||||||
|
builders: {
|
||||||
|
...standardBlockComponentBuilderMap,
|
||||||
|
...fileBlockBuilders,
|
||||||
|
...calloutBlockBuilders,
|
||||||
|
...spoilerBlockBuilders,
|
||||||
|
...mathBlockBuilders,
|
||||||
|
...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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
calloutBlock,
|
||||||
|
spoilerBlock,
|
||||||
|
mathBlock,
|
||||||
|
imageBlock,
|
||||||
|
soundBlock,
|
||||||
|
youtubeBlock,
|
||||||
|
],
|
||||||
|
shortcuts: [leaveCalloutOnEnter],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 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 []),
|
||||||
|
);
|
||||||
241
halkyon_learn_flutter/lib/src/core/editor/editor_blocks.dart
Normal file
241
halkyon_learn_flutter/lib/src/core/editor/editor_blocks.dart
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
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/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,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
Map<String, BlockComponentBuilder> buildersWithUnknown(EditorBlocks blocks) => {
|
||||||
|
...blocks.builders,
|
||||||
|
errorBlockComponentBuilderKey: _UnknownBlockBuilder(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
final dark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
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.
|
||||||
|
handler: (editorState, menuService, menuContext) =>
|
||||||
|
block.insert(context, editorState, editorState.selection),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
style: dark ? SelectionMenuStyle.dark : SelectionMenuStyle.light,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:flutter/foundation.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/scripts.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
|
@ -9,8 +10,9 @@ import 'package:material_ui/material_ui.dart';
|
||||||
/// and 100 dp beside it on desktops.
|
/// and 100 dp beside it on desktops.
|
||||||
///
|
///
|
||||||
/// Phones get the touch variant, with handles to drag the selection. Both
|
/// Phones get the touch variant, with handles to drag the selection. Both
|
||||||
/// draw superscript and subscript text (`withScripts`).
|
/// draw superscript and subscript text (`withScripts`), and formulas within
|
||||||
EditorStyle editorStyleOf(BuildContext context) {
|
/// lines (`withInlineMath`): as their source while [editing], else set.
|
||||||
|
EditorStyle editorStyleOf(BuildContext context, {bool editing = true}) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colors = theme.colorScheme;
|
final colors = theme.colorScheme;
|
||||||
final text = TextStyleConfiguration(
|
final text = TextStyleConfiguration(
|
||||||
|
|
@ -33,14 +35,20 @@ EditorStyle editorStyleOf(BuildContext context) {
|
||||||
selectionColor: selection,
|
selectionColor: selection,
|
||||||
dragHandleColor: colors.primary,
|
dragHandleColor: colors.primary,
|
||||||
textStyleConfiguration: text,
|
textStyleConfiguration: text,
|
||||||
textSpanDecorator: withScripts(mobileTextSpanDecoratorForAttribute),
|
textSpanDecorator: withInlineMath(
|
||||||
|
withScripts(mobileTextSpanDecoratorForAttribute),
|
||||||
|
editing: editing,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_ => EditorStyle.desktop(
|
_ => EditorStyle.desktop(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
cursorColor: colors.primary,
|
cursorColor: colors.primary,
|
||||||
selectionColor: selection,
|
selectionColor: selection,
|
||||||
textStyleConfiguration: text,
|
textStyleConfiguration: text,
|
||||||
textSpanDecorator: withScripts(defaultTextSpanDecoratorForAttribute),
|
textSpanDecorator: withInlineMath(
|
||||||
|
withScripts(defaultTextSpanDecoratorForAttribute),
|
||||||
|
editing: editing,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.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/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/stored_file_image.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/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.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:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
|
@ -15,9 +22,6 @@ import 'package:material_ui/material_ui.dart';
|
||||||
// fetched: content from elsewhere could otherwise make every device that
|
// fetched: content from elsewhere could otherwise make every device that
|
||||||
// shows it call a server of its choice.
|
// shows it call a server of its choice.
|
||||||
|
|
||||||
/// The block type of a sound.
|
|
||||||
const audioBlockType = 'audio';
|
|
||||||
|
|
||||||
/// The block attribute holding a file's URL, in images and sounds alike.
|
/// The block attribute holding a file's URL, in images and sounds alike.
|
||||||
const fileUrlKey = ImageBlockKeys.url;
|
const fileUrlKey = ImageBlockKeys.url;
|
||||||
|
|
||||||
|
|
@ -39,22 +43,90 @@ String previewOf(RichContent content, AppLocalizations l10n) {
|
||||||
? l10n.fileImage
|
? l10n.fileImage
|
||||||
: content.hasSounds
|
: content.hasSounds
|
||||||
? l10n.fileSound
|
? l10n.fileSound
|
||||||
|
: content.hasVideos
|
||||||
|
? l10n.youtubeVideo
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The editor's blocks, with images and sounds shown as above.
|
/// Images and sounds, shown as above.
|
||||||
final Map<String, BlockComponentBuilder> fileBlockBuilders = {
|
final Map<String, BlockComponentBuilder> fileBlockBuilders = {
|
||||||
...standardBlockComponentBuilderMap,
|
|
||||||
ImageBlockKeys.type: _FileBlockBuilder(_FileKind.image),
|
ImageBlockKeys.type: _FileBlockBuilder(_FileKind.image),
|
||||||
audioBlockType: _FileBlockBuilder(_FileKind.sound),
|
audioBlockType: _FileBlockBuilder(_FileKind.sound),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// An image from the person's device, kept as a file of theirs.
|
||||||
|
final imageBlock = InsertableBlock(
|
||||||
|
name: (l10n) => l10n.fileImage,
|
||||||
|
icon: LucideIcons.image,
|
||||||
|
group: InsertGroup.media,
|
||||||
|
keywords: const ['image', 'picture', 'photo'],
|
||||||
|
insert: (context, editorState, at) =>
|
||||||
|
_insertFile(context, editorState, at, _FileKind.image),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// A sound from the person's device, kept as a file of theirs.
|
||||||
|
final soundBlock = InsertableBlock(
|
||||||
|
name: (l10n) => l10n.fileSound,
|
||||||
|
icon: LucideIcons.volume2,
|
||||||
|
group: InsertGroup.media,
|
||||||
|
keywords: const ['sound', 'audio', 'pronunciation'],
|
||||||
|
insert: (context, editorState, at) =>
|
||||||
|
_insertFile(context, editorState, at, _FileKind.sound),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Lets the person pick a file of [kind] and inserts it at [at]; says why
|
||||||
|
/// when it can't be taken.
|
||||||
|
Future<void> _insertFile(
|
||||||
|
BuildContext context,
|
||||||
|
EditorState editorState,
|
||||||
|
Selection? at,
|
||||||
|
_FileKind kind,
|
||||||
|
) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
final assets = PersonScope.of(context).assets;
|
||||||
|
final image = kind == _FileKind.image;
|
||||||
|
final file = await DependenciesScope.of(context).files.pick(
|
||||||
|
label: image ? l10n.fileImages : l10n.fileSounds,
|
||||||
|
extensions: image ? imageExtensions : soundExtensions,
|
||||||
|
);
|
||||||
|
if (file == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void tell(String message) =>
|
||||||
|
messenger?.showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
final mediaType = mediaTypeOf(file.name);
|
||||||
|
if (mediaType == null ||
|
||||||
|
!mediaType.startsWith(image ? 'image/' : 'audio/') ||
|
||||||
|
file.bytes.isEmpty) {
|
||||||
|
tell(image ? l10n.fileNotAnImage : l10n.fileNotASound);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.bytes.length > AssetRepository.maxBytes) {
|
||||||
|
tell(l10n.fileTooLarge(AssetRepository.maxBytes ~/ (1024 * 1024)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final String id;
|
||||||
|
try {
|
||||||
|
id = await assets.add(file.bytes, fileName: file.name, mimeType: mediaType);
|
||||||
|
} on Exception catch (error) {
|
||||||
|
log('Keeping a picked file failed', name: 'Editor', error: error);
|
||||||
|
tell(l10n.fileNotKept);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await insertBlock(
|
||||||
|
editorState,
|
||||||
|
image ? fileImageNode(id) : audioNode(id),
|
||||||
|
at: at,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
enum _FileKind() {
|
enum _FileKind() {
|
||||||
image,
|
image,
|
||||||
sound,
|
sound,
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FileBlockBuilder(final _FileKind _kind) extends BlockComponentBuilder {
|
class _FileBlockBuilder(final _FileKind _kind) extends AtomicBlockBuilder {
|
||||||
@override
|
@override
|
||||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||||
final node = blockComponentContext.node;
|
final node = blockComponentContext.node;
|
||||||
|
|
@ -65,10 +137,6 @@ class _FileBlockBuilder(final _FileKind _kind) extends BlockComponentBuilder {
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
BlockComponentValidate get validate =>
|
|
||||||
(node) => node.delta == null && node.children.isEmpty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A file in the document: selected, copied and deleted as a whole, like the
|
/// A file in the document: selected, copied and deleted as a whole, like the
|
||||||
|
|
@ -85,26 +153,21 @@ class const _FileBlock({
|
||||||
|
|
||||||
class _FileBlockState()
|
class _FileBlockState()
|
||||||
extends State<_FileBlock>
|
extends State<_FileBlock>
|
||||||
with SelectableMixin, BlockComponentConfigurable {
|
with SelectableMixin, BlockComponentConfigurable, AtomicBlock<_FileBlock> {
|
||||||
final _contentKey = GlobalKey();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
BlockComponentConfiguration get configuration => widget.configuration;
|
BlockComponentConfiguration get configuration => widget.configuration;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Node get node => widget.node;
|
Node get node => widget.node;
|
||||||
|
|
||||||
late final _editorState = context.read<EditorState>();
|
|
||||||
|
|
||||||
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final assetId = assetIdOf(node.attributes[fileUrlKey]);
|
final assetId = assetIdOf(node.attributes[fileUrlKey]);
|
||||||
final content = switch ((widget.kind, assetId)) {
|
final content = switch ((widget.kind, assetId)) {
|
||||||
(_, null) => _Unavailable(
|
(_, null) => BlockNotice(
|
||||||
icon: LucideIcons.imageOff,
|
icon: LucideIcons.imageOff,
|
||||||
text: AppLocalizations.of(context).fileFromElsewhere,
|
text: l10n.fileFromElsewhere,
|
||||||
),
|
),
|
||||||
(_FileKind.image, final id?) => _FileImage(
|
(_FileKind.image, final id?) => _FileImage(
|
||||||
assetId: id,
|
assetId: id,
|
||||||
|
|
@ -112,81 +175,14 @@ class _FileBlockState()
|
||||||
),
|
),
|
||||||
(_FileKind.sound, final id?) => _Sound(assetId: id),
|
(_FileKind.sound, final id?) => _Sound(assetId: id),
|
||||||
};
|
};
|
||||||
return Padding(
|
return buildAtomic(
|
||||||
padding: margin,
|
removeLabel: switch (widget.kind) {
|
||||||
child: BlockSelectionContainer(
|
_FileKind.image => l10n.fileRemoveImage,
|
||||||
node: node,
|
_FileKind.sound => l10n.fileRemoveSound,
|
||||||
delegate: this,
|
},
|
||||||
listenable: _editorState.selectionNotifier,
|
child: content,
|
||||||
remoteSelection: _editorState.remoteSelections,
|
|
||||||
blockColor: _editorState.editorStyle.selectionColor,
|
|
||||||
supportTypes: const [BlockSelectionType.block],
|
|
||||||
child: Padding(
|
|
||||||
key: _contentKey,
|
|
||||||
padding: padding,
|
|
||||||
child: content,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An image of the person's, at most as wide as the text and a little taller
|
/// An image of the person's, at most as wide as the text and a little taller
|
||||||
|
|
@ -206,7 +202,7 @@ class const _FileImage({
|
||||||
image: StoredFileImage(PersonScope.of(context).assets, assetId),
|
image: StoredFileImage(PersonScope.of(context).assets, assetId),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
semanticLabel: AppLocalizations.of(context).fileImage,
|
semanticLabel: AppLocalizations.of(context).fileImage,
|
||||||
errorBuilder: (context, error, stackTrace) => _Unavailable(
|
errorBuilder: (context, error, stackTrace) => BlockNotice(
|
||||||
icon: LucideIcons.imageOff,
|
icon: LucideIcons.imageOff,
|
||||||
text: error is FileNotHereException
|
text: error is FileNotHereException
|
||||||
? AppLocalizations.of(context).fileImageUnavailable
|
? AppLocalizations.of(context).fileImageUnavailable
|
||||||
|
|
@ -237,15 +233,18 @@ class const _SoundButton() extends StatelessWidget {
|
||||||
final state = context.watch<SoundBloc>().state;
|
final state = context.watch<SoundBloc>().state;
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case SoundState.missing:
|
case SoundState.missing:
|
||||||
return _Unavailable(
|
return BlockNotice(
|
||||||
icon: LucideIcons.volumeOff,
|
icon: LucideIcons.volumeOff,
|
||||||
text: l10n.fileSoundUnavailable,
|
text: l10n.fileSoundUnavailable,
|
||||||
onRetry: () =>
|
action: TextButton(
|
||||||
context.read<SoundBloc>().add(const SoundEventToggled()),
|
onPressed: () =>
|
||||||
|
context.read<SoundBloc>().add(const SoundEventToggled()),
|
||||||
|
child: Text(l10n.commonTryAgain),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
// Trying again wouldn't change the file.
|
// Trying again wouldn't change the file.
|
||||||
case SoundState.unplayable:
|
case SoundState.unplayable:
|
||||||
return _Unavailable(
|
return BlockNotice(
|
||||||
icon: LucideIcons.volumeOff,
|
icon: LucideIcons.volumeOff,
|
||||||
text: l10n.fileSoundUnplayable,
|
text: l10n.fileSoundUnplayable,
|
||||||
);
|
);
|
||||||
|
|
@ -276,40 +275,3 @@ class const _SoundButton() extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In place of a file that can't be shown.
|
|
||||||
class const _Unavailable({
|
|
||||||
required final IconData icon,
|
|
||||||
required final String text,
|
|
||||||
final VoidCallback? onRetry,
|
|
||||||
}) 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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (onRetry != null)
|
|
||||||
TextButton(
|
|
||||||
onPressed: onRetry,
|
|
||||||
child: Text(AppLocalizations.of(context).commonTryAgain),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,32 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.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/scripts.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// Formats the selection of [editorState]: alignment, bold, italic,
|
/// Formats the selection of [editorState]: alignment, bold, italic,
|
||||||
/// underline, superscript and subscript. Each button shows whether the
|
/// underline, superscript and subscript, and with [onFormula] formulas
|
||||||
/// selection has its format already.
|
/// within the line. Each button shows whether the selection has its format
|
||||||
|
/// already. With [onInsert], an Insert button comes first, for blocks.
|
||||||
///
|
///
|
||||||
/// The bar takes no focus, so tapping a button leaves the editor focused and
|
/// 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,
|
/// 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.
|
/// it is the way to format at all. It scrolls sideways when narrow.
|
||||||
class const FormatBar({required final EditorState editorState, super.key})
|
class const FormatBar({
|
||||||
extends StatefulWidget {
|
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,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
/// How tall the bar is, so what docks it can make room for it.
|
||||||
|
static const height = 44.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FormatBar> createState() => _FormatBarState();
|
State<FormatBar> createState() => _FormatBarState();
|
||||||
}
|
}
|
||||||
|
|
@ -106,65 +119,84 @@ class _FormatBarState() extends State<FormatBar> {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final editorState = widget.editorState;
|
final editorState = widget.editorState;
|
||||||
return ExcludeFocus(
|
return ExcludeFocus(
|
||||||
child: SingleChildScrollView(
|
child: SizedBox(
|
||||||
scrollDirection: Axis.horizontal,
|
height: FormatBar.height,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
child: SingleChildScrollView(
|
||||||
child: Row(
|
scrollDirection: Axis.horizontal,
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
_FormatButton(
|
child: Row(
|
||||||
icon: LucideIcons.textAlignStart,
|
children: [
|
||||||
tooltip: l10n.formatAlignLeft,
|
if (widget.onInsert case final onInsert?) ...[
|
||||||
active: _isAligned('left'),
|
_FormatButton(
|
||||||
onPressed: () => _align('left'),
|
icon: LucideIcons.plus,
|
||||||
),
|
tooltip: l10n.editorInsert,
|
||||||
_FormatButton(
|
active: false,
|
||||||
icon: LucideIcons.textAlignCenter,
|
onPressed: onInsert,
|
||||||
tooltip: l10n.formatAlignCenter,
|
),
|
||||||
active: _isAligned('center'),
|
const _Divider(),
|
||||||
onPressed: () => _align('center'),
|
],
|
||||||
),
|
_FormatButton(
|
||||||
_FormatButton(
|
icon: LucideIcons.textAlignStart,
|
||||||
icon: LucideIcons.textAlignEnd,
|
tooltip: l10n.formatAlignLeft,
|
||||||
tooltip: l10n.formatAlignRight,
|
active: _isAligned('left'),
|
||||||
active: _isAligned('right'),
|
onPressed: () => _align('left'),
|
||||||
onPressed: () => _align('right'),
|
),
|
||||||
),
|
_FormatButton(
|
||||||
const _Divider(),
|
icon: LucideIcons.textAlignCenter,
|
||||||
_FormatButton(
|
tooltip: l10n.formatAlignCenter,
|
||||||
icon: LucideIcons.bold,
|
active: _isAligned('center'),
|
||||||
tooltip: l10n.formatBold,
|
onPressed: () => _align('center'),
|
||||||
active: _has(AppFlowyRichTextKeys.bold),
|
),
|
||||||
onPressed: () =>
|
_FormatButton(
|
||||||
editorState.toggleAttribute(AppFlowyRichTextKeys.bold),
|
icon: LucideIcons.textAlignEnd,
|
||||||
),
|
tooltip: l10n.formatAlignRight,
|
||||||
_FormatButton(
|
active: _isAligned('right'),
|
||||||
icon: LucideIcons.italic,
|
onPressed: () => _align('right'),
|
||||||
tooltip: l10n.formatItalic,
|
),
|
||||||
active: _has(AppFlowyRichTextKeys.italic),
|
const _Divider(),
|
||||||
onPressed: () =>
|
_FormatButton(
|
||||||
editorState.toggleAttribute(AppFlowyRichTextKeys.italic),
|
icon: LucideIcons.bold,
|
||||||
),
|
tooltip: l10n.formatBold,
|
||||||
_FormatButton(
|
active: _has(AppFlowyRichTextKeys.bold),
|
||||||
icon: LucideIcons.underline,
|
onPressed: () =>
|
||||||
tooltip: l10n.formatUnderline,
|
editorState.toggleAttribute(AppFlowyRichTextKeys.bold),
|
||||||
active: _has(AppFlowyRichTextKeys.underline),
|
),
|
||||||
onPressed: () =>
|
_FormatButton(
|
||||||
editorState.toggleAttribute(AppFlowyRichTextKeys.underline),
|
icon: LucideIcons.italic,
|
||||||
),
|
tooltip: l10n.formatItalic,
|
||||||
const _Divider(),
|
active: _has(AppFlowyRichTextKeys.italic),
|
||||||
_FormatButton(
|
onPressed: () =>
|
||||||
icon: LucideIcons.superscript,
|
editorState.toggleAttribute(AppFlowyRichTextKeys.italic),
|
||||||
tooltip: l10n.formatSuperscript,
|
),
|
||||||
active: _has(superscriptKey),
|
_FormatButton(
|
||||||
onPressed: () => toggleScript(editorState, superscriptKey),
|
icon: LucideIcons.underline,
|
||||||
),
|
tooltip: l10n.formatUnderline,
|
||||||
_FormatButton(
|
active: _has(AppFlowyRichTextKeys.underline),
|
||||||
icon: LucideIcons.subscript,
|
onPressed: () =>
|
||||||
tooltip: l10n.formatSubscript,
|
editorState.toggleAttribute(AppFlowyRichTextKeys.underline),
|
||||||
active: _has(subscriptKey),
|
),
|
||||||
onPressed: () => toggleScript(editorState, subscriptKey),
|
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),
|
||||||
|
),
|
||||||
|
if (widget.onFormula case final onFormula?)
|
||||||
|
_FormatButton(
|
||||||
|
icon: LucideIcons.sigma,
|
||||||
|
tooltip: l10n.formatInlineMath,
|
||||||
|
active: _has(inlineMathKey),
|
||||||
|
onPressed: onFormula,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
235
halkyon_learn_flutter/lib/src/core/editor/formula_screen.dart
Normal file
235
halkyon_learn_flutter/lib/src/core/editor/formula_screen.dart
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_math_fork/flutter_math.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/editor/math_view.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Edits a formula in LaTeX in the whole window, previewed as it is typed,
|
||||||
|
/// with buttons for its usual pieces. Completes with the formula once saved
|
||||||
|
/// (Ctrl+Enter saves too), or with null when left without saving.
|
||||||
|
Future<String?> editFormula(
|
||||||
|
BuildContext context, {
|
||||||
|
required String initial,
|
||||||
|
|
||||||
|
/// The app bar's title, saying what is being written.
|
||||||
|
required String title,
|
||||||
|
}) => Navigator.of(context).push<String>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
fullscreenDialog: true,
|
||||||
|
builder: (context) => _FormulaScreen(initial: initial, title: title),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// A piece of LaTeX a button puts in: `#` marks where what is selected goes,
|
||||||
|
/// or the caret when nothing is.
|
||||||
|
typedef _Snippet = ({String latex, String label});
|
||||||
|
|
||||||
|
/// The buttons, in groups: structures, big operators, symbols, layout.
|
||||||
|
const List<List<_Snippet>> _snippets = [
|
||||||
|
[
|
||||||
|
(latex: r'\frac{#}{}', label: r'\frac{a}{b}'),
|
||||||
|
(latex: r'\sqrt{#}', label: r'\sqrt{x}'),
|
||||||
|
(latex: r'\sqrt[]{#}', label: r'\sqrt[n]{x}'),
|
||||||
|
(latex: '{#}^{}', label: 'x^{n}'),
|
||||||
|
(latex: '{#}_{}', label: 'x_{i}'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
(latex: r'\sum_{#}^{}', label: r'\sum'),
|
||||||
|
(latex: r'\prod_{#}^{}', label: r'\prod'),
|
||||||
|
(latex: r'\int_{#}^{}', label: r'\int'),
|
||||||
|
(latex: r'\lim_{# \to }', label: r'\lim'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
(latex: r'\times #', label: r'\times'),
|
||||||
|
(latex: r'\cdot #', label: r'\cdot'),
|
||||||
|
(latex: r'\pm #', label: r'\pm'),
|
||||||
|
(latex: r'\to #', label: r'\to'),
|
||||||
|
(latex: r'\infty #', label: r'\infty'),
|
||||||
|
(latex: r'\pi #', label: r'\pi'),
|
||||||
|
(latex: r'\ne #', label: r'\ne'),
|
||||||
|
(latex: r'\le #', label: r'\le'),
|
||||||
|
(latex: r'\ge #', label: r'\ge'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
(latex: r'\begin{aligned}#\end{aligned}', label: r'\text{aligned}'),
|
||||||
|
(latex: r'\begin{cases}#\end{cases}', label: r'\text{cases}'),
|
||||||
|
(latex: r'\begin{pmatrix}#\end{pmatrix}', label: r'\text{matrix}'),
|
||||||
|
(latex: r' \\ #', label: r'\text{new line}'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
class const _FormulaScreen({
|
||||||
|
required final String initial,
|
||||||
|
required final String title,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_FormulaScreen> createState() => _FormulaScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FormulaScreenState() extends State<_FormulaScreen> {
|
||||||
|
late final _field = TextEditingController(text: widget.initial)
|
||||||
|
..addListener(() => setState(() {}));
|
||||||
|
final _focus = FocusNode();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_field.dispose();
|
||||||
|
_focus.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _save() => Navigator.of(context).pop(_field.text);
|
||||||
|
|
||||||
|
/// Puts [snippet] in place of the selection, the selection where its `#`
|
||||||
|
/// is; the caret then goes into its next empty group when there was a
|
||||||
|
/// selection, else to where the `#` was.
|
||||||
|
void _insert(_Snippet snippet) {
|
||||||
|
final value = _field.value;
|
||||||
|
final selection = value.selection.isValid
|
||||||
|
? value.selection
|
||||||
|
: TextSelection.collapsed(offset: value.text.length);
|
||||||
|
final selected = selection.textInside(value.text);
|
||||||
|
final hash = snippet.latex.indexOf('#');
|
||||||
|
final inserted = snippet.latex.replaceFirst('#', selected);
|
||||||
|
final emptyGroup = selected.isEmpty
|
||||||
|
? -1
|
||||||
|
: inserted.indexOf('{}', hash + selected.length);
|
||||||
|
final caret =
|
||||||
|
selection.start +
|
||||||
|
(emptyGroup >= 0 ? emptyGroup + 1 : hash + selected.length);
|
||||||
|
_field.value = TextEditingValue(
|
||||||
|
text: value.text.replaceRange(selection.start, selection.end, inserted),
|
||||||
|
selection: TextSelection.collapsed(offset: caret),
|
||||||
|
);
|
||||||
|
_focus.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
final formula = _field.text;
|
||||||
|
return CallbackShortcuts(
|
||||||
|
bindings: {
|
||||||
|
const SingleActivator(LogicalKeyboardKey.enter, control: true): _save,
|
||||||
|
const SingleActivator(LogicalKeyboardKey.enter, meta: true): _save,
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(widget.title),
|
||||||
|
actions: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _save,
|
||||||
|
child: Text(l10n.commonSave),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
maxWidth: readableContentMaxWidth,
|
||||||
|
),
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.formulaPreview,
|
||||||
|
style: theme.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(minHeight: 64),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: formula.trim().isEmpty
|
||||||
|
? Text(
|
||||||
|
l10n.formulaPreviewEmpty,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: MathView(formula: formula),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _field,
|
||||||
|
focusNode: _focus,
|
||||||
|
autofocus: true,
|
||||||
|
minLines: 3,
|
||||||
|
maxLines: null,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace'),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.formulaLatex,
|
||||||
|
// LaTeX reads the same in every language.
|
||||||
|
hintText: r'\frac{a}{b} = c^2',
|
||||||
|
helperText: l10n.formulaLatexHelper,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Divider(height: 1, color: colors.outlineVariant),
|
||||||
|
_SnippetBar(onSnippet: _insert),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The snippets' buttons, scrolling sideways, each showing what it writes.
|
||||||
|
class const _SnippetBar({required final ValueChanged<_Snippet> onSnippet})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (final (index, group) in _snippets.indexed) ...[
|
||||||
|
if (index > 0)
|
||||||
|
SizedBox(
|
||||||
|
height: 24,
|
||||||
|
child: VerticalDivider(
|
||||||
|
width: 12,
|
||||||
|
color: colors.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final snippet in group)
|
||||||
|
Tooltip(
|
||||||
|
message: snippet.label,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => onSnippet(snippet),
|
||||||
|
child: Math.tex(
|
||||||
|
snippet.label,
|
||||||
|
textStyle: TextStyle(color: colors.onSurface),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue