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
51 changed files with 5602 additions and 284 deletions
|
|
@ -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"}}},
|
||||
|
||||
"commonCancel": "Cancel",
|
||||
"commonSave": "Save",
|
||||
"commonClose": "Close",
|
||||
"commonUndo": "Undo",
|
||||
"commonTryAgain": "Try again",
|
||||
|
|
@ -543,6 +544,117 @@
|
|||
"fileStopSound": "Stop sound",
|
||||
"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.",
|
||||
"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",
|
||||
"ankiImportFileType": "Anki decks",
|
||||
"ankiImportTitle": "Import Anki deck",
|
||||
|
|
|
|||
|
|
@ -2842,6 +2842,12 @@ abstract class AppLocalizations {
|
|||
/// **'Cancel'**
|
||||
String get commonCancel;
|
||||
|
||||
/// No description provided for @commonSave.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get commonSave;
|
||||
|
||||
/// No description provided for @commonClose.
|
||||
///
|
||||
/// 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.'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1683,6 +1683,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get commonCancel => 'Cancel';
|
||||
|
||||
@override
|
||||
String get commonSave => 'Save';
|
||||
|
||||
@override
|
||||
String get commonClose => 'Close';
|
||||
|
||||
|
|
@ -1730,6 +1733,278 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get fileSoundUnplayable =>
|
||||
'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
|
||||
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/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/model/study_focus.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/widget/library_screen.dart';
|
||||
|
|
@ -107,6 +108,7 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
person.tags,
|
||||
person.settings,
|
||||
studysetId,
|
||||
focus: StudyFocus.fromQuery(state.uri.queryParameters),
|
||||
)..add(const StudySessionEventStarted()),
|
||||
child: StudyScreen(
|
||||
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/src/app/app_router.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/theme/app_theme.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';
|
||||
|
||||
/// 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
|
||||
/// ([PersonScope]).
|
||||
///
|
||||
|
|
@ -55,7 +61,11 @@ class _PersonAppState() extends State<PersonApp> {
|
|||
// reminder: remove it once they have moved.
|
||||
// ignore: deprecated_member_use
|
||||
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:halkyon_learn_flutter/src/core/content/content_blocks.dart';
|
||||
|
||||
/// Rich text as the editor stores it: the editor's document JSON plus the
|
||||
/// version of that format, so stored content can be migrated when it changes.
|
||||
|
|
@ -68,16 +69,23 @@ final class RichContent(
|
|||
List<Object?> get props => [formatVersion, editorJson];
|
||||
|
||||
/// The text without its formatting, one line per block; for previews.
|
||||
/// A block's text kept beside its blocks counts too: a formula, a
|
||||
/// callout's title, a video's caption, a study button's label.
|
||||
String get plainText {
|
||||
final blocks = <String>[];
|
||||
void visit(Object? node) {
|
||||
if (node case {'data': {'delta': final List<Object?> delta}}) {
|
||||
blocks.add(
|
||||
[
|
||||
for (final operation in delta)
|
||||
if (operation case {'insert': final String text}) text,
|
||||
].join(),
|
||||
);
|
||||
if (node case {'data': final Map<String, Object?> data}) {
|
||||
if (data[_textKeys[node['type']]] case final String text) {
|
||||
blocks.add(text);
|
||||
}
|
||||
if (data case {'delta': final List<Object?> delta}) {
|
||||
blocks.add(
|
||||
[
|
||||
for (final operation in delta)
|
||||
if (operation case {'insert': final String text}) text,
|
||||
].join(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (node case {'children': final List<Object?> children}) {
|
||||
children.forEach(visit);
|
||||
|
|
@ -88,14 +96,20 @@ final class RichContent(
|
|||
return blocks.where((block) => block.isNotEmpty).join('\n');
|
||||
}
|
||||
|
||||
/// Whether it holds nothing: no text, no image and no sound.
|
||||
bool get isBlank => plainText.trim().isEmpty && !hasImages && !hasSounds;
|
||||
/// Whether it holds nothing: no text, and no block that shows something
|
||||
/// without text (an image, a sound, a video, a study button).
|
||||
bool get isBlank =>
|
||||
plainText.trim().isEmpty &&
|
||||
!_nodes.any((node) => _somethingWithoutText.contains(node['type']));
|
||||
|
||||
/// Whether it shows an image, the person's or another.
|
||||
bool get hasImages => _hasBlock('image');
|
||||
|
||||
/// Whether it plays a sound.
|
||||
bool get hasSounds => _hasBlock('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.
|
||||
Set<String> get assetIds => {
|
||||
|
|
@ -142,6 +156,23 @@ String? assetIdOf(Object? url) =>
|
|||
|
||||
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.unmodifiable({
|
||||
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/files/file_picker.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/serverpod_about_gateway.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.
|
||||
required final FilePicker files,
|
||||
|
||||
/// Plays the YouTube videos in content.
|
||||
required final YoutubeVideos videos,
|
||||
|
||||
/// Reads Anki packages to import.
|
||||
required final AnkiPackageReader ankiPackages,
|
||||
final Future<void> Function()? _onDispose,
|
||||
|
|
@ -104,6 +108,7 @@ class Dependencies({
|
|||
maps: GeoMapRepository(),
|
||||
sounds: sounds,
|
||||
files: FileSelectorFilePicker(),
|
||||
videos: IframeYoutubeVideos(),
|
||||
ankiPackages: IsolateAnkiPackageReader(),
|
||||
openPerson: (person) async {
|
||||
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: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:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -9,8 +10,9 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// and 100 dp beside it on desktops.
|
||||
///
|
||||
/// Phones get the touch variant, with handles to drag the selection. Both
|
||||
/// draw superscript and subscript text (`withScripts`).
|
||||
EditorStyle editorStyleOf(BuildContext context) {
|
||||
/// draw superscript and subscript text (`withScripts`), and formulas within
|
||||
/// lines (`withInlineMath`): as their source while [editing], else set.
|
||||
EditorStyle editorStyleOf(BuildContext context, {bool editing = true}) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final text = TextStyleConfiguration(
|
||||
|
|
@ -33,14 +35,20 @@ EditorStyle editorStyleOf(BuildContext context) {
|
|||
selectionColor: selection,
|
||||
dragHandleColor: colors.primary,
|
||||
textStyleConfiguration: text,
|
||||
textSpanDecorator: withScripts(mobileTextSpanDecoratorForAttribute),
|
||||
textSpanDecorator: withInlineMath(
|
||||
withScripts(mobileTextSpanDecoratorForAttribute),
|
||||
editing: editing,
|
||||
),
|
||||
),
|
||||
_ => EditorStyle.desktop(
|
||||
padding: EdgeInsets.zero,
|
||||
cursorColor: colors.primary,
|
||||
selectionColor: selection,
|
||||
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:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/bloc/sound_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/media_type.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/widget/stored_file_image.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.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/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: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
|
||||
// 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.
|
||||
const fileUrlKey = ImageBlockKeys.url;
|
||||
|
||||
|
|
@ -39,22 +43,90 @@ String previewOf(RichContent content, AppLocalizations l10n) {
|
|||
? l10n.fileImage
|
||||
: content.hasSounds
|
||||
? 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 = {
|
||||
...standardBlockComponentBuilderMap,
|
||||
ImageBlockKeys.type: _FileBlockBuilder(_FileKind.image),
|
||||
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() {
|
||||
image,
|
||||
sound,
|
||||
}
|
||||
|
||||
class _FileBlockBuilder(final _FileKind _kind) extends BlockComponentBuilder {
|
||||
class _FileBlockBuilder(final _FileKind _kind) extends AtomicBlockBuilder {
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
final node = blockComponentContext.node;
|
||||
|
|
@ -65,10 +137,6 @@ class _FileBlockBuilder(final _FileKind _kind) extends BlockComponentBuilder {
|
|||
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
|
||||
|
|
@ -85,26 +153,21 @@ class const _FileBlock({
|
|||
|
||||
class _FileBlockState()
|
||||
extends State<_FileBlock>
|
||||
with SelectableMixin, BlockComponentConfigurable {
|
||||
final _contentKey = GlobalKey();
|
||||
|
||||
with SelectableMixin, BlockComponentConfigurable, AtomicBlock<_FileBlock> {
|
||||
@override
|
||||
BlockComponentConfiguration get configuration => widget.configuration;
|
||||
|
||||
@override
|
||||
Node get node => widget.node;
|
||||
|
||||
late final _editorState = context.read<EditorState>();
|
||||
|
||||
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final assetId = assetIdOf(node.attributes[fileUrlKey]);
|
||||
final content = switch ((widget.kind, assetId)) {
|
||||
(_, null) => _Unavailable(
|
||||
(_, null) => BlockNotice(
|
||||
icon: LucideIcons.imageOff,
|
||||
text: AppLocalizations.of(context).fileFromElsewhere,
|
||||
text: l10n.fileFromElsewhere,
|
||||
),
|
||||
(_FileKind.image, final id?) => _FileImage(
|
||||
assetId: id,
|
||||
|
|
@ -112,81 +175,14 @@ class _FileBlockState()
|
|||
),
|
||||
(_FileKind.sound, final id?) => _Sound(assetId: id),
|
||||
};
|
||||
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: Padding(
|
||||
key: _contentKey,
|
||||
padding: padding,
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
return buildAtomic(
|
||||
removeLabel: switch (widget.kind) {
|
||||
_FileKind.image => l10n.fileRemoveImage,
|
||||
_FileKind.sound => l10n.fileRemoveSound,
|
||||
},
|
||||
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
|
||||
|
|
@ -206,7 +202,7 @@ class const _FileImage({
|
|||
image: StoredFileImage(PersonScope.of(context).assets, assetId),
|
||||
fit: BoxFit.contain,
|
||||
semanticLabel: AppLocalizations.of(context).fileImage,
|
||||
errorBuilder: (context, error, stackTrace) => _Unavailable(
|
||||
errorBuilder: (context, error, stackTrace) => BlockNotice(
|
||||
icon: LucideIcons.imageOff,
|
||||
text: error is FileNotHereException
|
||||
? AppLocalizations.of(context).fileImageUnavailable
|
||||
|
|
@ -237,15 +233,18 @@ class const _SoundButton() extends StatelessWidget {
|
|||
final state = context.watch<SoundBloc>().state;
|
||||
switch (state) {
|
||||
case SoundState.missing:
|
||||
return _Unavailable(
|
||||
return BlockNotice(
|
||||
icon: LucideIcons.volumeOff,
|
||||
text: l10n.fileSoundUnavailable,
|
||||
onRetry: () =>
|
||||
context.read<SoundBloc>().add(const SoundEventToggled()),
|
||||
action: TextButton(
|
||||
onPressed: () =>
|
||||
context.read<SoundBloc>().add(const SoundEventToggled()),
|
||||
child: Text(l10n.commonTryAgain),
|
||||
),
|
||||
);
|
||||
// Trying again wouldn't change the file.
|
||||
case SoundState.unplayable:
|
||||
return _Unavailable(
|
||||
return BlockNotice(
|
||||
icon: LucideIcons.volumeOff,
|
||||
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: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:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Formats the selection of [editorState]: alignment, bold, italic,
|
||||
/// underline, superscript and subscript. Each button shows whether the
|
||||
/// selection has its format already.
|
||||
/// underline, superscript and subscript, and with [onFormula] formulas
|
||||
/// within the line. Each button shows whether the selection has its format
|
||||
/// already. With [onInsert], an Insert button comes first, for blocks.
|
||||
///
|
||||
/// The bar takes no focus, so tapping a button leaves the editor focused and
|
||||
/// its selection where it was; on phones, where there are no shortcut keys,
|
||||
/// it is the way to format at all. It scrolls sideways when narrow.
|
||||
class const FormatBar({required final EditorState editorState, super.key})
|
||||
extends StatefulWidget {
|
||||
class const FormatBar({
|
||||
required final EditorState editorState,
|
||||
|
||||
/// Called by the Insert button, which shows only with it.
|
||||
final VoidCallback? onInsert,
|
||||
|
||||
/// Called by the formula button (Σ), which shows only with it.
|
||||
final VoidCallback? onFormula,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
/// How tall the bar is, so what docks it can make room for it.
|
||||
static const height = 44.0;
|
||||
|
||||
@override
|
||||
State<FormatBar> createState() => _FormatBarState();
|
||||
}
|
||||
|
|
@ -106,65 +119,84 @@ class _FormatBarState() extends State<FormatBar> {
|
|||
final l10n = AppLocalizations.of(context);
|
||||
final editorState = widget.editorState;
|
||||
return ExcludeFocus(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
_FormatButton(
|
||||
icon: LucideIcons.textAlignStart,
|
||||
tooltip: l10n.formatAlignLeft,
|
||||
active: _isAligned('left'),
|
||||
onPressed: () => _align('left'),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.textAlignCenter,
|
||||
tooltip: l10n.formatAlignCenter,
|
||||
active: _isAligned('center'),
|
||||
onPressed: () => _align('center'),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.textAlignEnd,
|
||||
tooltip: l10n.formatAlignRight,
|
||||
active: _isAligned('right'),
|
||||
onPressed: () => _align('right'),
|
||||
),
|
||||
const _Divider(),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.bold,
|
||||
tooltip: l10n.formatBold,
|
||||
active: _has(AppFlowyRichTextKeys.bold),
|
||||
onPressed: () =>
|
||||
editorState.toggleAttribute(AppFlowyRichTextKeys.bold),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.italic,
|
||||
tooltip: l10n.formatItalic,
|
||||
active: _has(AppFlowyRichTextKeys.italic),
|
||||
onPressed: () =>
|
||||
editorState.toggleAttribute(AppFlowyRichTextKeys.italic),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.underline,
|
||||
tooltip: l10n.formatUnderline,
|
||||
active: _has(AppFlowyRichTextKeys.underline),
|
||||
onPressed: () =>
|
||||
editorState.toggleAttribute(AppFlowyRichTextKeys.underline),
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
child: SizedBox(
|
||||
height: FormatBar.height,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.onInsert case final onInsert?) ...[
|
||||
_FormatButton(
|
||||
icon: LucideIcons.plus,
|
||||
tooltip: l10n.editorInsert,
|
||||
active: false,
|
||||
onPressed: onInsert,
|
||||
),
|
||||
const _Divider(),
|
||||
],
|
||||
_FormatButton(
|
||||
icon: LucideIcons.textAlignStart,
|
||||
tooltip: l10n.formatAlignLeft,
|
||||
active: _isAligned('left'),
|
||||
onPressed: () => _align('left'),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.textAlignCenter,
|
||||
tooltip: l10n.formatAlignCenter,
|
||||
active: _isAligned('center'),
|
||||
onPressed: () => _align('center'),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.textAlignEnd,
|
||||
tooltip: l10n.formatAlignRight,
|
||||
active: _isAligned('right'),
|
||||
onPressed: () => _align('right'),
|
||||
),
|
||||
const _Divider(),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.bold,
|
||||
tooltip: l10n.formatBold,
|
||||
active: _has(AppFlowyRichTextKeys.bold),
|
||||
onPressed: () =>
|
||||
editorState.toggleAttribute(AppFlowyRichTextKeys.bold),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.italic,
|
||||
tooltip: l10n.formatItalic,
|
||||
active: _has(AppFlowyRichTextKeys.italic),
|
||||
onPressed: () =>
|
||||
editorState.toggleAttribute(AppFlowyRichTextKeys.italic),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.underline,
|
||||
tooltip: l10n.formatUnderline,
|
||||
active: _has(AppFlowyRichTextKeys.underline),
|
||||
onPressed: () =>
|
||||
editorState.toggleAttribute(AppFlowyRichTextKeys.underline),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
176
halkyon_learn_flutter/lib/src/core/editor/math_block.dart
Normal file
176
halkyon_learn_flutter/lib/src/core/editor/math_block.dart
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
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:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/formula_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/math_view.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A formula on a line of its own, written on its own screen
|
||||
/// ([editFormula]) and inserted once saved; a tap on it in the editor opens
|
||||
/// it again.
|
||||
final mathBlock = InsertableBlock(
|
||||
name: (l10n) => l10n.blockFormula,
|
||||
icon: LucideIcons.sigma,
|
||||
group: InsertGroup.blocks,
|
||||
keywords: const ['formula', 'math', 'equation', 'latex', 'tex'],
|
||||
insert: (context, editorState, at) async {
|
||||
final formula = await editFormula(
|
||||
context,
|
||||
initial: '',
|
||||
title: AppLocalizations.of(context).formulaTitle,
|
||||
);
|
||||
if (formula == null || formula.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
await insertBlock(
|
||||
editorState,
|
||||
Node(type: mathBlockType, attributes: {mathFormulaKey: formula}),
|
||||
at: at,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/// How formulas on their own lines are built.
|
||||
final Map<String, BlockComponentBuilder> mathBlockBuilders = {
|
||||
mathBlockType: _MathBlockBuilder(),
|
||||
};
|
||||
|
||||
/// Makes the selection of [editorState] a formula within its line, or
|
||||
/// plain text again if it is one; at a caret, writes a new formula on its
|
||||
/// own screen and puts it there. A formula within a line is text marked
|
||||
/// [inlineMathKey], its text the LaTeX: written as its source, shown set.
|
||||
Future<void> toggleInlineMath(
|
||||
BuildContext context,
|
||||
EditorState editorState,
|
||||
) async {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
if (!selection.isCollapsed) {
|
||||
await editorState.toggleAttribute(inlineMathKey);
|
||||
return;
|
||||
}
|
||||
final formula = await editFormula(
|
||||
context,
|
||||
initial: '',
|
||||
title: AppLocalizations.of(context).formulaInLineTitle,
|
||||
);
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
if (formula == null || formula.trim().isEmpty || node?.delta == null) {
|
||||
editorState.selection = selection;
|
||||
return;
|
||||
}
|
||||
final offset = selection.end.offset;
|
||||
final transaction = editorState.transaction
|
||||
..insertText(node!, offset, formula, attributes: {inlineMathKey: true})
|
||||
..afterSelection = Selection.collapsed(
|
||||
Position(path: node.path, offset: offset + formula.length),
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
/// [base], the way marked text is drawn, with formulas within lines drawn
|
||||
/// too: while [editing], as their LaTeX in the code font, which the caret
|
||||
/// moves through as text; else set, as a widget within the line.
|
||||
TextSpanDecoratorForAttribute withInlineMath(
|
||||
TextSpanDecoratorForAttribute base, {
|
||||
required bool editing,
|
||||
}) => (context, node, index, text, before, after) {
|
||||
final span = base(context, node, index, text, before, after);
|
||||
if (text.attributes?[inlineMathKey] != true) {
|
||||
return span;
|
||||
}
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final style = span.style ?? before.style ?? const TextStyle();
|
||||
if (editing) {
|
||||
return TextSpan(
|
||||
text: text.text,
|
||||
style: style.copyWith(fontFamily: 'monospace', color: colors.primary),
|
||||
);
|
||||
}
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: MathView(
|
||||
formula: text.text,
|
||||
display: false,
|
||||
fontSize: style.fontSize,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
class _MathBlockBuilder() extends AtomicBlockBuilder {
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
final node = blockComponentContext.node;
|
||||
return _MathBlock(key: node.key, node: node, configuration: configuration);
|
||||
}
|
||||
}
|
||||
|
||||
class const _MathBlock({
|
||||
required super.node,
|
||||
required super.configuration,
|
||||
super.key,
|
||||
}) extends BlockComponentStatefulWidget {
|
||||
@override
|
||||
State<_MathBlock> createState() => _MathBlockState();
|
||||
}
|
||||
|
||||
class _MathBlockState()
|
||||
extends State<_MathBlock>
|
||||
with SelectableMixin, BlockComponentConfigurable, AtomicBlock<_MathBlock> {
|
||||
@override
|
||||
BlockComponentConfiguration get configuration => widget.configuration;
|
||||
|
||||
@override
|
||||
Node get node => widget.node;
|
||||
|
||||
Future<void> _edit() async {
|
||||
final formula = await editFormula(
|
||||
context,
|
||||
initial: node.attributes[mathFormulaKey] as String? ?? '',
|
||||
title: AppLocalizations.of(context).formulaTitle,
|
||||
);
|
||||
if (formula != null && mounted) {
|
||||
final transaction = editorState.transaction
|
||||
..updateNode(node, {mathFormulaKey: formula});
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final formula = node.attributes[mathFormulaKey] as String? ?? '';
|
||||
final Widget shown = formula.trim().isEmpty
|
||||
? BlockNotice(icon: LucideIcons.sigma, text: l10n.formulaEmpty)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: MathView(formula: formula),
|
||||
);
|
||||
return buildAtomic(
|
||||
removeLabel: l10n.formulaRemove,
|
||||
child: !editing
|
||||
? shown
|
||||
: Tooltip(
|
||||
message: l10n.formulaEdit,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: _edit,
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: shown,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue