feat(editor): links, and copying and pasting with every format

Text gets a link from the format bar's new link button or with Ctrl+K
(⌘K): the address for the selection, an empty one taking it away, or at
a caret the text as well, written there linked. An address without its
scheme leads to the web. This replaces the editor's own link menu, which
had no button and spoke English only.

Copying and pasting between the app's editors keeps every format: bold,
links, furigana, formulas, callouts, spoilers. The system's clipboard
holds plain text only (the editor's own copy drops the HTML it makes),
so the blocks copied last stay in memory beside it, and a paste takes
them while the clipboard still holds their text; anything else pastes as
plain text, as before, and other apps get plain text. On phones, which
had no way to copy at all, a bar over selected text copies, cuts, pastes
and selects all (the editor's MobileFloatingToolbar), never over a block
taken whole, whose own buttons a tap is for.

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

View file

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

View file

@ -750,6 +750,13 @@
"formatSuperscript": "Hochgestellt", "formatSuperscript": "Hochgestellt",
"formatSubscript": "Tiefgestellt", "formatSubscript": "Tiefgestellt",
"formatRuby": "Furigana (eine Lesung über dem Text)", "formatRuby": "Furigana (eine Lesung über dem Text)",
"formatLink": "Link",
"linkTitle": "Link",
"linkText": "Text",
"linkAddress": "Adresse",
"linkAddressHint": "https://…",
"linkSave": "Speichern",
"linkRemove": "Link entfernen",
"rubyTitle": "Furigana", "rubyTitle": "Furigana",
"rubyReading": "Lesung", "rubyReading": "Lesung",
"rubyReadingHint": "In Kana, z. B. かんじ", "rubyReadingHint": "In Kana, z. B. かんじ",

View file

@ -828,6 +828,13 @@
"formatSuperscript": "Superscript", "formatSuperscript": "Superscript",
"formatSubscript": "Subscript", "formatSubscript": "Subscript",
"formatRuby": "Furigana (a reading above the text)", "formatRuby": "Furigana (a reading above the text)",
"formatLink": "Link",
"linkTitle": "Link",
"linkText": "Text",
"linkAddress": "Address",
"linkAddressHint": "https://…",
"linkSave": "Save",
"linkRemove": "Remove link",
"rubyTitle": "Furigana", "rubyTitle": "Furigana",
"rubyReading": "Reading", "rubyReading": "Reading",
"rubyReadingHint": "In kana, e.g. かんじ", "rubyReadingHint": "In kana, e.g. かんじ",

View file

@ -4432,6 +4432,48 @@ abstract class AppLocalizations {
/// **'Furigana (a reading above the text)'** /// **'Furigana (a reading above the text)'**
String get formatRuby; String get formatRuby;
/// No description provided for @formatLink.
///
/// In en, this message translates to:
/// **'Link'**
String get formatLink;
/// No description provided for @linkTitle.
///
/// In en, this message translates to:
/// **'Link'**
String get linkTitle;
/// No description provided for @linkText.
///
/// In en, this message translates to:
/// **'Text'**
String get linkText;
/// No description provided for @linkAddress.
///
/// In en, this message translates to:
/// **'Address'**
String get linkAddress;
/// No description provided for @linkAddressHint.
///
/// In en, this message translates to:
/// **'https://…'**
String get linkAddressHint;
/// No description provided for @linkSave.
///
/// In en, this message translates to:
/// **'Save'**
String get linkSave;
/// No description provided for @linkRemove.
///
/// In en, this message translates to:
/// **'Remove link'**
String get linkRemove;
/// No description provided for @rubyTitle. /// No description provided for @rubyTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -2749,6 +2749,27 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get formatRuby => 'Furigana (eine Lesung über dem Text)'; String get formatRuby => 'Furigana (eine Lesung über dem Text)';
@override
String get formatLink => 'Link';
@override
String get linkTitle => 'Link';
@override
String get linkText => 'Text';
@override
String get linkAddress => 'Adresse';
@override
String get linkAddressHint => 'https://…';
@override
String get linkSave => 'Speichern';
@override
String get linkRemove => 'Link entfernen';
@override @override
String get rubyTitle => 'Furigana'; String get rubyTitle => 'Furigana';

View file

@ -2726,6 +2726,27 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get formatRuby => 'Furigana (a reading above the text)'; String get formatRuby => 'Furigana (a reading above the text)';
@override
String get formatLink => 'Link';
@override
String get linkTitle => 'Link';
@override
String get linkText => 'Text';
@override
String get linkAddress => 'Address';
@override
String get linkAddressHint => 'https://…';
@override
String get linkSave => 'Save';
@override
String get linkRemove => 'Remove link';
@override @override
String get rubyTitle => 'Furigana'; String get rubyTitle => 'Furigana';

View file

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

View file

@ -3,13 +3,14 @@ import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart'; import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/link.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart'; import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart'; import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// Formats the selection of [editorState]: alignment, bold, italic, /// Formats the selection of [editorState]: alignment, bold, italic,
/// underline, superscript and subscript, and with [onFormula] formulas /// underline, a link, superscript and subscript, furigana, and with [onFormula] formulas
/// within the line. Each button shows whether the selection has its format /// within the line. Each button shows whether the selection has its format
/// already. With [onInsert], an Insert button comes first, for blocks. /// already. With [onInsert], an Insert button comes first, for blocks.
/// ///
@ -176,6 +177,12 @@ class _FormatBarState() extends State<FormatBar> {
onPressed: () => onPressed: () =>
editorState.toggleAttribute(AppFlowyRichTextKeys.underline), editorState.toggleAttribute(AppFlowyRichTextKeys.underline),
), ),
_FormatButton(
icon: LucideIcons.link,
tooltip: l10n.formatLink,
active: hasLink(editorState),
onPressed: () => editLink(context, editorState),
),
const _Divider(), const _Divider(),
_FormatButton( _FormatButton(
icon: LucideIcons.superscript, icon: LucideIcons.superscript,

View file

@ -0,0 +1,172 @@
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/widget/text_limits.dart';
import 'package:material_ui/material_ui.dart';
// Links: text marked with the address it leads to (the editor's `href`),
// opened when tapped where the text is shown. Given from the format bar's
// link button or with Ctrl+K (⌘K on a Mac), in place of the editor's own
// link menu, which shows in English only.
/// Ctrl+K (⌘K): gives the selection a link, or writes linked text at the
/// caret ([editLink]), asking in [context].
CommandShortcutEvent linkShortcut(BuildContext context) => CommandShortcutEvent(
key: 'link',
getDescription: () => AppLocalizations.of(context).formatLink,
command: 'ctrl+k',
macOSCommand: 'cmd+k',
handler: (editorState) {
if (editorState.selection == null) {
return KeyEventResult.ignored;
}
unawaited(editLink(context, editorState));
return KeyEventResult.handled;
},
);
/// Whether all of the selection of [editorState] leads somewhere.
bool hasLink(EditorState editorState) =>
editorState.selection != null &&
editorState.getDeltaAttributeValueInSelection<String>(
AppFlowyRichTextKeys.href,
) !=
null;
/// Gives the text selected in [editorState] a link, asked for starting from
/// the one it has; an empty address takes it away. At a caret, asks for the
/// text as well, and writes it there linked.
Future<void> editLink(BuildContext context, EditorState editorState) async {
final selection = editorState.selection;
if (selection == null) {
return;
}
final current = selection.isCollapsed
? null
: editorState.getDeltaAttributeValueInSelection<String>(
AppFlowyRichTextKeys.href,
);
final link = await showDialog<({String text, String address})>(
context: context,
builder: (context) => _LinkDialog(
address: current ?? '',
asksText: selection.isCollapsed,
),
);
editorState.selection = selection;
if (link == null) {
return;
}
final address = linkAddress(link.address);
if (!selection.isCollapsed) {
await editorState.formatDelta(selection, {
AppFlowyRichTextKeys.href: address,
});
return;
}
final node = editorState.getNodeAtPath(selection.end.path);
final text = link.text.trim();
if (node?.delta == null || address == null || text.isEmpty) {
return;
}
final transaction = editorState.transaction
..insertText(
node!,
selection.end.offset,
text,
attributes: {AppFlowyRichTextKeys.href: address},
)
..afterSelection = Selection.collapsed(
Position(path: node.path, offset: selection.end.offset + text.length),
);
await editorState.apply(transaction);
}
/// The address [typed] leads to: as typed where it names its scheme
/// (`https:`, `mailto:`…), else on the web (`https://`); null for none.
String? linkAddress(String typed) {
final address = typed.trim();
if (address.isEmpty) {
return null;
}
final hasScheme = RegExp('^[a-zA-Z][a-zA-Z0-9+.-]*:').hasMatch(address);
return hasScheme ? address : 'https://$address';
}
/// Asks for a link's address, and with [asksText] for the text it is on.
class const _LinkDialog({
required final String address,
required final bool asksText,
}) extends StatefulWidget {
@override
State<_LinkDialog> createState() => _LinkDialogState();
}
class _LinkDialogState() extends State<_LinkDialog> {
late final _text = TextEditingController();
late final _address = TextEditingController(text: widget.address);
@override
void dispose() {
_text.dispose();
_address.dispose();
super.dispose();
}
void _save() =>
Navigator.of(context).pop((text: _text.text, address: _address.text));
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(l10n.linkTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.asksText) ...[
TextField(
controller: _text,
autofocus: true,
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
decoration: InputDecoration(
labelText: l10n.linkText,
border: const OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
],
TextField(
controller: _address,
autofocus: !widget.asksText,
maxLength: TextLimits.address,
buildCounter: nearLimitCounter,
keyboardType: TextInputType.url,
autocorrect: false,
decoration: InputDecoration(
labelText: l10n.linkAddress,
hintText: l10n.linkAddressHint,
border: const OutlineInputBorder(),
),
onSubmitted: (_) => _save(),
),
],
),
actions: [
if (widget.address.isNotEmpty)
TextButton(
onPressed: () => Navigator.of(context).pop((text: '', address: '')),
child: Text(l10n.linkRemove),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.commonCancel),
),
FilledButton(onPressed: _save, child: Text(l10n.linkSave)),
],
);
}
}

View file

@ -2,13 +2,16 @@ import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/block_menu.dart'; import 'package:halkyon_learn_flutter/src/core/editor/block_menu.dart';
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.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/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_clipboard.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_style.dart'; import 'package:halkyon_learn_flutter/src/core/editor/editor_style.dart';
import 'package:halkyon_learn_flutter/src/core/editor/format_bar.dart'; import 'package:halkyon_learn_flutter/src/core/editor/format_bar.dart';
import 'package:halkyon_learn_flutter/src/core/editor/link.dart';
import 'package:halkyon_learn_flutter/src/core/editor/math_block.dart'; import 'package:halkyon_learn_flutter/src/core/editor/math_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -19,6 +22,9 @@ import 'package:material_ui/material_ui.dart';
/// With [formatBar], a [FormatBar] is docked below the editor while it has /// With [formatBar], a [FormatBar] is docked below the editor while it has
/// the focus, its Insert button first; the editor then takes what height is /// the focus, its Insert button first; the editor then takes what height is
/// left. On desktops, `/` opens the same blocks as Insert ([blocksOf]). /// left. On desktops, `/` opens the same blocks as Insert ([blocksOf]).
/// Copying and pasting keep the formats between the app's editors
/// ([EditorClipboard]): with the keys on desktops, and on phones from the
/// bar that shows over selected text (Copy, Cut, Paste, Select all).
class const RichContentEditor({ class const RichContentEditor({
/// The content the editor opens with; later changes to it are ignored. /// The content the editor opens with; later changes to it are ignored.
required final RichContent initialContent, required final RichContent initialContent,
@ -50,6 +56,8 @@ class _RichContentEditorState() extends State<RichContentEditor> {
document: _editable(widget.initialContent.toDocument()), document: _editable(widget.initialContent.toDocument()),
); );
late final StreamSubscription<EditorTransactionValue> _changes; late final StreamSubscription<EditorTransactionValue> _changes;
// Shared with the bar over selected text on phones, which follows it.
late final _scroll = EditorScrollController(editorState: _editorState);
FocusNode? _ownFocus; FocusNode? _ownFocus;
var _focused = false; var _focused = false;
@ -77,6 +85,7 @@ class _RichContentEditorState() extends State<RichContentEditor> {
_focus.removeListener(_onFocus); _focus.removeListener(_onFocus);
_ownFocus?.dispose(); _ownFocus?.dispose();
unawaited(_changes.cancel()); unawaited(_changes.cancel());
_scroll.dispose();
_editorState.dispose(); _editorState.dispose();
super.dispose(); super.dispose();
} }
@ -95,6 +104,16 @@ class _RichContentEditorState() extends State<RichContentEditor> {
} }
} }
/// Whether the selection lies in text only, where the bar over it on
/// phones belongs.
bool get _inText {
final selection = _editorState.selection;
return selection != null &&
_editorState
.getNodesInSelection(selection)
.every((node) => node.delta != null);
}
/// Lets the person choose a block and inserts it where the caret was. /// Lets the person choose a block and inserts it where the caret was.
/// The caret comes back after the block, or where it was when nothing was /// The caret comes back after the block, or where it was when nothing was
/// inserted: the menu took the focus, and the editor's selection with it. /// inserted: the menu took the focus, and the editor's selection with it.
@ -128,10 +147,12 @@ class _RichContentEditorState() extends State<RichContentEditor> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final blocks = blocksOf(context); final blocks = blocksOf(context);
final editor = LayoutBuilder( final appFlowyEditor = LayoutBuilder(
builder: (context, constraints) => AppFlowyEditor( builder: (context, constraints) => AppFlowyEditor(
editorState: _editorState, editorState: _editorState,
editorScrollController: _scroll,
editorStyle: editorStyleOf(context), editorStyle: editorStyleOf(context),
commandShortcutEvents: clipboardShortcuts(link: linkShortcut(context)),
blockComponentBuilders: buildersWithUnknown( blockComponentBuilders: buildersWithUnknown(
blocks, blocks,
AppLocalizations.of(context), AppLocalizations.of(context),
@ -154,6 +175,40 @@ class _RichContentEditorState() extends State<RichContentEditor> {
: appFlowyEditorAutoScrollEdgeOffset, : appFlowyEditorAutoScrollEdgeOffset,
), ),
); );
final editor = switch (defaultTargetPlatform) {
TargetPlatform.android || TargetPlatform.iOS => MobileFloatingToolbar(
editorState: _editorState,
editorScrollController: _scroll,
floatingToolbarHeight: _selectionBarHeight,
toolbarBuilder: (context, anchor, close) => !_inText
// Over a block taken whole (a video, a formula…), its own
// buttons are what a tap is for.
? const SizedBox.shrink()
: AdaptiveTextSelectionToolbar.editable(
clipboardStatus: ClipboardStatus.pasteable,
onCopy: () {
EditorClipboard.copy(_editorState);
close();
},
onCut: () {
unawaited(EditorClipboard.cut(_editorState));
close();
},
onPaste: () {
unawaited(EditorClipboard.paste(_editorState));
close();
},
onSelectAll: () => selectAllCommand.execute(_editorState),
onLiveTextInput: null,
onLookUp: null,
onSearchWeb: null,
onShare: null,
anchors: TextSelectionToolbarAnchors(primaryAnchor: anchor),
),
child: appFlowyEditor,
),
_ => appFlowyEditor,
};
if (!widget.formatBar) { if (!widget.formatBar) {
return editor; return editor;
} }
@ -177,6 +232,9 @@ class _RichContentEditorState() extends State<RichContentEditor> {
} }
} }
/// How tall the bar over selected text on phones is.
const _selectionBarHeight = 44.0;
Document _editable(Document document) { Document _editable(Document document) {
if (document.root.children.isEmpty) { if (document.root.children.isEmpty) {
document.insert([0], [paragraphNode()]); document.insert([0], [paragraphNode()]);

View file

@ -0,0 +1,299 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.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/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_clipboard.dart';
import 'package:halkyon_learn_flutter/src/core/editor/link.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:material_ui/material_ui.dart';
/// Content of one line of [runs], each a text and its marks.
RichContent line(List<(String, Map<String, Object?>)> runs) => RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
for (final (text, marks) in runs)
{'insert': text, if (marks.isNotEmpty) 'attributes': marks},
],
},
},
],
},
});
/// The runs of [content]'s first line: each its text and its marks.
List<List<Object?>> runsOf(RichContent content) {
final document = content.editorJson['document']! as Map<String, Object?>;
final first = (document['children']! as List).first as Map;
return [
for (final op in (first['data']! as Map)['delta']! as List)
run((op as Map)['insert']! as String, {
...?(op['attributes'] as Map<String, Object?>?),
}),
];
}
/// A run of text with its marks, as [runsOf] gives it.
List<Object?> run(String text, [Map<String, Object?> marks = const {}]) => [
text,
marks,
];
void main() {
/// What the system's clipboard holds, as the app's editors left it.
String? systemClipboard;
setUp(() {
systemClipboard = null;
EditorClipboard.clear();
});
/// Pumps an editor for each of [contents], one above the other, and gives
/// back what each holds after every change, by index.
Future<List<RichContent>> edit(
WidgetTester tester,
List<RichContent> contents,
) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async => switch (call.method) {
'Clipboard.setData' => () {
systemClipboard = (call.arguments as Map)['text'] as String?;
return null;
}(),
'Clipboard.getData' => {'text': systemClipboard},
_ => null,
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
final latest = [...contents];
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Scaffold(
body: Column(
children: [
for (final (index, content) in contents.indexed)
SizedBox(
height: 200,
child: RichContentEditor(
initialContent: content,
onChanged: (content) => latest[index] = content,
formatBar: true,
),
),
],
),
),
),
);
await tester.pump(const Duration(seconds: 1));
return latest;
}
EditorState editorAt(WidgetTester tester, int index) => tester
.widget<AppFlowyEditor>(find.byType(AppFlowyEditor).at(index))
.editorState;
/// Focuses the editor [index] and selects [start] to [end] of its first
/// line.
Future<EditorState> select(
WidgetTester tester,
int index,
int start,
int end,
) async {
await tester.tap(find.byType(AppFlowyEditor).at(index));
await tester.pump(const Duration(seconds: 1));
final editorState = editorAt(tester, index)
..selection = Selection.single(
path: [0],
startOffset: start,
endOffset: end,
);
await tester.pump(const Duration(seconds: 1));
return editorState;
}
Future<void> shortcut(WidgetTester tester, LogicalKeyboardKey key) async {
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(key);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump(const Duration(seconds: 1));
}
group('links', () {
testWidgets('the link button gives the selection the address typed, on '
'the web without its scheme; Remove link takes it away', (
tester,
) async {
final latest = await edit(tester, [RichContent.plain('Tae Kim guide')]);
await select(tester, 0, 0, 7);
await tester.tap(find.byTooltip('Link'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'guidetojapanese.org');
await tester.tap(find.text('Save'));
await tester.pump(const Duration(seconds: 1));
expect(runsOf(latest[0]), [
run('Tae Kim', {'href': 'https://guidetojapanese.org'}),
run(' guide'),
]);
await select(tester, 0, 0, 7);
await tester.tap(find.byTooltip('Link'));
await tester.pumpAndSettle();
expect(find.text('https://guidetojapanese.org'), findsOneWidget);
await tester.tap(find.text('Remove link'));
await tester.pump(const Duration(seconds: 1));
// The same text, unmarked (the editor may leave it in two runs).
expect(
[for (final [text, _] in runsOf(latest[0])) text].join(),
'Tae Kim guide',
);
expect(
[for (final [_, marks] in runsOf(latest[0])) marks],
everyElement(isEmpty),
);
});
testWidgets('at a caret, asks for the text too and writes it linked', (
tester,
) async {
final latest = await edit(tester, [RichContent.plain('See: ')]);
await select(tester, 0, 5, 5);
await tester.tap(find.byTooltip('Link'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).at(0), 'the guide');
await tester.enterText(
find.byType(TextField).at(1),
'mailto:tae@example.org',
);
await tester.tap(find.text('Save'));
await tester.pump(const Duration(seconds: 1));
expect(runsOf(latest[0]), [
run('See: '),
run('the guide', {'href': 'mailto:tae@example.org'}),
]);
});
testWidgets(
'Ctrl+K asks for the link of the selection',
(tester) async {
await edit(tester, [RichContent.plain('Tae Kim guide')]);
await select(tester, 0, 0, 7);
await shortcut(tester, LogicalKeyboardKey.keyK);
expect(find.widgetWithText(AlertDialog, 'Address'), findsOneWidget);
},
variant: TargetPlatformVariant.only(TargetPlatform.linux),
);
test('an address without its scheme leads to the web; none is none', () {
expect(linkAddress(' example.org/a '), 'https://example.org/a');
expect(linkAddress('http://example.org'), 'http://example.org');
expect(linkAddress('mailto:a@example.org'), 'mailto:a@example.org');
expect(linkAddress(' '), isNull);
});
});
group('copying and pasting', () {
final formatted = line([
('学生', {rubyKey: 'がくせい'}),
('だ', {'bold': true}),
('。', {}),
]);
testWidgets(
'between the editors keeps every format, while the system clipboard '
'holds what was copied',
(tester) async {
final latest = await edit(tester, [formatted, RichContent.empty()]);
await select(tester, 0, 0, 4);
await shortcut(tester, LogicalKeyboardKey.keyC);
expect(systemClipboard, '学生だ。');
await select(tester, 1, 0, 0);
await shortcut(tester, LogicalKeyboardKey.keyV);
expect(runsOf(latest[1]), runsOf(formatted));
},
variant: TargetPlatformVariant.only(TargetPlatform.linux),
);
testWidgets(
'pastes plain text once the system clipboard holds something else',
(tester) async {
final latest = await edit(tester, [formatted, RichContent.empty()]);
await select(tester, 0, 0, 4);
await shortcut(tester, LogicalKeyboardKey.keyC);
// Copied in another app meanwhile.
systemClipboard = 'from elsewhere';
await select(tester, 1, 0, 0);
await shortcut(tester, LogicalKeyboardKey.keyV);
expect(runsOf(latest[1]), [run('from elsewhere')]);
},
variant: TargetPlatformVariant.only(TargetPlatform.linux),
);
testWidgets(
'cutting takes the selection out, to paste with its formats',
(tester) async {
final latest = await edit(tester, [formatted, RichContent.empty()]);
await select(tester, 0, 0, 3);
await shortcut(tester, LogicalKeyboardKey.keyX);
expect(runsOf(latest[0]), [run('。')]);
await select(tester, 1, 0, 0);
await shortcut(tester, LogicalKeyboardKey.keyV);
expect(runsOf(latest[1]), [
run('学生', {rubyKey: 'がくせい'}),
run('だ', {'bold': true}),
]);
},
variant: TargetPlatformVariant.only(TargetPlatform.linux),
);
testWidgets(
'on a phone, selected text shows a bar to copy it, with its formats',
(tester) async {
final latest = await edit(tester, [formatted, RichContent.empty()]);
await select(tester, 0, 0, 4);
await tester.pump(const Duration(seconds: 1));
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsOneWidget);
await tester.tap(find.text('Copy'));
await tester.pump(const Duration(seconds: 1));
expect(systemClipboard, '学生だ。');
await select(tester, 1, 0, 0);
await EditorClipboard.paste(editorAt(tester, 1));
await tester.pump(const Duration(seconds: 1));
expect(runsOf(latest[1]), runsOf(formatted));
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
});
}