refactor(learn): the editor's general parts move to halkyon_editor (ADR 0033, D37)
Todo stores editor documents too, so the document, the editor, its view, the format bar, links, the clipboard, scripts and EditorBlocks move, with their strings and tests, to a shared package on the same appflowy_editor fork. What is Learn's alone stays in Learn on top of it, and what the editor took from it directly is inverted: EditorBlocks gains textMarks, formatActions and onEditorClosed, RichContentEditor a wrapEditor, and an InsertableBlock's name takes the BuildContext. Learn's screens use LearnContentEditor and LearnContentView, which add Learn's blocks and the grammar check. Learn behaves as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
4875b26174
commit
648dfe4051
29 changed files with 1676 additions and 1366 deletions
31
packages/halkyon_editor/.gitignore
vendored
Normal file
31
packages/halkyon_editor/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
7
packages/halkyon_editor/CHANGELOG.md
Normal file
7
packages/halkyon_editor/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
## 0.1.0
|
||||
|
||||
* The general parts of Halkyon Learn's editor, for every Halkyon app:
|
||||
`RichContent`, `RichContentEditor`, `RichContentView`, `FormatBar`,
|
||||
`EditorBlocks` with `basicBlocks` and what an app adds to them, `AtomicBlock`,
|
||||
`EditorClipboard`, links, superscript and subscript, with their texts
|
||||
(English, German).
|
||||
65
packages/halkyon_editor/README.md
Normal file
65
packages/halkyon_editor/README.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# halkyon_editor
|
||||
|
||||
Halkyon's rich text, shared by its apps: the document as an app keeps it, the
|
||||
one editor and the one read-only view of it, built on
|
||||
[`appflowy_editor`](https://github.com/AppFlowy-IO/appflowy-editor) (our fork,
|
||||
pinned, as every app's), with their texts in British English, American English
|
||||
and German. Moved out of Halkyon Learn on 6 Nov 2026, when Halkyon Todo took
|
||||
editor documents too (ADR 0033, D37).
|
||||
|
||||
## Using it
|
||||
|
||||
An app installs `HalkyonEditorLocalizations.delegate` beside its other
|
||||
delegates, and halkyon_ui's, whose sheets the Insert menu opens in.
|
||||
|
||||
```dart
|
||||
// Stored: RichContent is the editor's document JSON and its format's version.
|
||||
final content = RichContent.plain('Buy milk'); // or RichContent.empty()
|
||||
content.plainText; // 'Buy milk'
|
||||
content.isBlank; // false
|
||||
final stored = content.toJson();
|
||||
RichContent.fromJson(stored);
|
||||
|
||||
RichContentEditor(
|
||||
initialContent: content,
|
||||
onChanged: (content) => save(content),
|
||||
formatBar: true,
|
||||
);
|
||||
RichContentView(content: content);
|
||||
```
|
||||
|
||||
## What it holds
|
||||
|
||||
- **`RichContent`** (`lib/src/content/`): pure Dart, the document in JSON.
|
||||
`content_blocks.dart` names every block's type and attributes across the
|
||||
apps, for `plainText` and for what a block's type is called for good.
|
||||
- **`RichContentEditor`, `RichContentView`** (`lib/src/editor/`): the editor,
|
||||
with the `FormatBar` docked below it while it has the focus, copying and
|
||||
pasting that keep the formats (`EditorClipboard`), links (`editLink`, Ctrl+K)
|
||||
and superscript and subscript; the view, display only, taking the height of
|
||||
its content.
|
||||
- **`EditorBlocks`**: what an editor knows. `basicBlocks` is what it knows
|
||||
without an app (the editor's own builders; headings, lists, a checklist, a
|
||||
quote, a divider and a table to insert). An app adds its own blocks, and
|
||||
passes them as `blocks:` or through `EditorBlocksScope`, with:
|
||||
- `builders`, `insertable` (the Insert sheet and the desktop's `/` menu show
|
||||
the same list) and `shortcuts`;
|
||||
- `textMarks`, how marks of its own are drawn, each wrapping the one before
|
||||
it, and `formatActions`, their buttons, after subscript;
|
||||
- `onEditorClosed`, for what its text left outside the editor.
|
||||
`plus` joins two sets, `offering` narrows what is offered and nothing else.
|
||||
- **`AtomicBlock`, `BlockNotice`**: for an app's block taken whole, selected
|
||||
and removed as one. A type nothing knows shows as needing a newer app, and
|
||||
stays as it is.
|
||||
|
||||
`RichContentEditor`'s `wrapEditor` puts what an app adds around the editor
|
||||
itself, knowing its state and focus (Learn's check of the spelling).
|
||||
|
||||
Editors are tested as in Learn: host one in a scrollable body with a slot of
|
||||
fixed height, `pump(const Duration(seconds: 1))` after changes, and find its
|
||||
text with `findRichText: true`.
|
||||
|
||||
## Licence
|
||||
|
||||
GNU Affero General Public License, version 3 or any later version
|
||||
(`AGPL-3.0-or-later`), as the other packages of the apps; see [LICENSE](LICENSE).
|
||||
|
|
@ -1,3 +1,19 @@
|
|||
/// Halkyon's rich text: the editor, the read-only view and the document
|
||||
/// they keep.
|
||||
library;
|
||||
|
||||
export 'l10n/halkyon_editor_localizations.dart';
|
||||
export 'src/content/content_blocks.dart';
|
||||
export 'src/content/rich_content.dart';
|
||||
export 'src/editor/atomic_block.dart';
|
||||
export 'src/editor/basic_blocks.dart';
|
||||
export 'src/editor/block_menu.dart';
|
||||
export 'src/editor/editor_blocks.dart';
|
||||
export 'src/editor/editor_clipboard.dart';
|
||||
export 'src/editor/editor_style.dart';
|
||||
export 'src/editor/format_bar.dart';
|
||||
export 'src/editor/link.dart';
|
||||
export 'src/editor/rich_content_document.dart';
|
||||
export 'src/editor/rich_content_editor.dart';
|
||||
export 'src/editor/rich_content_view.dart';
|
||||
export 'src/editor/scripts.dart';
|
||||
|
|
|
|||
91
packages/halkyon_editor/lib/src/content/content_blocks.dart
Normal file
91
packages/halkyon_editor/lib/src/content/content_blocks.dart
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// The blocks of ours in rich text, beyond the editor's own: their types and
|
||||
// the attributes they keep, in the editor's document JSON. Pure Dart, so
|
||||
// `RichContent` and the server read them as the editor writes them.
|
||||
//
|
||||
// Every block keeps its type for good: content with a type an app doesn't
|
||||
// know stays as it is there, shown as needing a newer app.
|
||||
|
||||
/// A sound: the person's file at `url` (`assetUrl`), played on a tap.
|
||||
const audioBlockType = 'audio';
|
||||
|
||||
/// On text within a line: its furigana (ruby), the reading of the text it
|
||||
/// is on (`かんじ` on `漢字`), shown above it.
|
||||
const rubyKey = 'ruby';
|
||||
|
||||
/// On the editor's own `image` block: the id of the PDF a screenshot was
|
||||
/// taken from; missing for any other image.
|
||||
const imagePdfKey = 'pdf';
|
||||
|
||||
/// On the editor's own `image` block: the page of that PDF, from 1.
|
||||
const imagePageKey = 'page';
|
||||
|
||||
/// A boxed aside of a kind (a note, a tip, a warning…), holding blocks of
|
||||
/// its own as children.
|
||||
const calloutBlockType = 'callout';
|
||||
|
||||
/// The callout's kind, one of [calloutKinds]; any other reads as `note`.
|
||||
const calloutKindKey = 'kind';
|
||||
|
||||
/// The callout's title; empty or missing for its kind's name.
|
||||
const calloutTitleKey = 'title';
|
||||
|
||||
/// Whether the callout starts folded, its blocks hidden until opened.
|
||||
const calloutFoldedKey = 'folded';
|
||||
|
||||
/// The kinds of callouts, in the order they are offered.
|
||||
const calloutKinds = [
|
||||
'note',
|
||||
'info',
|
||||
'tip',
|
||||
'success',
|
||||
'question',
|
||||
'warning',
|
||||
'danger',
|
||||
'quote',
|
||||
];
|
||||
|
||||
/// Text hidden until it is revealed, in the block's own `delta`.
|
||||
const spoilerBlockType = 'spoiler';
|
||||
|
||||
/// A formula set on its own line.
|
||||
const mathBlockType = 'math';
|
||||
|
||||
/// The formula in LaTeX, of a [mathBlockType] block.
|
||||
const mathFormulaKey = 'formula';
|
||||
|
||||
/// The text mark of a formula within a line: its text is the LaTeX.
|
||||
const inlineMathKey = 'math';
|
||||
|
||||
/// A YouTube video, played only once someone asks for it.
|
||||
const youtubeBlockType = 'youtube';
|
||||
|
||||
/// The video's id: the 11 characters YouTube names it by.
|
||||
const youtubeVideoKey = 'video';
|
||||
|
||||
/// Where the video starts, in whole seconds; missing for its beginning.
|
||||
const youtubeStartKey = 'start';
|
||||
|
||||
/// The caption shown in place of the video; optional.
|
||||
const youtubeCaptionKey = 'caption';
|
||||
|
||||
/// A button starting a study session of a studyset's cards.
|
||||
const studyButtonBlockType = 'study_button';
|
||||
|
||||
/// The id of the studyset studied.
|
||||
const studyButtonStudysetKey = 'studyset';
|
||||
|
||||
/// The id of the tag whose cards alone are studied; missing for all.
|
||||
const studyButtonTagKey = 'tag';
|
||||
|
||||
/// The name of that tag, to find it by once its id is gone (a tag folded
|
||||
/// into another of its name).
|
||||
const studyButtonTagNameKey = 'tagName';
|
||||
|
||||
/// The button's label; empty or missing for a plain "Study".
|
||||
const studyButtonLabelKey = 'label';
|
||||
|
||||
/// Whether the studyset's topics are studied too; true when missing.
|
||||
const studyButtonTopicsKey = 'topics';
|
||||
|
||||
/// Whether front/back and vocabulary cards ask their back first.
|
||||
const studyButtonBackFirstKey = 'backFirst';
|
||||
275
packages/halkyon_editor/lib/src/content/rich_content.dart
Normal file
275
packages/halkyon_editor/lib/src/content/rich_content.dart
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_editor/src/content/content_blocks.dart';
|
||||
|
||||
/// Rich text as the editor stores it: the editor's document JSON plus the
|
||||
/// version of that format, so stored content can be migrated when it changes.
|
||||
///
|
||||
/// Pure Dart on purpose: models hold it, the data layer encodes it into the
|
||||
/// content payload, and the server can read it without Flutter. The editor
|
||||
/// converts it to and from its own document type in `rich_content_document.dart`.
|
||||
///
|
||||
/// Made from the JSON the editor writes for a document, which it keeps as a
|
||||
/// deeply unmodifiable copy.
|
||||
final class RichContent(
|
||||
Map<String, Object?> editorJson, {
|
||||
|
||||
/// The version of the format [editorJson] is in.
|
||||
final int formatVersion = currentFormatVersion,
|
||||
}) with Equatable {
|
||||
/// A document without any blocks.
|
||||
factory empty() => RichContent(const {
|
||||
'document': {'type': 'page'},
|
||||
});
|
||||
|
||||
/// [text] as a document, a paragraph per line.
|
||||
factory plain(String text) => RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
for (final line in text.split('\n'))
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
if (line.isNotEmpty) {'insert': line},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/// Reads the JSON written by [toJson].
|
||||
///
|
||||
/// Throws a [FormatException] for anything else, including content in a
|
||||
/// newer format than this app knows.
|
||||
factory fromJson(Map<String, Object?> json) {
|
||||
final formatVersion = json['formatVersion'];
|
||||
final editorJson = json['editor'];
|
||||
if (formatVersion is! int || editorJson is! Map<String, Object?>) {
|
||||
throw FormatException('Not rich content.', json);
|
||||
}
|
||||
if (formatVersion > currentFormatVersion) {
|
||||
throw FormatException(
|
||||
'Rich content in format $formatVersion, but this app only knows '
|
||||
'formats up to $currentFormatVersion.',
|
||||
json,
|
||||
);
|
||||
}
|
||||
return RichContent(editorJson, formatVersion: formatVersion);
|
||||
}
|
||||
|
||||
/// The format this app writes.
|
||||
static const currentFormatVersion = 1;
|
||||
|
||||
/// The editor's document JSON, deeply unmodifiable.
|
||||
final Map<String, Object?> editorJson = _unmodifiableMap(editorJson);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [formatVersion, editorJson];
|
||||
|
||||
/// The text without its formatting, one line per block; for previews.
|
||||
/// A block's text kept beside its blocks counts too: a formula, a
|
||||
/// callout's title, a video's caption, a study button's label.
|
||||
String get plainText {
|
||||
final blocks = <String>[];
|
||||
void visit(Object? node) {
|
||||
if (node case {'data': final Map<String, Object?> data}) {
|
||||
if (data[_textKeys[node['type']]] case final String text) {
|
||||
blocks.add(text);
|
||||
}
|
||||
if (data case {'delta': final List<Object?> delta}) {
|
||||
blocks.add(
|
||||
[
|
||||
for (final operation in delta)
|
||||
if (operation case {'insert': final String text}) text,
|
||||
].join(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (node case {'children': final List<Object?> children}) {
|
||||
children.forEach(visit);
|
||||
}
|
||||
}
|
||||
|
||||
visit(editorJson['document']);
|
||||
return blocks.where((block) => block.isNotEmpty).join('\n');
|
||||
}
|
||||
|
||||
/// Whether it holds nothing: no text, and no block that shows something
|
||||
/// without text (an image, a sound, a video, a study button).
|
||||
bool get isBlank =>
|
||||
plainText.trim().isEmpty &&
|
||||
!_nodes.any((node) => _somethingWithoutText.contains(node['type']));
|
||||
|
||||
/// Whether it shows an image, the person's or another.
|
||||
bool get hasImages => _hasBlock('image');
|
||||
|
||||
/// Whether it plays a sound.
|
||||
bool get hasSounds => _hasBlock(audioBlockType);
|
||||
|
||||
/// Whether it plays a video.
|
||||
bool get hasVideos => _hasBlock(youtubeBlockType);
|
||||
|
||||
/// The person's files it shows or plays, by their asset rows' ids.
|
||||
Set<String> get assetIds => {
|
||||
for (final node in _nodes)
|
||||
if (node case {'data': {'url': final url}}) ?assetIdOf(url),
|
||||
};
|
||||
|
||||
/// The person's images it shows, by their asset rows' ids, in order and
|
||||
/// each once: what an assistant that reads images may be sent of it (ADR
|
||||
/// 0021, D12).
|
||||
List<String> get imageIds => {
|
||||
for (final node in _nodes)
|
||||
if (node case {'type': 'image', 'data': {'url': final url}})
|
||||
?assetIdOf(url),
|
||||
}.toList();
|
||||
|
||||
bool _hasBlock(String type) => _nodes.any((node) => node['type'] == type);
|
||||
|
||||
/// This content without its first block when that is a heading reading
|
||||
/// [text], whatever its case and spaces (decided 2026-10-16): a heading
|
||||
/// that repeats the title shown above it, as imported lessons often
|
||||
/// open. Unchanged otherwise.
|
||||
RichContent withoutHeading(String text) {
|
||||
String tidy(String text) =>
|
||||
text.trim().replaceAll(RegExp(r'\s+'), ' ').toLowerCase();
|
||||
if (editorJson['document']
|
||||
case {
|
||||
'children': [
|
||||
{
|
||||
'type': 'heading',
|
||||
'data': {'delta': final List<Object?> delta},
|
||||
},
|
||||
...final rest,
|
||||
],
|
||||
} &&
|
||||
final Map<String, Object?> document
|
||||
when tidy(text).isNotEmpty) {
|
||||
final heading = [
|
||||
for (final operation in delta)
|
||||
if (operation case {'insert': final String text}) text,
|
||||
].join();
|
||||
if (tidy(heading) == tidy(text)) {
|
||||
return RichContent({
|
||||
...editorJson,
|
||||
'document': {...document, 'children': rest},
|
||||
}, formatVersion: formatVersion);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Every block, depth first.
|
||||
Iterable<Map<String, Object?>> get _nodes sync* {
|
||||
Iterable<Map<String, Object?>> visit(Object? node) sync* {
|
||||
if (node is Map<String, Object?>) {
|
||||
yield node;
|
||||
if (node case {'children': final List<Object?> children}) {
|
||||
for (final child in children) {
|
||||
yield* visit(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield* visit(editorJson['document']);
|
||||
}
|
||||
|
||||
/// This content with [text] after it, a paragraph per line: as text
|
||||
/// taken from a PDF joins what is written. An empty last line takes the
|
||||
/// text rather than staying above it.
|
||||
RichContent withParagraphs(String text) {
|
||||
final document =
|
||||
editorJson['document'] as Map<String, Object?>? ??
|
||||
const {'type': 'page'};
|
||||
final children = [...?document['children'] as List<Object?>?];
|
||||
if (children.lastOrNull
|
||||
case {
|
||||
'type': 'paragraph',
|
||||
'data': {'delta': final List<Object?> delta},
|
||||
}
|
||||
when delta.isEmpty) {
|
||||
children.removeLast();
|
||||
}
|
||||
return RichContent({
|
||||
'document': {
|
||||
...document,
|
||||
'children': [
|
||||
...children,
|
||||
for (final line in text.split('\n'))
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
if (line.isNotEmpty) {'insert': line},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}, formatVersion: formatVersion);
|
||||
}
|
||||
|
||||
/// The JSON stored in the content payload.
|
||||
Map<String, Object?> toJson() => {
|
||||
'formatVersion': formatVersion,
|
||||
'editor': editorJson,
|
||||
};
|
||||
}
|
||||
|
||||
/// The URL naming the person's file [assetId] in content: an image's or a
|
||||
/// sound's (ADR 0004, rule 5).
|
||||
String assetUrl(String assetId) => '$_assetScheme$assetId';
|
||||
|
||||
/// The asset row [url] names, if it names one.
|
||||
String? assetIdOf(Object? url) =>
|
||||
url is String &&
|
||||
url.startsWith(_assetScheme) &&
|
||||
url.length > _assetScheme.length
|
||||
? url.substring(_assetScheme.length)
|
||||
: null;
|
||||
|
||||
const _assetScheme = 'asset:';
|
||||
|
||||
/// The person's files [json] (a payload's JSON, or a part of it) names: the
|
||||
/// `url` of every block that shows or plays one, as [RichContent.assetIds]
|
||||
/// finds them, at any depth.
|
||||
Set<String> assetIdsIn(Object? json) => switch (json) {
|
||||
final Map<String, Object?> map => {
|
||||
?assetIdOf(map['url']),
|
||||
for (final value in map.values) ...assetIdsIn(value),
|
||||
},
|
||||
final List<Object?> list => {for (final value in list) ...assetIdsIn(value)},
|
||||
_ => const {},
|
||||
};
|
||||
|
||||
/// The attribute holding the text of a block that keeps it beside its
|
||||
/// blocks, by the block's type.
|
||||
const _textKeys = {
|
||||
mathBlockType: mathFormulaKey,
|
||||
calloutBlockType: calloutTitleKey,
|
||||
youtubeBlockType: youtubeCaptionKey,
|
||||
studyButtonBlockType: studyButtonLabelKey,
|
||||
};
|
||||
|
||||
/// The blocks that show something without any text.
|
||||
const _somethingWithoutText = {
|
||||
'image',
|
||||
audioBlockType,
|
||||
youtubeBlockType,
|
||||
studyButtonBlockType,
|
||||
};
|
||||
|
||||
Map<String, Object?> _unmodifiableMap(Map<String, Object?> map) =>
|
||||
Map.unmodifiable({
|
||||
for (final MapEntry(:key, :value) in map.entries)
|
||||
key: _unmodifiableValue(value),
|
||||
});
|
||||
|
||||
Object? _unmodifiableValue(Object? value) => switch (value) {
|
||||
Map<String, Object?>() => _unmodifiableMap(value),
|
||||
List<Object?>() => List<Object?>.unmodifiable(value.map(_unmodifiableValue)),
|
||||
_ => value,
|
||||
};
|
||||
186
packages/halkyon_editor/lib/src/editor/atomic_block.dart
Normal file
186
packages/halkyon_editor/lib/src/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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
95
packages/halkyon_editor/lib/src/editor/basic_blocks.dart
Normal file
95
packages/halkyon_editor/lib/src/editor/basic_blocks.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:halkyon_editor/l10n/halkyon_editor_localizations.dart';
|
||||
import 'package:halkyon_editor/src/editor/editor_blocks.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The blocks every editor knows, an app's own or none beside them: the
|
||||
/// editor's own, and those of them people can insert.
|
||||
final basicBlocks = EditorBlocks(
|
||||
builders: standardBlockComponentBuilderMap,
|
||||
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: (context) => HalkyonEditorLocalizations.of(context).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: (context) => HalkyonEditorLocalizations.of(context).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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// The blocks of the editors and views below [context]: an app's
|
||||
/// ([EditorBlocksScope]), else [basicBlocks].
|
||||
EditorBlocks editorBlocksOf(BuildContext context) =>
|
||||
EditorBlocksScope.maybeOf(context) ?? basicBlocks;
|
||||
|
||||
/// A line of text of a kind, which the caret goes into.
|
||||
InsertableBlock _textBlock({
|
||||
required String Function(HalkyonEditorLocalizations l10n) name,
|
||||
required IconData icon,
|
||||
required List<String> keywords,
|
||||
required Node Function() node,
|
||||
}) => InsertableBlock(
|
||||
name: (context) => name(HalkyonEditorLocalizations.of(context)),
|
||||
icon: icon,
|
||||
group: InsertGroup.text,
|
||||
keywords: keywords,
|
||||
insert: (context, editorState, at) =>
|
||||
insertBlock(editorState, node(), at: at, caretIn: const []),
|
||||
);
|
||||
54
packages/halkyon_editor/lib/src/editor/block_menu.dart
Normal file
54
packages/halkyon_editor/lib/src/editor/block_menu.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import 'package:halkyon_editor/l10n/halkyon_editor_localizations.dart';
|
||||
import 'package:halkyon_editor/src/editor/editor_blocks.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Lets the person choose one of [blocks] to insert, listed by their
|
||||
/// [InsertGroup]; completes with it, or with null when they close the menu.
|
||||
Future<InsertableBlock?> chooseBlock(
|
||||
BuildContext context,
|
||||
List<InsertableBlock> blocks,
|
||||
) => showAdaptiveSheet<InsertableBlock>(
|
||||
context,
|
||||
builder: (context) => _BlockMenu(blocks: blocks),
|
||||
);
|
||||
|
||||
class const _BlockMenu({required final List<InsertableBlock> blocks})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonEditorLocalizations.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(context)),
|
||||
onTap: () => Navigator.of(context).pop(block),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
395
packages/halkyon_editor/lib/src/editor/editor_blocks.dart
Normal file
395
packages/halkyon_editor/lib/src/editor/editor_blocks.dart
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_editor/l10n/halkyon_editor_localizations.dart';
|
||||
import 'package:halkyon_editor/src/content/content_blocks.dart';
|
||||
import 'package:halkyon_editor/src/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(BuildContext context) name,
|
||||
|
||||
/// Its icon in the menus.
|
||||
required final IconData icon,
|
||||
|
||||
/// Where the Insert menu lists it.
|
||||
required final InsertGroup group,
|
||||
|
||||
/// Words besides its name that find it after a `/`.
|
||||
required final List<String> keywords,
|
||||
|
||||
/// Inserts it into the editor's state at the caret (null when there is
|
||||
/// none), with the editor's context, for what the block asks first.
|
||||
required final Future<void> Function(
|
||||
BuildContext context,
|
||||
EditorState editorState,
|
||||
Selection? at,
|
||||
)
|
||||
insert,
|
||||
|
||||
/// Whether it may go inside a callout; else it goes after the callout.
|
||||
final bool insideCallouts = true,
|
||||
|
||||
/// Whether the format bar has a button of its own for it too, at its
|
||||
/// end: for what the editor's place is for (a screenshot beside a PDF).
|
||||
final bool inFormatBar = false,
|
||||
});
|
||||
|
||||
/// A button an app adds to the format bar, after subscript: a mark of its
|
||||
/// own on the selection.
|
||||
final class const FormatAction({
|
||||
/// Its icon.
|
||||
required final IconData icon,
|
||||
|
||||
/// Its name, under the context of the bar.
|
||||
required final String Function(BuildContext context) tooltip,
|
||||
|
||||
/// Whether the selection has what it does already.
|
||||
required final bool Function(EditorState editorState) active,
|
||||
|
||||
/// Does it, with the editor's context, for what it asks first.
|
||||
required final Future<void> Function(
|
||||
BuildContext context,
|
||||
EditorState editorState,
|
||||
)
|
||||
run,
|
||||
|
||||
/// Whether the editor takes the focus back once it is done: for what asks
|
||||
/// on a screen of its own.
|
||||
final bool refocus = false,
|
||||
});
|
||||
|
||||
/// A way of drawing marked text, wrapping [base], the way so far; [editing]
|
||||
/// says whether the text is being written or only shown.
|
||||
typedef TextMark = TextSpanDecoratorForAttribute Function(
|
||||
TextSpanDecoratorForAttribute base, {
|
||||
required bool editing,
|
||||
});
|
||||
|
||||
/// 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; with them what an app adds around
|
||||
/// its blocks, marks within lines above all, which the package can't know.
|
||||
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 [],
|
||||
|
||||
/// How marks of the app's own are drawn, over superscript and subscript,
|
||||
/// each wrapping the one before it.
|
||||
final List<TextMark> textMarks = const [],
|
||||
|
||||
/// The format bar's buttons for those marks, in their order.
|
||||
final List<FormatAction> formatActions = const [],
|
||||
|
||||
/// Called as an editor goes, for what its text left outside it.
|
||||
final List<VoidCallback> onEditorClosed = 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],
|
||||
textMarks: [...textMarks, ...more.textMarks],
|
||||
formatActions: [...formatActions, ...more.formatActions],
|
||||
onEditorClosed: [...onEditorClosed, ...more.onEditorClosed],
|
||||
);
|
||||
|
||||
/// These blocks, but offering only [offered] to insert; what is in a
|
||||
/// document already is drawn all the same.
|
||||
EditorBlocks offering(List<InsertableBlock> offered) => EditorBlocks(
|
||||
builders: builders,
|
||||
insertable: offered,
|
||||
shortcuts: shortcuts,
|
||||
textMarks: textMarks,
|
||||
formatActions: formatActions,
|
||||
onEditorClosed: onEditorClosed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Gives the editors and views below the blocks of an app.
|
||||
class const EditorBlocksScope({
|
||||
required final EditorBlocks blocks,
|
||||
required super.child,
|
||||
super.key,
|
||||
}) extends InheritedWidget {
|
||||
/// The blocks the scope above [context] gives, if there is one.
|
||||
static EditorBlocks? maybeOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<EditorBlocksScope>()?.blocks;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(EditorBlocksScope oldWidget) =>
|
||||
blocks != oldWidget.blocks;
|
||||
}
|
||||
|
||||
/// The builders of [blocks] with a box for any type they don't know, as
|
||||
/// content from a newer app may hold: it says so, and the block stays.
|
||||
///
|
||||
/// The editor's own text blocks say what they are in the app's language
|
||||
/// ([l10n]) while empty: the editor's own placeholders follow a locale of
|
||||
/// their own, set for the whole process, in few languages and not British
|
||||
/// English.
|
||||
Map<String, BlockComponentBuilder> buildersWithUnknown(
|
||||
EditorBlocks blocks,
|
||||
HalkyonEditorLocalizations l10n,
|
||||
) => {
|
||||
for (final MapEntry(key: type, value: builder) in blocks.builders.entries)
|
||||
type: _withPlaceholder(builder, l10n),
|
||||
errorBlockComponentBuilderKey: _UnknownBlockBuilder(),
|
||||
};
|
||||
|
||||
/// [builder] alike, but saying what its block is in [l10n]'s language while
|
||||
/// empty, for the editor's own text blocks; others as they are.
|
||||
BlockComponentBuilder _withPlaceholder(
|
||||
BlockComponentBuilder builder,
|
||||
HalkyonEditorLocalizations l10n,
|
||||
) {
|
||||
BlockComponentConfiguration saying(String Function(Node node) text) =>
|
||||
builder.configuration.copyWith(placeholderText: text);
|
||||
return switch (builder) {
|
||||
final ParagraphBlockComponentBuilder paragraph =>
|
||||
ParagraphBlockComponentBuilder(
|
||||
// A space, not nothing, keeps an empty line its height.
|
||||
configuration: saying(
|
||||
(_) => switch (defaultTargetPlatform) {
|
||||
// Phones insert through the Insert button, not a `/` menu.
|
||||
TargetPlatform.android || TargetPlatform.iOS => ' ',
|
||||
_ => l10n.editorSlashHint,
|
||||
},
|
||||
),
|
||||
showPlaceholder: paragraph.showPlaceholder,
|
||||
),
|
||||
final TodoListBlockComponentBuilder todo => TodoListBlockComponentBuilder(
|
||||
configuration: saying((_) => l10n.blockChecklistItem),
|
||||
textStyleBuilder: todo.textStyleBuilder,
|
||||
iconBuilder: todo.iconBuilder,
|
||||
toggleChildrenTriggers: todo.toggleChildrenTriggers,
|
||||
),
|
||||
final BulletedListBlockComponentBuilder bulleted =>
|
||||
BulletedListBlockComponentBuilder(
|
||||
configuration: saying((_) => l10n.blockListItem),
|
||||
iconBuilder: bulleted.iconBuilder,
|
||||
),
|
||||
final NumberedListBlockComponentBuilder numbered =>
|
||||
NumberedListBlockComponentBuilder(
|
||||
configuration: saying((_) => l10n.blockListItem),
|
||||
iconBuilder: numbered.iconBuilder,
|
||||
),
|
||||
final QuoteBlockComponentBuilder quote => QuoteBlockComponentBuilder(
|
||||
configuration: saying((_) => l10n.blockQuote),
|
||||
iconBuilder: quote.iconBuilder,
|
||||
),
|
||||
final HeadingBlockComponentBuilder heading => HeadingBlockComponentBuilder(
|
||||
configuration: saying(
|
||||
(node) => l10n.blockHeading(
|
||||
node.attributes[HeadingBlockKeys.level] as int? ?? 1,
|
||||
),
|
||||
),
|
||||
textStyleBuilder: heading.textStyleBuilder,
|
||||
),
|
||||
_ => builder,
|
||||
};
|
||||
}
|
||||
|
||||
/// The `/` menu of [blocks] in place of the editor's own, whose items can't
|
||||
/// be translated and whose Image item makes images that never show. Desktop
|
||||
/// only, as the editor's (phones use the Insert button).
|
||||
CharacterShortcutEvent slashMenu(BuildContext context, EditorBlocks blocks) {
|
||||
return customSlashCommand(
|
||||
[
|
||||
// Named here, in the build: the menu asks later, outside any.
|
||||
for (final (block, name) in blocks.insertable.map(
|
||||
(block) => (block, block.name(context)),
|
||||
))
|
||||
SelectionMenuItem(
|
||||
getName: () => name,
|
||||
icon: (editorState, selected, style) => Icon(
|
||||
block.icon,
|
||||
size: 18,
|
||||
color: selected
|
||||
? style.selectionMenuItemSelectedIconColor
|
||||
: style.selectionMenuItemIconColor,
|
||||
),
|
||||
keywords: block.keywords,
|
||||
// The menu's context is in the overlay; the editor's is the one
|
||||
// with its surroundings. The menu goes first: it would stay above
|
||||
// everything until the block is in, a dialog asking for it too.
|
||||
handler: (editorState, menuService, menuContext) {
|
||||
menuService.dismiss();
|
||||
return block.insert(context, editorState, editorState.selection);
|
||||
},
|
||||
),
|
||||
],
|
||||
style: _slashMenuStyle(Theme.of(context).colorScheme),
|
||||
);
|
||||
}
|
||||
|
||||
/// The `/` menu in the theme's colours rather than the editor's own: on the
|
||||
/// surface Material gives menus, as the app's other menus are, the item the
|
||||
/// arrow keys reach tinted as a focused menu item is.
|
||||
SelectionMenuStyle _slashMenuStyle(ColorScheme colors) {
|
||||
// Material's state layer for a focused menu item: the text colour at 10%.
|
||||
final focused = Color.alphaBlend(
|
||||
colors.onSurface.withValues(alpha: 0.1),
|
||||
colors.surfaceContainer,
|
||||
);
|
||||
return SelectionMenuStyle(
|
||||
selectionMenuBackgroundColor: colors.surfaceContainer,
|
||||
selectionMenuItemTextColor: colors.onSurface,
|
||||
selectionMenuItemIconColor: colors.onSurfaceVariant,
|
||||
selectionMenuItemSelectedTextColor: colors.onSurface,
|
||||
selectionMenuItemSelectedIconColor: colors.onSurface,
|
||||
selectionMenuItemSelectedColor: focused,
|
||||
selectionMenuUnselectedLabelColor: colors.onSurfaceVariant,
|
||||
selectionMenuDividerColor: colors.outlineVariant,
|
||||
selectionMenuLinkBorderColor: colors.outline,
|
||||
selectionMenuInvalidLinkColor: colors.error,
|
||||
selectionMenuButtonColor: colors.primary,
|
||||
selectionMenuButtonTextColor: colors.onPrimary,
|
||||
selectionMenuButtonIconColor: colors.onPrimary,
|
||||
selectionMenuButtonBorderColor: colors.primary,
|
||||
selectionMenuTabIndicatorColor: colors.primary,
|
||||
);
|
||||
}
|
||||
|
||||
/// The character shortcuts of [blocks], then the editor's own, its `/`
|
||||
/// menu replaced by [slash].
|
||||
List<CharacterShortcutEvent> characterShortcutsOf(
|
||||
EditorBlocks blocks,
|
||||
CharacterShortcutEvent slash,
|
||||
) => [
|
||||
...blocks.shortcuts,
|
||||
for (final event in standardCharacterShortcutEvents)
|
||||
if (event.key != slash.key) event,
|
||||
slash,
|
||||
];
|
||||
|
||||
/// Inserts [block] at [at] (the caret; the end of the document without
|
||||
/// one): in place of the empty line the caret is on, else after the block
|
||||
/// the caret is in. A block may not go inside a callout unless
|
||||
/// [insideCallouts], and never inside a table: then it goes after them.
|
||||
///
|
||||
/// The caret then goes to [caretIn], a path inside the block (`[]` for the
|
||||
/// block itself, a heading say), or else to an empty line after the block.
|
||||
/// That line is added unless one follows already; with a [caretIn] only if
|
||||
/// [lineAfter], for a block that is typed into but can't be left by typing,
|
||||
/// such as a table.
|
||||
Future<void> insertBlock(
|
||||
EditorState editorState,
|
||||
Node block, {
|
||||
Selection? at,
|
||||
Path? caretIn,
|
||||
bool lineAfter = false,
|
||||
bool insideCallouts = true,
|
||||
}) async {
|
||||
final root = editorState.document.root;
|
||||
var current = at == null ? null : editorState.getNodeAtPath(at.end.path);
|
||||
// Up to a block directly in the page, or in a callout if allowed.
|
||||
while (current != null &&
|
||||
current.parent != null &&
|
||||
current.parent != root &&
|
||||
!(insideCallouts && current.parent!.type == calloutBlockType)) {
|
||||
current = current.parent;
|
||||
}
|
||||
final transaction = editorState.transaction;
|
||||
final Path path;
|
||||
final Node? after;
|
||||
if (current == null) {
|
||||
path = [root.children.length];
|
||||
after = null;
|
||||
} else if (isEmptyLine(current)) {
|
||||
path = current.path;
|
||||
after = current.next;
|
||||
transaction
|
||||
..insertNode(path, block)
|
||||
..deleteNode(current);
|
||||
} else {
|
||||
path = current.path.next;
|
||||
after = current.next;
|
||||
}
|
||||
if (current == null || !isEmptyLine(current)) {
|
||||
transaction.insertNode(path, block);
|
||||
}
|
||||
if ((caretIn == null || lineAfter) &&
|
||||
(after == null || !isEmptyLine(after))) {
|
||||
transaction.insertNode(path.next, paragraphNode());
|
||||
}
|
||||
transaction.afterSelection = Selection.collapsed(
|
||||
Position(path: caretIn == null ? path.next : [...path, ...caretIn]),
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
/// Whether [node] is an empty paragraph, nothing in it.
|
||||
bool isEmptyLine(Node node) =>
|
||||
node.type == ParagraphBlockKeys.type &&
|
||||
(node.delta?.isEmpty ?? false) &&
|
||||
node.children.isEmpty;
|
||||
|
||||
class _UnknownBlockBuilder() extends BlockComponentBuilder {
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
final node = blockComponentContext.node;
|
||||
return _UnknownBlock(
|
||||
key: node.key,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
BlockComponentValidate get validate =>
|
||||
(_) => true;
|
||||
}
|
||||
|
||||
/// A block this app can't show, from a newer one: it says so, and stays as
|
||||
/// it is unless the person removes it.
|
||||
class const _UnknownBlock({
|
||||
required super.node,
|
||||
required super.configuration,
|
||||
super.key,
|
||||
}) extends BlockComponentStatefulWidget {
|
||||
@override
|
||||
State<_UnknownBlock> createState() => _UnknownBlockState();
|
||||
}
|
||||
|
||||
class _UnknownBlockState()
|
||||
extends State<_UnknownBlock>
|
||||
with
|
||||
SelectableMixin,
|
||||
BlockComponentConfigurable,
|
||||
AtomicBlock<_UnknownBlock> {
|
||||
@override
|
||||
BlockComponentConfiguration get configuration => widget.configuration;
|
||||
|
||||
@override
|
||||
Node get node => widget.node;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonEditorLocalizations.of(context);
|
||||
return buildAtomic(
|
||||
removeLabel: l10n.blockRemove,
|
||||
child: BlockNotice(
|
||||
icon: LucideIcons.circleFadingArrowUp,
|
||||
text: l10n.blockUnknown,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
138
packages/halkyon_editor/lib/src/editor/editor_clipboard.dart
Normal file
138
packages/halkyon_editor/lib/src/editor/editor_clipboard.dart
Normal 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};
|
||||
64
packages/halkyon_editor/lib/src/editor/editor_style.dart
Normal file
64
packages/halkyon_editor/lib/src/editor/editor_style.dart
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_editor/src/editor/basic_blocks.dart';
|
||||
import 'package:halkyon_editor/src/editor/editor_blocks.dart';
|
||||
import 'package:halkyon_editor/src/editor/scripts.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The rich text's look at [context], editing or showing it: the theme's
|
||||
/// text and colours, in light and dark mode alike, and no padding of its
|
||||
/// own, as what holds the text pads it. The editor's defaults are black text
|
||||
/// and 100 dp beside it on desktops.
|
||||
///
|
||||
/// Phones get the touch variant, with handles to drag the selection. Both
|
||||
/// draw superscript and subscript text (`withScripts`) and the marks of
|
||||
/// [blocks] (of those above [context] without): marked or as their source
|
||||
/// while [editing], else set.
|
||||
/// The text is the theme's `bodyLarge` unless [textStyle] says otherwise.
|
||||
EditorStyle editorStyleOf(
|
||||
BuildContext context, {
|
||||
bool editing = true,
|
||||
TextStyle? textStyle,
|
||||
EditorBlocks? blocks,
|
||||
}) {
|
||||
final marks = (blocks ?? editorBlocksOf(context)).textMarks;
|
||||
TextSpanDecoratorForAttribute marked(TextSpanDecoratorForAttribute base) =>
|
||||
marks.fold(
|
||||
withScripts(base),
|
||||
(decorator, mark) => mark(decorator, editing: editing),
|
||||
);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final text = TextStyleConfiguration(
|
||||
text: (textStyle ?? theme.textTheme.bodyLarge!).copyWith(
|
||||
color: colors.onSurface,
|
||||
),
|
||||
href: TextStyle(
|
||||
color: colors.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
code: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
color: colors.onSurfaceVariant,
|
||||
backgroundColor: colors.surfaceContainerHighest,
|
||||
),
|
||||
);
|
||||
final selection = colors.primary.withValues(alpha: 0.25);
|
||||
return switch (defaultTargetPlatform) {
|
||||
TargetPlatform.android || TargetPlatform.iOS => EditorStyle.mobile(
|
||||
padding: EdgeInsets.zero,
|
||||
cursorColor: colors.primary,
|
||||
selectionColor: selection,
|
||||
dragHandleColor: colors.primary,
|
||||
textStyleConfiguration: text,
|
||||
textSpanDecorator: marked(mobileTextSpanDecoratorForAttribute),
|
||||
),
|
||||
_ => EditorStyle.desktop(
|
||||
padding: EdgeInsets.zero,
|
||||
cursorColor: colors.primary,
|
||||
selectionColor: selection,
|
||||
textStyleConfiguration: text,
|
||||
textSpanDecorator: marked(defaultTextSpanDecoratorForAttribute),
|
||||
),
|
||||
};
|
||||
}
|
||||
279
packages/halkyon_editor/lib/src/editor/format_bar.dart
Normal file
279
packages/halkyon_editor/lib/src/editor/format_bar.dart
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:halkyon_editor/l10n/halkyon_editor_localizations.dart';
|
||||
import 'package:halkyon_editor/src/editor/editor_blocks.dart';
|
||||
import 'package:halkyon_editor/src/editor/link.dart';
|
||||
import 'package:halkyon_editor/src/editor/scripts.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Formats the selection of [editorState]: alignment, bold, italic,
|
||||
/// underline, a link, superscript and subscript, and the app's own
|
||||
/// [actions]. Each button shows whether the selection has its format
|
||||
/// already. With [onInsert], an Insert button comes first, for blocks; the
|
||||
/// [pinned] blocks have buttons at the end, outside the scrolling, so they
|
||||
/// are always at hand.
|
||||
///
|
||||
/// The bar takes no focus, so tapping a button leaves the editor focused and
|
||||
/// its selection where it was; on phones, where there are no shortcut keys,
|
||||
/// it is the way to format at all. It scrolls sideways when narrow.
|
||||
class const FormatBar({
|
||||
required final EditorState editorState,
|
||||
|
||||
/// Called by the Insert button, which shows only with it.
|
||||
final VoidCallback? onInsert,
|
||||
|
||||
/// The app's own buttons, after subscript.
|
||||
final List<FormatAction> actions = const [],
|
||||
|
||||
/// Called with the action whose button was pressed.
|
||||
final ValueChanged<FormatAction>? onAction,
|
||||
|
||||
/// Blocks with a button of their own at the end.
|
||||
final List<InsertableBlock> pinned = const [],
|
||||
|
||||
/// Called with the pinned block whose button was pressed.
|
||||
final ValueChanged<InsertableBlock>? onPinned,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
/// How tall the bar is, so what docks it can make room for it.
|
||||
static const height = 44.0;
|
||||
|
||||
@override
|
||||
State<FormatBar> createState() => _FormatBarState();
|
||||
}
|
||||
|
||||
class _FormatBarState() extends State<FormatBar> {
|
||||
late StreamSubscription<EditorTransactionValue> _changes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_follow(widget.editorState);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FormatBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.editorState != widget.editorState) {
|
||||
_unfollow(oldWidget.editorState);
|
||||
_follow(widget.editorState);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unfollow(widget.editorState);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// The buttons show the formats of the selection: anew whenever it moves
|
||||
// or the document changes.
|
||||
void _follow(EditorState editorState) {
|
||||
editorState.selectionNotifier.addListener(_changed);
|
||||
_changes = editorState.transactionStream.listen((_) => _changed());
|
||||
}
|
||||
|
||||
void _unfollow(EditorState editorState) {
|
||||
editorState.selectionNotifier.removeListener(_changed);
|
||||
unawaited(_changes.cancel());
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every block in the selection is aligned [align]; no alignment
|
||||
/// is left.
|
||||
bool _isAligned(String align) {
|
||||
final editorState = widget.editorState;
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return false;
|
||||
}
|
||||
final nodes = editorState.getNodesInSelection(selection);
|
||||
return nodes.isNotEmpty &&
|
||||
nodes.every(
|
||||
(node) =>
|
||||
((node.attributes[blockComponentAlign] as String?) ?? 'left') ==
|
||||
align,
|
||||
);
|
||||
}
|
||||
|
||||
void _align(String align) {
|
||||
final editorState = widget.editorState;
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
editorState.updateNode(
|
||||
selection,
|
||||
(node) => node.copyWith(
|
||||
attributes: {...node.attributes, blockComponentAlign: align},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether all of the selection has the mark [key]; at a caret, whether
|
||||
/// the next text typed takes it.
|
||||
bool _has(String key) =>
|
||||
widget.editorState.selection != null &&
|
||||
(widget.editorState.getDeltaAttributeValueInSelection<bool>(key) ??
|
||||
false);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonEditorLocalizations.of(context);
|
||||
final editorState = widget.editorState;
|
||||
final formats = SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.onInsert case final onInsert?) ...[
|
||||
_FormatButton(
|
||||
icon: LucideIcons.plus,
|
||||
tooltip: l10n.editorInsert,
|
||||
active: false,
|
||||
onPressed: onInsert,
|
||||
),
|
||||
const _Divider(),
|
||||
],
|
||||
_FormatButton(
|
||||
icon: LucideIcons.textAlignStart,
|
||||
tooltip: l10n.formatAlignLeft,
|
||||
active: _isAligned('left'),
|
||||
onPressed: () => _align('left'),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.textAlignCenter,
|
||||
tooltip: l10n.formatAlignCenter,
|
||||
active: _isAligned('center'),
|
||||
onPressed: () => _align('center'),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.textAlignEnd,
|
||||
tooltip: l10n.formatAlignRight,
|
||||
active: _isAligned('right'),
|
||||
onPressed: () => _align('right'),
|
||||
),
|
||||
const _Divider(),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.bold,
|
||||
tooltip: l10n.formatBold,
|
||||
active: _has(AppFlowyRichTextKeys.bold),
|
||||
onPressed: () =>
|
||||
editorState.toggleAttribute(AppFlowyRichTextKeys.bold),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.italic,
|
||||
tooltip: l10n.formatItalic,
|
||||
active: _has(AppFlowyRichTextKeys.italic),
|
||||
onPressed: () =>
|
||||
editorState.toggleAttribute(AppFlowyRichTextKeys.italic),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.underline,
|
||||
tooltip: l10n.formatUnderline,
|
||||
active: _has(AppFlowyRichTextKeys.underline),
|
||||
onPressed: () =>
|
||||
editorState.toggleAttribute(AppFlowyRichTextKeys.underline),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.link,
|
||||
tooltip: l10n.formatLink,
|
||||
active: hasLink(editorState),
|
||||
onPressed: () => editLink(context, editorState),
|
||||
),
|
||||
const _Divider(),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.superscript,
|
||||
tooltip: l10n.formatSuperscript,
|
||||
active: _has(superscriptKey),
|
||||
onPressed: () => toggleScript(editorState, superscriptKey),
|
||||
),
|
||||
_FormatButton(
|
||||
icon: LucideIcons.subscript,
|
||||
tooltip: l10n.formatSubscript,
|
||||
active: _has(subscriptKey),
|
||||
onPressed: () => toggleScript(editorState, subscriptKey),
|
||||
),
|
||||
for (final action in widget.actions)
|
||||
_FormatButton(
|
||||
icon: action.icon,
|
||||
tooltip: action.tooltip(context),
|
||||
active: action.active(editorState),
|
||||
onPressed: () => widget.onAction?.call(action),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return ExcludeFocus(
|
||||
child: SizedBox(
|
||||
height: FormatBar.height,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: formats),
|
||||
if (widget.pinned.isNotEmpty) ...[
|
||||
const _Divider(),
|
||||
for (final block in widget.pinned)
|
||||
_FormatButton(
|
||||
icon: block.icon,
|
||||
tooltip: block.name(context),
|
||||
active: false,
|
||||
onPressed: () => widget.onPinned?.call(block),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A format's button: its icon tinted, on a faint fill, while [active].
|
||||
class const _FormatButton({
|
||||
required final IconData icon,
|
||||
required final String tooltip,
|
||||
required final bool active,
|
||||
required final VoidCallback onPressed,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: active ? colors.primary : colors.onSurfaceVariant,
|
||||
),
|
||||
tooltip: tooltip,
|
||||
isSelected: active,
|
||||
onPressed: onPressed,
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: active
|
||||
? colors.primary.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Between groups of buttons.
|
||||
class const _Divider() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 24,
|
||||
child: VerticalDivider(
|
||||
width: 12,
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
178
packages/halkyon_editor/lib/src/editor/link.dart
Normal file
178
packages/halkyon_editor/lib/src/editor/link.dart
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:halkyon_editor/l10n/halkyon_editor_localizations.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.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: () => HalkyonEditorLocalizations.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();
|
||||
// Marked as required while empty, as it is typed.
|
||||
late final _address = TextEditingController(text: widget.address)
|
||||
..addListener(() => setState(() {}));
|
||||
|
||||
@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 = HalkyonEditorLocalizations.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: requiredDecoration(
|
||||
context,
|
||||
InputDecoration(
|
||||
labelText: l10n.linkAddress,
|
||||
hintText: l10n.linkAddressHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
missing: _address.text.trim().isEmpty,
|
||||
),
|
||||
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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:halkyon_editor/src/content/rich_content.dart';
|
||||
|
||||
/// Opens [RichContent] in the editor.
|
||||
extension RichContentToDocument on RichContent {
|
||||
/// A new editor document with this content.
|
||||
///
|
||||
/// Content in an older format gets migrated here once there is a second
|
||||
/// format; until then, only the current one is accepted.
|
||||
Document toDocument() {
|
||||
if (formatVersion != RichContent.currentFormatVersion) {
|
||||
throw UnsupportedError(
|
||||
'No migration for rich content in format $formatVersion.',
|
||||
);
|
||||
}
|
||||
// A mutable deep copy: the editor keeps parts of the JSON it is given and
|
||||
// may change them in place, while RichContent's JSON is unmodifiable.
|
||||
final json = jsonDecode(jsonEncode(editorJson)) as Map<String, Object?>;
|
||||
_sizeTables(json['document']);
|
||||
return Document.fromJson(json);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sizes a table keeps, with what it takes where it has none.
|
||||
final _tableSizes = {
|
||||
TableBlockKeys.colDefaultWidth: TableDefaults.colWidth,
|
||||
TableBlockKeys.rowDefaultHeight: TableDefaults.rowHeight,
|
||||
TableBlockKeys.colMinimumWidth: TableDefaults.colMinimumWidth,
|
||||
};
|
||||
|
||||
/// Gives the tables in [node] their sizes as the editor reads them, which
|
||||
/// casts them to decimals and fails on whole numbers or none (checked with
|
||||
/// our fork of v6.2): JSON written elsewhere, such as imports and archives,
|
||||
/// may hold `40` where the editor itself writes `40.0`.
|
||||
void _sizeTables(Object? node) {
|
||||
if (node is! Map<String, Object?>) {
|
||||
return;
|
||||
}
|
||||
double? decimal(Object? size) => switch (size) {
|
||||
final num size => size.toDouble(),
|
||||
final String size => double.tryParse(size),
|
||||
_ => null,
|
||||
};
|
||||
if (node['data'] case final Map<String, Object?> data) {
|
||||
switch (node['type']) {
|
||||
case TableBlockKeys.type:
|
||||
for (final MapEntry(:key, value: fallback) in _tableSizes.entries) {
|
||||
data[key] = decimal(data[key]) ?? fallback;
|
||||
}
|
||||
if (decimal(data[TableBlockKeys.colsHeight]) case final height?) {
|
||||
data[TableBlockKeys.colsHeight] = height;
|
||||
}
|
||||
case TableCellBlockKeys.type:
|
||||
for (final key in [
|
||||
TableCellBlockKeys.width,
|
||||
TableCellBlockKeys.height,
|
||||
]) {
|
||||
if (decimal(data[key]) case final size?) {
|
||||
data[key] = size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node['children'] case final List<Object?> children) {
|
||||
children.forEach(_sizeTables);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the editor's document as [RichContent].
|
||||
extension DocumentToRichContent on Document {
|
||||
/// This document's content, in the current format.
|
||||
RichContent toRichContent() => RichContent(toJson());
|
||||
}
|
||||
289
packages/halkyon_editor/lib/src/editor/rich_content_editor.dart
Normal file
289
packages/halkyon_editor/lib/src/editor/rich_content_editor.dart
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_editor/l10n/halkyon_editor_localizations.dart';
|
||||
import 'package:halkyon_editor/src/content/rich_content.dart';
|
||||
import 'package:halkyon_editor/src/editor/basic_blocks.dart';
|
||||
import 'package:halkyon_editor/src/editor/block_menu.dart';
|
||||
import 'package:halkyon_editor/src/editor/editor_blocks.dart';
|
||||
import 'package:halkyon_editor/src/editor/editor_clipboard.dart';
|
||||
import 'package:halkyon_editor/src/editor/editor_style.dart';
|
||||
import 'package:halkyon_editor/src/editor/format_bar.dart';
|
||||
import 'package:halkyon_editor/src/editor/link.dart';
|
||||
import 'package:halkyon_editor/src/editor/rich_content_document.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// What an app puts around an editor: [editor] within whatever it adds,
|
||||
/// knowing the editor's state and focus (Learn's check of the spelling, say).
|
||||
typedef EditorWrapper = Widget Function(
|
||||
BuildContext context,
|
||||
EditorState editorState,
|
||||
FocusNode focusNode,
|
||||
Widget editor,
|
||||
);
|
||||
|
||||
/// Edits [initialContent] and reports each change as new [RichContent].
|
||||
///
|
||||
/// Empty content opens with an empty paragraph, so there is somewhere to type.
|
||||
/// 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
|
||||
/// left. On desktops, `/` opens the same blocks as Insert ([blocks]).
|
||||
/// 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({
|
||||
/// The content the editor opens with; later changes to it are ignored.
|
||||
required final RichContent initialContent,
|
||||
|
||||
/// Called with the whole content after every change to the document.
|
||||
required final ValueChanged<RichContent> onChanged,
|
||||
|
||||
/// Whether the editor takes the focus as it opens, its cursor at the end.
|
||||
final bool autofocus = false,
|
||||
|
||||
/// The editor's focus, to follow or move it; one of its own otherwise.
|
||||
final FocusNode? focusNode,
|
||||
|
||||
/// Whether a [FormatBar] shows below the editor while it has the focus.
|
||||
final bool formatBar = false,
|
||||
|
||||
/// The room around the text, within the editor's scrolling.
|
||||
final EdgeInsets padding = EdgeInsets.zero,
|
||||
|
||||
/// The blocks it knows; those above it ([editorBlocksOf]) without.
|
||||
final EditorBlocks? blocks,
|
||||
|
||||
/// Puts what an app adds around the editor itself, inside its bars.
|
||||
final EditorWrapper? wrapEditor,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
/// Whether an editor's popup (its `/` menu) has the focus for now: it
|
||||
/// gives the focus back to its editor as it closes, so the editor losing
|
||||
/// the focus meanwhile hasn't been left, and must stay.
|
||||
static bool get popupHasFocus => keepEditorFocusNotifier.shouldKeepFocus;
|
||||
|
||||
@override
|
||||
State<RichContentEditor> createState() => _RichContentEditorState();
|
||||
}
|
||||
|
||||
class _RichContentEditorState() extends State<RichContentEditor> {
|
||||
late final EditorState _editorState = EditorState(
|
||||
document: _editable(widget.initialContent.toDocument()),
|
||||
);
|
||||
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;
|
||||
var _focused = false;
|
||||
// As of the last build, for what happens outside one.
|
||||
late EditorBlocks _blocks;
|
||||
|
||||
FocusNode get _focus => widget.focusNode ?? (_ownFocus ??= FocusNode());
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_changes = _editorState.transactionStream.listen(_onTransaction);
|
||||
_focus.addListener(_onFocus);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(RichContentEditor oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.focusNode != widget.focusNode) {
|
||||
(oldWidget.focusNode ?? _ownFocus)?.removeListener(_onFocus);
|
||||
_focus.addListener(_onFocus);
|
||||
_onFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// What its text showed outside it may be under the pointer.
|
||||
for (final closed in _blocks.onEditorClosed) {
|
||||
closed();
|
||||
}
|
||||
_focus.removeListener(_onFocus);
|
||||
_ownFocus?.dispose();
|
||||
unawaited(_changes.cancel());
|
||||
_scroll.dispose();
|
||||
_editorState.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocus() {
|
||||
if (_focus.hasFocus != _focused) {
|
||||
setState(() => _focused = _focus.hasFocus);
|
||||
}
|
||||
}
|
||||
|
||||
void _onTransaction(EditorTransactionValue value) {
|
||||
final (time, transaction, _) = value;
|
||||
// Selection changes arrive as transactions too; only report the document.
|
||||
if (time == TransactionTime.after && transaction.operations.isNotEmpty) {
|
||||
widget.onChanged(_editorState.document.toRichContent());
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
Future<void> _insert() async {
|
||||
final at = _editorState.selection;
|
||||
final block = await chooseBlock(context, _blocks.insertable);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
await _insertBlock(block, at);
|
||||
}
|
||||
|
||||
/// Inserts [block], if any, at [at], and gives the editor its caret and
|
||||
/// focus back.
|
||||
Future<void> _insertBlock(InsertableBlock? block, Selection? at) async {
|
||||
if (block != null) {
|
||||
await block.insert(context, _editorState, at);
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (_editorState.selection == null && at != null) {
|
||||
_editorState.selection = at;
|
||||
}
|
||||
_focus.requestFocus();
|
||||
}
|
||||
|
||||
/// Does [action]; the editor's focus comes back after any screen of its.
|
||||
Future<void> _run(FormatAction action) async {
|
||||
await action.run(context, _editorState);
|
||||
if (action.refocus && mounted) {
|
||||
_focus.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final blocks = _blocks = widget.blocks ?? editorBlocksOf(context);
|
||||
final appFlowyEditor = LayoutBuilder(
|
||||
builder: (context, constraints) => AppFlowyEditor(
|
||||
editorState: _editorState,
|
||||
editorScrollController: _scroll,
|
||||
editorStyle: editorStyleOf(
|
||||
context,
|
||||
blocks: blocks,
|
||||
).copyWith(padding: widget.padding),
|
||||
commandShortcutEvents: clipboardShortcuts(link: linkShortcut(context)),
|
||||
blockComponentBuilders: buildersWithUnknown(
|
||||
blocks,
|
||||
HalkyonEditorLocalizations.of(context),
|
||||
),
|
||||
characterShortcutEvents: characterShortcutsOf(
|
||||
blocks,
|
||||
slashMenu(context, blocks),
|
||||
),
|
||||
autoFocus: widget.autofocus,
|
||||
focusNode: _focus,
|
||||
// The box the editor keeps around the selection while scrolling it
|
||||
// into view has to fit inside the editor: a taller one, as the
|
||||
// default is for a card's field, sticks out at both ends, and the
|
||||
// editor then scrolls up and down for ever, the app frozen.
|
||||
autoScrollEdgeOffset: constraints.hasBoundedHeight
|
||||
? math.min(
|
||||
appFlowyEditorAutoScrollEdgeOffset,
|
||||
constraints.maxHeight / 2,
|
||||
)
|
||||
: appFlowyEditorAutoScrollEdgeOffset,
|
||||
),
|
||||
);
|
||||
final wrapped =
|
||||
widget.wrapEditor?.call(
|
||||
context,
|
||||
_editorState,
|
||||
_focus,
|
||||
appFlowyEditor,
|
||||
) ??
|
||||
appFlowyEditor;
|
||||
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: wrapped,
|
||||
),
|
||||
_ => wrapped,
|
||||
};
|
||||
if (!widget.formatBar) {
|
||||
return editor;
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: editor),
|
||||
if (_focused) ...[
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
FormatBar(
|
||||
editorState: _editorState,
|
||||
onInsert: _insert,
|
||||
actions: blocks.formatActions,
|
||||
onAction: (action) => unawaited(_run(action)),
|
||||
pinned: [
|
||||
for (final block in blocks.insertable)
|
||||
if (block.inFormatBar) block,
|
||||
],
|
||||
onPinned: (block) =>
|
||||
unawaited(_insertBlock(block, _editorState.selection)),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How tall the bar over selected text on phones is.
|
||||
const _selectionBarHeight = 44.0;
|
||||
|
||||
Document _editable(Document document) {
|
||||
if (document.root.children.isEmpty) {
|
||||
document.insert([0], [paragraphNode()]);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
111
packages/halkyon_editor/lib/src/editor/rich_content_view.dart
Normal file
111
packages/halkyon_editor/lib/src/editor/rich_content_view.dart
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:halkyon_editor/l10n/halkyon_editor_localizations.dart';
|
||||
import 'package:halkyon_editor/src/content/rich_content.dart';
|
||||
import 'package:halkyon_editor/src/editor/basic_blocks.dart';
|
||||
import 'package:halkyon_editor/src/editor/editor_blocks.dart';
|
||||
import 'package:halkyon_editor/src/editor/editor_style.dart';
|
||||
import 'package:halkyon_editor/src/editor/rich_content_document.dart';
|
||||
|
||||
/// Shows [content] read-only: the one renderer for rich text wherever it is
|
||||
/// shown rather than edited, so every block looks the same everywhere.
|
||||
///
|
||||
/// Display only: its text can't be selected, and a tap on it goes to what
|
||||
/// surrounds it (a field opening in the composer, say); what a block holds
|
||||
/// to act on, such as a sound's button, still takes its taps. The editor's
|
||||
/// own selection would show a caret in text nobody can change and, on
|
||||
/// phones, open the keyboard for it.
|
||||
///
|
||||
/// Takes the height of its content, so it fits into scrolling lists.
|
||||
class const RichContentView({
|
||||
/// The content to show.
|
||||
required final RichContent content,
|
||||
|
||||
/// The text's style; the theme's `bodyLarge` without.
|
||||
final TextStyle? textStyle,
|
||||
|
||||
/// How paragraphs and headings align where the editor gave them no
|
||||
/// alignment of their own; at the start without. Lists, quotes and the
|
||||
/// other blocks keep the start: centred, a list's markers drift apart.
|
||||
final TextAlign? textAlign,
|
||||
|
||||
/// The blocks it knows; those above it ([editorBlocksOf]) without.
|
||||
final EditorBlocks? blocks,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<RichContentView> createState() => _RichContentViewState();
|
||||
}
|
||||
|
||||
class _RichContentViewState() extends State<RichContentView> {
|
||||
late EditorState _editorState = _readOnly(widget.content, widget.textAlign);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(RichContentView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.content != oldWidget.content ||
|
||||
widget.textAlign != oldWidget.textAlign) {
|
||||
// The old editor is still attached until this frame is built.
|
||||
final previous = _editorState;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => previous.dispose());
|
||||
_editorState = _readOnly(widget.content, widget.textAlign);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_editorState.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// The editor lays itself out in an overlay that needs a bounded height;
|
||||
// IntrinsicHeight gives it its content's height, as the editor's own example
|
||||
// does for editors in a list.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final blocks = widget.blocks ?? editorBlocksOf(context);
|
||||
return IntrinsicHeight(
|
||||
child: AppFlowyEditor(
|
||||
key: ObjectKey(_editorState),
|
||||
editorState: _editorState,
|
||||
editorStyle: editorStyleOf(
|
||||
context,
|
||||
editing: false,
|
||||
textStyle: widget.textStyle,
|
||||
blocks: blocks,
|
||||
),
|
||||
blockComponentBuilders: buildersWithUnknown(
|
||||
blocks,
|
||||
HalkyonEditorLocalizations.of(context),
|
||||
),
|
||||
editable: false,
|
||||
disableSelectionService: true,
|
||||
disableKeyboardService: true,
|
||||
shrinkWrap: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [content] to show, its top-level paragraphs and headings aligned as
|
||||
/// [textAlign] says where they don't say themselves. The editor lays a text
|
||||
/// block's text out only as wide as it is, so aligning the text alone moves
|
||||
/// nothing: the block's own `align` places it. The document is never saved.
|
||||
EditorState _readOnly(RichContent content, TextAlign? textAlign) {
|
||||
final document = content.toDocument();
|
||||
final align = switch (textAlign) {
|
||||
TextAlign.center => 'center',
|
||||
TextAlign.right => 'right',
|
||||
_ => null,
|
||||
};
|
||||
if (align != null) {
|
||||
for (final node in document.root.children) {
|
||||
if ((node.type == ParagraphBlockKeys.type ||
|
||||
node.type == HeadingBlockKeys.type) &&
|
||||
node.attributes[blockComponentAlign] == null) {
|
||||
node.updateAttributes({blockComponentAlign: align});
|
||||
}
|
||||
}
|
||||
}
|
||||
return EditorState(document: document)..editable = false;
|
||||
}
|
||||
64
packages/halkyon_editor/lib/src/editor/scripts.dart
Normal file
64
packages/halkyon_editor/lib/src/editor/scripts.dart
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The inline mark of superscript text, which the editor has none of: `true`
|
||||
/// on the text's attributes in the document.
|
||||
const superscriptKey = 'superscript';
|
||||
|
||||
/// The inline mark of subscript text, as [superscriptKey].
|
||||
const subscriptKey = 'subscript';
|
||||
|
||||
/// How much smaller raised or lowered text is.
|
||||
const _scriptScale = 0.75;
|
||||
|
||||
/// Toggles [key] ([superscriptKey] or [subscriptKey]) over the editor's
|
||||
/// selection, clearing the other one: text is never both. At a caret, the
|
||||
/// next text typed takes it, as bold does.
|
||||
void toggleScript(EditorState editorState, String key) {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
if (selection.isCollapsed) {
|
||||
editorState.toggleAttribute(key);
|
||||
return;
|
||||
}
|
||||
final other = key == superscriptKey ? subscriptKey : superscriptKey;
|
||||
final isOn =
|
||||
editorState.getDeltaAttributeValueInSelection<bool>(key) ?? false;
|
||||
editorState.formatDelta(selection, {key: isOn ? null : true, other: null});
|
||||
}
|
||||
|
||||
/// [base], the editor's own way of drawing marked text (links, say), with
|
||||
/// superscript and subscript drawn too: by the font's own raised and lowered
|
||||
/// glyphs, a little smaller. A style rather than a widget of its own, since
|
||||
/// the editor maps its caret through the text as drawn.
|
||||
TextSpanDecoratorForAttribute withScripts(
|
||||
TextSpanDecoratorForAttribute base,
|
||||
) => (context, node, index, text, before, after) {
|
||||
final span = base(context, node, index, text, before, after);
|
||||
final feature = switch (text.attributes) {
|
||||
{superscriptKey: true} => const FontFeature.superscripts(),
|
||||
{subscriptKey: true} => const FontFeature.subscripts(),
|
||||
_ => null,
|
||||
};
|
||||
if (feature == null || span is! TextSpan) {
|
||||
return span;
|
||||
}
|
||||
final style = span.style ?? const TextStyle();
|
||||
return TextSpan(
|
||||
text: span.text,
|
||||
children: span.children,
|
||||
style: style.copyWith(
|
||||
fontFeatures: [...?style.fontFeatures, feature],
|
||||
fontSize: (style.fontSize ?? 16) * _scriptScale,
|
||||
),
|
||||
recognizer: span.recognizer,
|
||||
mouseCursor: span.mouseCursor,
|
||||
onEnter: span.onEnter,
|
||||
onExit: span.onExit,
|
||||
semanticsLabel: span.semanticsLabel,
|
||||
locale: span.locale,
|
||||
spellOut: span.spellOut,
|
||||
);
|
||||
};
|
||||
361
packages/halkyon_editor/test/content/rich_content_test.dart
Normal file
361
packages/halkyon_editor/test/content/rich_content_test.dart
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
|
||||
void main() {
|
||||
/// The editor's JSON for a page with one paragraph per text.
|
||||
Map<String, Object?> paragraphs(List<String> texts) => {
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
for (final text in texts)
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': text},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
group('RichContent', () {
|
||||
test('without a first heading that repeats a title, whatever its case '
|
||||
'and spaces; anything else stays', () {
|
||||
RichContent page(List<Map<String, Object?>> children) => RichContent({
|
||||
'document': {'type': 'page', 'children': children},
|
||||
});
|
||||
Map<String, Object?> heading(String text) => {
|
||||
'type': 'heading',
|
||||
'data': {
|
||||
'level': 1,
|
||||
'delta': [
|
||||
{'insert': text},
|
||||
],
|
||||
},
|
||||
};
|
||||
final text = {
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'In Japanese…'},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
page([heading('What is Kanji?'), text])
|
||||
.withoutHeading('what is kanji?'),
|
||||
page([text]),
|
||||
);
|
||||
final other = page([heading('Kanji'), text]);
|
||||
expect(other.withoutHeading('What is Kanji?'), other);
|
||||
final later = page([text, heading('What is Kanji?')]);
|
||||
expect(later.withoutHeading('What is Kanji?'), later);
|
||||
expect(other.withoutHeading(''), other);
|
||||
});
|
||||
|
||||
test('equals content with the same JSON, whatever the key order', () {
|
||||
final a = RichContent({
|
||||
'document': {'type': 'page', 'children': <Object?>[]},
|
||||
});
|
||||
final b = RichContent({
|
||||
'document': {'children': <Object?>[], 'type': 'page'},
|
||||
});
|
||||
|
||||
expect(a, b);
|
||||
expect(a.hashCode, b.hashCode);
|
||||
});
|
||||
|
||||
test('differs when the text differs', () {
|
||||
expect(
|
||||
RichContent(paragraphs(['猫'])),
|
||||
isNot(RichContent(paragraphs(['犬']))),
|
||||
);
|
||||
});
|
||||
|
||||
test('differs when the format version differs', () {
|
||||
expect(
|
||||
RichContent(paragraphs(['猫'])),
|
||||
isNot(RichContent(paragraphs(['猫']), formatVersion: 0)),
|
||||
);
|
||||
});
|
||||
|
||||
test('is not affected by later changes to the JSON it was made from', () {
|
||||
final json = paragraphs(['猫']);
|
||||
final content = RichContent(json);
|
||||
|
||||
final document = json['document']! as Map<String, Object?>;
|
||||
(document['children']! as List<Object?>).clear();
|
||||
|
||||
expect(content, RichContent(paragraphs(['猫'])));
|
||||
});
|
||||
|
||||
test('cannot be changed through its JSON', () {
|
||||
final content = RichContent(paragraphs(['猫']));
|
||||
final document = content.editorJson['document']! as Map<String, Object?>;
|
||||
final children = document['children']! as List<Object?>;
|
||||
|
||||
expect(() => document['type'] = 'heading', throwsUnsupportedError);
|
||||
expect(children.clear, throwsUnsupportedError);
|
||||
});
|
||||
|
||||
test('survives a JSON round trip, Japanese text and marks included', () {
|
||||
final content = RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'heading',
|
||||
'data': {
|
||||
'level': 2,
|
||||
'delta': [
|
||||
{'insert': '日本語'},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{
|
||||
'insert': '猫',
|
||||
'attributes': {'bold': true},
|
||||
},
|
||||
{'insert': 'は動物です。'},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
final stored = jsonEncode(content.toJson());
|
||||
final read = RichContent.fromJson(
|
||||
jsonDecode(stored) as Map<String, Object?>,
|
||||
);
|
||||
|
||||
expect(read, content);
|
||||
});
|
||||
|
||||
test('rejects content in a newer format than this app knows', () {
|
||||
expect(
|
||||
() => RichContent.fromJson({
|
||||
'formatVersion': RichContent.currentFormatVersion + 1,
|
||||
'editor': RichContent.empty().editorJson,
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects JSON that is not rich content', () {
|
||||
expect(
|
||||
() => RichContent.fromJson({'formatVersion': 1, 'editor': 'text'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RichContent.fromJson({'editor': RichContent.empty().editorJson}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("RichContent.imageIds lists the person's images it shows, in order "
|
||||
'and once each; no sound, nor an image from elsewhere', () {
|
||||
Map<String, Object?> block(String type, String url) => {
|
||||
'type': type,
|
||||
'data': {'url': url},
|
||||
};
|
||||
final content = RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
block('image', 'asset:b'),
|
||||
block('audio', 'asset:sound'),
|
||||
block('image', 'https://example.org/cat.png'),
|
||||
block('image', 'asset:a'),
|
||||
block('image', 'asset:b'),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(content.imageIds, ['b', 'a']);
|
||||
});
|
||||
|
||||
group('RichContent.plainText', () {
|
||||
test('is the text without formatting, one line per block', () {
|
||||
expect(
|
||||
RichContent(paragraphs(['ひらがな', 'かたかな'])).plainText,
|
||||
'ひらがな\nかたかな',
|
||||
);
|
||||
});
|
||||
|
||||
test('joins the pieces of a formatted block', () {
|
||||
final content = RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'big '},
|
||||
{
|
||||
'insert': 'cat',
|
||||
'attributes': {'bold': true},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(content.plainText, 'big cat');
|
||||
});
|
||||
|
||||
test('is empty for empty content', () {
|
||||
expect(RichContent.empty().plainText, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('files', () {
|
||||
RichContent blocks(List<Map<String, Object?>> children) => RichContent({
|
||||
'document': {'type': 'page', 'children': children},
|
||||
});
|
||||
|
||||
test('names the asset rows its images and sounds show', () {
|
||||
final content = blocks([
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': assetUrl('cat')},
|
||||
},
|
||||
{
|
||||
'type': 'audio',
|
||||
'data': {'url': assetUrl('neko')},
|
||||
},
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': 'https://example.com/dog.png'},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(content.assetIds, {'cat', 'neko'});
|
||||
expect(content.hasImages, isTrue);
|
||||
expect(content.hasSounds, isTrue);
|
||||
});
|
||||
|
||||
test('content of only an image or a sound is not blank', () {
|
||||
expect(
|
||||
blocks([
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': assetUrl('cat')},
|
||||
},
|
||||
]).isBlank,
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
blocks([
|
||||
{
|
||||
'type': 'audio',
|
||||
'data': {'url': assetUrl('neko')},
|
||||
},
|
||||
]).isBlank,
|
||||
isFalse,
|
||||
);
|
||||
expect(RichContent.plain(' ').isBlank, isTrue);
|
||||
expect(RichContent.empty().isBlank, isTrue);
|
||||
});
|
||||
|
||||
test("a block's text counts as text: a formula, a callout's title, a "
|
||||
"video's caption, a study button's label, a spoiler's hidden text", () {
|
||||
final content = blocks([
|
||||
{
|
||||
'type': mathBlockType,
|
||||
'data': {mathFormulaKey: 'E = mc^2'},
|
||||
},
|
||||
{
|
||||
'type': calloutBlockType,
|
||||
'data': {calloutTitleKey: 'Mind the particle'},
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'は, not わ'},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': youtubeBlockType,
|
||||
'data': {youtubeVideoKey: 'dQw4w9WgXcQ', youtubeCaptionKey: 'Listen'},
|
||||
},
|
||||
{
|
||||
'type': studyButtonBlockType,
|
||||
'data': {
|
||||
studyButtonStudysetKey: 'japanese',
|
||||
studyButtonLabelKey: 'Go',
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': spoilerBlockType,
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'ねこ'},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
content.plainText,
|
||||
'E = mc^2\nMind the particle\nは, not わ\nListen\nGo\nねこ',
|
||||
);
|
||||
});
|
||||
|
||||
test('a video or a study button is something without any text; an '
|
||||
'empty formula or callout is nothing', () {
|
||||
expect(
|
||||
blocks([
|
||||
{
|
||||
'type': youtubeBlockType,
|
||||
'data': {youtubeVideoKey: 'dQw4w9WgXcQ'},
|
||||
},
|
||||
]).isBlank,
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
blocks([
|
||||
{
|
||||
'type': studyButtonBlockType,
|
||||
'data': {studyButtonStudysetKey: 'japanese'},
|
||||
},
|
||||
]).isBlank,
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
blocks([
|
||||
{
|
||||
'type': mathBlockType,
|
||||
'data': {mathFormulaKey: ' '},
|
||||
},
|
||||
{'type': calloutBlockType, 'data': <String, Object?>{}},
|
||||
]).isBlank,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('reads only asset URLs as files of the person', () {
|
||||
expect(assetIdOf(assetUrl('cat')), 'cat');
|
||||
expect(assetIdOf('asset:'), isNull);
|
||||
expect(assetIdOf('https://example.com/cat.png'), isNull);
|
||||
expect(assetIdOf(42), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
371
packages/halkyon_editor/test/editor/editor_blocks_test.dart
Normal file
371
packages/halkyon_editor/test/editor/editor_blocks_test.dart
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/test_app.dart';
|
||||
|
||||
/// Wraps text marked `shout` in capitals, or lower case while [editing].
|
||||
TextSpanDecoratorForAttribute _shouting(
|
||||
TextSpanDecoratorForAttribute base, {
|
||||
required bool editing,
|
||||
}) => (context, node, index, text, before, after) {
|
||||
final span = base(context, node, index, text, before, after);
|
||||
return text.attributes?['shout'] == true
|
||||
? TextSpan(
|
||||
text: editing ? text.text.toLowerCase() : text.text.toUpperCase(),
|
||||
style: span.style,
|
||||
)
|
||||
: span;
|
||||
};
|
||||
|
||||
RichContent _shouted(String text) => RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{
|
||||
'insert': text,
|
||||
'attributes': {'shout': true},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/// A block of a type nothing here knows, as a newer app may have written.
|
||||
RichContent _unknownBlock() => RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{'type': 'from_the_future'},
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'around it'},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Widget _app(Widget child) => MaterialApp(
|
||||
localizationsDelegates: editorTestDelegates,
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
|
||||
Widget _editor({
|
||||
RichContent? content,
|
||||
EditorBlocks? blocks,
|
||||
EditorWrapper? wrapEditor,
|
||||
ValueChanged<RichContent>? onChanged,
|
||||
}) => SizedBox(
|
||||
height: 400,
|
||||
child: RichContentEditor(
|
||||
initialContent: content ?? RichContent.plain('Water'),
|
||||
onChanged: onChanged ?? (_) {},
|
||||
formatBar: true,
|
||||
blocks: blocks,
|
||||
wrapEditor: wrapEditor,
|
||||
),
|
||||
);
|
||||
|
||||
InsertableBlock _block(String name) => InsertableBlock(
|
||||
name: (context) => name,
|
||||
icon: LucideIcons.star,
|
||||
group: InsertGroup.blocks,
|
||||
keywords: const [],
|
||||
insert: (context, editorState, at) async {},
|
||||
);
|
||||
|
||||
FormatAction _action({
|
||||
bool refocus = false,
|
||||
Future<void> Function(BuildContext, EditorState)? run,
|
||||
}) => FormatAction(
|
||||
icon: LucideIcons.star,
|
||||
tooltip: (context) => 'Star',
|
||||
active: (_) => false,
|
||||
run: run ?? (context, editorState) async {},
|
||||
refocus: refocus,
|
||||
);
|
||||
|
||||
Future<void> _focusEditor(WidgetTester tester) async {
|
||||
await tester.tap(find.byType(AppFlowyEditor));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('without any blocks of an app', () {
|
||||
testWidgets('the editor offers the basic blocks, listed by kind', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_app(_editor()));
|
||||
await _focusEditor(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Insert'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Heading 1'), findsOneWidget);
|
||||
expect(find.text('Checklist'), findsOneWidget);
|
||||
expect(find.text('Table'), findsOneWidget);
|
||||
expect(find.text('Media'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a block it does not know says so in the view', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_app(ListView(children: [RichContentView(content: _unknownBlock())])),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
final l10n = lookupHalkyonEditorLocalizations(const Locale('en'));
|
||||
expect(find.text(l10n.blockUnknown), findsOneWidget);
|
||||
expect(find.text('around it', findRichText: true), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('what an app adds', () {
|
||||
testWidgets('marks of its own are drawn, set in the view and as written '
|
||||
'in the editor', (tester) async {
|
||||
final blocks = EditorBlocks(
|
||||
builders: basicBlocks.builders,
|
||||
insertable: basicBlocks.insertable,
|
||||
textMarks: const [_shouting],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
ListView(
|
||||
children: [
|
||||
RichContentView(content: _shouted('Water'), blocks: blocks),
|
||||
_editor(content: _shouted('Water'), blocks: blocks),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(find.text('WATER', findRichText: true), findsOneWidget);
|
||||
expect(find.text('water', findRichText: true), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('its format button comes last of the formats and its run gets '
|
||||
"the editor's state", (tester) async {
|
||||
EditorState? ran;
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
_editor(
|
||||
blocks: EditorBlocks(
|
||||
builders: basicBlocks.builders,
|
||||
insertable: basicBlocks.insertable,
|
||||
formatActions: [
|
||||
_action(run: (context, editorState) async => ran = editorState),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await _focusEditor(tester);
|
||||
|
||||
await tester.ensureVisible(find.byTooltip('Star'));
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester.getTopLeft(find.byTooltip('Star')).dx,
|
||||
greaterThan(tester.getTopLeft(find.byTooltip('Subscript')).dx),
|
||||
);
|
||||
await tester.tap(find.byTooltip('Star'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(
|
||||
ran,
|
||||
same(
|
||||
tester
|
||||
.widget<AppFlowyEditor>(find.byType(AppFlowyEditor))
|
||||
.editorState,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a format button asked to takes the focus back to the editor', (
|
||||
tester,
|
||||
) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
SizedBox(
|
||||
height: 400,
|
||||
child: RichContentEditor(
|
||||
initialContent: RichContent.plain('Water'),
|
||||
onChanged: (_) {},
|
||||
formatBar: true,
|
||||
focusNode: focusNode,
|
||||
blocks: EditorBlocks(
|
||||
builders: basicBlocks.builders,
|
||||
insertable: basicBlocks.insertable,
|
||||
formatActions: [
|
||||
_action(
|
||||
refocus: true,
|
||||
run: (context, editorState) async => focusNode.unfocus(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await _focusEditor(tester);
|
||||
|
||||
await tester.ensureVisible(find.byTooltip('Star'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Star'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('it hears of an editor going', (tester) async {
|
||||
var closed = 0;
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
_editor(
|
||||
blocks: EditorBlocks(
|
||||
builders: basicBlocks.builders,
|
||||
insertable: basicBlocks.insertable,
|
||||
onEditorClosed: [() => closed++],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(closed, 0);
|
||||
|
||||
await tester.pumpWidget(_app(const SizedBox()));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(closed, 1);
|
||||
});
|
||||
|
||||
testWidgets('what it wraps the editor in knows its state and focus', (
|
||||
tester,
|
||||
) async {
|
||||
const wrapper = Key('wrapper');
|
||||
EditorState? seen;
|
||||
FocusNode? seenFocus;
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
_editor(
|
||||
wrapEditor: (context, editorState, focusNode, editor) {
|
||||
seen = editorState;
|
||||
seenFocus = focusNode;
|
||||
return KeyedSubtree(key: wrapper, child: editor);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await _focusEditor(tester);
|
||||
|
||||
final editor = find.byType(AppFlowyEditor);
|
||||
expect(
|
||||
find.descendant(of: find.byKey(wrapper), matching: editor),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(seen, same(tester.widget<AppFlowyEditor>(editor).editorState));
|
||||
expect(seenFocus?.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('blocks from a scope count, and those given beat them', (
|
||||
tester,
|
||||
) async {
|
||||
Future<void> insertMenu(Widget editor) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
EditorBlocksScope(
|
||||
blocks: EditorBlocks(
|
||||
builders: basicBlocks.builders,
|
||||
insertable: [_block('From the scope')],
|
||||
),
|
||||
child: editor,
|
||||
),
|
||||
),
|
||||
);
|
||||
await _focusEditor(tester);
|
||||
await tester.tap(find.byTooltip('Insert'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await insertMenu(_editor());
|
||||
expect(find.text('From the scope'), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await insertMenu(
|
||||
_editor(
|
||||
blocks: EditorBlocks(
|
||||
builders: basicBlocks.builders,
|
||||
insertable: [_block('Given')],
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Given'), findsOneWidget);
|
||||
expect(find.text('From the scope'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
test('plus keeps what either adds, in their order', () {
|
||||
void first() {}
|
||||
void second() {}
|
||||
final star = _action();
|
||||
final moon = _action();
|
||||
final both =
|
||||
EditorBlocks(
|
||||
builders: const {},
|
||||
insertable: const [],
|
||||
textMarks: const [_shouting],
|
||||
formatActions: [star],
|
||||
onEditorClosed: [first],
|
||||
).plus(
|
||||
EditorBlocks(
|
||||
builders: const {},
|
||||
insertable: const [],
|
||||
formatActions: [moon],
|
||||
onEditorClosed: [second],
|
||||
),
|
||||
);
|
||||
|
||||
expect(both.textMarks, [_shouting]);
|
||||
expect(both.formatActions, [star, moon]);
|
||||
expect(both.onEditorClosed, [first, second]);
|
||||
});
|
||||
|
||||
test('offering changes what is offered, nothing else', () {
|
||||
void closed() {}
|
||||
final star = _action();
|
||||
final shortcut = CharacterShortcutEvent(
|
||||
key: 'nothing',
|
||||
character: '~',
|
||||
handler: (_) async => false,
|
||||
);
|
||||
final block = _block('One');
|
||||
final blocks = EditorBlocks(
|
||||
builders: basicBlocks.builders,
|
||||
insertable: basicBlocks.insertable,
|
||||
shortcuts: [shortcut],
|
||||
textMarks: const [_shouting],
|
||||
formatActions: [star],
|
||||
onEditorClosed: [closed],
|
||||
).offering([block]);
|
||||
|
||||
expect(blocks.insertable, [block]);
|
||||
expect(blocks.builders, basicBlocks.builders);
|
||||
expect(blocks.shortcuts, [shortcut]);
|
||||
expect(blocks.textMarks, [_shouting]);
|
||||
expect(blocks.formatActions, [star]);
|
||||
expect(blocks.onEditorClosed, [closed]);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue