diff --git a/analysis_options.yaml b/analysis_options.yaml index 761ba183..799a1e25 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,11 +4,29 @@ include: package:flutter_lints/flutter.yaml # https://dart.dev/guides/language/analysis-options linter: rules: - - always_declare_return_types - require_trailing_commas - - unnecessary_raw_strings + + - prefer_collection_literals + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + + - sized_box_for_whitespace - use_decorated_box + - unnecessary_parenthesis + - unnecessary_await_in_return + - unnecessary_raw_strings + + - avoid_unnecessary_containers + - avoid_redundant_argument_values + - avoid_unused_constructor_parameters + + - always_declare_return_types + + - sort_constructors_first + - unawaited_futures + analyzer: exclude: - lib/src/l10n/** diff --git a/example/lib/home_page.dart b/example/lib/home_page.dart index 8802910e..678b5143 100644 --- a/example/lib/home_page.dart +++ b/example/lib/home_page.dart @@ -16,7 +16,9 @@ import 'package:example/pages/editor.dart'; import 'package:example/pages/editor_list.dart'; import 'package:example/pages/fixed_toolbar_editor.dart'; import 'package:example/pages/focus_example_for_editor.dart'; +import 'package:example/pages/inline_comment_editor.dart'; import 'package:example/pages/markdown_editor.dart'; +import 'package:example/pages/ten_thousand_links_showcase.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -281,6 +283,24 @@ class _HomePageState extends State { ); }), + _buildListTile(context, 'Inline Comment', () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const InlineCommentEditor(), + ), + ); + }), + + _buildListTile(context, '10,000 Links (one link per paragraph)', () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TenThousandLinksShowcase(), + ), + ); + }), + // Encoder Demo _buildSeparator(context, 'Export To X Demo'), _buildListTile(context, 'Export To JSON', () { diff --git a/example/lib/pages/inline_comment_editor.dart b/example/lib/pages/inline_comment_editor.dart new file mode 100644 index 00000000..f40c9886 --- /dev/null +++ b/example/lib/pages/inline_comment_editor.dart @@ -0,0 +1,193 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class InlineCommentEditor extends StatefulWidget { + const InlineCommentEditor({super.key}); + + @override + State createState() => _InlineCommentEditorState(); +} + +class _InlineCommentEditorState extends State { + late final EditorState editorState; + late final EditorScrollController editorScrollController; + late final InlineCommentController commentController; + final List _comments = []; + int _commentCounter = 0; + + @override + void initState() { + super.initState(); + + // Create a document with some example text + final document = Document( + root: pageNode( + children: [ + headingNode( + level: 1, + delta: Delta()..insert('Inline Comment Demo'), + ), + paragraphNode( + delta: Delta() + ..insert( + 'Select any text in this editor and click the comment button ' + 'in the floating toolbar to add a comment. ' + 'The commented text will be highlighted with a yellow background.', + ), + ), + paragraphNode( + delta: Delta() + ..insert( + 'You can also click on highlighted text to see which comment ' + 'is associated with it. The sidebar on the right shows all ' + 'comments with their positions aligned to the text.', + ), + ), + paragraphNode( + delta: Delta() + ..insert( + 'Try adding multiple comments to different parts of the text ' + 'and observe how the sidebar updates automatically.', + ), + ), + ], + ), + ); + + editorState = EditorState(document: document); + + editorScrollController = EditorScrollController( + editorState: editorState, + shrinkWrap: false, + ); + + commentController = InlineCommentController( + onCommentAdded: _onCommentAdded, + onCommentDeleted: _onCommentDeleted, + onCommentTapped: _onCommentTapped, + ); + } + + @override + void dispose() { + editorScrollController.dispose(); + editorState.dispose(); + commentController.dispose(); + super.dispose(); + } + + Future _onCommentAdded( + String nodeId, + int startOffset, + int endOffset, + String? initialText, + ) async { + // Show a dialog for the user to enter a comment + final content = await showDialog( + context: context, + builder: (ctx) { + final textController = TextEditingController(); + return AlertDialog( + title: const Text('Add Comment'), + content: TextField( + controller: textController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Enter your comment...', + ), + maxLines: 3, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, textController.text), + child: const Text('Add'), + ), + ], + ); + }, + ); + + if (content == null || content.isEmpty) return null; + + final commentId = 'cmt-${++_commentCounter}'; + final comment = InlineComment( + id: commentId, + content: content, + authorName: 'Demo User', + createdAt: DateTime.now(), + ); + + setState(() => _comments.add(comment)); + commentController.updateComments(_comments); + + return commentId; + } + + Future _onCommentDeleted(String commentId) async { + setState(() => _comments.removeWhere((c) => c.id == commentId)); + commentController.updateComments(_comments); + } + + void _onCommentTapped(String commentId, BuildContext context) { + final comment = commentController.findById(commentId); + if (comment == null) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${comment.authorName}: ${comment.content}'), + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Inline Comment Demo')), + body: InlineCommentWidget( + editorState: editorState, + controller: commentController, + showSidebar: true, + sidebarWidth: 260, + child: FloatingToolbar( + items: [ + paragraphItem, + ...headingItems, + ...markdownFormatItems, + quoteItem, + bulletedListItem, + numberedListItem, + linkItem, + buildTextColorItem(), + buildHighlightColorItem(), + buildCommentToolbarItem(commentController), + ], + tooltipBuilder: (context, _, message, child) { + return Tooltip( + message: message, + preferBelow: false, + child: child, + ); + }, + editorState: editorState, + editorScrollController: editorScrollController, + textDirection: TextDirection.ltr, + child: AppFlowyEditor( + editorState: editorState, + editorScrollController: editorScrollController, + editorStyle: EditorStyle.desktop( + padding: const EdgeInsets.symmetric(horizontal: 40), + textSpanDecorator: buildCommentTextSpanDecorator( + controller: commentController, + ), + ), + ), + ), + ), + ); + } +} diff --git a/example/lib/pages/ten_thousand_links_showcase.dart b/example/lib/pages/ten_thousand_links_showcase.dart new file mode 100644 index 00000000..265f9fee --- /dev/null +++ b/example/lib/pages/ten_thousand_links_showcase.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:example/pages/editor.dart'; +import 'package:flutter/material.dart'; + +class TenThousandLinksShowcase extends StatefulWidget { + const TenThousandLinksShowcase({super.key}); + + @override + State createState() => + _TenThousandLinksShowcaseState(); +} + +class _TenThousandLinksShowcaseState extends State { + late final Future _jsonString = _buildLinksDocument(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('10,000 Links Showcase')), + body: Editor( + jsonString: _jsonString, + onEditorStateChange: (_) {}, + ), + ); + } + + Future _buildLinksDocument() { + final document = Document( + root: pageNode( + children: List.generate( + 10000, + (index) => paragraphNode( + delta: Delta() + ..insert( + 'Link ${index + 1}', + attributes: { + AppFlowyRichTextKeys.href: + 'https://example.com/links/${index + 1}', + }, + ), + ), + ), + ), + ); + + return Future.value(jsonEncode(document.toJson())); + } +} diff --git a/lib/appflowy_editor.dart b/lib/appflowy_editor.dart index 8e062d9b..b304312c 100644 --- a/lib/appflowy_editor.dart +++ b/lib/appflowy_editor.dart @@ -9,6 +9,17 @@ export 'src/editor/block_component/rich_text/default_selectable_mixin.dart'; export 'src/editor/block_component/table_block_component/table.dart'; // editor part, including editor component, block component, etc. export 'src/editor/editor.dart'; +// Inline Comment +export 'src/editor/inline_comment/inline_comment.dart'; +export 'src/editor/inline_comment/inline_comment_controller.dart'; +export 'src/editor/inline_comment/inline_comment_service.dart' + show CommentAnchor; +export 'src/editor/inline_comment/comment_sidebar_widget.dart'; +export 'src/editor/inline_comment/inline_comment_widget.dart'; +export 'src/editor/inline_comment/comment_toolbar_item.dart'; +export 'src/editor/inline_comment/comment_mobile_toolbar_item.dart'; +export 'src/editor/inline_comment/comment_text_span_decorator.dart' + show buildCommentTextSpanDecorator; export 'src/editor/find_replace_menu/find_and_replace.dart'; export 'src/editor/l10n/appflowy_editor_l10n.dart'; export 'src/editor/selection_menu/selection_menu.dart'; diff --git a/lib/src/core/document/deprecated/node.dart b/lib/src/core/document/deprecated/node.dart new file mode 100644 index 00000000..f7a8ddcf --- /dev/null +++ b/lib/src/core/document/deprecated/node.dart @@ -0,0 +1,317 @@ +import 'dart:collection'; + +import 'package:appflowy_editor/src/core/document/attributes.dart'; +import 'package:appflowy_editor/src/core/document/path.dart'; +import 'package:appflowy_editor/src/core/document/text_delta.dart'; +import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart'; +import 'package:flutter/material.dart'; + +/// +/// ⚠️ THIS FILE HAS BEEN DEPRECATED. +/// ⚠️ THIS FILE HAS BEEN DEPRECATED. +/// ⚠️ THIS FILE HAS BEEN DEPRECATED. +/// +/// ONLY USE FOR MIGRATION. +/// +final class NodeV0 extends ChangeNotifier with LinkedListEntry { + NodeV0({ + required this.type, + Attributes? attributes, + this.parent, + LinkedList? children, + }) : children = children ?? LinkedList(), + _attributes = attributes ?? {} { + for (final child in this.children) { + child.parent = this; + } + } + + factory NodeV0.fromJson(Map json) { + assert(json['type'] is String); + + final jType = json['type'] as String; + final jChildren = json['children'] as List?; + final jAttributes = json['attributes'] != null + ? Attributes.from(json['attributes'] as Map) + : Attributes.from({}); + + final children = LinkedList(); + if (jChildren != null) { + children.addAll( + jChildren.map( + (jChild) => NodeV0.fromJson( + Map.from(jChild), + ), + ), + ); + } + + NodeV0 node; + + if (jType == 'text') { + final jDelta = json['delta'] as List?; + final delta = jDelta == null ? Delta() : Delta.fromJson(jDelta); + node = TextNodeV0( + children: children, + attributes: jAttributes, + delta: delta, + ); + } else { + node = NodeV0( + type: jType, + children: children, + attributes: jAttributes, + ); + } + + for (final child in children) { + child.parent = node; + } + + return node; + } + + final String type; + final LinkedList children; + NodeV0? parent; + Attributes _attributes; + + // Renderable + final key = GlobalKey(); + final layerLink = LayerLink(); + + Attributes get attributes => {..._attributes}; + + String get id { + if (subtype != null) { + return '$type/$subtype'; + } + + return type; + } + + String? get subtype { + if (attributes[BuiltInAttributeKey.subtype] is String) { + return attributes[BuiltInAttributeKey.subtype] as String; + } + + return null; + } + + Path get path => _computePath(); + + void updateAttributes(Attributes attributes) { + final oldAttributes = this.attributes; + + _attributes = composeAttributes(this.attributes, attributes) ?? {}; + + // Notifies the new attributes + // if attributes contains 'subtype', should notify parent to rebuild node + // else, just notify current node. + final bool shouldNotifyParent = + this.attributes['subtype'] != oldAttributes['subtype']; + shouldNotifyParent ? parent?.notifyListeners() : notifyListeners(); + } + + NodeV0? childAtIndex(int index) { + if (children.length <= index || index < 0) { + return null; + } + + return children.elementAt(index); + } + + NodeV0? childAtPath(Path path) { + if (path.isEmpty) { + return this; + } + + return childAtIndex(path.first)?.childAtPath(path.sublist(1)); + } + + void insert(NodeV0 entry, {int? index}) { + final length = children.length; + index ??= length; + + if (children.isEmpty) { + entry.parent = this; + children.add(entry); + notifyListeners(); + + return; + } + + // If index is out of range, insert at the end. + // If index is negative, insert at the beginning. + // If index is positive, insert at the index. + if (index >= length) { + children.last.insertAfter(entry); + } else if (index <= 0) { + children.first.insertBefore(entry); + } else { + childAtIndex(index)?.insertBefore(entry); + } + } + + @override + void insertAfter(NodeV0 entry) { + entry.parent = parent; + super.insertAfter(entry); + + // Notifies the new node. + parent?.notifyListeners(); + } + + @override + void insertBefore(NodeV0 entry) { + entry.parent = parent; + super.insertBefore(entry); + + // Notifies the new node. + parent?.notifyListeners(); + } + + @override + void unlink() { + super.unlink(); + + parent?.notifyListeners(); + parent = null; + } + + Map toJson() { + final map = { + 'type': type, + }; + if (children.isNotEmpty) { + map['children'] = + children.map((node) => node.toJson()).toList(growable: false); + } + if (attributes.isNotEmpty) { + map['attributes'] = attributes; + } + + return map; + } + + NodeV0 copyWith({ + String? type, + LinkedList? children, + Attributes? attributes, + }) { + final node = NodeV0( + type: type ?? this.type, + attributes: attributes ?? {...this.attributes}, + children: children, + ); + if (children == null && this.children.isNotEmpty) { + for (final child in this.children) { + node.children.add( + child.copyWith()..parent = node, + ); + } + } + + return node; + } + + Path _computePath([Path previous = const []]) { + if (parent == null) { + return previous; + } + var index = 0; + for (final child in parent!.children) { + if (child == this) { + break; + } + index += 1; + } + + return parent!._computePath([index, ...previous]); + } +} + +final class TextNodeV0 extends NodeV0 { + TextNodeV0({ + required Delta delta, + super.children, + Attributes? attributes, + }) : _delta = delta, + super( + type: 'text', + attributes: attributes ?? {}, + ); + + TextNodeV0.empty({Attributes? attributes}) + : _delta = Delta(operations: [TextInsert('')]), + super( + type: 'text', + attributes: attributes ?? {}, + ); + + Delta _delta; + + Delta get delta => _delta; + + set delta(Delta v) { + _delta = v; + notifyListeners(); + } + + @override + Map toJson() { + final map = super.toJson(); + map['delta'] = delta.toJson(); + + return map; + } + + @override + TextNodeV0 copyWith({ + String? type = 'text', + LinkedList? children, + Attributes? attributes, + Delta? delta, + }) { + final textNode = TextNodeV0( + children: children, + attributes: attributes ?? this.attributes, + delta: delta ?? this.delta, + ); + if (children == null && this.children.isNotEmpty) { + for (final child in this.children) { + textNode.children.add( + child.copyWith()..parent = textNode, + ); + } + } + + return textNode; + } + + String toPlainText() => _delta.toPlainText(); +} + +extension NodeV0Equality on Iterable { + bool equals(Iterable other) { + if (length != other.length) { + return false; + } + for (var i = 0; i < length; i++) { + if (!_nodeEquals(elementAt(i), other.elementAt(i))) { + return false; + } + } + + return true; + } + + bool _nodeEquals(T base, U other) { + if (identical(this, other)) return true; + + return base is NodeV0 && + other is NodeV0 && + other.type == base.type && + other.children.equals(base.children); + } +} diff --git a/lib/src/core/document/diff.dart b/lib/src/core/document/diff.dart index 93b6780f..3826b726 100644 --- a/lib/src/core/document/diff.dart +++ b/lib/src/core/document/diff.dart @@ -8,9 +8,20 @@ List diffDocuments(Document oldDocument, Document newDocument) { } List diffNodes(Node oldNode, Node newNode) { - List operations = []; + final List operations = []; - if (!_equality.equals(oldNode.attributes, newNode.attributes)) { + if (oldNode.type != newNode.type && oldNode.path.isNotEmpty) { + operations.add( + UpdateNodeTypeOperation( + oldNode.path, + oldNode.id, + newNode.type, + oldNode.type, + newNode.attributes, + oldNode.attributes, + ), + ); + } else if (!_equality.equals(oldNode.attributes, newNode.attributes)) { operations.add( UpdateOperation(oldNode.path, newNode.attributes, oldNode.attributes), ); diff --git a/lib/src/core/document/document.dart b/lib/src/core/document/document.dart index 3a7c4fa0..38371c22 100644 --- a/lib/src/core/document/document.dart +++ b/lib/src/core/document/document.dart @@ -153,6 +153,35 @@ class Document { return true; } + /// Updates the [Node] type at the given [Path] while preserving its id, + /// children, external values, and temporary metadata. + bool updateNodeType(Path path, String type, Attributes attributes) { + if (path.isEmpty) { + return false; + } + + final target = nodeAtPath(path); + final parent = target?.parent; + if (target == null || parent == null) { + return false; + } + + final index = path.last; + final replacement = Node( + type: type, + id: target.id, + attributes: {...attributes}, + children: target.children.toList(growable: false), + ) + ..externalValues = target.externalValues + ..extraInfos = target.extraInfos; + + target.unlink(); + parent.insert(replacement, index: index); + + return true; + } + /// Updates the [Node] with [Delta] at the given [Path] bool updateText(Path path, Delta delta) { if (path.isEmpty) { @@ -163,7 +192,8 @@ class Document { if (target == null || targetDelta == null) { return false; } - target.updateAttributes({'delta': (targetDelta.compose(delta)).toJson()}); + + target.updateAttributes({'delta': targetDelta.compose(delta).toJson()}); return true; } @@ -196,4 +226,8 @@ class Document { 'document': root.toJson(), }; } + + Document deepCopy() { + return Document.fromJson(toJson()); + } } diff --git a/lib/src/core/document/node.dart b/lib/src/core/document/node.dart index 76c8da4f..6db07139 100644 --- a/lib/src/core/document/node.dart +++ b/lib/src/core/document/node.dart @@ -271,19 +271,24 @@ final class Node extends ChangeNotifier with LinkedListEntry { /// Be careful of the children, they will be deep copied if not provided. Node copyWith({ String? type, + String? id, + bool preserveChildIds = false, Iterable? children, Attributes? attributes, }) { final node = Node( type: type ?? this.type, - id: nanoid(6), + id: id ?? nanoid(6), attributes: attributes ?? {...this.attributes}, children: children ?? [], ); if (children == null && _children.isNotEmpty) { for (final child in _children) { node._children.add( - child.copyWith()..parent = node, + child.copyWith( + id: preserveChildIds ? child.id : null, + preserveChildIds: preserveChildIds, + )..parent = node, ); } } @@ -385,16 +390,23 @@ final class TextNode extends Node { Attributes? attributes, Delta? delta, String? id, + bool preserveChildIds = false, }) { final textNode = TextNode( children: children ?? [], attributes: attributes ?? this.attributes, delta: delta ?? this.delta, ); + if (id != null) { + textNode.id = id; + } if (children == null && this.children.isNotEmpty) { for (final child in this.children) { textNode._children.add( - child.copyWith()..parent = textNode, + child.copyWith( + id: preserveChildIds ? child.id : null, + preserveChildIds: preserveChildIds, + )..parent = textNode, ); } } diff --git a/lib/src/core/document/path.dart b/lib/src/core/document/path.dart index 3a1238d5..711aec6a 100644 --- a/lib/src/core/document/path.dart +++ b/lib/src/core/document/path.dart @@ -65,7 +65,7 @@ extension PathExtensions on Path { } Path get next { - Path nextPath = Path.from(this, growable: true); + final Path nextPath = Path.from(this); if (isEmpty) { return nextPath; } @@ -77,7 +77,7 @@ extension PathExtensions on Path { } Path nextNPath(int n) { - Path nextPath = Path.from(this, growable: true); + final Path nextPath = Path.from(this); if (isEmpty) { return nextPath; } @@ -89,11 +89,11 @@ extension PathExtensions on Path { } Path child(int index) { - return Path.from(this, growable: true)..add(index); + return Path.from(this)..add(index); } Path get previous { - Path previousPath = Path.from(this, growable: true); + final Path previousPath = Path.from(this); if (isEmpty) { return previousPath; } @@ -105,7 +105,7 @@ extension PathExtensions on Path { } Path previousNPath(int n) { - Path previousPath = Path.from(this, growable: true); + final Path previousPath = Path.from(this); if (isEmpty) { return previousPath; } @@ -121,7 +121,7 @@ extension PathExtensions on Path { return this; } - return Path.from(this, growable: true)..removeLast(); + return Path.from(this)..removeLast(); } bool isAncestorOf(Path other) { @@ -149,7 +149,7 @@ extension PathExtensions on Path { bool isSameDepth = false, }) { selection = selection?.normalized; - bool result = selection != null && + final bool result = selection != null && selection.start.path <= this && this <= selection.end.path; if (isSameDepth) { diff --git a/lib/src/core/document/rules/at_least_one_editable_node_rule.dart b/lib/src/core/document/rules/at_least_one_editable_node_rule.dart index 8f113af6..c318b6fa 100644 --- a/lib/src/core/document/rules/at_least_one_editable_node_rule.dart +++ b/lib/src/core/document/rules/at_least_one_editable_node_rule.dart @@ -31,6 +31,6 @@ class AtLeastOneEditableNodeRule extends DocumentRule { ..afterSelection = Selection.collapsed( Position(path: [0]), ); - editorState.apply(transaction); + await editorState.apply(transaction); } } diff --git a/lib/src/core/document/text_delta.dart b/lib/src/core/document/text_delta.dart index 9f153ec9..85a03ebb 100644 --- a/lib/src/core/document/text_delta.dart +++ b/lib/src/core/document/text_delta.dart @@ -487,7 +487,7 @@ class Delta extends Iterable { /// This method joins two Delta together. Delta operator +(Delta other) { - var operations = [..._operations]; + final operations = [..._operations]; if (other._operations.isNotEmpty) { operations.add(other._operations[0]); operations.addAll(other._operations.sublist(1)); diff --git a/lib/src/core/location/position.dart b/lib/src/core/location/position.dart index 79e73715..b2ca8484 100644 --- a/lib/src/core/location/position.dart +++ b/lib/src/core/location/position.dart @@ -1,9 +1,6 @@ import 'package:appflowy_editor/src/core/document/path.dart'; class Position { - final Path path; - final int offset; - Position({ required this.path, this.offset = 0, @@ -22,6 +19,8 @@ class Position { offset: offset ?? 0, ); } + final Path path; + final int offset; @override bool operator ==(Object other) { diff --git a/lib/src/core/transform/operation.dart b/lib/src/core/transform/operation.dart index 723a6f68..2d44849a 100644 --- a/lib/src/core/transform/operation.dart +++ b/lib/src/core/transform/operation.dart @@ -182,6 +182,101 @@ class UpdateOperation extends Operation { path.hashCode ^ attributes.hashCode ^ oldAttributes.hashCode; } +/// [UpdateNodeTypeOperation] changes a node's block type without replacing +/// the node in the document tree. +class UpdateNodeTypeOperation extends Operation { + const UpdateNodeTypeOperation( + super.path, + this.nodeId, + this.type, + this.oldType, + this.attributes, + this.oldAttributes, + ); + + factory UpdateNodeTypeOperation.fromJson(Map json) { + final path = json['path'] as Path; + final nodeId = json['nodeId'] as String? ?? ''; + final type = json['type'] as String; + final oldType = json['oldType'] as String; + final attributes = json['attributes'] as Attributes; + final oldAttributes = json['oldAttributes'] as Attributes; + + return UpdateNodeTypeOperation( + path, + nodeId, + type, + oldType, + attributes, + oldAttributes, + ); + } + + final String nodeId; + final String type; + final String oldType; + final Attributes attributes; + final Attributes oldAttributes; + + @override + Operation invert() => UpdateNodeTypeOperation( + path, + nodeId, + oldType, + type, + oldAttributes, + attributes, + ); + + @override + Map toJson() { + return { + 'op': 'update_node_type', + 'path': path, + 'nodeId': nodeId, + 'type': type, + 'oldType': oldType, + 'attributes': {...attributes}, + 'oldAttributes': {...oldAttributes}, + }; + } + + @override + Operation copyWith({Path? path}) { + return UpdateNodeTypeOperation( + path ?? this.path, + nodeId, + type, + oldType, + {...attributes}, + {...oldAttributes}, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is UpdateNodeTypeOperation && + other.path.equals(path) && + other.nodeId == nodeId && + other.type == type && + other.oldType == oldType && + mapEquals(other.attributes, attributes) && + mapEquals(other.oldAttributes, oldAttributes); + } + + @override + int get hashCode => Object.hash( + Object.hashAll(path), + nodeId, + type, + oldType, + hashAttributes(attributes), + hashAttributes(oldAttributes), + ); +} + /// [UpdateTextOperation] represents a text update operation. class UpdateTextOperation extends Operation { const UpdateTextOperation( diff --git a/lib/src/core/transform/transaction.dart b/lib/src/core/transform/transaction.dart index f1f5a01b..ee3531fc 100644 --- a/lib/src/core/transform/transaction.dart +++ b/lib/src/core/transform/transaction.dart @@ -92,13 +92,35 @@ class Transaction { ); } + /// Updates a node's type while keeping the same node id and children. + /// + /// Unlike [updateNode], this replaces the node attributes. Block types have + /// different data contracts, so carrying old type-specific attributes into + /// the new block type would leak stale state. + void updateNodeType( + Node node, + String type, + Attributes attributes, + ) { + add( + UpdateNodeTypeOperation( + node.path, + node.id, + type, + node.type, + {...attributes}, + {...node.attributes}, + ), + ); + } + /// Deletes the [Node] in the document. void deleteNode(Node node) { deleteNodesAtPath(node.path); if (beforeSelection != null) { final nodePath = node.path; final selectionPath = beforeSelection!.start.path; - if (!(nodePath.equals(selectionPath))) { + if (!nodePath.equals(selectionPath)) { afterSelection = beforeSelection; } } diff --git a/lib/src/editor/block_component/base_component/block_component_action_wrapper.dart b/lib/src/editor/block_component/base_component/block_component_action_wrapper.dart index 4af7c689..40ec4bb4 100644 --- a/lib/src/editor/block_component/base_component/block_component_action_wrapper.dart +++ b/lib/src/editor/block_component/base_component/block_component_action_wrapper.dart @@ -80,7 +80,6 @@ class _BlockComponentActionWrapperState opaque: false, child: Row( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ ValueListenableBuilder( diff --git a/lib/src/editor/block_component/base_component/block_component_configuration.dart b/lib/src/editor/block_component/base_component/block_component_configuration.dart index ff4a7f83..6afea8ba 100644 --- a/lib/src/editor/block_component/base_component/block_component_configuration.dart +++ b/lib/src/editor/block_component/base_component/block_component_configuration.dart @@ -10,6 +10,7 @@ typedef BlockComponentTextStyleBuilder = TextStyle Function( class BlockComponentConfiguration { const BlockComponentConfiguration({ this.padding = _padding, + this.margin = _margin, this.indentPadding = _indentPadding, this.placeholderText = _placeholderText, this.textStyle = _textStyle, @@ -23,6 +24,11 @@ class BlockComponentConfiguration { /// It works only for the block component itself, not for the children. final EdgeInsets Function(Node node) padding; + /// The margin of a block component. + /// + /// Sits outside the padding/background/selection area. + final EdgeInsets Function(Node node) margin; + /// The padding of a block component. /// /// It works only for the block that needs to be indented. @@ -53,6 +59,7 @@ class BlockComponentConfiguration { BlockComponentConfiguration copyWith({ EdgeInsets Function(Node node)? padding, + EdgeInsets Function(Node node)? margin, BlockComponentTextStyleBuilder? textStyle, String Function(Node node)? placeholderText, BlockComponentTextStyleBuilder? placeholderTextStyle, @@ -62,6 +69,7 @@ class BlockComponentConfiguration { }) { return BlockComponentConfiguration( padding: padding ?? this.padding, + margin: margin ?? this.margin, textStyle: textStyle ?? this.textStyle, placeholderText: placeholderText ?? this.placeholderText, placeholderTextStyle: placeholderTextStyle ?? this.placeholderTextStyle, @@ -78,6 +86,7 @@ mixin BlockComponentConfigurable on State { Node get node; EdgeInsets get padding => configuration.padding(node); + EdgeInsets get margin => configuration.margin(node); TextStyle textStyleWithTextSpan({TextSpan? textSpan}) => configuration.textStyle(node, textSpan: textSpan); @@ -94,6 +103,10 @@ EdgeInsets _padding(Node node) { return const EdgeInsets.symmetric(vertical: 4.0); } +EdgeInsets _margin(Node node) { + return EdgeInsets.zero; +} + EdgeInsets _indentPadding(Node node, TextDirection textDirection) { switch (textDirection) { case TextDirection.ltr: @@ -119,7 +132,7 @@ TextStyle _placeholderTextStyle(Node node, {TextSpan? textSpan}) { } EdgeInsets _blockSelectionAreaPadding(Node node) { - return const EdgeInsets.symmetric(vertical: 0.0); + return const EdgeInsets.symmetric(); } TextAlign _textAlign(Node node) { diff --git a/lib/src/editor/block_component/base_component/convert_to_paragraph_command.dart b/lib/src/editor/block_component/base_component/convert_to_paragraph_command.dart index cad54b2e..c94ee2c5 100644 --- a/lib/src/editor/block_component/base_component/convert_to_paragraph_command.dart +++ b/lib/src/editor/block_component/base_component/convert_to_paragraph_command.dart @@ -52,7 +52,6 @@ CommandShortcutEventHandler _convertToParagraphCommandHandler = (editorState) { textDirection: textDirection, children: node.children.map((e) => e.deepCopy()).toList(), ), - deepCopy: true, ) ..deleteNode(node) ..afterSelection = transaction.beforeSelection; diff --git a/lib/src/editor/block_component/base_component/indent_command.dart b/lib/src/editor/block_component/base_component/indent_command.dart index 3844563d..fd6d3bef 100644 --- a/lib/src/editor/block_component/base_component/indent_command.dart +++ b/lib/src/editor/block_component/base_component/indent_command.dart @@ -85,7 +85,7 @@ CommandShortcutEventHandler _indentCommandHandler = (editorState) { final transaction = editorState.transaction ..deleteNodes(nodes) - ..insertNodes(startPath, nodes, deepCopy: true) + ..insertNodes(startPath, nodes) ..afterSelection = afterSelection; editorState.apply(transaction); diff --git a/lib/src/editor/block_component/base_component/markdown_format_helper.dart b/lib/src/editor/block_component/base_component/markdown_format_helper.dart index d71c8d48..dff61f6b 100644 --- a/lib/src/editor/block_component/base_component/markdown_format_helper.dart +++ b/lib/src/editor/block_component/base_component/markdown_format_helper.dart @@ -1,4 +1,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; + +const _nodeEquality = DeepCollectionEquality(); /// Formats the current node to specified markdown style. /// @@ -55,23 +58,58 @@ Future formatMarkdownSymbol( final afterSelection = Selection.collapsed( Position( path: node.path, - offset: 0, ), ); final formattedNodes = nodesBuilder(text, node, delta); - // Create a transaction that replaces the current node with the - // formatted node. - final transaction = editorState.transaction - ..insertNodes( - node.path, - formattedNodes, - ) - ..deleteNode(node) - ..afterSelection = afterSelection; + final transaction = editorState.transaction; + if (_canUpdateNodeTypeInPlace(node, formattedNodes)) { + final formattedNode = formattedNodes.single; + transaction.updateNodeType( + node, + formattedNode.type, + formattedNode.attributes, + ); + } else { + // Create a transaction that replaces the current node with the + // formatted node. + transaction + ..insertNodes( + node.path, + formattedNodes, + ) + ..deleteNode(node); + } + transaction.afterSelection = afterSelection; await editorState.apply(transaction); return true; } + +bool _canUpdateNodeTypeInPlace(Node node, List formattedNodes) { + if (formattedNodes.length != 1) { + return false; + } + + return _hasSameDescendantContent(node, formattedNodes.single); +} + +bool _hasSameDescendantContent(Node before, Node after) { + if (before.children.length != after.children.length) { + return false; + } + + for (var i = 0; i < before.children.length; i++) { + final beforeChild = before.children.elementAt(i); + final afterChild = after.children.elementAt(i); + if (beforeChild.type != afterChild.type || + !_nodeEquality.equals(beforeChild.attributes, afterChild.attributes) || + !_hasSameDescendantContent(beforeChild, afterChild)) { + return false; + } + } + + return true; +} diff --git a/lib/src/editor/block_component/base_component/outdent_command.dart b/lib/src/editor/block_component/base_component/outdent_command.dart index 50e3ee38..45e3ee16 100644 --- a/lib/src/editor/block_component/base_component/outdent_command.dart +++ b/lib/src/editor/block_component/base_component/outdent_command.dart @@ -87,7 +87,7 @@ CommandShortcutEventHandler _outdentCommandHandler = (editorState) { final transaction = editorState.transaction ..deleteNodes(nodes) - ..insertNodes(startPath, nodes, deepCopy: true) + ..insertNodes(startPath, nodes) ..afterSelection = afterSelection; editorState.apply(transaction); diff --git a/lib/src/editor/block_component/base_component/selection/block_selection_area.dart b/lib/src/editor/block_component/base_component/selection/block_selection_area.dart index 31de6ef0..92b894b5 100644 --- a/lib/src/editor/block_component/base_component/selection/block_selection_area.dart +++ b/lib/src/editor/block_component/base_component/selection/block_selection_area.dart @@ -89,7 +89,7 @@ class _BlockSelectionAreaState extends State { return ValueListenableBuilder( key: ValueKey(widget.node.id + widget.supportTypes.toString()), valueListenable: widget.listenable, - builder: ((context, value, child) { + builder: (context, value, child) { final sizedBox = child ?? const SizedBox.shrink(); final selection = value?.normalized; @@ -158,12 +158,19 @@ class _BlockSelectionAreaState extends State { return sizedBox; } + final radius = editorState.selectionExtraInfo is Map + ? (editorState.selectionExtraInfo![ + selectionExtraInfoSelectionRadius] as double?) ?? + 0.0 + : 0.0; + return SelectionAreaPaint( rects: prevSelectionRects!, selectionColor: widget.selectionColor, + radius: radius, ); } - }), + }, child: const SizedBox.shrink(), ); } diff --git a/lib/src/editor/block_component/base_component/selection/selection_area_painter.dart b/lib/src/editor/block_component/base_component/selection/selection_area_painter.dart index a85372d9..08f0dd72 100644 --- a/lib/src/editor/block_component/base_component/selection/selection_area_painter.dart +++ b/lib/src/editor/block_component/base_component/selection/selection_area_painter.dart @@ -8,7 +8,6 @@ class AnimatedSelectionAreaPaint extends StatefulWidget { super.key, required this.rects, this.withAnimation = false, - s, }); final List rects; @@ -87,10 +86,12 @@ class SelectionAreaPaint extends StatelessWidget { super.key, required this.rects, required this.selectionColor, + this.radius = 0.0, }); final List rects; final Color selectionColor; + final double radius; @override Widget build(BuildContext context) { @@ -98,6 +99,7 @@ class SelectionAreaPaint extends StatelessWidget { painter: SelectionAreaPainter( rects: rects, selectionColor: selectionColor, + radius: radius, ), ); } @@ -107,10 +109,12 @@ class SelectionAreaPainter extends CustomPainter { SelectionAreaPainter({ required this.rects, required this.selectionColor, + this.radius = 0.0, }); final List rects; final Color selectionColor; + final double radius; @override void paint(Canvas canvas, Size size) { @@ -118,22 +122,32 @@ class SelectionAreaPainter extends CustomPainter { ..color = selectionColor ..style = PaintingStyle.fill; + final rRect = RRect.fromRectAndRadius; + for (var rect in rects) { // if rect.width is 0, we draw a small rect to indicate the selection area if (rect.width <= 0) { rect = Rect.fromLTWH(rect.left, rect.top, 8.0, rect.height); } - canvas.drawRect( - rect, - paint, - ); + if (radius > 0) { + canvas.drawRRect( + rRect(rect, Radius.circular(radius)), + paint, + ); + } else { + canvas.drawRect( + rect, + paint, + ); + } } } @override bool shouldRepaint(SelectionAreaPainter oldDelegate) { return selectionColor != oldDelegate.selectionColor || - !const DeepCollectionEquality().equals(rects, oldDelegate.rects); + !const DeepCollectionEquality().equals(rects, oldDelegate.rects) || + radius != oldDelegate.radius; } } diff --git a/lib/src/editor/block_component/base_component/widget/nested_list_widget.dart b/lib/src/editor/block_component/base_component/widget/nested_list_widget.dart index b9fa41c7..a4dbadc7 100644 --- a/lib/src/editor/block_component/base_component/widget/nested_list_widget.dart +++ b/lib/src/editor/block_component/base_component/widget/nested_list_widget.dart @@ -37,24 +37,18 @@ class NestedListWidget extends StatelessWidget { children: [ child, Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: children, ), ], ), NestedListMode.column => Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ child, Padding( padding: indentPadding, child: Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: children, ), diff --git a/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_block_component.dart b/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_block_component.dart index ece9679d..c7727677 100644 --- a/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_block_component.dart +++ b/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_block_component.dart @@ -127,7 +127,6 @@ class _BulletedListBlockComponentWidgetState alignment: alignment, child: Row( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, textDirection: textDirection, children: [ diff --git a/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_character_shortcut.dart b/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_character_shortcut.dart index fac5c8b2..2b51b408 100644 --- a/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_character_shortcut.dart +++ b/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_character_shortcut.dart @@ -10,8 +10,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; CharacterShortcutEvent formatAsteriskToBulletedList = CharacterShortcutEvent( key: 'format asterisk to bulleted list', character: ' ', - handler: (editorState) async => - await _formatSymbolToBulletedList(editorState, '*'), + handler: (editorState) async => _formatSymbolToBulletedList(editorState, '*'), ); /// Convert '- ' to bulleted list @@ -24,8 +23,7 @@ CharacterShortcutEvent formatAsteriskToBulletedList = CharacterShortcutEvent( CharacterShortcutEvent formatMinusToBulletedList = CharacterShortcutEvent( key: 'format minus to bulleted list', character: ' ', - handler: (editorState) async => - await _formatSymbolToBulletedList(editorState, '-'), + handler: (editorState) async => _formatSymbolToBulletedList(editorState, '-'), ); /// Insert a new block after the bulleted list block. @@ -38,7 +36,7 @@ CharacterShortcutEvent formatMinusToBulletedList = CharacterShortcutEvent( CharacterShortcutEvent insertNewLineAfterBulletedList = CharacterShortcutEvent( key: 'insert new block after bulleted list', character: '\n', - handler: (editorState) async => await insertNewLineInType( + handler: (editorState) async => insertNewLineInType( editorState, 'bulleted_list', ), diff --git a/lib/src/editor/block_component/divider_block_component/divider_block_component.dart b/lib/src/editor/block_component/divider_block_component/divider_block_component.dart index c11b21aa..34654480 100644 --- a/lib/src/editor/block_component/divider_block_component/divider_block_component.dart +++ b/lib/src/editor/block_component/divider_block_component/divider_block_component.dart @@ -143,11 +143,16 @@ class _DividerBlockComponentWidgetState child = widget.wrapper!(context, node, child); } + child = Padding( + padding: margin, + child: child, + ); + return child; } @override - Position start() => Position(path: widget.node.path, offset: 0); + Position start() => Position(path: widget.node.path); @override Position end() => Position(path: widget.node.path, offset: 1); diff --git a/lib/src/editor/block_component/divider_block_component/divider_character_shortcut.dart b/lib/src/editor/block_component/divider_block_component/divider_character_shortcut.dart index 49122b59..9f52ef90 100644 --- a/lib/src/editor/block_component/divider_block_component/divider_character_shortcut.dart +++ b/lib/src/editor/block_component/divider_block_component/divider_character_shortcut.dart @@ -51,7 +51,7 @@ Future _convertSyntaxToDivider( ..insertNode(path, paragraphNode()) ..deleteNode(node) ..afterSelection = Selection.collapsed(Position(path: path.next)); - editorState.apply(transaction); + await editorState.apply(transaction); return true; } diff --git a/lib/src/editor/block_component/divider_block_component/divider_menu_item.dart b/lib/src/editor/block_component/divider_block_component/divider_menu_item.dart index 3a9b9594..2f2021b8 100644 --- a/lib/src/editor/block_component/divider_block_component/divider_menu_item.dart +++ b/lib/src/editor/block_component/divider_block_component/divider_menu_item.dart @@ -9,7 +9,7 @@ SelectionMenuItem dividerMenuItem = SelectionMenuItem( style: style, ), keywords: ['horizontal rule', 'divider'], - handler: (editorState, _, __) { + handler: (editorState, _, __) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return; @@ -25,6 +25,6 @@ SelectionMenuItem dividerMenuItem = SelectionMenuItem( ..insertNode(insertedPath, dividerNode()) ..insertNode(insertedPath, paragraphNode()) ..afterSelection = Selection.collapsed(Position(path: insertedPath.next)); - editorState.apply(transaction); + await editorState.apply(transaction); }, ); diff --git a/lib/src/editor/block_component/heading_block_component/heading_block_component.dart b/lib/src/editor/block_component/heading_block_component/heading_block_component.dart index 528521fe..231e5d40 100644 --- a/lib/src/editor/block_component/heading_block_component/heading_block_component.dart +++ b/lib/src/editor/block_component/heading_block_component/heading_block_component.dart @@ -135,7 +135,6 @@ class _HeadingBlockComponentWidgetState child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, textDirection: textDirection, children: [ Flexible( @@ -185,14 +184,19 @@ class _HeadingBlockComponentWidgetState supportTypes: const [ BlockSelectionType.block, ], - child: child, + child: Padding( + padding: padding, + child: child, + ), ); - child = Container( - padding: padding, - decoration: decoration, - child: child, - ); + final decoration = this.decoration; + if (decoration != null) { + child = DecoratedBox( + decoration: decoration, + child: child, + ); + } if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( @@ -203,6 +207,11 @@ class _HeadingBlockComponentWidgetState ); } + child = Padding( + padding: margin, + child: child, + ); + return child; } diff --git a/lib/src/editor/block_component/heading_block_component/heading_character_shortcut.dart b/lib/src/editor/block_component/heading_block_component/heading_character_shortcut.dart index 8086678d..7b9c652a 100644 --- a/lib/src/editor/block_component/heading_block_component/heading_character_shortcut.dart +++ b/lib/src/editor/block_component/heading_block_component/heading_character_shortcut.dart @@ -10,7 +10,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; CharacterShortcutEvent formatSignToHeading = CharacterShortcutEvent( key: 'format sign to heading list', character: ' ', - handler: (editorState) async => await formatMarkdownSymbol( + handler: (editorState) async => formatMarkdownSymbol( editorState, (node) => true, (_, text, selection) { @@ -59,7 +59,7 @@ CharacterShortcutEvent insertNewLineAfterHeading = CharacterShortcutEvent( final transaction = editorState.transaction; transaction.insertNode(selection.start.path, paragraphNode()); transaction.afterSelection = Selection.collapsed( - Position(path: selection.start.path.next, offset: 0), + Position(path: selection.start.path.next), ); await editorState.apply(transaction); diff --git a/lib/src/editor/block_component/heading_block_component/heading_command_shortcut.dart b/lib/src/editor/block_component/heading_block_component/heading_command_shortcut.dart index 6bde34ac..77236f2d 100644 --- a/lib/src/editor/block_component/heading_block_component/heading_command_shortcut.dart +++ b/lib/src/editor/block_component/heading_block_component/heading_command_shortcut.dart @@ -70,7 +70,7 @@ KeyEventResult _toggleAttribute( } final node = editorState.getNodeAtPath(selection.start.path)!; - bool isHeading = isBody ?? + final bool isHeading = isBody ?? node.type == HeadingBlockKeys.type && node.attributes[HeadingBlockKeys.level] == level; diff --git a/lib/src/editor/block_component/image_block_component/image_block_component.dart b/lib/src/editor/block_component/image_block_component/image_block_component.dart index c9f480b1..1bffeccb 100644 --- a/lib/src/editor/block_component/image_block_component/image_block_component.dart +++ b/lib/src/editor/block_component/image_block_component/image_block_component.dart @@ -227,11 +227,16 @@ class ImageBlockComponentWidgetState extends State ); } + child = Padding( + padding: margin, + child: child, + ); + return child; } @override - Position start() => Position(path: widget.node.path, offset: 0); + Position start() => Position(path: widget.node.path); @override Position end() => Position(path: widget.node.path, offset: 1); diff --git a/lib/src/editor/block_component/image_block_component/image_upload_widget.dart b/lib/src/editor/block_component/image_block_component/image_upload_widget.dart index 5fcabdae..7c5a592a 100644 --- a/lib/src/editor/block_component/image_block_component/image_upload_widget.dart +++ b/lib/src/editor/block_component/image_block_component/image_upload_widget.dart @@ -333,7 +333,6 @@ class _UploadImageMenuState extends State { onTap: () async { final result = await _filePicker.pickFiles( dialogTitle: '', - allowMultiple: false, type: kIsWeb ? fp.FileType.custom : fp.FileType.image, allowedExtensions: kIsWeb ? allowedExtensions : null, withData: kIsWeb, @@ -358,7 +357,6 @@ class _UploadImageMenuState extends State { ), child: _imagePathOrContent != null ? Align( - alignment: Alignment.center, child: kIsWeb ? Image.memory( dataFromBase64String(_imagePathOrContent!), @@ -437,7 +435,6 @@ extension InsertImage on EditorState { transaction.afterSelection = Selection.collapsed( Position( path: node.path.next, - offset: 0, ), ); diff --git a/lib/src/editor/block_component/image_block_component/resizable_image.dart b/lib/src/editor/block_component/image_block_component/resizable_image.dart index 3a520af6..c8da9d33 100644 --- a/lib/src/editor/block_component/image_block_component/resizable_image.dart +++ b/lib/src/editor/block_component/image_block_component/resizable_image.dart @@ -163,7 +163,7 @@ class _ResizableImageState extends State { padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4.0)), - border: Border.all(width: 1, color: Colors.black), + border: Border.all(), ), child: Text(AppFlowyEditorL10n.current.imageLoadFailed), ); @@ -190,7 +190,7 @@ class _ResizableImageState extends State { }, onHorizontalDragUpdate: (details) { if (onUpdate != null) { - var offset = (details.globalPosition.dx - initialOffset); + var offset = details.globalPosition.dx - initialOffset; if (widget.alignment == Alignment.center) { offset *= 2.0; } @@ -216,7 +216,7 @@ class _ResizableImageState extends State { borderRadius: const BorderRadius.all( Radius.circular(5.0), ), - border: Border.all(width: 1, color: Colors.white), + border: Border.all(color: Colors.white), ), ), ) diff --git a/lib/src/editor/block_component/numbered_list_block_component/numbered_list_block_component.dart b/lib/src/editor/block_component/numbered_list_block_component/numbered_list_block_component.dart index ac20ca70..bfb2dd41 100644 --- a/lib/src/editor/block_component/numbered_list_block_component/numbered_list_block_component.dart +++ b/lib/src/editor/block_component/numbered_list_block_component/numbered_list_block_component.dart @@ -138,7 +138,6 @@ class _NumberedListBlockComponentWidgetState alignment: alignment, child: Row( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, textDirection: textDirection, children: [ @@ -315,7 +314,7 @@ extension on int { String result = ''; int number = this; while (number > 0) { - int remainder = (number - 1) % 26; + final int remainder = (number - 1) % 26; result = String.fromCharCode(remainder + 65) + result; number = (number - 1) ~/ 26; } diff --git a/lib/src/editor/block_component/numbered_list_block_component/numbered_list_character_shortcut.dart b/lib/src/editor/block_component/numbered_list_block_component/numbered_list_character_shortcut.dart index 8978bd9e..62de0fb5 100644 --- a/lib/src/editor/block_component/numbered_list_block_component/numbered_list_character_shortcut.dart +++ b/lib/src/editor/block_component/numbered_list_block_component/numbered_list_character_shortcut.dart @@ -12,7 +12,7 @@ final _numberRegex = RegExp(r'^(\d+)\.'); CharacterShortcutEvent formatNumberToNumberedList = CharacterShortcutEvent( key: 'format number to numbered list', character: ' ', - handler: (editorState) async => await formatMarkdownSymbol( + handler: (editorState) async => formatMarkdownSymbol( editorState, (node) => node.type != NumberedListBlockKeys.type, (node, text, selection) { @@ -75,7 +75,7 @@ CharacterShortcutEvent formatNumberToNumberedList = CharacterShortcutEvent( CharacterShortcutEvent insertNewLineAfterNumberedList = CharacterShortcutEvent( key: 'insert new block after numbered list', character: '\n', - handler: (editorState) async => await insertNewLineInType( + handler: (editorState) async => insertNewLineInType( editorState, NumberedListBlockKeys.type, ), diff --git a/lib/src/editor/block_component/paragraph_block_component/paragraph_block_component.dart b/lib/src/editor/block_component/paragraph_block_component/paragraph_block_component.dart index 7fbcee29..dd0cad46 100644 --- a/lib/src/editor/block_component/paragraph_block_component/paragraph_block_component.dart +++ b/lib/src/editor/block_component/paragraph_block_component/paragraph_block_component.dart @@ -158,7 +158,6 @@ class _ParagraphBlockComponentWidgetState alignment: alignment, child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, textDirection: textDirection, children: [ @@ -187,14 +186,8 @@ class _ParagraphBlockComponentWidgetState ), ); - child = Container( - key: blockComponentKey, - decoration: withBackgroundColor ? decoration : null, - padding: padding, - child: child, - ); - child = BlockSelectionContainer( + key: blockComponentKey, node: node, delegate: this, listenable: editorState.selectionNotifier, @@ -203,9 +196,20 @@ class _ParagraphBlockComponentWidgetState supportTypes: const [ BlockSelectionType.block, ], - child: child, + child: Padding( + padding: padding, + child: child, + ), ); + final decoration = this.decoration; + if (withBackgroundColor && decoration != null) { + child = DecoratedBox( + decoration: decoration, + child: child, + ); + } + if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, diff --git a/lib/src/editor/block_component/quote_block_component/quote_block_component.dart b/lib/src/editor/block_component/quote_block_component/quote_block_component.dart index 9c0c581b..4f106464 100644 --- a/lib/src/editor/block_component/quote_block_component/quote_block_component.dart +++ b/lib/src/editor/block_component/quote_block_component/quote_block_component.dart @@ -124,7 +124,6 @@ class _QuoteBlockComponentWidgetState extends State child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, textDirection: textDirection, children: [ @@ -185,6 +184,11 @@ class _QuoteBlockComponentWidgetState extends State ); } + child = Padding( + padding: margin, + child: child, + ); + return child; } } diff --git a/lib/src/editor/block_component/quote_block_component/quote_character_shortcut.dart b/lib/src/editor/block_component/quote_block_component/quote_character_shortcut.dart index 7160b60c..fd0fd965 100644 --- a/lib/src/editor/block_component/quote_block_component/quote_character_shortcut.dart +++ b/lib/src/editor/block_component/quote_block_component/quote_character_shortcut.dart @@ -12,7 +12,7 @@ const _doubleQuotes = ['"', '“']; CharacterShortcutEvent formatDoubleQuoteToQuote = CharacterShortcutEvent( key: 'format greater to quote', character: ' ', - handler: (editorState) async => await formatMarkdownSymbol( + handler: (editorState) async => formatMarkdownSymbol( editorState, (node) => node.type != QuoteBlockKeys.type, (_, text, __) => _doubleQuotes.any((element) => element == text), diff --git a/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart b/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart index e859b513..b1453549 100644 --- a/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart +++ b/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart @@ -28,6 +28,12 @@ typedef AppFlowyTextSpanOverlayBuilder = List Function( SelectableMixin delegate, ); +typedef AppFlowyTextSpanBackgroundBuilder = List Function( + BuildContext context, + Node node, + SelectableMixin delegate, +); + class AppFlowyRichText extends StatefulWidget { const AppFlowyRichText({ super.key, @@ -40,6 +46,7 @@ class AppFlowyRichText extends StatefulWidget { this.textDirection = TextDirection.ltr, this.textSpanDecoratorForCustomAttributes, this.textSpanOverlayBuilder, + this.textSpanBackgroundBuilder, this.textAlign, this.cursorColor = const Color.fromARGB(255, 0, 0, 0), this.selectionColor = const Color.fromARGB(53, 111, 201, 231), @@ -94,6 +101,11 @@ class AppFlowyRichText extends StatefulWidget { /// You can use this to customize the text span overlay, for example, a hover menu in linked text. final AppFlowyTextSpanOverlayBuilder? textSpanOverlayBuilder; + /// customize the text span background builder + /// + /// You can use this to customize the text span background, for example, a highlight behind the text. + final AppFlowyTextSpanBackgroundBuilder? textSpanBackgroundBuilder; + final TextDirection textDirection; final Color cursorColor; @@ -132,6 +144,10 @@ class _AppFlowyRichTextState extends State widget.textSpanOverlayBuilder ?? widget.editorState.editorStyle.textSpanOverlayBuilder; + AppFlowyTextSpanBackgroundBuilder? get textSpanBackgroundBuilder => + widget.textSpanBackgroundBuilder ?? + widget.editorState.editorStyle.textSpanBackgroundBuilder; + @override void initState() { super.initState(); @@ -143,6 +159,7 @@ class _AppFlowyRichTextState extends State Widget child = Stack( children: [ _buildPlaceholderText(context), + ..._buildRichTextBackground(context), _buildRichText(context), ..._buildRichTextOverlay(context), ], @@ -173,7 +190,7 @@ class _AppFlowyRichTextState extends State } @override - Position start() => Position(path: widget.node.path, offset: 0); + Position start() => Position(path: widget.node.path); @override Position end() => Position( @@ -204,7 +221,7 @@ class _AppFlowyRichTextState extends State } final textPosition = TextPosition(offset: position.offset); - double? placeholderCursorHeight = + final double? placeholderCursorHeight = _placeholderRenderParagraph?.getFullHeightForCaret(textPosition); Offset? placeholderCursorOffset = _placeholderRenderParagraph?.getOffsetForCaret( @@ -415,6 +432,19 @@ class _AppFlowyRichTextState extends State ); } + List _buildRichTextBackground(BuildContext context) { + if (textKey.currentContext == null) { + return []; + } + + return textSpanBackgroundBuilder?.call( + context, + widget.node, + this, + ) ?? + []; + } + Widget _buildRichText(BuildContext context) { final textInserts = widget.node.delta!.whereType(); TextSpan textSpan = getTextSpan(textInserts: textInserts); @@ -565,7 +595,7 @@ class _AppFlowyRichTextState extends State required Iterable textInserts, }) { int offset = 0; - List textSpans = []; + final List textSpans = []; for (final textInsert in textInserts) { TextStyle textStyle = textStyleConfiguration.text.copyWith( height: textStyleConfiguration.lineHeight, @@ -712,8 +742,8 @@ extension AppFlowyRichTextAttributes on Attributes { bool get code => this[AppFlowyRichTextKeys.code] == true; bool get strikethrough { - return (containsKey(AppFlowyRichTextKeys.strikethrough) && - this[AppFlowyRichTextKeys.strikethrough] == true); + return containsKey(AppFlowyRichTextKeys.strikethrough) && + this[AppFlowyRichTextKeys.strikethrough] == true; } Color? get color { diff --git a/lib/src/editor/block_component/rich_text/appflowy_rich_text_keys.dart b/lib/src/editor/block_component/rich_text/appflowy_rich_text_keys.dart index 39f6ee13..b4113fdc 100644 --- a/lib/src/editor/block_component/rich_text/appflowy_rich_text_keys.dart +++ b/lib/src/editor/block_component/rich_text/appflowy_rich_text_keys.dart @@ -43,4 +43,9 @@ class AppFlowyRichTextKeys { textColor, backgroundColor, ]; + + /// The attribute key used to store a list of comment ids anchored to a text + /// range. Intentionally excluded from [supportSliced] so that new text + /// typed adjacent to a commented range does NOT inherit the comment marker. + static const String commentIds = 'comment-ids'; } diff --git a/lib/src/editor/block_component/table_block_component/table_action.dart b/lib/src/editor/block_component/table_block_component/table_action.dart index a332de5f..0f07ff42 100644 --- a/lib/src/editor/block_component/table_block_component/table_action.dart +++ b/lib/src/editor/block_component/table_block_component/table_action.dart @@ -56,17 +56,17 @@ class TableActions { } } - static void setBgColor( + static Future setBgColor( Node node, int position, EditorState editorState, String? color, TableDirection dir, - ) { + ) async { if (dir == TableDirection.col) { - _setColBgColor(node, position, editorState, color); + await _setColBgColor(node, position, editorState, color); } else { - _setRowBgColor(node, position, editorState, color); + await _setRowBgColor(node, position, editorState, color); } } } @@ -76,7 +76,7 @@ void _addCol(Node tableNode, int position, EditorState editorState) { final transaction = editorState.transaction; - List cellNodes = []; + final List cellNodes = []; final int rowsLen = tableNode.attributes[TableBlockKeys.rowsLen], colsLen = tableNode.attributes[TableBlockKeys.colsLen]; @@ -117,8 +117,6 @@ void _addCol(Node tableNode, int position, EditorState editorState) { } else { insertPath = getCellNode(tableNode, position - 1, rowsLen - 1)!.path.next; } - // TODO(zoli): this calls notifyListener rowsLen+1 times. isn't there a better - // way? transaction.insertNodes(insertPath, cellNodes); transaction.updateNode(tableNode, {TableBlockKeys.colsLen: colsLen + 1}); @@ -222,7 +220,7 @@ void _deleteCol(Node tableNode, int col, EditorState editorState) { transaction.deleteNode(tableNode); tableNode.dispose(); } else { - List nodes = []; + final List nodes = []; for (var i = 0; i < rowsLen; i++) { nodes.add(getCellNode(tableNode, col, i)!); } @@ -250,7 +248,7 @@ void _deleteRow(Node tableNode, int row, EditorState editorState) { transaction.deleteNode(tableNode); tableNode.dispose(); } else { - List nodes = []; + final List nodes = []; for (var i = 0; i < colsLen; i++) { nodes.add(getCellNode(tableNode, i, row)!); } @@ -269,7 +267,7 @@ void _duplicateCol(Node tableNode, int col, EditorState editorState) { final int rowsLen = tableNode.attributes[TableBlockKeys.rowsLen], colsLen = tableNode.attributes[TableBlockKeys.colsLen]; - List nodes = []; + final List nodes = []; for (var i = 0; i < rowsLen; i++) { final node = getCellNode(tableNode, col, i)!; nodes.add( @@ -319,15 +317,15 @@ void _duplicateRow(Node tableNode, int row, EditorState editorState) async { transaction = editorState.transaction; transaction.updateNode(tableNode, {TableBlockKeys.rowsLen: rowsLen + 1}); - editorState.apply(transaction, withUpdateSelection: false); + await editorState.apply(transaction, withUpdateSelection: false); } -void _setColBgColor( +Future _setColBgColor( Node tableNode, int col, EditorState editorState, String? color, -) { +) async { final transaction = editorState.transaction; final rowslen = tableNode.attributes[TableBlockKeys.rowsLen]; @@ -339,15 +337,15 @@ void _setColBgColor( ); } - editorState.apply(transaction, withUpdateSelection: false); + await editorState.apply(transaction, withUpdateSelection: false); } -void _setRowBgColor( +Future _setRowBgColor( Node tableNode, int row, EditorState editorState, String? color, -) { +) async { final transaction = editorState.transaction; final colsLen = tableNode.attributes[TableBlockKeys.colsLen]; @@ -359,7 +357,7 @@ void _setRowBgColor( ); } - editorState.apply(transaction, withUpdateSelection: false); + await editorState.apply(transaction, withUpdateSelection: false); } void _clearCol( diff --git a/lib/src/editor/block_component/table_block_component/table_action_menu.dart b/lib/src/editor/block_component/table_block_component/table_action_menu.dart index 068708c7..c9c9b655 100644 --- a/lib/src/editor/block_component/table_block_component/table_action_menu.dart +++ b/lib/src/editor/block_component/table_block_component/table_action_menu.dart @@ -92,8 +92,8 @@ void showActionMenu( _showColorMenu( context, - (color) { - TableActions.setBgColor( + (color) async { + await TableActions.setBgColor( node, position, editorState, diff --git a/lib/src/editor/block_component/table_block_component/table_block_component.dart b/lib/src/editor/block_component/table_block_component/table_block_component.dart index 074e1a1b..b7370429 100644 --- a/lib/src/editor/block_component/table_block_component/table_block_component.dart +++ b/lib/src/editor/block_component/table_block_component/table_block_component.dart @@ -25,15 +25,6 @@ class TableBlockKeys { } class TableStyle { - final double colWidth; - final double rowHeight; - final double colMinimumWidth; - final double borderWidth; - final Widget addIcon; - final Widget handlerIcon; - final Color borderColor; - final Color borderHoverColor; - const TableStyle({ this.colWidth = 160, this.rowHeight = 40, @@ -44,6 +35,14 @@ class TableStyle { this.borderColor = TableDefaults.borderColor, this.borderHoverColor = TableDefaults.borderHoverColor, }); + final double colWidth; + final double rowHeight; + final double colMinimumWidth; + final double borderWidth; + final Widget addIcon; + final Widget handlerIcon; + final Color borderColor; + final Color borderHoverColor; } class TableDefaults { @@ -269,6 +268,11 @@ class _TableBlockComponentWidgetState extends State ); } + child = Padding( + padding: margin, + child: child, + ); + return child; } @@ -277,7 +281,7 @@ class _TableBlockComponentWidgetState extends State RenderBox get _renderBox => context.findRenderObject() as RenderBox; @override - Position start() => Position(path: widget.node.path, offset: 0); + Position start() => Position(path: widget.node.path); @override Position end() => Position(path: widget.node.path, offset: 1); @@ -350,7 +354,7 @@ SelectionMenuItem tableMenuItem = SelectionMenuItem( style: style, ), keywords: ['table'], - handler: (editorState, _, __) { + handler: (editorState, _, __) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return; @@ -375,7 +379,6 @@ SelectionMenuItem tableMenuItem = SelectionMenuItem( transaction.afterSelection = Selection.collapsed( Position( path: selection.end.path + [0, 0], - offset: 0, ), ); } else { @@ -383,11 +386,10 @@ SelectionMenuItem tableMenuItem = SelectionMenuItem( transaction.afterSelection = Selection.collapsed( Position( path: selection.end.path.next + [0, 0], - offset: 0, ), ); } - editorState.apply(transaction); + await editorState.apply(transaction); }, ); diff --git a/lib/src/editor/block_component/table_block_component/table_col.dart b/lib/src/editor/block_component/table_block_component/table_col.dart index 0d1a719b..646c0195 100644 --- a/lib/src/editor/block_component/table_block_component/table_col.dart +++ b/lib/src/editor/block_component/table_block_component/table_col.dart @@ -34,7 +34,7 @@ class _TableColState extends State { @override Widget build(BuildContext context) { - List children = []; + final List children = []; if (widget.colIdx == 0) { children.add( TableColBorder( diff --git a/lib/src/editor/block_component/table_block_component/table_commands.dart b/lib/src/editor/block_component/table_block_component/table_commands.dart index b70c41da..2a08eb6a 100644 --- a/lib/src/editor/block_component/table_block_component/table_commands.dart +++ b/lib/src/editor/block_component/table_block_component/table_commands.dart @@ -286,10 +286,10 @@ Node? _getNextNode(Iterable nodes, int colDiff, int rowDiff) { table.children.last.attributes[TableCellBlockKeys.rowPosition] + 1; // Calculate the next column index, considering the column difference and wrapping around with modulo. - var nextCol = (col + colDiff) % numCols; + final nextCol = (col + colDiff) % numCols; // Calculate the next row index, taking into account the row difference and adjusting for additional rows due to column change. - var nextRow = row + rowDiff + ((col + colDiff) ~/ numCols); + final nextRow = row + rowDiff + ((col + colDiff) ~/ numCols); return isValidPosition(nextCol, nextRow, numCols, numRows) ? getCellNode(table, nextCol, nextRow) @@ -311,10 +311,10 @@ Node? _getPreviousNode(Iterable nodes, int colDiff, int rowDiff) { table.children.last.attributes[TableCellBlockKeys.rowPosition] + 1; // Calculate the previous column index, ensuring it wraps within the table boundaries using modulo. - var prevCol = (col - colDiff + numCols) % numCols; + final prevCol = (col - colDiff + numCols) % numCols; // Calculate the previous row index, considering table boundaries and adjusting for potential column underflow. - var prevRow = row - rowDiff - ((col - colDiff) < 0 ? 1 : 0); + final prevRow = row - rowDiff - ((col - colDiff) < 0 ? 1 : 0); return isValidPosition(prevCol, prevRow, numCols, numRows) ? getCellNode(table, prevCol, prevRow) diff --git a/lib/src/editor/block_component/table_block_component/table_node.dart b/lib/src/editor/block_component/table_block_component/table_node.dart index 44489e28..718abaf5 100644 --- a/lib/src/editor/block_component/table_block_component/table_node.dart +++ b/lib/src/editor/block_component/table_block_component/table_node.dart @@ -4,11 +4,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/editor/block_component/table_block_component/table_config.dart'; class TableNode { - final TableConfig _config; - - final Node node; - final List> _cells = []; - TableNode({ required this.node, }) : _config = TableConfig.fromJson(node.attributes) { @@ -78,6 +73,10 @@ class TableNode { factory TableNode.fromJson(Map json) { return TableNode(node: Node.fromJson(json)); } + final TableConfig _config; + + final Node node; + final List> _cells = []; static TableNode fromList(List> cols, {TableConfig? config}) { assert( @@ -93,7 +92,7 @@ class TableNode { config = config ?? TableConfig(); - Node node = Node( + final Node node = Node( type: TableBlockKeys.type, attributes: {} ..addAll({ @@ -193,7 +192,7 @@ class TableNode { Transaction? transaction, }) { // The extra 8 is because of paragraph padding - double maxHeight = _cells + final double maxHeight = _cells .map((c) => c[row].children.first.rect.height + 8) .reduce(max); diff --git a/lib/src/editor/block_component/table_block_component/table_view.dart b/lib/src/editor/block_component/table_block_component/table_view.dart index 81e159aa..1294f638 100644 --- a/lib/src/editor/block_component/table_block_component/table_view.dart +++ b/lib/src/editor/block_component/table_block_component/table_view.dart @@ -33,7 +33,7 @@ class _TableViewState extends State { children: [ ..._buildColumns(context), TableActionButton( - padding: const EdgeInsets.only(left: 0), + padding: const EdgeInsets.only(), icon: widget.tableStyle.addIcon, width: 28, height: widget.tableNode.colsHeight, diff --git a/lib/src/editor/block_component/todo_list_block_component/todo_list_block_component.dart b/lib/src/editor/block_component/todo_list_block_component/todo_list_block_component.dart index 584f55c7..248656ef 100644 --- a/lib/src/editor/block_component/todo_list_block_component/todo_list_block_component.dart +++ b/lib/src/editor/block_component/todo_list_block_component/todo_list_block_component.dart @@ -155,7 +155,6 @@ class _TodoListBlockComponentWidgetState alignment: alignment, child: Row( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, textDirection: textDirection, children: [ diff --git a/lib/src/editor/block_component/todo_list_block_component/todo_list_character_shortcut.dart b/lib/src/editor/block_component/todo_list_block_component/todo_list_character_shortcut.dart index fc2ba050..84f20ce1 100644 --- a/lib/src/editor/block_component/todo_list_block_component/todo_list_character_shortcut.dart +++ b/lib/src/editor/block_component/todo_list_block_component/todo_list_character_shortcut.dart @@ -86,7 +86,7 @@ CharacterShortcutEvent formatHyphenFilledBracketsToCheckedBox = CharacterShortcutEvent insertNewLineAfterTodoList = CharacterShortcutEvent( key: 'insert new block after todo list', character: '\n', - handler: (editorState) async => await insertNewLineInType( + handler: (editorState) async => insertNewLineInType( editorState, 'todo_list', attributes: { diff --git a/lib/src/editor/command/selection_commands.dart b/lib/src/editor/command/selection_commands.dart index 5c4d322b..e1e23680 100644 --- a/lib/src/editor/command/selection_commands.dart +++ b/lib/src/editor/command/selection_commands.dart @@ -144,13 +144,11 @@ extension SelectionTransform on EditorState { transaction.insertNodes( node.path + [0], last.children, - deepCopy: true, ); } else { transaction.insertNodes( node.path.next, last.children, - deepCopy: true, ); } } diff --git a/lib/src/editor/command/text_commands.dart b/lib/src/editor/command/text_commands.dart index 8885c641..1712aa98 100644 --- a/lib/src/editor/command/text_commands.dart +++ b/lib/src/editor/command/text_commands.dart @@ -70,14 +70,12 @@ extension TextTransforms on EditorState { transaction.insertNode( next, nodeBuilder(insertedNode), - deepCopy: true, ); // Set the selection to be at the beginning of the new paragraph. transaction.afterSelection = Selection.collapsed( Position( path: next, - offset: 0, ), ); transaction.selectionExtraInfo = {}; @@ -344,7 +342,7 @@ extension TextTransforms on EditorState { List getTextInSelection([ Selection? selection, ]) { - List res = []; + final List res = []; selection ??= this.selection; if (selection == null || selection.isCollapsed) { return res; diff --git a/lib/src/editor/editor_component/entry/page_block_component.dart b/lib/src/editor/editor_component/entry/page_block_component.dart index 81678e12..5464882f 100644 --- a/lib/src/editor/editor_component/entry/page_block_component.dart +++ b/lib/src/editor/editor_component/entry/page_block_component.dart @@ -97,7 +97,6 @@ class PageBlockComponent extends BlockComponentStatelessWidget { return ScrollablePositionedList.builder( shrinkWrap: scrollController.shrinkWrap, - scrollDirection: Axis.vertical, itemCount: items.length + extentCount, itemBuilder: (context, index) { editorState.updateAutoScroller(Scrollable.of(context)); diff --git a/lib/src/editor/editor_component/service/ime/delta_input_on_non_text_update_impl.dart b/lib/src/editor/editor_component/service/ime/delta_input_on_non_text_update_impl.dart index 3a4e9d9c..3e4a19fe 100644 --- a/lib/src/editor/editor_component/service/ime/delta_input_on_non_text_update_impl.dart +++ b/lib/src/editor/editor_component/service/ime/delta_input_on_non_text_update_impl.dart @@ -33,7 +33,7 @@ Future onNonTextUpdate( } } else if (PlatformExtension.isLinux) { if (selection != null) { - editorState.updateSelectionWithReason( + await editorState.updateSelectionWithReason( Selection.collapsed( Position( path: selection.start.path, @@ -44,7 +44,7 @@ Future onNonTextUpdate( } } else if (PlatformExtension.isMacOS) { if (selection != null) { - editorState.updateSelectionWithReason( + await editorState.updateSelectionWithReason( Selection.collapsed( Position( path: selection.start.path, @@ -54,26 +54,7 @@ Future onNonTextUpdate( ); } } else if (PlatformExtension.isAndroid) { - // on some Android keyboards (e.g. Gboard), they use non-text update to update the selection when moving cursor - // by space bar. - // for the another keyboards (e.g. system keyboard), they will trigger the - // `onFloatingCursor` event instead. - AppFlowyEditorLog.input.debug('[Android] onNonTextUpdate: $nonTextUpdate'); - if (selection != null) { - final nonTextUpdateStart = nonTextUpdate.selection.start; - final selectionStart = selection.start.offset; - if (nonTextUpdateStart != selectionStart) { - editorState.updateSelectionWithReason( - Selection.collapsed( - Position( - path: selection.start.path, - offset: nonTextUpdateStart, - ), - ), - reason: SelectionUpdateReason.uiEvent, - ); - } - } + await handleAndroidNonTextUpdate(nonTextUpdate, editorState); } else if (PlatformExtension.isIOS) { // on iOS, the cursor movement will trigger the `onFloatingCursor` event. // so we don't need to handle the non-text update here. @@ -81,6 +62,55 @@ Future onNonTextUpdate( } } +@visibleForTesting +Future handleAndroidNonTextUpdate( + TextEditingDeltaNonTextUpdate nonTextUpdate, + EditorState editorState, +) async { + // on some Android keyboards (e.g. Gboard), they use non-text update to update the selection when moving cursor + // by space bar. + // for the another keyboards (e.g. system keyboard), they will trigger the + // `onFloatingCursor` event instead. + AppFlowyEditorLog.input.debug('[Android] onNonTextUpdate: $nonTextUpdate'); + + final selection = editorState.selection; + if (selection == null) { + return false; + } + + if (_isSelectAllNonTextUpdate(nonTextUpdate)) { + return selectAllCommand.execute(editorState) == KeyEventResult.handled; + } + + final nonTextUpdateStart = nonTextUpdate.selection.start; + final selectionStart = selection.start.offset; + if (nonTextUpdateStart != selectionStart) { + await editorState.updateSelectionWithReason( + Selection.collapsed( + Position( + path: selection.start.path, + offset: nonTextUpdateStart, + ), + ), + reason: SelectionUpdateReason.uiEvent, + ); + + return true; + } + + return false; +} + +bool _isSelectAllNonTextUpdate(TextEditingDeltaNonTextUpdate nonTextUpdate) { + final selection = nonTextUpdate.selection; + + return nonTextUpdate.oldText.isNotEmpty && + selection.start == 0 && + selection.end == nonTextUpdate.oldText.length && + !selection.isCollapsed && + nonTextUpdate.composing.isCollapsed; +} + Future _checkIfBacktickPressed( EditorState editorState, TextEditingDeltaNonTextUpdate nonTextUpdate, diff --git a/lib/src/editor/editor_component/service/ime/delta_input_service.dart b/lib/src/editor/editor_component/service/ime/delta_input_service.dart index f2342398..f8f24c63 100644 --- a/lib/src/editor/editor_component/service/ime/delta_input_service.dart +++ b/lib/src/editor/editor_component/service/ime/delta_input_service.dart @@ -137,6 +137,8 @@ class DeltaTextInputService extends TextInputService with DeltaTextInputClient { TextInputControl? newControl, ) {} + bool onFocusReceived() => false; + @override void performSelector(String selectorName) { final currentTextEditingValue = this.currentTextEditingValue; diff --git a/lib/src/editor/editor_component/service/ime/non_delta_input_service.dart b/lib/src/editor/editor_component/service/ime/non_delta_input_service.dart index 53f5a2ee..475f5a26 100644 --- a/lib/src/editor/editor_component/service/ime/non_delta_input_service.dart +++ b/lib/src/editor/editor_component/service/ime/non_delta_input_service.dart @@ -71,6 +71,21 @@ class NonDeltaTextInputService extends TextInputService with TextInputClient { TextInputConfiguration configuration, ) { final formattedValue = textEditingValue.format(); + + // On Windows, the IME (e.g. Korean / other CJK) owns the editing state + // while a composing region is active. Pushing `setEditingState` back to the + // engine mid-composition cancels/forks the composition and corrupts the + // text (jamo get scrambled / duplicated). The text input connection is + // already attached at this point, so we simply skip re-attaching while a + // composition is in progress and let the IME drive the state. + final composing = composingTextRange; + if (PlatformExtension.isWindows && + composing != null && + composing.isValid && + !composing.isCollapsed) { + return; + } + if (!formattedValue.isValid() || currentTextEditingValue == formattedValue) { return; @@ -99,6 +114,15 @@ class NonDeltaTextInputService extends TextInputService with TextInputClient { @override void updateEditingValue(TextEditingValue value) { + // Compensate for a Flutter Windows behavior change: during IME composition + // (e.g. Korean / other CJK), the engine reports the caret collapsed at the + // START (left edge) of the composing region. The downstream insert/replace + // logic assumes the caret sits at the END of the composing text, so without + // this normalization each new jamo is inserted to the LEFT of the syllable + // and the text ends up reversed/scrambled (e.g. "ㅕㄴㅇ와" instead of the + // platform's "와ㅕㄴㅇ"). + value = _normalizeComposingSelection(value); + if (currentTextEditingValue == value) { return; } @@ -257,6 +281,27 @@ class NonDeltaTextInputService extends TextInputService with TextInputClient { contentInsertionConfiguration?.onContentInserted.call(content); } + // On Windows the IME reports the caret at the start of the composing region + // during composition; move it to the end so the rest of the pipeline (which + // assumes caret-at-end) inserts the composing text in the correct position. + TextEditingValue _normalizeComposingSelection(TextEditingValue value) { + if (!PlatformExtension.isWindows) { + return value; + } + final composing = value.composing; + final selection = value.selection; + if (composing.isValid && + !composing.isCollapsed && + selection.isCollapsed && + selection.baseOffset == composing.start) { + return value.copyWith( + selection: TextSelection.collapsed(offset: composing.end), + ); + } + + return value; + } + void _updateComposing(TextEditingDelta delta) { if (delta is TextEditingDeltaNonTextUpdate) { composingTextRange = delta.composing; diff --git a/lib/src/editor/editor_component/service/keyboard_service_widget.dart b/lib/src/editor/editor_component/service/keyboard_service_widget.dart index 86f167f6..451d16a3 100644 --- a/lib/src/editor/editor_component/service/keyboard_service_widget.dart +++ b/lib/src/editor/editor_component/service/keyboard_service_widget.dart @@ -244,7 +244,6 @@ class KeyboardServiceWidgetState extends State textEditingValue, TextInputConfiguration( viewId: View.of(context).viewId, - enableDeltaModel: false, inputType: TextInputType.multiline, textCapitalization: TextCapitalization.sentences, inputAction: TextInputAction.newline, diff --git a/lib/src/editor/editor_component/service/renderer/block_component_action.dart b/lib/src/editor/editor_component/service/renderer/block_component_action.dart index 4de537e6..c3e2e77b 100644 --- a/lib/src/editor/editor_component/service/renderer/block_component_action.dart +++ b/lib/src/editor/editor_component/service/renderer/block_component_action.dart @@ -42,8 +42,6 @@ class BlockComponentActionList extends StatelessWidget { @override Widget build(BuildContext context) { return Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ BlockComponentActionButton( diff --git a/lib/src/editor/editor_component/service/renderer/block_component_service.dart b/lib/src/editor/editor_component/service/renderer/block_component_service.dart index 1173109a..506735bb 100644 --- a/lib/src/editor/editor_component/service/renderer/block_component_service.dart +++ b/lib/src/editor/editor_component/service/renderer/block_component_service.dart @@ -53,7 +53,7 @@ mixin BlockComponentSelectable { /// the start position of the block component. /// /// For the text block component, the start position is always 0. - Position start(Node node) => Position(path: node.path, offset: 0); + Position start(Node node) => Position(path: node.path); /// the end position of the block component. /// diff --git a/lib/src/editor/editor_component/service/renderer/block_component_widget.dart b/lib/src/editor/editor_component/service/renderer/block_component_widget.dart index 2a473d65..42e15a98 100644 --- a/lib/src/editor/editor_component/service/renderer/block_component_widget.dart +++ b/lib/src/editor/editor_component/service/renderer/block_component_widget.dart @@ -128,9 +128,12 @@ mixin NestedBlockComponentStatefulWidgetMixin< @override Widget build(BuildContext context) { - return node.children.isEmpty - ? buildComponent(context, withBackgroundColor: true) - : buildComponentWithChildren(context); + return Padding( + padding: configuration.margin(node), + child: node.children.isEmpty + ? buildComponent(context) + : buildComponentWithChildren(context), + ); } Widget buildComponentWithChildren(BuildContext context) { diff --git a/lib/src/editor/editor_component/service/scroll/editor_scroll_controller.dart b/lib/src/editor/editor_component/service/scroll/editor_scroll_controller.dart index 0676bcd0..0ba6c010 100644 --- a/lib/src/editor/editor_component/service/scroll/editor_scroll_controller.dart +++ b/lib/src/editor/editor_component/service/scroll/editor_scroll_controller.dart @@ -209,7 +209,6 @@ class EditorScrollController { if (index < start || index > end) { itemScrollController.jumpTo( index: max(0, index), - alignment: 0, ); } } @@ -247,7 +246,7 @@ class EditorScrollController { // Determine the first visible item by finding the item with the // smallest trailing edge that is greater than 0. i.e. the first // item whose trailing edge in visible in the viewport. - int min = positions + final int min = positions .where((ItemPosition position) => position.itemTrailingEdge > 0) .reduce( (ItemPosition min, ItemPosition position) => diff --git a/lib/src/editor/editor_component/service/scroll/mobile_scroll_service.dart b/lib/src/editor/editor_component/service/scroll/mobile_scroll_service.dart index a313e9c3..686d3260 100644 --- a/lib/src/editor/editor_component/service/scroll/mobile_scroll_service.dart +++ b/lib/src/editor/editor_component/service/scroll/mobile_scroll_service.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -73,7 +75,13 @@ class _MobileScrollServiceState extends State @override void jumpTo(int index) { - editorScrollController.itemScrollController.jumpTo(index: index); + final (start, end) = editorScrollController.visibleRangeNotifier.value; + + if (index < start || index > end) { + editorScrollController.itemScrollController.jumpTo( + index: max(0, index), + ); + } } @override diff --git a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart index fc4fb1a7..9166f667 100644 --- a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart +++ b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart @@ -2,6 +2,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_selection_service.dart'; import 'package:appflowy_editor/src/editor/editor_component/service/selection/shared.dart'; import 'package:appflowy_editor/src/service/selection/selection_gesture.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -51,6 +52,17 @@ class _DesktopSelectionServiceWidgetState Position? _panStartPosition; + /// The word selected by a double-tap. When non-null, the pan that immediately + /// follows the double-tap extends the selection by whole words instead of by + /// characters, keeping this word fully selected. Cleared on a plain tap, + /// triple-tap, or when the pan ends. + Selection? _wordSelectionAnchor; + + /// The global offset of the double-tap that produced [_wordSelectionAnchor]. + /// A following pan only enters word mode when it starts at (essentially) the + /// same point, so a stale anchor cannot hijack an unrelated later drag. + Offset? _wordSelectionAnchorOffset; + bool _isDraggingSelection = false; Offset? _lastPanOffset; @@ -164,6 +176,8 @@ class _DesktopSelectionServiceWidgetState _panStartScrollDy = null; _panStartPosition = null; _lastPanOffset = null; + _wordSelectionAnchor = null; + _wordSelectionAnchorOffset = null; } void _clearContextMenu() { @@ -255,6 +269,10 @@ class _DesktopSelectionServiceWidgetState void _onTapDown(TapDownDetails details) { _clearContextMenu(); + // A plain tap starts a fresh gesture; drop any pending word-drag anchor. + _wordSelectionAnchor = null; + _wordSelectionAnchorOffset = null; + final canTap = _interceptors.every( (element) => element.canTap?.call(details) ?? true, ); @@ -302,10 +320,16 @@ class _DesktopSelectionServiceWidgetState final node = getNodeInOffset(offset); final selection = node?.selectable?.getWordBoundaryInOffset(offset); if (selection == null) { + _wordSelectionAnchor = null; + _wordSelectionAnchorOffset = null; clearSelection(); return; } + // Arm word-drag mode: the pan that continues from this double-tap will + // extend the selection by whole words, keeping this word fully selected. + _wordSelectionAnchor = selection.normalized; + _wordSelectionAnchorOffset = offset; updateSelection(selection); } @@ -317,6 +341,9 @@ class _DesktopSelectionServiceWidgetState if (!canTripleTap) { return updateSelection(null); } + // Triple-tap is not word-drag mode. + _wordSelectionAnchor = null; + _wordSelectionAnchorOffset = null; final offset = details.globalPosition; final node = getNodeInOffset(offset); final selectable = node?.selectable; @@ -325,7 +352,7 @@ class _DesktopSelectionServiceWidgetState return; } - Selection selection = Selection( + final Selection selection = Selection( start: selectable.start(), end: selectable.end(), ); @@ -380,7 +407,19 @@ class _DesktopSelectionServiceWidgetState } void _onPanStart(DragStartDetails details) { - clearSelection(); + // In word-drag mode (a pan that continues from a double-tap) keep the + // double-tapped word selected; the drag extends it by whole words. The pan + // shares the double-tap's pointer-down, so it must start at essentially the + // same point — otherwise a stale anchor is dropped and this is a plain drag. + final anchorOffset = _wordSelectionAnchorOffset; + final isWordSelectionDrag = _wordSelectionAnchor != null && + anchorOffset != null && + (details.globalPosition - anchorOffset).distance <= kDoubleTapSlop; + if (!isWordSelectionDrag) { + _wordSelectionAnchor = null; + _wordSelectionAnchorOffset = null; + clearSelection(); + } final canPanStart = _interceptors.every( (interceptor) => interceptor.canPanStart?.call(details) ?? true, @@ -426,7 +465,6 @@ class _DesktopSelectionServiceWidgetState editorState.service.scrollService?.startAutoScroll( _lastPanOffset!, - edgeOffset: 200, duration: const Duration(milliseconds: 2), ); } @@ -450,6 +488,26 @@ class _DesktopSelectionServiceWidgetState return; } + final selectable = getNodeInOffset(panEndOffset)?.selectable; + if (selectable == null) { + return; + } + + final Selection? selection = _wordSelectionAnchor != null + ? _wordSelectionDuringDrag(selectable, panEndOffset) + : _characterSelectionDuringDrag(selectable, panEndOffset); + + if (selection != null && selection != currentSelection.value) { + updateSelection(selection); + } + } + + /// Character-level drag selection: the anchor is the exact character position + /// where the pan started and the extent tracks the cursor character. + Selection? _characterSelectionDuringDrag( + SelectableMixin selectable, + Offset panEndOffset, + ) { final double? currentDy = editorState.service.scrollService?.dy; final Offset panStartOffset = currentDy == null || _panStartScrollDy == null ? _panStartOffset! @@ -458,12 +516,7 @@ class _DesktopSelectionServiceWidgetState _panStartScrollDy! - currentDy, ); - final selectable = getNodeInOffset(panEndOffset)?.selectable; - if (selectable == null) { - return; - } - - final Selection selection = Selection( + return Selection( start: _panStartPosition!, end: selectable .getSelectionInRange( @@ -472,10 +525,44 @@ class _DesktopSelectionServiceWidgetState ) .end, ); + } - if (selection != currentSelection.value) { - updateSelection(selection); + /// Word-level drag selection (double-tap then drag): the double-tapped word + /// stays fully selected while the selection extends to the whole word under + /// the cursor, in either direction. + Selection? _wordSelectionDuringDrag( + SelectableMixin selectable, + Offset panEndOffset, + ) { + final anchor = _wordSelectionAnchor; + if (anchor == null) { + return null; } + + final focus = selectable.getWordBoundaryInOffset(panEndOffset)?.normalized; + if (focus == null) { + return null; + } + + // If the focus word starts before the anchor word, the drag goes backward: + // keep the anchor word's end as the base and extend to the focus word's + // start. Otherwise the drag goes forward (or stays on the same word). + if (_comparePosition(focus.start, anchor.start) < 0) { + return Selection(start: anchor.end, end: focus.start); + } + + return Selection(start: anchor.start, end: focus.end); + } + + /// Compares two positions in document order. + /// Returns a negative value if [a] is before [b], zero if equal, and a + /// positive value if [a] is after [b]. + int _comparePosition(Position a, Position b) { + if (a.path.equals(b.path)) { + return a.offset.compareTo(b.offset); + } + + return a.path < b.path ? -1 : 1; } void _handleAutoScrollWhileDragging() { diff --git a/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart b/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart index b178f8ce..45cc2fb5 100644 --- a/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart +++ b/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart @@ -219,7 +219,7 @@ class _MobileSelectionServiceWidgetState final node = editorState.getNodeAtPath(selection.start.path); final selectable = node?.selectable; - var rect = selectable?.getCursorRectInPosition( + final rect = selectable?.getCursorRectInPosition( selection.start, shiftWithBaseOffset: true, ); @@ -348,7 +348,6 @@ class _MobileSelectionServiceWidgetState if (isCollapsedHandleVisible) { editorState.updateSelectionWithReason( editorState.selection, - reason: SelectionUpdateReason.transaction, ); } }, @@ -661,7 +660,6 @@ class _MobileSelectionServiceWidgetState selection, reason: SelectionUpdateReason.uiEvent, customSelectionType: SelectionType.inline, - extraInfo: null, ); } @@ -688,7 +686,7 @@ class _MobileSelectionServiceWidgetState return; } - Selection selection = Selection( + final Selection selection = Selection( start: selectable.start(), end: selectable.end(), ); @@ -758,7 +756,6 @@ class _MobileSelectionServiceWidgetState Selection.collapsed(position), reason: SelectionUpdateReason.uiEvent, customSelectionType: SelectionType.inline, - extraInfo: null, ); } diff --git a/lib/src/editor/editor_component/service/selection/shared.dart b/lib/src/editor/editor_component/service/selection/shared.dart index e24bada8..0e2dab2f 100644 --- a/lib/src/editor/editor_component/service/selection/shared.dart +++ b/lib/src/editor/editor_component/service/selection/shared.dart @@ -86,34 +86,12 @@ extension EditorStateSelection on EditorState { } final node = filteredNodes[min]; - if (node.children.isNotEmpty && - _getCachedRect(node.children.first, rectCache).top <= offset.dy) { - final skipSortingChildren = - node.selectable?.skipSortingChildrenWhenSelecting ?? false; - List children; - - if (skipSortingChildren) { - children = node.children; - } else { - children = node.children.toList(growable: false) - ..sort( - (a, b) { - final aRect = _getCachedRect(a, rectCache!); - final bRect = _getCachedRect(b, rectCache); - - return aRect.bottom != bRect.bottom - ? aRect.bottom.compareTo(bRect.bottom) - : aRect.left.compareTo(bRect.left); - }, - ); - } - - // if the nodes are in the same tab view, their rect are overlaid, - // we need to filter out the invisible nodes - bool hasInvisibleChildren = false; + if (node.children.isNotEmpty) { + // First, filter out invisible children (Offstage or Opacity 0) + // This must happen BEFORE checking rect conditions final visibleChildren = []; - for (final child in children) { + for (final child in node.children) { final context = child.key.currentContext; var isVisible = true; context?.visitAncestorElements((element) { @@ -124,29 +102,55 @@ extension EditorStateSelection on EditorState { return false; } + if (widget is Offstage && widget.offstage) { + isVisible = false; + + return false; + } + return true; }); if (isVisible) { visibleChildren.add(child); - } else { - hasInvisibleChildren = true; } } - if (hasInvisibleChildren) { - children = visibleChildren; - } - if (children.isEmpty) { - return node; - } + // Now check if any visible child's rect contains the offset + if (visibleChildren.isNotEmpty && + _getCachedRect(visibleChildren.first, rectCache).top <= offset.dy) { + final skipSortingChildren = + node.selectable?.skipSortingChildrenWhenSelecting ?? false; - return getNodeInOffset( - children, - offset, - 0, - children.length - 1, - rectCache, - ); + List children; + + if (skipSortingChildren) { + children = visibleChildren; + } else { + children = visibleChildren.toList(growable: false) + ..sort( + (a, b) { + final aRect = _getCachedRect(a, rectCache!); + final bRect = _getCachedRect(b, rectCache); + + return aRect.bottom != bRect.bottom + ? aRect.bottom.compareTo(bRect.bottom) + : aRect.left.compareTo(bRect.left); + }, + ); + } + + if (children.isEmpty) { + return node; + } + + return getNodeInOffset( + children, + offset, + 0, + children.length - 1, + rectCache, + ); + } } return node; diff --git a/lib/src/editor/editor_component/service/shortcuts/character/format_single_character/format_single_character.dart b/lib/src/editor/editor_component/service/shortcuts/character/format_single_character/format_single_character.dart index 5c118f2e..ca181fbb 100644 --- a/lib/src/editor/editor_component/service/shortcuts/character/format_single_character/format_single_character.dart +++ b/lib/src/editor/editor_component/service/shortcuts/character/format_single_character/format_single_character.dart @@ -87,7 +87,7 @@ class CheckSingleFormatFormatResult { ); final lastCharIndex = plainText.lastIndexOf(character); final textAfterLastChar = plainText.substring(lastCharIndex + 1); - bool textAfterLastCharIsEmpty = textAfterLastChar.trim().isEmpty; + final bool textAfterLastCharIsEmpty = textAfterLastChar.trim().isEmpty; // The following conditions won't trigger the single character formatting: // 1. There is no 'Character' in the plainText: lastIndexOf returns -1. @@ -120,6 +120,17 @@ class CheckSingleFormatFormatResult { return (false, null); } + // 6. For underscore italic, only auto-format when the opening underscore + // starts a word, i.e. it is at the very start or preceded by whitespace. + // This prevents intra-word underscores like `a_b_c` from being italicized, + // matching the common markdown behavior (asterisks are unaffected). + if (character == '_') { + final openingIndex = startIndex + lastCharIndex; + if (openingIndex > 0 && rawPlainText[openingIndex - 1].trim().isNotEmpty) { + return (false, null); + } + } + return ( true, CheckSingleFormatFormatResult( diff --git a/lib/src/editor/editor_component/service/shortcuts/character/slash_command.dart b/lib/src/editor/editor_component/service/shortcuts/character/slash_command.dart index 962719c2..124ee995 100644 --- a/lib/src/editor/editor_component/service/shortcuts/character/slash_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/character/slash_command.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -23,7 +24,7 @@ const Set _defaultSupportSlashMenuNodeTypes = { final CharacterShortcutEvent slashCommand = CharacterShortcutEvent( key: 'show the slash menu', character: '/', - handler: (editorState) async => await _showSlashMenu( + handler: (editorState) async => _showSlashMenu( editorState, standardSelectionMenuItems, ), @@ -111,7 +112,7 @@ Future _showSlashMenu( style: style, ); if (!kIsWeb && Platform.environment.containsKey('FLUTTER_TEST')) { - _selectionMenuService?.show(); + unawaited(_selectionMenuService!.show()); } else { await _selectionMenuService?.show(); } diff --git a/lib/src/editor/editor_component/service/shortcuts/character_shortcut_events/format_double_characters/utils.dart b/lib/src/editor/editor_component/service/shortcuts/character_shortcut_events/format_double_characters/utils.dart index 9aa9bf0f..8eeab75c 100644 --- a/lib/src/editor/editor_component/service/shortcuts/character_shortcut_events/format_double_characters/utils.dart +++ b/lib/src/editor/editor_component/service/shortcuts/character_shortcut_events/format_double_characters/utils.dart @@ -9,7 +9,7 @@ Future handleDoubleCharacterReplacement({ }) async { assert(character.length == 1); - Selection? selection = editorState.selection; + final Selection? selection = editorState.selection; if (selection == null) { return false; } diff --git a/lib/src/editor/editor_component/service/shortcuts/command/arrow_left_command.dart b/lib/src/editor/editor_component/service/shortcuts/command/arrow_left_command.dart index 0d42ccd8..6be14fd2 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/arrow_left_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/arrow_left_command.dart @@ -34,7 +34,7 @@ CommandShortcutEventHandler _arrowLeftCommandHandler = (editorState) { if (isRTL(editorState)) { editorState.moveCursorBackward(SelectionMoveRange.character); } else { - editorState.moveCursorForward(SelectionMoveRange.character); + editorState.moveCursorForward(); } return KeyEventResult.handled; diff --git a/lib/src/editor/editor_component/service/shortcuts/command/arrow_right_command.dart b/lib/src/editor/editor_component/service/shortcuts/command/arrow_right_command.dart index 833cb77f..b63422d5 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/arrow_right_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/arrow_right_command.dart @@ -32,7 +32,7 @@ CommandShortcutEventHandler _arrowRightCommandHandler = (editorState) { return KeyEventResult.ignored; } if (isRTL(editorState)) { - editorState.moveCursorForward(SelectionMoveRange.character); + editorState.moveCursorForward(); } else { editorState.moveCursorBackward(SelectionMoveRange.character); } diff --git a/lib/src/editor/editor_component/service/shortcuts/command/backspace_command.dart b/lib/src/editor/editor_component/service/shortcuts/command/backspace_command.dart index 2a0b2949..95fbac78 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/backspace_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/backspace_command.dart @@ -58,7 +58,6 @@ CommandShortcutEventHandler _backspaceInCollapsedSelection = (editorState) { transaction.afterSelection = Selection.collapsed( Position( path: position.path, - offset: 0, ), ); editorState.apply(transaction); @@ -85,7 +84,6 @@ CommandShortcutEventHandler _backspaceInCollapsedSelection = (editorState) { ..afterSelection = Selection.collapsed( Position( path: path, - offset: 0, ), ); } else { @@ -97,7 +95,7 @@ CommandShortcutEventHandler _backspaceInCollapsedSelection = (editorState) { return KeyEventResult.handled; } - Node? tableParent = + final Node? tableParent = node.findParent((element) => element.type == TableBlockKeys.type); Node? prevTableParent; final prev = node.previousNodeWhere((element) { diff --git a/lib/src/editor/editor_component/service/shortcuts/command/copy_paste_extension.dart b/lib/src/editor/editor_component/service/shortcuts/command/copy_paste_extension.dart index fedf4ed0..b542f2cb 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/copy_paste_extension.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/copy_paste_extension.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:appflowy_editor/appflowy_editor.dart'; final _listTypes = [ @@ -85,7 +87,6 @@ extension EditorCopyPaste on EditorState { if (lastNode.delta != null) { nodes.last.insertDelta( delta.slice(selection.endIndex), - insertAfter: true, ); } } @@ -129,7 +130,7 @@ extension EditorCopyPaste on EditorState { // delete the selection first. if (!selection.isCollapsed) { - deleteSelection(selection); + await deleteSelection(selection); } // fetch selection again.selection = editorState.selection; diff --git a/lib/src/editor/editor_component/service/shortcuts/command/delete_command.dart b/lib/src/editor/editor_component/service/shortcuts/command/delete_command.dart index 0f48f284..bb1910a6 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/delete_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/delete_command.dart @@ -47,7 +47,7 @@ CommandShortcutEventHandler _deleteInCollapsedSelection = (editorState) { final transaction = editorState.transaction; if (position.offset == delta.length) { - Node? tableParent = + final Node? tableParent = node.findParent((element) => element.type == TableBlockKeys.type); Node? nextTableParent; final next = node.findDownward((element) { diff --git a/lib/src/editor/editor_component/service/shortcuts/command/find_replace_command.dart b/lib/src/editor/editor_component/service/shortcuts/command/find_replace_command.dart index 968f9302..c638cf65 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/find_replace_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/find_replace_command.dart @@ -125,8 +125,6 @@ KeyEventResult _showFindAndReplaceDialog( showReplaceMenu: openReplace, localizations: localizations, style: style, - showRegexButton: true, - showCaseSensitiveButton: true, ); _findReplaceService?.show(); diff --git a/lib/src/editor/editor_component/service/shortcuts/command/paste_command.dart b/lib/src/editor/editor_component/service/shortcuts/command/paste_command.dart index 8fb8be63..30603853 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/paste_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/paste_command.dart @@ -66,7 +66,7 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) { } if (text != null && text.isNotEmpty) { - editorState.pastePlainText(text); + await editorState.pastePlainText(text); } }(); @@ -110,7 +110,6 @@ extension on EditorState { Future pastePlainText(String plainText) async { final selectionAttributes = getDeltaAttributesInSelectionStart(); - // TODO remove this deletion after refactoring pasteHtmlIfAvailable below final selection = await deleteSelectionIfNeeded(); if (selection == null) { @@ -129,14 +128,14 @@ extension on EditorState { ..trimRight(), ) .map((paragraph) { - Delta delta = Delta(); + final Delta delta = Delta(); if (_hrefRegex.hasMatch(paragraph) || _phoneRegex.hasMatch(paragraph)) { final match = _hrefRegex.firstMatch(paragraph) ?? _phoneRegex.firstMatch(paragraph); if (match != null) { - int startPos = match.start; - int endPos = match.end; + final int startPos = match.start; + final int endPos = match.end; final String? entity = match.group(0); if (entity != null) { /// insert the text before the link or phone diff --git a/lib/src/editor/editor_component/style/default_text_span_decorator_for_attribute.dart b/lib/src/editor/editor_component/style/default_text_span_decorator_for_attribute.dart index 5431dd98..073699aa 100644 --- a/lib/src/editor/editor_component/style/default_text_span_decorator_for_attribute.dart +++ b/lib/src/editor/editor_component/style/default_text_span_decorator_for_attribute.dart @@ -38,7 +38,7 @@ TextSpan defaultTextSpanDecoratorForAttribute( HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed) { tapCount = 0; - editorLaunchUrl(href); + await editorLaunchUrl(href); return; } diff --git a/lib/src/editor/editor_component/style/editor_style.dart b/lib/src/editor/editor_component/style/editor_style.dart index f994b7bb..10093226 100644 --- a/lib/src/editor/editor_component/style/editor_style.dart +++ b/lib/src/editor/editor_component/style/editor_style.dart @@ -7,6 +7,73 @@ import 'package:flutter/material.dart'; /// the [AppFlowyEditor]. /// class EditorStyle { + const EditorStyle.desktop({ + EdgeInsets? padding, + Color? cursorColor, + Color? selectionColor, + TextStyleConfiguration? textStyleConfiguration, + TextSpanDecoratorForAttribute? textSpanDecorator, + this.textSpanOverlayBuilder, + this.textSpanBackgroundBuilder, + this.defaultTextDirection, + this.cursorWidth = 2.0, + this.textScaleFactor = 1.0, + this.maxWidth, + }) : padding = padding ?? const EdgeInsets.symmetric(horizontal: 100), + cursorColor = cursorColor ?? const Color(0xFF00BCF0), + selectionColor = + selectionColor ?? const Color.fromARGB(53, 111, 201, 231), + textStyleConfiguration = textStyleConfiguration ?? + const TextStyleConfiguration( + text: TextStyle(fontSize: 16, color: Colors.black), + ), + textSpanDecorator = + textSpanDecorator ?? defaultTextSpanDecoratorForAttribute, + magnifierSize = Size.zero, + mobileDragHandleBallSize = Size.zero, + mobileDragHandleWidth = 0.0, + enableHapticFeedbackOnAndroid = false, + dragHandleColor = Colors.transparent, + mobileDragHandleTopExtend = null, + mobileDragHandleWidthExtend = null, + mobileDragHandleLeftExtend = null, + mobileDragHandleHeightExtend = null, + autoDismissCollapsedHandleDuration = const Duration(); + + const EditorStyle.mobile({ + EdgeInsets? padding, + Color? cursorColor, + Color? dragHandleColor, + Color? selectionColor, + TextStyleConfiguration? textStyleConfiguration, + TextSpanDecoratorForAttribute? textSpanDecorator, + this.textSpanOverlayBuilder, + this.textSpanBackgroundBuilder, + this.defaultTextDirection, + this.magnifierSize = const Size(72, 48), + this.mobileDragHandleBallSize = const Size(8, 8), + this.mobileDragHandleWidth = 2.0, + this.cursorWidth = 2.0, + this.enableHapticFeedbackOnAndroid = true, + this.textScaleFactor = 1.0, + this.maxWidth, + this.mobileDragHandleTopExtend, + this.mobileDragHandleWidthExtend, + this.mobileDragHandleLeftExtend, + this.mobileDragHandleHeightExtend, + this.autoDismissCollapsedHandleDuration = const Duration(seconds: 3), + }) : padding = padding ?? const EdgeInsets.symmetric(horizontal: 20), + cursorColor = cursorColor ?? const Color(0xFF00BCF0), + dragHandleColor = dragHandleColor ?? const Color(0xFF00BCF0), + selectionColor = + selectionColor ?? const Color.fromARGB(53, 111, 201, 231), + textStyleConfiguration = textStyleConfiguration ?? + const TextStyleConfiguration( + text: TextStyle(fontSize: 16, color: Colors.black), + ), + textSpanDecorator = + textSpanDecorator ?? mobileTextSpanDecoratorForAttribute; + const EditorStyle({ required this.padding, required this.cursorColor, @@ -15,6 +82,7 @@ class EditorStyle { required this.textStyleConfiguration, required this.textSpanDecorator, this.textSpanOverlayBuilder, + this.textSpanBackgroundBuilder, this.magnifierSize = const Size(72, 48), this.mobileDragHandleBallSize = const Size(8, 8), this.mobileDragHandleWidth = 2.0, @@ -68,6 +136,9 @@ class EditorStyle { /// Customize the text span overlay builder. final AppFlowyTextSpanOverlayBuilder? textSpanOverlayBuilder; + /// Customize the text span background builder. + final AppFlowyTextSpanBackgroundBuilder? textSpanBackgroundBuilder; + final String? defaultTextDirection; // The size of the magnifier. @@ -105,71 +176,6 @@ class EditorStyle { final double textScaleFactor; - const EditorStyle.desktop({ - EdgeInsets? padding, - Color? cursorColor, - Color? selectionColor, - TextStyleConfiguration? textStyleConfiguration, - TextSpanDecoratorForAttribute? textSpanDecorator, - this.textSpanOverlayBuilder, - this.defaultTextDirection, - this.cursorWidth = 2.0, - this.textScaleFactor = 1.0, - this.maxWidth, - }) : padding = padding ?? const EdgeInsets.symmetric(horizontal: 100), - cursorColor = cursorColor ?? const Color(0xFF00BCF0), - selectionColor = - selectionColor ?? const Color.fromARGB(53, 111, 201, 231), - textStyleConfiguration = textStyleConfiguration ?? - const TextStyleConfiguration( - text: TextStyle(fontSize: 16, color: Colors.black), - ), - textSpanDecorator = - textSpanDecorator ?? defaultTextSpanDecoratorForAttribute, - magnifierSize = Size.zero, - mobileDragHandleBallSize = Size.zero, - mobileDragHandleWidth = 0.0, - enableHapticFeedbackOnAndroid = false, - dragHandleColor = Colors.transparent, - mobileDragHandleTopExtend = null, - mobileDragHandleWidthExtend = null, - mobileDragHandleLeftExtend = null, - mobileDragHandleHeightExtend = null, - autoDismissCollapsedHandleDuration = const Duration(seconds: 0); - - const EditorStyle.mobile({ - EdgeInsets? padding, - Color? cursorColor, - Color? dragHandleColor, - Color? selectionColor, - TextStyleConfiguration? textStyleConfiguration, - TextSpanDecoratorForAttribute? textSpanDecorator, - this.textSpanOverlayBuilder, - this.defaultTextDirection, - this.magnifierSize = const Size(72, 48), - this.mobileDragHandleBallSize = const Size(8, 8), - this.mobileDragHandleWidth = 2.0, - this.cursorWidth = 2.0, - this.enableHapticFeedbackOnAndroid = true, - this.textScaleFactor = 1.0, - this.maxWidth, - this.mobileDragHandleTopExtend, - this.mobileDragHandleWidthExtend, - this.mobileDragHandleLeftExtend, - this.mobileDragHandleHeightExtend, - this.autoDismissCollapsedHandleDuration = const Duration(seconds: 3), - }) : padding = padding ?? const EdgeInsets.symmetric(horizontal: 20), - cursorColor = cursorColor ?? const Color(0xFF00BCF0), - dragHandleColor = dragHandleColor ?? const Color(0xFF00BCF0), - selectionColor = - selectionColor ?? const Color.fromARGB(53, 111, 201, 231), - textStyleConfiguration = textStyleConfiguration ?? - const TextStyleConfiguration( - text: TextStyle(fontSize: 16, color: Colors.black), - ), - textSpanDecorator = - textSpanDecorator ?? mobileTextSpanDecoratorForAttribute; - EditorStyle copyWith({ EdgeInsets? padding, Color? cursorColor, @@ -178,6 +184,7 @@ class EditorStyle { TextStyleConfiguration? textStyleConfiguration, TextSpanDecoratorForAttribute? textSpanDecorator, AppFlowyTextSpanOverlayBuilder? textSpanOverlayBuilder, + AppFlowyTextSpanBackgroundBuilder? textSpanBackgroundBuilder, String? defaultTextDirection, Size? magnifierSize, Size? mobileDragHandleBallSize, @@ -202,6 +209,8 @@ class EditorStyle { textSpanDecorator: textSpanDecorator ?? this.textSpanDecorator, textSpanOverlayBuilder: textSpanOverlayBuilder ?? this.textSpanOverlayBuilder, + textSpanBackgroundBuilder: + textSpanBackgroundBuilder ?? this.textSpanBackgroundBuilder, defaultTextDirection: defaultTextDirection, magnifierSize: magnifierSize ?? this.magnifierSize, mobileDragHandleBallSize: diff --git a/lib/src/editor/editor_component/style/mobile_text_span_decorator_for_attribute.dart b/lib/src/editor/editor_component/style/mobile_text_span_decorator_for_attribute.dart index 21a342db..a6316c63 100644 --- a/lib/src/editor/editor_component/style/mobile_text_span_decorator_for_attribute.dart +++ b/lib/src/editor/editor_component/style/mobile_text_span_decorator_for_attribute.dart @@ -29,8 +29,8 @@ TextSpan mobileTextSpanDecoratorForAttribute( ..onTapUp = (_) async { if (timer != null && timer!.isActive) { // Implement single tap logic - safeLaunchUrl(hrefAddress); timer!.cancel(); + await safeLaunchUrl(hrefAddress); return; } @@ -107,9 +107,9 @@ class _LinkEditFormState extends State { @override Widget build(BuildContext context) { - var hrefAddressTextEditingController = + final hrefAddressTextEditingController = TextEditingController(text: widget.hrefAddress); - var hrefTextTextEditingController = + final hrefTextTextEditingController = TextEditingController(text: widget.hrefText); return Form( diff --git a/lib/src/editor/find_replace_menu/find_replace_widget.dart b/lib/src/editor/find_replace_menu/find_replace_widget.dart index b6a69875..e52f0987 100644 --- a/lib/src/editor/find_replace_menu/find_replace_widget.dart +++ b/lib/src/editor/find_replace_menu/find_replace_widget.dart @@ -75,7 +75,6 @@ class _FindAndReplaceMenuWidgetState extends State { @override Widget build(BuildContext context) { return Column( - mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( @@ -417,7 +416,6 @@ class _ReplaceMenuState extends State { child: TextField( key: const Key('replaceTextField'), focusNode: replaceTextFieldFocusNode, - autofocus: false, controller: replaceTextEditingController, onSubmitted: (_) { _replaceSelectedWord(); diff --git a/lib/src/editor/find_replace_menu/search_algorithm.dart b/lib/src/editor/find_replace_menu/search_algorithm.dart index 8b67d971..3b770231 100644 --- a/lib/src/editor/find_replace_menu/search_algorithm.dart +++ b/lib/src/editor/find_replace_menu/search_algorithm.dart @@ -56,11 +56,11 @@ class BoyerMoore extends SearchAlgorithm { } List _searchMethod(String pattern, String text) { - int m = pattern.length; - int n = text.length; + final int m = pattern.length; + final int n = text.length; - Map badchar = {}; - List matches = []; + final Map badchar = {}; + final List matches = []; _badCharHeuristic(pattern, m, badchar); @@ -91,7 +91,7 @@ class BoyerMoore extends SearchAlgorithm { // Fill the actual value of last occurrence of a character // (indices of table are characters and values are index of occurrence) for (int i = 0; i < size; i++) { - String ch = pat[i]; + final String ch = pat[i]; badchar[ch] = i; } } diff --git a/lib/src/editor/find_replace_menu/search_service.dart b/lib/src/editor/find_replace_menu/search_service.dart index bbe71bc4..8a7777ad 100644 --- a/lib/src/editor/find_replace_menu/search_service.dart +++ b/lib/src/editor/find_replace_menu/search_service.dart @@ -49,13 +49,13 @@ class SearchService { for (final n in _getAllNodes()) { //matches list will contain the offsets where the desired word, //is found. - List matches = searchAlgorithm + final List matches = searchAlgorithm .searchMethod(pattern, n.delta!.toPlainText()) .map((e) => e.start) .toList(); //we will store this list of offsets along with their path, //in a list of positions. - for (int matchedOffset in matches) { + for (final int matchedOffset in matches) { matchedPositions.add(Position(path: n.path, offset: matchedOffset)); } } @@ -112,7 +112,7 @@ class SearchService { Position start, [ bool isNavigating = false, ]) async { - Position end = Position( + final Position end = Position( path: start.path, offset: start.offset + queriedPattern.length, ); @@ -131,7 +131,7 @@ class SearchService { //not selected. if (selectedIndex > -1) { final currentMatch = matchedPositions[selectedIndex]; - Position end = Position( + final Position end = Position( path: currentMatch.path, offset: currentMatch.offset + queriedPattern.length, ); diff --git a/lib/src/editor/find_replace_menu/search_service_v2.dart b/lib/src/editor/find_replace_menu/search_service_v2.dart index 869ed428..3383e75c 100644 --- a/lib/src/editor/find_replace_menu/search_service_v2.dart +++ b/lib/src/editor/find_replace_menu/search_service_v2.dart @@ -82,7 +82,7 @@ class SearchServiceV2 { for (final node in nodes) { if (node.delta != null) { final text = node.delta!.toPlainText(); - List matches = searchAlgorithm + final List matches = searchAlgorithm .searchMethod( caseSensitive ? pattern : pattern.toLowerCase(), caseSensitive ? text : text.toLowerCase(), @@ -91,7 +91,7 @@ class SearchServiceV2 { .toList(); // we will store this list of offsets along with their path, // in a list of positions. - for (int matchedOffset in matches) { + for (final int matchedOffset in matches) { result.add( Position(path: node.path, offset: matchedOffset), ); diff --git a/lib/src/editor/find_replace_menu/search_service_v3.dart b/lib/src/editor/find_replace_menu/search_service_v3.dart index 822b221b..b100cdcc 100644 --- a/lib/src/editor/find_replace_menu/search_service_v3.dart +++ b/lib/src/editor/find_replace_menu/search_service_v3.dart @@ -8,9 +8,11 @@ const selectionExtraInfoDisableToolbar = 'selectionExtraInfoDisableToolbar'; class SearchServiceV3 { SearchServiceV3({ required this.editorState, + this.jumpInterceptor, }); final EditorState editorState; + final JumpInterceptor? jumpInterceptor; // matchWrappers.value will contain a list of matchWrappers of the matched patterns // the position here consists of the match and the node path of @@ -60,7 +62,7 @@ class SearchServiceV3 { } String _getRegexReplaced(String replaceText, Match match) { - List groups = match + final List groups = match .groups(List.generate(match.groupCount + 1, (index) => index)); String replacedText = replaceText; @@ -84,7 +86,7 @@ class SearchServiceV3 { try { pattern = _getPattern(target); } on FormatException { - matchWrappers.value.clear(); + matchWrappers.value = []; return 'Regex'; } @@ -93,7 +95,7 @@ class SearchServiceV3 { // this means we have a new pattern, but before we highlight the new matches, // lets unhighlight the old pattern _findAndHighlight(queriedPattern, unHighlight: true); - matchWrappers.value.clear(); + matchWrappers.value = []; queriedPattern = pattern; targetString = target; } @@ -144,7 +146,7 @@ class SearchServiceV3 { final matches = searchAlgorithm.searchMethod(pattern, text); // we will store this list of offsets along with their path, // in a list of positions. - for (Match match in matches) { + for (final Match match in matches) { result.add( MatchWrapper(match, node.path), ); @@ -163,7 +165,9 @@ class SearchServiceV3 { ) { final MatchWrapper(:selection, :path) = matchWrappers.value[selectedIndex]; - editorState.scrollService?.jumpTo(path.first); + if (jumpInterceptor?.call(path) == false) { + editorState.scrollService?.jumpTo(path.first); + } editorState.updateSelectionWithReason( selection, @@ -171,6 +175,8 @@ class SearchServiceV3 { extraInfo: { selectionExtraInfoDisableToolbar: true, selectionExtraInfoDoNotAttachTextService: true, + selectionExtraInfoDisableMobileToolbarKey: true, + selectionExtraInfoSelectionRadius: 6.0, }, ); } @@ -223,15 +229,13 @@ class SearchServiceV3 { ); await editorState.apply(transaction); - matchWrappers.value.clear(); + matchWrappers.value = []; _findAndHighlight(queriedPattern); } /// Replaces all the found occurrences of pattern with replaceText void replaceAllMatches(String replaceText) { - if (replaceText.isEmpty || - queriedPattern.isEmpty || - matchWrappers.value.isEmpty) { + if (queriedPattern.isEmpty || matchWrappers.value.isEmpty) { return; } @@ -255,7 +259,7 @@ class SearchServiceV3 { editorState.apply(transaction); } - matchWrappers.value.clear(); + matchWrappers.value = []; } } @@ -282,3 +286,5 @@ extension on Pattern { } } } + +typedef JumpInterceptor = bool Function(Path path); diff --git a/lib/src/editor/inline_comment/comment_card_widget.dart b/lib/src/editor/inline_comment/comment_card_widget.dart new file mode 100644 index 00000000..220c6b20 --- /dev/null +++ b/lib/src/editor/inline_comment/comment_card_widget.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'inline_comment.dart'; +import 'inline_comment_controller.dart'; + +/// A card widget that displays a single [InlineComment]. +/// +/// Shows the author name, relative time, comment content, and action buttons +/// (delete). The card is visually elevated when [isFocused] is true. +class CommentCard extends StatelessWidget { + const CommentCard({ + super.key, + required this.comment, + required this.controller, + this.isFocused = false, + this.onFocusChanged, + }); + + final InlineComment comment; + final InlineCommentController controller; + final bool isFocused; + final ValueChanged? onFocusChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return GestureDetector( + onTap: () => onFocusChanged?.call(!isFocused), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: isFocused + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isFocused + ? colorScheme.primary.withValues(alpha: 0.5) + : colorScheme.outlineVariant.withValues(alpha: 0.4), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(context), + const SizedBox(height: 6), + Text( + comment.content, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.85), + height: 1.4, + ), + maxLines: isFocused ? null : 2, + overflow: isFocused ? null : TextOverflow.ellipsis, + ), + if (isFocused && !comment.isResolved) ...[ + const SizedBox(height: 8), + _buildActions(context), + ], + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Row( + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Text( + comment.authorName.isNotEmpty + ? comment.authorName[0].toUpperCase() + : '?', + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + comment.authorName, + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (comment.isResolved) ...[ + Icon( + Icons.check_circle, + size: 14, + color: colorScheme.primary, + ), + const SizedBox(width: 4), + ], + Text( + _formatRelativeTime(comment.createdAt), + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.45), + fontSize: 11, + ), + ), + ], + ); + } + + Widget _buildActions(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Align( + alignment: Alignment.centerRight, + child: SizedBox( + height: 28, + child: TextButton( + onPressed: () async { + final id = comment.id; + await controller.onCommentDeleted(id); + controller.removeComment(id); + }, + style: TextButton.styleFrom( + foregroundColor: colorScheme.onSurface.withValues(alpha: 0.5), + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.delete_outline, size: 14), + SizedBox(width: 4), + Text('Delete', style: TextStyle(fontSize: 12)), + ], + ), + ), + ), + ); + } + + String _formatRelativeTime(DateTime dateTime) { + final now = DateTime.now(); + final diff = now.difference(dateTime); + if (diff.inDays > 365) return '${diff.inDays ~/ 365}y'; + if (diff.inDays > 30) return '${diff.inDays ~/ 30}mo'; + if (diff.inDays > 0) return '${diff.inDays}d'; + if (diff.inHours > 0) return '${diff.inHours}h'; + if (diff.inMinutes > 0) return '${diff.inMinutes}m'; + + return 'now'; + } +} diff --git a/lib/src/editor/inline_comment/comment_mobile_toolbar_item.dart b/lib/src/editor/inline_comment/comment_mobile_toolbar_item.dart new file mode 100644 index 00000000..d75e38b8 --- /dev/null +++ b/lib/src/editor/inline_comment/comment_mobile_toolbar_item.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/inline_comment/comment_utils.dart'; +import 'package:flutter/material.dart'; + +/// Returns a [MobileToolbarItem] that adds an inline comment to the current +/// text selection. +/// +/// Pass the [InlineCommentController] that manages comments for the editor. +/// +/// The item icon is hidden (returns `null`) when: +/// - There is no active selection. +/// - The selection is collapsed (no text selected). +/// - None of the selected nodes carries a delta. +/// +/// When the user taps the icon the same add-comment flow as the desktop item +/// is executed: existing comment-ids are collected, [onCommentAdded] is called, +/// and the result is written back via [EditorState.formatDelta]. +MobileToolbarItem buildCommentMobileToolbarItem( + InlineCommentController controller, +) { + return MobileToolbarItem.action( + itemIconBuilder: (context, editorState, __) { + final selection = editorState.selection; + if (selection == null || selection.isCollapsed) return null; + if (!selection.isSingle) return null; + + final node = editorState.getNodeAtPath(selection.start.path); + if (node?.delta == null) return null; + + return Icon( + Icons.comment_outlined, + color: MobileToolbarTheme.of(context).iconColor, + size: 22, + ); + }, + actionHandler: (context, editorState) { + unawaited(_addComment(controller, editorState)); + }, + ); +} + +Future _addComment( + InlineCommentController controller, + EditorState editorState, +) async { + final selection = editorState.selection?.normalized; + if (selection == null || selection.isCollapsed) return; + + // Only single-node selections are supported. + if (!selection.isSingle) return; + + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null || node.id.isEmpty) return; + + final existingIds = collectExistingCommentIds(editorState, selection); + + final commentId = await controller.onCommentAdded( + node.id, + selection.startIndex, + selection.endIndex, + null, + ); + + if (commentId == null) return; + + await editorState.formatDelta( + selection, + { + AppFlowyRichTextKeys.commentIds: [...existingIds, commentId], + }, + ); +} diff --git a/lib/src/editor/inline_comment/comment_sidebar_widget.dart b/lib/src/editor/inline_comment/comment_sidebar_widget.dart new file mode 100644 index 00000000..d2f0b9d4 --- /dev/null +++ b/lib/src/editor/inline_comment/comment_sidebar_widget.dart @@ -0,0 +1,140 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'inline_comment_service.dart'; +import 'comment_card_widget.dart'; + +/// Desktop sidebar that displays all [InlineComment]s in document order. +/// +/// Uses [InlineCommentService.scanDocumentAnchors] to determine each comment's +/// order by its text position, then renders cards in a scrollable column. +class CommentSidebarWidget extends StatefulWidget { + const CommentSidebarWidget({ + super.key, + required this.editorState, + required this.controller, + this.sidebarWidth = 240.0, + }); + + final EditorState editorState; + final InlineCommentController controller; + + /// Width of the sidebar in logical pixels. + final double sidebarWidth; + + @override + State createState() => _CommentSidebarWidgetState(); +} + +class _CommentSidebarWidgetState extends State { + List _orderedCommentIds = []; + String? _focusedCommentId; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onCommentsChanged); + _updateOrder(); + } + + @override + void didUpdateWidget(covariant CommentSidebarWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller.removeListener(_onCommentsChanged); + widget.controller.addListener(_onCommentsChanged); + } + _updateOrder(); + } + + @override + void dispose() { + widget.controller.removeListener(_onCommentsChanged); + super.dispose(); + } + + void _onCommentsChanged() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _updateOrder(); + setState(() {}); + } + }); + } + + void _updateOrder() { + final anchors = InlineCommentService.scanDocumentAnchors( + widget.editorState.document, + ); + // Sort comment ids by their document position (path then offset). + final sortedIds = anchors.entries.toList() + ..sort((a, b) { + final pathCmp = _comparePaths(a.value.nodePath, b.value.nodePath); + if (pathCmp != 0) return pathCmp; + + return a.value.startOffset.compareTo(b.value.startOffset); + }); + _orderedCommentIds = sortedIds.map((e) => e.key).toList(); + } + + int _comparePaths(List a, List b) { + for (int i = 0; i < a.length && i < b.length; i++) { + if (a[i] != b[i]) return a[i].compareTo(b[i]); + } + + return a.length.compareTo(b.length); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + width: widget.sidebarWidth, + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ), + ), + child: _buildSidebar(), + ); + } + + Widget _buildSidebar() { + return ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + // Filter to comments that exist in both the controller and the document. + final comments = _orderedCommentIds + .map((id) => widget.controller.findById(id)) + .whereType() + .toList(); + + if (comments.isEmpty) return const SizedBox.shrink(); + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (int i = 0; i < comments.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + CommentCard( + comment: comments[i], + controller: widget.controller, + isFocused: _focusedCommentId == comments[i].id, + onFocusChanged: (focused) { + setState(() { + _focusedCommentId = focused ? comments[i].id : null; + }); + }, + ), + ], + ], + ), + ); + }, + ); + } +} diff --git a/lib/src/editor/inline_comment/comment_text_span_decorator.dart b/lib/src/editor/inline_comment/comment_text_span_decorator.dart new file mode 100644 index 00000000..20d0f26c --- /dev/null +++ b/lib/src/editor/inline_comment/comment_text_span_decorator.dart @@ -0,0 +1,150 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +/// Builds a [TextSpanDecoratorForAttribute] that highlights text with +/// inline comments. +/// +/// Pass the result as the `textSpanDecorator` of your [EditorStyle]: +/// +/// ```dart +/// EditorStyle.desktop( +/// textSpanDecorator: buildCommentTextSpanDecorator( +/// controller: myController, +/// ), +/// ) +/// ``` +/// +/// If you have an existing decorator (e.g. the default href handler), pass +/// it as [base] to chain them together. +TextSpanDecoratorForAttribute buildCommentTextSpanDecorator({ + required InlineCommentController controller, + TextSpanDecoratorForAttribute? base, +}) { + base ??= defaultTextSpanDecoratorForAttribute; + + return (context, node, index, textInsert, before, after) { + final intermediate = + base!.call(context, node, index, textInsert, before, after); + if (intermediate is! TextSpan) { + return intermediate; + } + + return applyCommentDecoration( + context: context, + node: node, + index: index, + textInsert: textInsert, + before: intermediate, + after: after, + controller: controller, + ); + }; +} + +/// Applies comment highlight decoration to a [TextInsert] that carries comment ids. +/// +/// This function is designed to be used as part of a chained +/// [TextSpanDecoratorForAttribute]. It reads the `comment-ids` attribute, +/// looks up matching [InlineComment] objects from [InlineCommentController], +/// and returns a decorated [TextSpan] with: +/// +/// - A background highlight color +/// - An underline in the same color (PDF annotation style) +/// - A [TapGestureRecognizer] that invokes [InlineCommentController.onCommentTapped] +/// +/// Returns [before] unchanged when: +/// - The text insert carries no `comment-ids` +/// - No matching [InlineComment] is found in the controller +/// - The controller is not available in the widget tree +/// +/// [recognizerProvider] is an optional factory that returns a managed +/// [TapGestureRecognizer] for a given comment-id key. When provided, the +/// caller owns the recognizer lifecycle (creation and disposal). When omitted, +/// a short-lived recognizer is created locally — callers should prefer +/// providing a [recognizerProvider] to avoid memory leaks. +InlineSpan applyCommentDecoration({ + required BuildContext context, + required Node node, + required int index, + required TextInsert textInsert, + required InlineSpan before, + required InlineSpan after, + required InlineCommentController controller, + TapGestureRecognizer Function( + String commentId, + InlineCommentController controller, + BuildContext context, + )? recognizerProvider, +}) { + // 1. Read comment-ids attribute + final attributes = textInsert.attributes; + if (attributes == null) return before; + + final rawIds = attributes[AppFlowyRichTextKeys.commentIds]; + if (rawIds == null) return before; + + final List ids; + if (rawIds is List) { + ids = rawIds.map((e) => e.toString()).toList(); + } else if (rawIds is String) { + ids = [rawIds]; + } else { + return before; + } + + if (ids.isEmpty) return before; + + // 2. Find valid comments (at least one in the controller) + final comments = ids + .map((id) => controller.findById(id)) + .whereType() + .toList(); + + if (comments.isEmpty) return before; + + // 3. Use the first valid comment's highlight color + final color = controller.highlightColorFor(comments.first); + + // 4. Build highlighted style + // `before` may be a TextSpan or WidgetSpan; only operate on TextSpan + if (before is! TextSpan) return before; + + final existingStyle = before.style ?? const TextStyle(); + final highlightStyle = existingStyle.copyWith( + backgroundColor: color, + decoration: TextDecoration.underline, + decorationColor: color.withValues(alpha: 0.8), + ); + + // 5. TapGestureRecognizer — single tap triggers onCommentTapped. + // Use the managed recognizer from the provider when available so that the + // caller (e.g. _InlineCommentServiceWidgetState) can properly dispose it. + // Fall back to a locally-created recognizer only when no provider is given. + final TapGestureRecognizer recognizer; + if (recognizerProvider != null) { + recognizer = recognizerProvider(comments.first.id, controller, context); + // Refresh the onTap binding so it captures the current comments list and + // BuildContext every time the decoration is re-applied. + recognizer.onTap = () { + for (final comment in comments) { + controller.onCommentTapped?.call(comment.id, context); + } + }; + } else { + recognizer = TapGestureRecognizer() + ..onTap = () { + for (final comment in comments) { + controller.onCommentTapped?.call(comment.id, context); + } + }; + } + + return TextSpan( + text: textInsert.text, + style: highlightStyle, + recognizer: recognizer, + mouseCursor: SystemMouseCursors.click, + children: before.children, + ); +} diff --git a/lib/src/editor/inline_comment/comment_toolbar_item.dart b/lib/src/editor/inline_comment/comment_toolbar_item.dart new file mode 100644 index 00000000..8a34e79b --- /dev/null +++ b/lib/src/editor/inline_comment/comment_toolbar_item.dart @@ -0,0 +1,118 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/inline_comment/comment_utils.dart'; +import 'package:flutter/material.dart'; + +const _kCommentItemId = 'editor.comment'; + +/// Returns a [ToolbarItem] that adds an inline comment on the selected text. +/// +/// Pass the [InlineCommentController] that manages comments for the editor. +/// +/// Usage: +/// ```dart +/// AppFlowyEditor( +/// floatingToolbarItems: [ +/// ...standardFloatingToolbarItems, +/// buildCommentToolbarItem(myController), +/// ], +/// ) +/// ``` +ToolbarItem buildCommentToolbarItem(InlineCommentController controller) { + return ToolbarItem( + id: _kCommentItemId, + group: 4, + isActive: _isActive, + builder: (context, editorState, highlightColor, iconColor, tooltipBuilder) { + final child = _CommentToolbarButton( + editorState: editorState, + iconColor: iconColor, + controller: controller, + ); + + if (tooltipBuilder != null) { + return tooltipBuilder( + context, + _kCommentItemId, + 'Comment', + child, + ); + } + + return child; + }, + ); +} + +bool _isActive(EditorState editorState) { + final selection = editorState.selection; + if (selection == null || selection.isCollapsed) { + return false; + } + + if (!selection.isSingle) { + return false; + } + + final node = editorState.getNodeAtPath(selection.start.path); + + return node?.delta != null; +} + +// --------------------------------------------------------------------------- +// Internal button widget — keeps async logic out of the builder closure. +// --------------------------------------------------------------------------- + +class _CommentToolbarButton extends StatelessWidget { + const _CommentToolbarButton({ + required this.editorState, + required this.iconColor, + required this.controller, + }); + + final EditorState editorState; + final Color? iconColor; + final InlineCommentController controller; + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon( + Icons.comment_outlined, + size: 16, + color: iconColor, + ), + onPressed: () => _onPressed(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor(width: 28, height: 28), + ); + } + + Future _onPressed() async { + final selection = editorState.selection?.normalized; + if (selection == null || selection.isCollapsed) return; + + // Only single-node selections are supported (comment spans one node). + if (!selection.isSingle) return; + + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null || node.id.isEmpty) return; + + final existingIds = collectExistingCommentIds(editorState, selection); + + final commentId = await controller.onCommentAdded( + node.id, + selection.startIndex, + selection.endIndex, + null, + ); + + if (commentId == null) return; + + await editorState.formatDelta( + selection, + { + AppFlowyRichTextKeys.commentIds: [...existingIds, commentId], + }, + ); + } +} diff --git a/lib/src/editor/inline_comment/comment_utils.dart b/lib/src/editor/inline_comment/comment_utils.dart new file mode 100644 index 00000000..223b9cf3 --- /dev/null +++ b/lib/src/editor/inline_comment/comment_utils.dart @@ -0,0 +1,44 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +/// Collects all comment-ids from the delta operations that overlap +/// with [normalizedSelection] in the editor. +/// +/// The [normalizedSelection] must be a single-node selection (i.e. +/// [Selection.isSingle] is `true`). Only delta ops whose character range +/// overlaps `[startIndex, endIndex)` are considered, so comment-ids that +/// exist outside the selected text are not included. +List collectExistingCommentIds( + EditorState editorState, + Selection normalizedSelection, +) { + final existing = {}; + + // Only single-node selections are supported (caller should ensure this). + final node = editorState.getNodeAtPath(normalizedSelection.start.path); + final delta = node?.delta; + if (delta == null) return []; + + final startIndex = normalizedSelection.startIndex; + final endIndex = normalizedSelection.endIndex; + + int offset = 0; + for (final op in delta) { + if (op is TextInsert) { + final opEnd = offset + op.text.length; + // Only process ops that overlap with the selection range. + if (offset < endIndex && opEnd > startIndex) { + final raw = op.attributes?[AppFlowyRichTextKeys.commentIds]; + if (raw is List) { + existing.addAll(raw.map((e) => e.toString())); + } else if (raw is String) { + existing.add(raw); + } + } + offset = opEnd; + } else { + offset += 1; + } + } + + return existing.toList(); +} diff --git a/lib/src/editor/inline_comment/inline_comment.dart b/lib/src/editor/inline_comment/inline_comment.dart new file mode 100644 index 00000000..3b157cd2 --- /dev/null +++ b/lib/src/editor/inline_comment/inline_comment.dart @@ -0,0 +1,47 @@ +/// An inline comment anchored to a text range in the document. +/// +/// The editor stores only [id] inside the delta attributes +/// (`comment-ids` key). All comment content is managed externally +/// (e.g. by the AppFlowy app) and passed in via [InlineCommentController]. +class InlineComment { + const InlineComment({ + required this.id, + required this.content, + required this.authorName, + required this.createdAt, + this.isResolved = false, + this.metadata, + }); + + final String id; + final String content; + final String authorName; + final DateTime createdAt; + final bool isResolved; + final Map? metadata; + + InlineComment copyWith({ + String? id, + String? content, + String? authorName, + DateTime? createdAt, + bool? isResolved, + Map? metadata, + }) { + return InlineComment( + id: id ?? this.id, + content: content ?? this.content, + authorName: authorName ?? this.authorName, + createdAt: createdAt ?? this.createdAt, + isResolved: isResolved ?? this.isResolved, + metadata: metadata ?? this.metadata, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || other is InlineComment && id == other.id; + + @override + int get hashCode => id.hashCode; +} diff --git a/lib/src/editor/inline_comment/inline_comment_controller.dart b/lib/src/editor/inline_comment/inline_comment_controller.dart new file mode 100644 index 00000000..59e59786 --- /dev/null +++ b/lib/src/editor/inline_comment/inline_comment_controller.dart @@ -0,0 +1,129 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +/// Called when the user requests to add a comment on the selected text. +/// +/// Parameters: +/// - [nodeId]: the id of the [Node] that contains the anchored text. +/// - [startOffset]: delta offset of the selection start within the node. +/// - [endOffset]: delta offset of the selection end within the node. +/// - [initialText]: optional pre-filled comment text. +/// +/// Returns the newly created comment id, or null if the operation was +/// cancelled / failed. +typedef OnCommentAdded = Future Function( + String nodeId, + int startOffset, + int endOffset, + String? initialText, +); + +/// Called when a comment anchor is fully removed from the document +/// (i.e. all text carrying that comment-id has been deleted). +typedef OnCommentDeleted = Future Function(String commentId); + +/// Called when the user taps on highlighted text that carries a comment. +/// +/// The host app can use this to show a detail sheet, scroll to the comment +/// in a sidebar, etc. +typedef OnCommentTapped = void Function( + String commentId, + BuildContext context, +); + +/// Controls inline comments displayed in an [AppFlowyEditor]. +/// +/// The editor **never** stores comment content — it only stores comment ids +/// (anchors) inside the delta attributes. All comment data lives outside the +/// editor and is pushed in via [updateComments] / [updateComment]. +/// +/// Usage: +/// ```dart +/// final controller = InlineCommentController( +/// onCommentAdded: (nodeId, start, end, _) async { +/// // create comment in your backend, return its id +/// return 'cmt-${uuid()}'; +/// }, +/// onCommentDeleted: (id) async { /* remove from backend */ }, +/// ); +/// +/// AppFlowyEditor( +/// editorState: editorState, +/// inlineCommentController: controller, +/// ); +/// ``` +class InlineCommentController extends ChangeNotifier { + InlineCommentController({ + required this.onCommentAdded, + required this.onCommentDeleted, + this.onCommentTapped, + List? initialComments, + this.commentHighlightColorBuilder, + this.defaultHighlightColor = const Color(0x66FFC107), + }) : _comments = List.from(initialComments ?? []); + + // --------------------------------------------------------------------------- + // Callbacks (set by the host app) + // --------------------------------------------------------------------------- + + final OnCommentAdded onCommentAdded; + final OnCommentDeleted onCommentDeleted; + final OnCommentTapped? onCommentTapped; + + // --------------------------------------------------------------------------- + // Styling + // --------------------------------------------------------------------------- + + /// Override per-comment highlight color. Receives the comment and returns + /// the desired background color. Falls back to [defaultHighlightColor]. + final Color Function(InlineComment comment)? commentHighlightColorBuilder; + + /// Default highlight color applied to text that carries a comment. + final Color defaultHighlightColor; + + // --------------------------------------------------------------------------- + // Comment list + // --------------------------------------------------------------------------- + + List _comments; + + /// All comments currently known to the editor. + List get comments => List.unmodifiable(_comments); + + /// Replace the full comment list (e.g. after a server sync). + void updateComments(List comments) { + _comments = List.from(comments); + notifyListeners(); + } + + /// Update a single comment by id, notifying listeners. + void updateComment(InlineComment comment) { + final idx = _comments.indexWhere((c) => c.id == comment.id); + if (idx >= 0) { + _comments[idx] = comment; + notifyListeners(); + } + } + + /// Remove a comment by id, notifying listeners. + void removeComment(String commentId) { + final before = _comments.length; + _comments.removeWhere((c) => c.id == commentId); + if (_comments.length != before) notifyListeners(); + } + + /// Look up a comment by id; returns null if not found. + InlineComment? findById(String id) { + for (final c in _comments) { + if (c.id == id) { + return c; + } + } + + return null; + } + + /// Returns the highlight color to use for [comment]. + Color highlightColorFor(InlineComment comment) => + commentHighlightColorBuilder?.call(comment) ?? defaultHighlightColor; +} diff --git a/lib/src/editor/inline_comment/inline_comment_service.dart b/lib/src/editor/inline_comment/inline_comment_service.dart new file mode 100644 index 00000000..5f1bfb75 --- /dev/null +++ b/lib/src/editor/inline_comment/inline_comment_service.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:appflowy_editor/appflowy_editor.dart'; + +/// Anchors a comment to a position in the document. +/// +/// Records the [nodePath] of the [Node] and the [startOffset] within +/// that node's delta where the comment first appears. +class CommentAnchor { + const CommentAnchor({ + required this.nodePath, + required this.startOffset, + }); + + final Path nodePath; + final int startOffset; +} + +/// Listens to the [EditorState] transaction stream and notifies the +/// [InlineCommentController] when a comment anchor is fully removed from +/// the document (i.e. all text carrying that comment-id has been deleted). +class InlineCommentService { + InlineCommentService({ + required this.editorState, + required this.controller, + }) { + _subscription = editorState.transactionStream.listen(_onTransaction); + } + + final EditorState editorState; + final InlineCommentController controller; + late final StreamSubscription _subscription; + + void dispose() { + _subscription.cancel(); + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /// Scans the entire document delta and returns a mapping from comment id to + /// its first anchor position (nodePath + startOffset within that node's delta). + /// + /// This is a convenience wrapper around [scanDocumentAnchors] that uses the + /// service's own [editorState]. + Map scanAnchors() => + scanDocumentAnchors(editorState.document); + + /// Scans [document] and returns a mapping from comment id to its first anchor + /// position (nodePath + startOffset within that node's delta). + /// + /// This is a **pure read operation** with no side effects, and can be called + /// without instantiating an [InlineCommentService]. + static Map scanDocumentAnchors(Document document) { + final result = {}; + _visitDescendantsStatic(document.root, (node) { + final delta = node.delta; + if (delta == null) return; + + int offset = 0; + for (final op in delta) { + if (op is TextInsert) { + final attrs = op.attributes; + if (attrs != null && attrs.containsKey('comment-ids')) { + final raw = attrs['comment-ids']; + final ids = _extractCommentIdsStatic(raw); + for (final id in ids) { + // Only record the first occurrence. + result.putIfAbsent( + id, + () => CommentAnchor( + nodePath: node.path, + startOffset: offset, + ), + ); + } + } + offset += op.length; + } else { + offset += op.length; + } + } + }); + + return result; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /// Visits all descendant nodes recursively, starting from [node]'s children. + /// The [node] itself is intentionally skipped (typically the document root). + static void _visitDescendantsStatic( + Node node, + void Function(Node) visitor, + ) { + for (final child in node.children) { + visitor(child); + if (child.children.isNotEmpty) { + _visitDescendantsStatic(child, visitor); + } + } + } + + /// Extract a `List` of comment ids from the attribute value, which + /// may be stored as a `List` or a plain `String`. + static List _extractCommentIdsStatic(dynamic raw) { + if (raw is List) { + return raw.map((e) => e.toString()).toList(); + } else if (raw is String) { + return [raw]; + } + + return []; + } + + void _onTransaction(EditorTransactionValue value) { + final (time, _, _) = value; + if (time != TransactionTime.after) { + return; + } + + // After every transaction, collect the comment ids still present in the + // document and compare against the controller's known comment list. + // Any id that is in the controller but no longer in the document has been + // fully deleted. + final anchors = scanAnchors(); + final existingIds = anchors.keys.toSet(); + + for (final comment in List.of(controller.comments)) { + if (!existingIds.contains(comment.id)) { + // Notify the host application and remove from the controller's list. + unawaited(controller.onCommentDeleted(comment.id)); + controller.removeComment(comment.id); + } + } + } +} diff --git a/lib/src/editor/inline_comment/inline_comment_widget.dart b/lib/src/editor/inline_comment/inline_comment_widget.dart new file mode 100644 index 00000000..ec3e756c --- /dev/null +++ b/lib/src/editor/inline_comment/inline_comment_widget.dart @@ -0,0 +1,112 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'inline_comment_service.dart'; + +/// A wrapper widget that adds inline comment support to [AppFlowyEditor]. +/// +/// Place this widget **around** [AppFlowyEditor] instead of modifying the +/// editor itself. The editor remains completely unaware of comments. +/// +/// **Important:** To render comment highlights the caller must pass a +/// [buildCommentTextSpanDecorator] as the `textSpanDecorator` of the +/// [EditorStyle] supplied to [AppFlowyEditor]. This widget no longer +/// monkey-patches the decorator at runtime, which avoids the bug where +/// `AppFlowyEditor._updateValues()` would overwrite the decorator. +/// +/// Usage: +/// ```dart +/// InlineCommentWidget( +/// editorState: editorState, +/// controller: myController, +/// showSidebar: true, +/// child: AppFlowyEditor( +/// editorState: editorState, +/// editorStyle: EditorStyle.desktop( +/// textSpanDecorator: buildCommentTextSpanDecorator( +/// controller: myController, +/// ), +/// ), +/// ), +/// ) +/// ``` +class InlineCommentWidget extends StatefulWidget { + const InlineCommentWidget({ + super.key, + required this.editorState, + required this.controller, + required this.child, + this.showSidebar = false, + this.sidebarWidth = 240.0, + }); + + final EditorState editorState; + final InlineCommentController controller; + + /// The [AppFlowyEditor] (or any other widget) to wrap. + final Widget child; + + /// Whether to show the comment sidebar to the right of the editor. + /// + /// Defaults to `false`. Only meaningful on desktop platforms. + final bool showSidebar; + + /// Width of the comment sidebar in logical pixels. + /// + /// Defaults to `240.0`. Ignored when [showSidebar] is `false`. + final double sidebarWidth; + + @override + State createState() => _InlineCommentWidgetState(); +} + +class _InlineCommentWidgetState extends State { + late InlineCommentService _service; + + @override + void initState() { + super.initState(); + _service = InlineCommentService( + editorState: widget.editorState, + controller: widget.controller, + ); + } + + @override + void didUpdateWidget(covariant InlineCommentWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.editorState != oldWidget.editorState || + widget.controller != oldWidget.controller) { + _service.dispose(); + _service = InlineCommentService( + editorState: widget.editorState, + controller: widget.controller, + ); + } + } + + @override + void dispose() { + _service.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.showSidebar) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: widget.child), + CommentSidebarWidget( + editorState: widget.editorState, + controller: widget.controller, + sidebarWidth: widget.sidebarWidth, + ), + ], + ); + } + + return widget.child; + } +} diff --git a/lib/src/editor/selection_menu/selection_menu_icon.dart b/lib/src/editor/selection_menu/selection_menu_icon.dart index 383fb0e6..a6bced2a 100644 --- a/lib/src/editor/selection_menu/selection_menu_icon.dart +++ b/lib/src/editor/selection_menu/selection_menu_icon.dart @@ -9,7 +9,7 @@ class SelectionMenuIconWidget extends StatelessWidget { required this.isSelected, required this.style, }) { - assert((name == null && icon != null) || ((name != null && icon == null))); + assert((name == null && icon != null) || (name != null && icon == null)); } final String? name; diff --git a/lib/src/editor/selection_menu/selection_menu_item_widget.dart b/lib/src/editor/selection_menu/selection_menu_item_widget.dart index 444bdd47..187da454 100644 --- a/lib/src/editor/selection_menu/selection_menu_item_widget.dart +++ b/lib/src/editor/selection_menu/selection_menu_item_widget.dart @@ -67,8 +67,8 @@ class _SelectionMenuItemWidgetState extends State { fontSize: 12.0, ), ), - onPressed: () { - widget.item.handler( + onPressed: () async { + await widget.item.handler( widget.editorState, widget.menuService, context, diff --git a/lib/src/editor/selection_menu/selection_menu_service.dart b/lib/src/editor/selection_menu/selection_menu_service.dart index 73974b16..004d489a 100644 --- a/lib/src/editor/selection_menu/selection_menu_service.dart +++ b/lib/src/editor/selection_menu/selection_menu_service.dart @@ -272,8 +272,8 @@ final List standardSelectionMenuItems = [ style: style, ), keywords: ['text'], - handler: (editorState, _, __) { - insertNodeAfterSelection(editorState, paragraphNode()); + handler: (editorState, _, __) async { + await insertNodeAfterSelection(editorState, paragraphNode()); }, ), SelectionMenuItem( @@ -284,8 +284,8 @@ final List standardSelectionMenuItems = [ style: style, ), keywords: ['heading 1, h1'], - handler: (editorState, _, __) { - insertHeadingAfterSelection(editorState, 1); + handler: (editorState, _, __) async { + await insertHeadingAfterSelection(editorState, 1); }, ), SelectionMenuItem( @@ -296,8 +296,8 @@ final List standardSelectionMenuItems = [ style: style, ), keywords: ['heading 2, h2'], - handler: (editorState, _, __) { - insertHeadingAfterSelection(editorState, 2); + handler: (editorState, _, __) async { + await insertHeadingAfterSelection(editorState, 2); }, ), SelectionMenuItem( @@ -308,8 +308,8 @@ final List standardSelectionMenuItems = [ style: style, ), keywords: ['heading 3, h3'], - handler: (editorState, _, __) { - insertHeadingAfterSelection(editorState, 3); + handler: (editorState, _, __) async { + await insertHeadingAfterSelection(editorState, 3); }, ), SelectionMenuItem( @@ -320,7 +320,7 @@ final List standardSelectionMenuItems = [ style: style, ), keywords: ['image'], - handler: (editorState, menuService, context) { + handler: (editorState, menuService, context) async { final container = Overlay.of(context, rootOverlay: true); showImageMenu(container, editorState, menuService); }, @@ -333,8 +333,8 @@ final List standardSelectionMenuItems = [ style: style, ), keywords: ['bulleted list', 'list', 'unordered list'], - handler: (editorState, _, __) { - insertBulletedListAfterSelection(editorState); + handler: (editorState, _, __) async { + await insertBulletedListAfterSelection(editorState); }, ), SelectionMenuItem( @@ -345,8 +345,8 @@ final List standardSelectionMenuItems = [ style: style, ), keywords: ['numbered list', 'list', 'ordered list'], - handler: (editorState, _, __) { - insertNumberedListAfterSelection(editorState); + handler: (editorState, _, __) async { + await insertNumberedListAfterSelection(editorState); }, ), SelectionMenuItem( @@ -357,8 +357,8 @@ final List standardSelectionMenuItems = [ style: style, ), keywords: ['todo list', 'list', 'checkbox list'], - handler: (editorState, _, __) { - insertCheckboxAfterSelection(editorState); + handler: (editorState, _, __) async { + await insertCheckboxAfterSelection(editorState); }, ), SelectionMenuItem( @@ -369,8 +369,8 @@ final List standardSelectionMenuItems = [ style: style, ), keywords: ['quote', 'refer'], - handler: (editorState, _, __) { - insertQuoteAfterSelection(editorState); + handler: (editorState, _, __) async { + await insertQuoteAfterSelection(editorState); }, ), dividerMenuItem, @@ -386,8 +386,8 @@ final List singleColumnVisibleMenuItems = [ style: style, ), keywords: ['text'], - handler: (editorState, _, __) { - insertNodeAfterSelection(editorState, paragraphNode()); + handler: (editorState, _, __) async { + await insertNodeAfterSelection(editorState, paragraphNode()); }, ), SelectionMenuItem( @@ -398,8 +398,8 @@ final List singleColumnVisibleMenuItems = [ style: style, ), keywords: ['heading 1, h1'], - handler: (editorState, _, __) { - insertHeadingAfterSelection(editorState, 1); + handler: (editorState, _, __) async { + await insertHeadingAfterSelection(editorState, 1); }, ), SelectionMenuItem( @@ -410,8 +410,8 @@ final List singleColumnVisibleMenuItems = [ style: style, ), keywords: ['heading 2, h2'], - handler: (editorState, _, __) { - insertHeadingAfterSelection(editorState, 2); + handler: (editorState, _, __) async { + await insertHeadingAfterSelection(editorState, 2); }, ), ]; diff --git a/lib/src/editor/selection_menu/selection_menu_widget.dart b/lib/src/editor/selection_menu/selection_menu_widget.dart index a25946e5..f2d70300 100644 --- a/lib/src/editor/selection_menu/selection_menu_widget.dart +++ b/lib/src/editor/selection_menu/selection_menu_widget.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -5,7 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; -typedef SelectionMenuItemHandler = void Function( +typedef SelectionMenuItemHandler = Future Function( EditorState editorState, SelectionMenuService menuService, BuildContext context, @@ -19,78 +20,6 @@ typedef SelectionMenuItemNameBuilder = Widget Function( /// Selection Menu Item class SelectionMenuItem { - SelectionMenuItem({ - required String Function() getName, - required this.icon, - required this.keywords, - required SelectionMenuItemHandler handler, - this.nameBuilder, - this.deleteKeywords = false, - this.deleteSlash = true, - }) : _getName = getName { - this.handler = (editorState, menuService, context) { - if (deleteSlash || deleteKeywords) { - _deleteSlash(editorState); - } - - handler(editorState, menuService, context); - onSelected?.call(); - }; - } - - final String Function() _getName; - final Widget Function( - EditorState editorState, - bool onSelected, - SelectionMenuStyle style, - ) icon; - final SelectionMenuItemNameBuilder? nameBuilder; - - String get name => _getName(); - - /// Customizes keywords for item. - /// - /// The keywords are used to quickly retrieve items. - final List keywords; - - List get allKeywords => keywords + [name.toLowerCase()]; - late final SelectionMenuItemHandler handler; - - VoidCallback? onSelected; - - bool deleteSlash; - bool deleteKeywords; - - void _deleteSlash(EditorState editorState) { - final selection = editorState.selection; - if (selection == null || !selection.isCollapsed) { - return; - } - final node = editorState.getNodeAtPath(selection.end.path); - final delta = node?.delta; - if (node == null || delta == null) { - return; - } - final end = selection.start.offset; - int deletedIndex = 0; - - if (deleteKeywords) { - deletedIndex = 0; - } else if (deleteSlash) { - deletedIndex = delta.toPlainText().substring(0, end).lastIndexOf('/'); - } - - // delete all the texts after '/' along with '/' - final transaction = editorState.transaction - ..deleteText( - node, - deletedIndex, - end - deletedIndex, - ); - - editorState.apply(transaction); - } - /// Creates a selection menu entry for inserting a [Node]. /// [getName] and [iconData] define the appearance within the selection menu. /// @@ -105,8 +34,10 @@ class SelectionMenuItem { factory SelectionMenuItem.node({ required String Function() getName, required List keywords, - required Node Function(EditorState editorState, BuildContext context) - nodeBuilder, + required FutureOr Function( + EditorState editorState, + BuildContext context, + ) nodeBuilder, IconData? iconData, Widget Function( EditorState editorState, @@ -115,6 +46,7 @@ class SelectionMenuItem { )? iconBuilder, SelectionMenuItemNameBuilder? nameBuilder, bool Function(EditorState editorState, Node node)? insertBefore, + bool Function(EditorState editorState, Node node)? insertAfter, bool Function(EditorState editorState, Node node)? replace, Selection? Function( EditorState editorState, @@ -146,7 +78,7 @@ class SelectionMenuItem { return const SizedBox.shrink(); }, keywords: keywords, - handler: (editorState, _, context) { + handler: (editorState, _, context) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return; @@ -156,7 +88,7 @@ class SelectionMenuItem { if (node == null || delta == null) { return; } - final newNode = nodeBuilder(editorState, context); + final newNode = await nodeBuilder(editorState, context); final transaction = editorState.transaction; final bReplace = replace?.call(editorState, node) ?? false; final bInsertBefore = insertBefore?.call(editorState, node) ?? false; @@ -183,10 +115,90 @@ class SelectionMenuItem { transaction.deleteNode(node); } - editorState.apply(transaction); + await editorState.apply(transaction); + + insertAfter?.call(editorState, newNode); }, ); } + + SelectionMenuItem({ + required String Function() getName, + required this.icon, + required this.keywords, + required SelectionMenuItemHandler handler, + this.nameBuilder, + this.deleteKeywords = false, + this.deleteSlash = true, + }) : _getName = getName { + this.handler = (editorState, menuService, context) async { + try { + if (deleteSlash || deleteKeywords) { + await _deleteSlash(editorState); + } + } catch (e) { + AppFlowyEditorLog.ui.debug('Error deleting slash or keywords: $e'); + } + + if (context.mounted) { + await handler(editorState, menuService, context); + } + onSelected?.call(); + }; + } + + final String Function() _getName; + final Widget Function( + EditorState editorState, + bool onSelected, + SelectionMenuStyle style, + ) icon; + final SelectionMenuItemNameBuilder? nameBuilder; + + String get name => _getName(); + + /// Customizes keywords for item. + /// + /// The keywords are used to quickly retrieve items. + final List keywords; + + List get allKeywords => keywords + [name.toLowerCase()]; + late final SelectionMenuItemHandler handler; + + VoidCallback? onSelected; + + bool deleteSlash; + bool deleteKeywords; + + Future _deleteSlash(EditorState editorState) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final node = editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + final end = selection.start.offset; + int deletedIndex = 0; + + if (deleteKeywords) { + deletedIndex = 0; + } else if (deleteSlash) { + deletedIndex = delta.toPlainText().substring(0, end).lastIndexOf('/'); + } + + // delete all the texts after '/' along with '/' + final transaction = editorState.transaction + ..deleteText( + node, + deletedIndex, + end - deletedIndex, + ); + + await editorState.apply(transaction); + } } class SelectionMenuStyle { @@ -418,7 +430,7 @@ class _SelectionMenuWidgetState extends State { int selectedIndex, ) { if (widget.singleColumn) { - List itemWidgets = []; + final List itemWidgets = []; for (var i = 0; i < items.length; i++) { itemWidgets.add( AutoScrollTag( @@ -449,7 +461,7 @@ class _SelectionMenuWidgetState extends State { ), ); } else { - List columns = []; + final List columns = []; List itemWidgets = []; // apply item count filter if (itemCountFilter > 0) { @@ -619,7 +631,7 @@ class _SelectionMenuWidgetState extends State { return KeyEventResult.ignored; } - void _deleteLastCharacters({int length = 1}) { + Future _deleteLastCharacters({int length = 1}) async { final selection = widget.editorState.selection; if (selection == null || !selection.isCollapsed) { return; @@ -637,10 +649,10 @@ class _SelectionMenuWidgetState extends State { selection.start.offset - length, length, ); - widget.editorState.apply(transaction); + await widget.editorState.apply(transaction); } - void _insertText(String text) { + Future _insertText(String text) async { final selection = widget.editorState.selection; if (selection == null || !selection.isSingle) { return; @@ -656,6 +668,6 @@ class _SelectionMenuWidgetState extends State { selection.end.offset, text, ); - widget.editorState.apply(transaction); + await widget.editorState.apply(transaction); } } diff --git a/lib/src/editor/toolbar/desktop/floating_toolbar.dart b/lib/src/editor/toolbar/desktop/floating_toolbar.dart index eb29dbff..1c3cf164 100644 --- a/lib/src/editor/toolbar/desktop/floating_toolbar.dart +++ b/lib/src/editor/toolbar/desktop/floating_toolbar.dart @@ -168,8 +168,6 @@ class _FloatingToolbarState extends State void _onScrollPositionChanged() { _clear(); - // TODO: optimize the toolbar showing logic, making it more smooth. - // A quick idea: based on the scroll controller's offset to display the toolbar. _showAfterDelay(); } @@ -254,7 +252,7 @@ class _FloatingToolbarState extends State Widget _buildToolbar(BuildContext context) { final brightness = Theme.of(context).brightness; - bool needRefreshToolbar = brightness != this.brightness; + final bool needRefreshToolbar = brightness != this.brightness; if (needRefreshToolbar) { this.brightness = brightness; } diff --git a/lib/src/editor/toolbar/desktop/floating_toolbar_widget.dart b/lib/src/editor/toolbar/desktop/floating_toolbar_widget.dart index d9606346..c1497ad7 100644 --- a/lib/src/editor/toolbar/desktop/floating_toolbar_widget.dart +++ b/lib/src/editor/toolbar/desktop/floating_toolbar_widget.dart @@ -83,7 +83,7 @@ class _FloatingToolbarWidgetState extends State { @override Widget build(BuildContext context) { - var activeItems = _computeActiveItems(); + final activeItems = _computeActiveItems(); if (activeItems.isEmpty) { return const SizedBox.shrink(); } diff --git a/lib/src/editor/toolbar/desktop/items/color/color_picker.dart b/lib/src/editor/toolbar/desktop/items/color/color_picker.dart index bfe64b40..92026f45 100644 --- a/lib/src/editor/toolbar/desktop/items/color/color_picker.dart +++ b/lib/src/editor/toolbar/desktop/items/color/color_picker.dart @@ -90,7 +90,6 @@ class _ColorPickerState extends State { ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, children: options .map((e) => _buildColorItem(e, e.colorHex == selectedColor)) .toList(), @@ -323,7 +322,7 @@ class _CustomColorItemState extends State { String _fixOpacity(String opacity) { // if opacity is 0 - 99, return it // otherwise return 100 - RegExp regex = RegExp('^(0|[1-9][0-9]?)'); + final RegExp regex = RegExp('^(0|[1-9][0-9]?)'); if (regex.hasMatch(opacity)) { return opacity; } else { diff --git a/lib/src/editor/toolbar/desktop/items/heading_toolbar_items.dart b/lib/src/editor/toolbar/desktop/items/heading_toolbar_items.dart index ec116479..fda2c1e6 100644 --- a/lib/src/editor/toolbar/desktop/items/heading_toolbar_items.dart +++ b/lib/src/editor/toolbar/desktop/items/heading_toolbar_items.dart @@ -5,8 +5,6 @@ List headingItems = [1, 2, 3] .toList(growable: false); class _HeadingToolbarItem extends ToolbarItem { - final int level; - _HeadingToolbarItem(this.level) : super( id: 'editor.h$level', @@ -59,6 +57,7 @@ class _HeadingToolbarItem extends ToolbarItem { return child; }, ); + final int level; static String levelToTooltips(int level) { if (level == 1) { diff --git a/lib/src/editor/toolbar/mobile/toolbar_items/blocks_mobile_toolbar_item.dart b/lib/src/editor/toolbar/mobile/toolbar_items/blocks_mobile_toolbar_item.dart index 7c050f79..7fdf05c6 100644 --- a/lib/src/editor/toolbar/mobile/toolbar_items/blocks_mobile_toolbar_item.dart +++ b/lib/src/editor/toolbar/mobile/toolbar_items/blocks_mobile_toolbar_item.dart @@ -132,15 +132,14 @@ class _BlocksMenuState extends State<_BlocksMenu> { } class _ListUnit { - final AFMobileIcons icon; - final String label; - final String name; - final int? level; - _ListUnit({ required this.icon, required this.label, required this.name, this.level, }); + final AFMobileIcons icon; + final String label; + final String name; + final int? level; } diff --git a/lib/src/editor/toolbar/mobile/toolbar_items/color/text_and_background_color_tool_bar_item.dart b/lib/src/editor/toolbar/mobile/toolbar_items/color/text_and_background_color_tool_bar_item.dart index c9a78092..3dd905f6 100644 --- a/lib/src/editor/toolbar/mobile/toolbar_items/color/text_and_background_color_tool_bar_item.dart +++ b/lib/src/editor/toolbar/mobile/toolbar_items/color/text_and_background_color_tool_bar_item.dart @@ -49,7 +49,7 @@ class _TextAndBackgroundColorMenuState @override Widget build(BuildContext context) { final style = MobileToolbarTheme.of(context); - List myTabs = [ + final List myTabs = [ Tab( text: AppFlowyEditorL10n.current.textColor, ), diff --git a/lib/src/editor/toolbar/mobile/toolbar_items/color/text_color_options_widgets.dart b/lib/src/editor/toolbar/mobile/toolbar_items/color/text_color_options_widgets.dart index bbeae65b..40e0c295 100644 --- a/lib/src/editor/toolbar/mobile/toolbar_items/color/text_color_options_widgets.dart +++ b/lib/src/editor/toolbar/mobile/toolbar_items/color/text_color_options_widgets.dart @@ -75,8 +75,6 @@ class _TextColorOptionsWidgetsState extends State { e.colorHex, ); }); - } else { - // TODO(yijing): handle when no text is selected } }, isSelected: isSelected, diff --git a/lib/src/editor/toolbar/mobile/toolbar_items/heading_mobile_toolbar_item.dart b/lib/src/editor/toolbar/mobile/toolbar_items/heading_mobile_toolbar_item.dart index 3e91370b..5132e5ee 100644 --- a/lib/src/editor/toolbar/mobile/toolbar_items/heading_mobile_toolbar_item.dart +++ b/lib/src/editor/toolbar/mobile/toolbar_items/heading_mobile_toolbar_item.dart @@ -114,13 +114,12 @@ class _HeadingMenuState extends State<_HeadingMenu> { } class HeadingUnit { - final AFMobileIcons icon; - final String label; - final int level; - HeadingUnit({ required this.icon, required this.label, required this.level, }); + final AFMobileIcons icon; + final String label; + final int level; } diff --git a/lib/src/editor/toolbar/mobile/toolbar_items/link_mobile_toolbar_item.dart b/lib/src/editor/toolbar/mobile/toolbar_items/link_mobile_toolbar_item.dart index 0657d0bc..d4c52387 100644 --- a/lib/src/editor/toolbar/mobile/toolbar_items/link_mobile_toolbar_item.dart +++ b/lib/src/editor/toolbar/mobile/toolbar_items/link_mobile_toolbar_item.dart @@ -89,7 +89,6 @@ class _MobileLinkMenuState extends State { decoration: InputDecoration( hintText: 'URL', contentPadding: const EdgeInsets.symmetric( - vertical: 0, horizontal: 8, ), enabledBorder: OutlineInputBorder( diff --git a/lib/src/editor/toolbar/mobile/toolbar_items/list_mobile_toolbar_item.dart b/lib/src/editor/toolbar/mobile/toolbar_items/list_mobile_toolbar_item.dart index f144e957..f15ab64f 100644 --- a/lib/src/editor/toolbar/mobile/toolbar_items/list_mobile_toolbar_item.dart +++ b/lib/src/editor/toolbar/mobile/toolbar_items/list_mobile_toolbar_item.dart @@ -88,13 +88,12 @@ class _ListMenuState extends State<_ListMenu> { } class ListUnit { - final AFMobileIcons icon; - final String label; - final String name; - ListUnit({ required this.icon, required this.label, required this.name, }); + final AFMobileIcons icon; + final String label; + final String name; } diff --git a/lib/src/editor/toolbar/mobile/toolbar_items/quote_mobile_toolbar_item.dart b/lib/src/editor/toolbar/mobile/toolbar_items/quote_mobile_toolbar_item.dart index a9b7e20c..47c964e9 100644 --- a/lib/src/editor/toolbar/mobile/toolbar_items/quote_mobile_toolbar_item.dart +++ b/lib/src/editor/toolbar/mobile/toolbar_items/quote_mobile_toolbar_item.dart @@ -15,7 +15,7 @@ final quoteMobileToolbarItem = MobileToolbarItem.action( return; } final isQuote = node.type == QuoteBlockKeys.type; - editorState.formatNode( + await editorState.formatNode( selection, (node) => node.copyWith( type: isQuote ? ParagraphBlockKeys.type : QuoteBlockKeys.type, diff --git a/lib/src/editor/toolbar/mobile/toolbar_items/text_decoration_mobile_toolbar_item.dart b/lib/src/editor/toolbar/mobile/toolbar_items/text_decoration_mobile_toolbar_item.dart index 539918d4..33968c13 100644 --- a/lib/src/editor/toolbar/mobile/toolbar_items/text_decoration_mobile_toolbar_item.dart +++ b/lib/src/editor/toolbar/mobile/toolbar_items/text_decoration_mobile_toolbar_item.dart @@ -101,13 +101,12 @@ class _TextDecorationMenuState extends State<_TextDecorationMenu> { } class TextDecorationUnit { - final AFMobileIcons icon; - final String label; - final String name; - TextDecorationUnit({ required this.icon, required this.label, required this.name, }); + final AFMobileIcons icon; + final String label; + final String name; } diff --git a/lib/src/editor/toolbar/mobile/toolbar_items/todo_list_mobile_toolbar_item.dart b/lib/src/editor/toolbar/mobile/toolbar_items/todo_list_mobile_toolbar_item.dart index 15ddb9be..790cdacd 100644 --- a/lib/src/editor/toolbar/mobile/toolbar_items/todo_list_mobile_toolbar_item.dart +++ b/lib/src/editor/toolbar/mobile/toolbar_items/todo_list_mobile_toolbar_item.dart @@ -16,7 +16,7 @@ final todoListMobileToolbarItem = MobileToolbarItem.action( } final isTodoList = node.type == TodoListBlockKeys.type; - editorState.formatNode( + await editorState.formatNode( selection, (node) => node.copyWith( type: isTodoList ? ParagraphBlockKeys.type : TodoListBlockKeys.type, diff --git a/lib/src/editor/toolbar/mobile/utils/keyboard_height_observer.dart b/lib/src/editor/toolbar/mobile/utils/keyboard_height_observer.dart index ef88c4e1..82f84baf 100644 --- a/lib/src/editor/toolbar/mobile/utils/keyboard_height_observer.dart +++ b/lib/src/editor/toolbar/mobile/utils/keyboard_height_observer.dart @@ -7,8 +7,6 @@ typedef KeyboardHeightCallback = void Function(double height); // the KeyboardHeightPlugin only accepts one listener, so we need to create a // singleton class to manage the multiple listeners. class KeyboardHeightObserver { - static int androidSDKVersion = -1; - KeyboardHeightObserver._() { if (PlatformExtension.isAndroid && androidSDKVersion == -1) { DeviceInfoPlugin().androidInfo.then( @@ -21,6 +19,7 @@ class KeyboardHeightObserver { currentKeyboardHeight = height; }); } + static int androidSDKVersion = -1; static final KeyboardHeightObserver instance = KeyboardHeightObserver._(); static double currentKeyboardHeight = 0; diff --git a/lib/src/editor/util/platform_extension.dart b/lib/src/editor/util/platform_extension.dart index ff46ffcd..d84d7418 100644 --- a/lib/src/editor/util/platform_extension.dart +++ b/lib/src/editor/util/platform_extension.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:universal_html/html.dart' show window; import 'package:universal_platform/universal_platform.dart'; -// TODO(Xazin): Refactor to honor `Theme.platform` extension PlatformExtension on Platform { static String get _webPlatform => window.navigator.platform?.toLowerCase() ?? ''; diff --git a/lib/src/editor/util/property_notifier.dart b/lib/src/editor/util/property_notifier.dart index 7a440a35..bc5ac02a 100644 --- a/lib/src/editor/util/property_notifier.dart +++ b/lib/src/editor/util/property_notifier.dart @@ -1,28 +1,137 @@ +import 'dart:collection'; + import 'package:flutter/foundation.dart'; -/// [PropertyValueNotifier] is a subclass of [ValueNotifier]. +/// [PropertyValueNotifier] is similar to [ValueNotifier] except it always +/// notifies listeners on assignment, even when the new value compares equal to +/// the previous one. /// -/// The difference is that [PropertyValueNotifier] will notify listeners even -/// when the value is the same as the previous value. +/// It deliberately does NOT extend [ChangeNotifier]. The default +/// [ChangeNotifier] stores listeners in a list and performs a linear scan + +/// array shift on every [removeListener] call. When a single notifier (such as +/// [EditorState.selectionNotifier]) has tens of thousands of listeners — the +/// case for large documents where every visible block component subscribes — +/// disposing many block components on a single frame becomes O(n²). Fast +/// scrolling through a 10k-node document then spends seconds inside dispose +/// methods alone. /// -/// - -class PropertyValueNotifier extends ChangeNotifier - implements ValueListenable { - /// Creates a [ChangeNotifier] that wraps this value. +/// This implementation backs the listener registry with a [LinkedHashSet], +/// giving O(1) add/remove while preserving insertion-order dispatch and the +/// usual [ChangeNotifier] semantics around reentrant add/remove during +/// notification. +class PropertyValueNotifier implements ValueListenable { PropertyValueNotifier(this._value); - /// The current value stored in this notifier. - /// - /// When the value is replaced with something that is not equal to the old - /// value as evaluated by the equality operator ==, this class notifies its - /// listeners. + T _value; + @override T get value => _value; - T _value; set value(T newValue) { _value = newValue; notifyListeners(); } + + final LinkedHashSet _listeners = LinkedHashSet(); + + // Non-null only while a notification dispatch is in progress AND a listener + // mutated the listener set during that dispatch. Mutations are deferred so + // dispatch can iterate [_listeners] safely without snapshotting every frame. + Set? _pendingRemovals; + List? _pendingAdditions; + int _notificationDepth = 0; + bool _disposed = false; + + bool get hasListeners => _listeners.isNotEmpty; + + @override + void addListener(VoidCallback listener) { + assert(_debugAssertNotDisposed()); + if (_notificationDepth > 0) { + (_pendingAdditions ??= []).add(listener); + } else { + _listeners.add(listener); + } + } + + @override + void removeListener(VoidCallback listener) { + if (_notificationDepth > 0) { + // If it was queued for addition during this same dispatch, undo that + // first so contains-checks work as callers expect. + _pendingAdditions?.remove(listener); + (_pendingRemovals ??= {}).add(listener); + } else { + _listeners.remove(listener); + } + } + + @protected + @visibleForTesting + void notifyListeners() { + assert(_debugAssertNotDisposed()); + if (_listeners.isEmpty) { + return; + } + _notificationDepth++; + try { + for (final listener in _listeners) { + if (_pendingRemovals != null && _pendingRemovals!.contains(listener)) { + continue; + } + try { + listener(); + } catch (exception, stack) { + FlutterError.reportError( + FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'appflowy_editor', + context: ErrorDescription( + 'while dispatching notifications for $runtimeType', + ), + ), + ); + } + } + } finally { + _notificationDepth--; + if (_notificationDepth == 0) { + final removals = _pendingRemovals; + if (removals != null) { + _pendingRemovals = null; + _listeners.removeAll(removals); + } + final additions = _pendingAdditions; + if (additions != null) { + _pendingAdditions = null; + _listeners.addAll(additions); + } + } + } + } + + void dispose() { + assert(_debugAssertNotDisposed()); + _disposed = true; + _listeners.clear(); + _pendingRemovals = null; + _pendingAdditions = null; + } + + bool _debugAssertNotDisposed() { + assert(() { + if (_disposed) { + throw FlutterError( + 'A $runtimeType was used after being disposed.\n' + 'Once you have called dispose() on a $runtimeType, it can no longer ' + 'be used.', + ); + } + + return true; + }()); + + return true; + } } diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index 055be52c..efc73593 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -32,6 +32,10 @@ const selectionExtraInfoDoNotAttachTextService = 'selectionExtraInfoDoNotAttachTextService'; const _selectionDragModeKey = 'selection_drag_mode'; +/// The border radius for selection area rendering. +/// The type of this value is double. +const selectionExtraInfoSelectionRadius = 'selectionExtraInfoSelectionRadius'; + class ApplyOptions { const ApplyOptions({ this.recordUndo = true, @@ -427,11 +431,16 @@ class EditorState { Future apply( Transaction transaction, { bool isRemote = false, - ApplyOptions options = const ApplyOptions(recordUndo: true), + ApplyOptions options = const ApplyOptions(), bool withUpdateSelection = true, bool skipHistoryDebounce = false, + bool skipEditableCheck = false, }) async { - if (!editable || isDisposed) { + if (isDisposed) { + return; + } + + if (!editable && !skipEditableCheck) { return; } @@ -735,6 +744,8 @@ class EditorState { if (!mapEquals(op.attributes, op.oldAttributes)) { document.update(op.path, op.attributes); } + } else if (op is UpdateNodeTypeOperation) { + _applyUpdateNodeTypeOperation(op); } else if (op is DeleteOperation) { document.delete(op.path, op.nodes.length); } else if (op is UpdateTextOperation) { @@ -763,6 +774,8 @@ class EditorState { ); } } + } else if (op is UpdateNodeTypeOperation) { + _applyUpdateNodeTypeOperation(op); } else if (op is UpdateOperation) { document.update(op.path, op.attributes); } else if (op is DeleteOperation) { @@ -786,4 +799,32 @@ class EditorState { return selection; } + + bool _applyUpdateNodeTypeOperation(UpdateNodeTypeOperation op) { + final node = _resolveUpdateNodeTypeTarget(op); + if (node == null) { + return false; + } + + return document.updateNodeType(node.path, op.type, op.attributes); + } + + Node? _resolveUpdateNodeTypeTarget(UpdateNodeTypeOperation op) { + final pathNode = document.nodeAtPath(op.path); + if (op.nodeId.isEmpty || pathNode?.id == op.nodeId) { + return pathNode; + } + + final iterator = NodeIterator( + document: document, + startNode: document.root, + ); + while (iterator.moveNext()) { + if (iterator.current.id == op.nodeId) { + return iterator.current; + } + } + + return null; + } } diff --git a/lib/src/extensions/position_extension.dart b/lib/src/extensions/position_extension.dart index d98bf406..d42bc2ce 100644 --- a/lib/src/extensions/position_extension.dart +++ b/lib/src/extensions/position_extension.dart @@ -123,7 +123,7 @@ extension PositionExtension on Position { // If the current node is not multiline, this will be ~= 0 // so the loop will be skipped. - final remainingMultilineHeight = (textHeight - caretHeight); + final remainingMultilineHeight = textHeight - caretHeight; // Linearly search for a new position. // It's acceptable to use a linear search because the starting point is @@ -224,7 +224,7 @@ extension PositionExtension on Position { final delta = node.delta; if (delta != null) { if (upwards) { - return Position(path: path, offset: 0); + return Position(path: path); } else { final length = delta.length; // move the cursor to the end of the node diff --git a/lib/src/flutter/overlay.dart b/lib/src/flutter/overlay.dart index 05516a74..786f4be3 100644 --- a/lib/src/flutter/overlay.dart +++ b/lib/src/flutter/overlay.dart @@ -1,4 +1,3 @@ -// TODO: Remove this file until we update the flutter version to 3.5.x // // This file is copied from flutter(3.5.x) repo. // @@ -573,8 +572,6 @@ class OverlayState extends State with TickerProviderStateMixin { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - // TODO(jacobr): use IterableProperty instead as that would - // provide a slightly more consistent string summary of the List. properties .add(DiagnosticsProperty>('entries', _entries)); } diff --git a/lib/src/flutter/scrollable_positioned_list/src/element_registry.dart b/lib/src/flutter/scrollable_positioned_list/src/element_registry.dart index f35e71cd..7447b372 100644 --- a/lib/src/flutter/scrollable_positioned_list/src/element_registry.dart +++ b/lib/src/flutter/scrollable_positioned_list/src/element_registry.dart @@ -46,12 +46,11 @@ class _RegistryWidgetState extends State { } class _InheritedRegistryWidget extends InheritedWidget { - final _RegistryWidgetState state; - const _InheritedRegistryWidget({ required this.state, required super.child, }); + final _RegistryWidgetState state; @override bool updateShouldNotify(InheritedWidget oldWidget) => true; diff --git a/lib/src/flutter/scrollable_positioned_list/src/positioned_list.dart b/lib/src/flutter/scrollable_positioned_list/src/positioned_list.dart index 808ca9e8..c166f3a7 100644 --- a/lib/src/flutter/scrollable_positioned_list/src/positioned_list.dart +++ b/lib/src/flutter/scrollable_positioned_list/src/positioned_list.dart @@ -331,7 +331,7 @@ class _PositionedListState extends State { } final positions = []; RenderViewportBase? viewport; - for (var element in elements) { + for (final element in elements) { final RenderBox box = element.renderObject as RenderBox; viewport ??= RenderAbstractViewport.of(box) as RenderViewportBase?; var anchor = 0.0; diff --git a/lib/src/flutter/scrollable_positioned_list/src/scroll_offset_listener.dart b/lib/src/flutter/scrollable_positioned_list/src/scroll_offset_listener.dart index 8c9d1ed2..c44848ae 100644 --- a/lib/src/flutter/scrollable_positioned_list/src/scroll_offset_listener.dart +++ b/lib/src/flutter/scrollable_positioned_list/src/scroll_offset_listener.dart @@ -9,9 +9,6 @@ import 'scroll_offset_notifier.dart'; /// This is an experimental API and is subject to change. /// Behavior may be ill-defined in some cases. Please file bugs. abstract class ScrollOffsetListener { - /// Stream of scroll offset deltas. - Stream get changes; - /// Construct a ScrollOffsetListener. /// /// Set [recordProgrammaticScrolls] to false to prevent reporting of @@ -22,4 +19,7 @@ abstract class ScrollOffsetListener { ScrollOffsetNotifier( recordProgrammaticScrolls: recordProgrammaticScrolls, ); + + /// Stream of scroll offset deltas. + Stream get changes; } diff --git a/lib/src/flutter/scrollable_positioned_list/src/scroll_offset_notifier.dart b/lib/src/flutter/scrollable_positioned_list/src/scroll_offset_notifier.dart index 775e342f..7ca8c70d 100644 --- a/lib/src/flutter/scrollable_positioned_list/src/scroll_offset_notifier.dart +++ b/lib/src/flutter/scrollable_positioned_list/src/scroll_offset_notifier.dart @@ -5,9 +5,8 @@ import 'dart:async'; import 'scroll_offset_listener.dart'; class ScrollOffsetNotifier implements ScrollOffsetListener { - final bool recordProgrammaticScrolls; - ScrollOffsetNotifier({this.recordProgrammaticScrolls = true}); + final bool recordProgrammaticScrolls; final _streamController = StreamController(); diff --git a/lib/src/flutter/scrollable_positioned_list/src/scroll_view.dart b/lib/src/flutter/scrollable_positioned_list/src/scroll_view.dart index a6b7ef27..6f02695e 100644 --- a/lib/src/flutter/scrollable_positioned_list/src/scroll_view.dart +++ b/lib/src/flutter/scrollable_positioned_list/src/scroll_view.dart @@ -13,8 +13,6 @@ import 'viewport.dart'; /// A version of [CustomScrollView] that allows does not constrict the extents /// to be within 0 and 1. See [CustomScrollView] for more information. class UnboundedCustomScrollView extends CustomScrollView { - final bool _shrinkWrap; - const UnboundedCustomScrollView({ super.key, super.scrollDirection, @@ -34,6 +32,7 @@ class UnboundedCustomScrollView extends CustomScrollView { super( shrinkWrap: false, ); + final bool _shrinkWrap; // [CustomScrollView] enforces constraints on [CustomScrollView.anchor], so // we need our own version. diff --git a/lib/src/flutter/scrollable_positioned_list/src/scrollable_positioned_list.dart b/lib/src/flutter/scrollable_positioned_list/src/scrollable_positioned_list.dart index acb12b65..de84a1f4 100644 --- a/lib/src/flutter/scrollable_positioned_list/src/scrollable_positioned_list.dart +++ b/lib/src/flutter/scrollable_positioned_list/src/scrollable_positioned_list.dart @@ -340,7 +340,7 @@ class _ScrollablePositionedListState extends State @override void initState() { super.initState(); - ItemPosition? initialPosition = + final ItemPosition? initialPosition = PageStorage.maybeOf(context)?.readState(context); primary.target = initialPosition?.index ?? widget.initialScrollIndex; primary.alignment = @@ -612,8 +612,6 @@ class _ScrollablePositionedListState extends State }); }; setState(() { - // TODO: _startScroll can be re-entrant, which invalidates this assert. - // assert(!_isTransitioning); secondary.target = index; secondary.alignment = alignment; _isTransitioning = true; @@ -642,7 +640,7 @@ class _ScrollablePositionedListState extends State if (opacity.value >= 0.5) { // Secondary [ListView] is more visible than the primary; make it the // new primary. - var temp = primary; + final temp = primary; primary = secondary; secondary = temp; } diff --git a/lib/src/flutter/scrollable_positioned_list/src/viewport.dart b/lib/src/flutter/scrollable_positioned_list/src/viewport.dart index 83411a9c..69da9d12 100644 --- a/lib/src/flutter/scrollable_positioned_list/src/viewport.dart +++ b/lib/src/flutter/scrollable_positioned_list/src/viewport.dart @@ -95,7 +95,7 @@ class UnboundedRenderViewport extends RenderViewport { @override void performResize() { super.performResize(); - // TODO: Figure out why this override is needed as a result of + // https://github.com/flutter/flutter/pull/61973 and see if it can be // removed somehow. switch (axis) { diff --git a/lib/src/flutter/scrollable_positioned_list/src/wrapping.dart b/lib/src/flutter/scrollable_positioned_list/src/wrapping.dart index c6b5ef43..ec2df4ae 100644 --- a/lib/src/flutter/scrollable_positioned_list/src/wrapping.dart +++ b/lib/src/flutter/scrollable_positioned_list/src/wrapping.dart @@ -44,7 +44,6 @@ class CustomShrinkWrappingViewport extends CustomViewport { super.crossAxisDirection, double anchor = 0.0, required super.offset, - List? children, super.center, super.cacheExtent, super.slivers, diff --git a/lib/src/history/undo_manager.dart b/lib/src/history/undo_manager.dart index 400691b5..200f7215 100644 --- a/lib/src/history/undo_manager.dart +++ b/lib/src/history/undo_manager.dart @@ -26,13 +26,12 @@ enum TransactionSource { /// If a [HistoryItem] is not sealed, operations can be added sequentially. /// Otherwise, the operations should be added to a new [HistoryItem]. final class HistoryItem extends LinkedListEntry { + HistoryItem(); final List operations = []; Selection? beforeSelection; Selection? afterSelection; bool _sealed = false; - HistoryItem(); - /// Seal the history item. /// When an item is sealed, no more operations can be added /// to the item. @@ -68,11 +67,10 @@ final class HistoryItem extends LinkedListEntry { } class FixedSizeStack { + FixedSizeStack(this.maxSize); final _list = LinkedList(); final int maxSize; - FixedSizeStack(this.maxSize); - void push(HistoryItem stackItem) { if (_list.length >= maxSize) { _list.remove(_list.first); @@ -105,13 +103,12 @@ class FixedSizeStack { } class UndoManager { - final FixedSizeStack undoStack; - final FixedSizeStack redoStack; - EditorState? state; - UndoManager([int stackSize = 20]) : undoStack = FixedSizeStack(stackSize), redoStack = FixedSizeStack(stackSize); + final FixedSizeStack undoStack; + final FixedSizeStack redoStack; + EditorState? state; /// Record a transaction into the appropriate stack based on [source]. /// diff --git a/lib/src/infra/clipboard.dart b/lib/src/infra/clipboard.dart index ba302a2c..f8ad2ac2 100644 --- a/lib/src/infra/clipboard.dart +++ b/lib/src/infra/clipboard.dart @@ -42,7 +42,6 @@ class AppFlowyClipboard { return AppFlowyClipboardData( text: data?.text, - html: null, ); } diff --git a/lib/src/infra/mobile/af_mobile_icon.dart b/lib/src/infra/mobile/af_mobile_icon.dart index 2d88823b..d0cd388f 100644 --- a/lib/src/infra/mobile/af_mobile_icon.dart +++ b/lib/src/infra/mobile/af_mobile_icon.dart @@ -22,8 +22,8 @@ enum AFMobileIcons { divider('toolbar_icons/divider'), close('toolbar_icons/close'); - final String iconPath; const AFMobileIcons(this.iconPath); + final String iconPath; } /// {@tool snippet} diff --git a/lib/src/plugins/blocks/columns/columns_block_component.dart b/lib/src/plugins/blocks/columns/columns_block_component.dart index 4937c2c2..37f9ad5b 100644 --- a/lib/src/plugins/blocks/columns/columns_block_component.dart +++ b/lib/src/plugins/blocks/columns/columns_block_component.dart @@ -106,6 +106,11 @@ class ColumnsBlockComponentState extends State child: child, ); + child = Padding( + padding: margin, + child: child, + ); + return child; } diff --git a/lib/src/plugins/html/encoder/parser/table_node_parser.dart b/lib/src/plugins/html/encoder/parser/table_node_parser.dart index f7b1d0a4..0f87354a 100644 --- a/lib/src/plugins/html/encoder/parser/table_node_parser.dart +++ b/lib/src/plugins/html/encoder/parser/table_node_parser.dart @@ -37,7 +37,7 @@ class HtmlTableNodeParser extends HTMLNodeParser { final Node cell = getCellNode(node, j, i)!; for (final childnode in cell.children) { - HTMLNodeParser? parser = encodeParsers.firstWhereOrNull( + final HTMLNodeParser? parser = encodeParsers.firstWhereOrNull( (element) => element.id == childnode.type, ); diff --git a/lib/src/plugins/html/html_document_decoder.dart b/lib/src/plugins/html/html_document_decoder.dart index 63e845f9..17e5ea40 100644 --- a/lib/src/plugins/html/html_document_decoder.dart +++ b/lib/src/plugins/html/html_document_decoder.dart @@ -27,7 +27,7 @@ class DocumentHTMLDecoder extends Converter { final document = parse(input); final body = document.body; if (body == null) { - return Document.blank(withInitialText: false); + return Document.blank(); } ///This is used for temporarily handling documents copied from Google Docs, @@ -38,7 +38,7 @@ class DocumentHTMLDecoder extends Converter { final parseForSingleChild = body.children.length == 1 && HTMLTags.formattingElements.contains(body.children.first.localName); - return Document.blank(withInitialText: false) + return Document.blank() ..insert( [0], parseForSingleChild @@ -213,7 +213,7 @@ class DocumentHTMLDecoder extends Converter { int columnPosition = 0; for (final data in element.children) { - Attributes attributes = { + final Attributes attributes = { TableCellBlockKeys.colPosition: columnPosition, TableCellBlockKeys.rowPosition: rowPosition, }; diff --git a/lib/src/plugins/html/html_document_encoder.dart b/lib/src/plugins/html/html_document_encoder.dart index b1f4ce7a..2ed9ea20 100644 --- a/lib/src/plugins/html/html_document_encoder.dart +++ b/lib/src/plugins/html/html_document_encoder.dart @@ -11,7 +11,7 @@ class DocumentHTMLEncoder extends Converter { String convert(Document input) { final buffer = StringBuffer(); for (final node in input.root.children) { - HTMLNodeParser? parser = encodeParsers.firstWhereOrNull( + final HTMLNodeParser? parser = encodeParsers.firstWhereOrNull( (element) => element.id == node.type, ); if (parser != null) { diff --git a/lib/src/plugins/markdown/decoder/custom_syntaxes/underline_syntax.dart b/lib/src/plugins/markdown/decoder/custom_syntaxes/underline_syntax.dart index e724e684..9b1a80e2 100644 --- a/lib/src/plugins/markdown/decoder/custom_syntaxes/underline_syntax.dart +++ b/lib/src/plugins/markdown/decoder/custom_syntaxes/underline_syntax.dart @@ -6,7 +6,8 @@ class UnderlineInlineSyntax extends md.InlineSyntax { @override bool onMatch(md.InlineParser parser, Match match) { final text = match.group(1) ?? ''; - List nestedNodes = md.InlineParser(text, parser.document).parse(); + final List nestedNodes = + md.InlineParser(text, parser.document).parse(); parser.addNode(md.Element('u', nestedNodes)); return true; diff --git a/lib/src/plugins/markdown/decoder/delta_markdown_decoder.dart b/lib/src/plugins/markdown/decoder/delta_markdown_decoder.dart index eb959061..fe870c35 100644 --- a/lib/src/plugins/markdown/decoder/delta_markdown_decoder.dart +++ b/lib/src/plugins/markdown/decoder/delta_markdown_decoder.dart @@ -10,13 +10,12 @@ import 'custom_syntaxes/formula_syntax.dart'; class DeltaMarkdownDecoder extends Converter implements md.NodeVisitor { - final _delta = Delta(); - final Attributes _attributes = {}; - final List customInlineSyntaxes; - DeltaMarkdownDecoder({ this.customInlineSyntaxes = const [], }); + final _delta = Delta(); + final Attributes _attributes = {}; + final List customInlineSyntaxes; @override Delta convert(String input) { diff --git a/lib/src/plugins/markdown/decoder/parser/markdown_todo_list_parser.dart b/lib/src/plugins/markdown/decoder/parser/markdown_todo_list_parser.dart index 235cffa2..0b3c1231 100644 --- a/lib/src/plugins/markdown/decoder/parser/markdown_todo_list_parser.dart +++ b/lib/src/plugins/markdown/decoder/parser/markdown_todo_list_parser.dart @@ -53,7 +53,6 @@ class MarkdownTodoListParserV2 extends CustomMarkdownParser { : parseElementChildren( [last], parsers, - listType: MarkdownListType.unknown, ), ), ]; diff --git a/lib/src/plugins/markdown/decoder/parser/markdown_unordered_list_item_parser.dart b/lib/src/plugins/markdown/decoder/parser/markdown_unordered_list_item_parser.dart index 68b2b3ce..2399771c 100644 --- a/lib/src/plugins/markdown/decoder/parser/markdown_unordered_list_item_parser.dart +++ b/lib/src/plugins/markdown/decoder/parser/markdown_unordered_list_item_parser.dart @@ -49,7 +49,6 @@ class MarkdownUnorderedListItemParserV2 extends CustomMarkdownParser { children: parseElementChildren( ec.reversed.toList(), parsers, - listType: MarkdownListType.unknown, ), ), ]; diff --git a/lib/src/plugins/markdown/encoder/document_markdown_encoder.dart b/lib/src/plugins/markdown/encoder/document_markdown_encoder.dart index b83f3b43..510e8a0e 100644 --- a/lib/src/plugins/markdown/encoder/document_markdown_encoder.dart +++ b/lib/src/plugins/markdown/encoder/document_markdown_encoder.dart @@ -16,7 +16,7 @@ class DocumentMarkdownEncoder extends Converter { String convert(Document input) { final buffer = StringBuffer(); for (final node in input.root.children) { - NodeParser? parser = parsers.firstWhereOrNull( + final NodeParser? parser = parsers.firstWhereOrNull( (element) => element.id == node.type, ); if (parser != null) { diff --git a/lib/src/plugins/pdf/extension/color_ext.dart b/lib/src/plugins/pdf/extension/color_ext.dart index 6de28a90..81bfbe28 100644 --- a/lib/src/plugins/pdf/extension/color_ext.dart +++ b/lib/src/plugins/pdf/extension/color_ext.dart @@ -46,7 +46,7 @@ int rgbaToHex(int red, int green, int blue, {double opacity = 1}) { red = (red > 255) ? 255 : red; green = (green > 255) ? 255 : green; blue = (blue > 255) ? 255 : blue; - int alpha = opacity.toInt(); + final int alpha = opacity.toInt(); return int.parse( '0x${alpha.toRadixString(16)}${red.toRadixString(16)}${green.toRadixString(16)}${blue.toRadixString(16)}', diff --git a/lib/src/plugins/pdf/html_to_pdf_encoder.dart b/lib/src/plugins/pdf/html_to_pdf_encoder.dart index d88b8aae..f23ca3c5 100644 --- a/lib/src/plugins/pdf/html_to_pdf_encoder.dart +++ b/lib/src/plugins/pdf/html_to_pdf_encoder.dart @@ -5,21 +5,21 @@ import 'dart:typed_data'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:html/dom.dart' as dom; import 'package:html/parser.dart' show parse; -import 'package:pdf/widgets.dart' as pw; -import 'package:pdf/pdf.dart' as pdf; -import 'extension/color_ext.dart'; import 'package:http/http.dart'; import 'package:markdown/markdown.dart' as md; +import 'package:pdf/pdf.dart' as pdf; +import 'package:pdf/widgets.dart' as pw; + +import 'extension/color_ext.dart'; /// This class handles conversion from html to pdf class PdfHTMLEncoder { - final pw.Font? font; - final List fontFallback; - PdfHTMLEncoder({ this.font, required this.fontFallback, }); + final pw.Font? font; + final List fontFallback; Future convert(String input) async { final htmlx = md.markdownToHtml( @@ -201,16 +201,16 @@ class PdfHTMLEncoder { } Future> _parseRawTableData(dom.Element element) async { - List tableRows = []; + final List tableRows = []; - for (dom.Element row in element.querySelectorAll('tr')) { - List rowData = []; + for (final dom.Element row in element.querySelectorAll('tr')) { + final List rowData = []; for (final dom.Element cell in row.children) { - List cellContent = []; + final List cellContent = []; //NOTE: Handle nested HTML tags within table cells for (final dom.Node node in cell.nodes) { if (node.nodeType == dom.Node.ELEMENT_NODE) { - dom.Element element = node as dom.Element; + final dom.Element element = node as dom.Element; if (HTMLTags.formattingElements.contains(element.localName)) { final attributes = _parserFormattingElementAttributes(element); cellContent.add( @@ -246,7 +246,7 @@ class PdfHTMLEncoder { return [ pw.Table( children: tableRows, - border: pw.TableBorder.all(color: pdf.PdfColors.black), + border: pw.TableBorder.all(), ), ]; } @@ -399,7 +399,6 @@ class PdfHTMLEncoder { dom.Element element, { required String type, }) { - //TODO: Handle Numbered Lists & Handle nested lists if (type == TodoListBlockKeys.type) { final bracketRightIndex = element.text.indexOf(']') + 1; final strippedString = @@ -444,7 +443,7 @@ class PdfHTMLEncoder { return pw.Image(pw.MemoryImage(networkImage)); } else { - File localImage = File(src); + final File localImage = File(src); return pw.Image(pw.MemoryImage(await localImage.readAsBytes())); } diff --git a/lib/src/plugins/quill_delta/quill_delta_encoder.dart b/lib/src/plugins/quill_delta/quill_delta_encoder.dart index 9a269037..684513c4 100644 --- a/lib/src/plugins/quill_delta/quill_delta_encoder.dart +++ b/lib/src/plugins/quill_delta/quill_delta_encoder.dart @@ -21,7 +21,7 @@ class QuillDeltaEncoder extends Converter { @override Document convert(Delta input) { final iterator = input.iterator; - final document = Document.blank(withInitialText: false); + final document = Document.blank(); Node node = paragraphNode(); int index = 0; @@ -221,7 +221,7 @@ class QuillDeltaEncoder extends Converter { if (color.startsWith('#')) { return '0xFF${color.substring(1)}'; } else if (color.startsWith("rgba")) { - List rgbaList = color.substring(5, color.length - 1).split(','); + final List rgbaList = color.substring(5, color.length - 1).split(','); return Color.fromRGBO( int.parse(rgbaList[0]), diff --git a/lib/src/render/color_menu/color_picker.dart b/lib/src/render/color_menu/color_picker.dart index 9c508c94..88051e9e 100644 --- a/lib/src/render/color_menu/color_picker.dart +++ b/lib/src/render/color_menu/color_picker.dart @@ -86,7 +86,6 @@ class _ColorPickerState extends State { child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, children: _buildColorOptionLists(widget.colorOptionLists), ), ), @@ -95,7 +94,7 @@ class _ColorPickerState extends State { } List _buildColorOptionLists(List colorOptionLists) { - List colorOptionMenu = []; + final List colorOptionMenu = []; for (var i = 0; i < colorOptionLists.length; i++) { if (i != 0) { colorOptionMenu.add(const SizedBox(height: 6)); @@ -124,7 +123,6 @@ class _ColorPickerState extends State { Widget _buildColorItems(ColorOptionList colorOptionList) { return Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, children: [ _buildCustomColorItem(colorOptionList), ...colorOptionList.colorOptions.map( @@ -152,7 +150,6 @@ class _ColorPickerState extends State { hoverColor: widget.pickerItemHoverColor, onTap: () => onTap(option.colorHex), child: Row( - mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // padding @@ -188,11 +185,10 @@ class _ColorPickerState extends State { Widget _buildCustomColorItem(ColorOptionList colorOptionList) { return ExpansionTile( - tilePadding: const EdgeInsets.only(left: 0), + tilePadding: const EdgeInsets.only(), title: SizedBox( height: 36, child: Row( - mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ const SizedBox(width: 6), @@ -286,7 +282,7 @@ class _ColorPickerState extends State { } String _fixOpacity(String opacity) { - RegExp regex = RegExp('[a-zA-Z]'); + final RegExp regex = RegExp('[a-zA-Z]'); if (regex.hasMatch(opacity) || int.parse(opacity) > 100 || int.parse(opacity) < 0) { diff --git a/lib/src/render/selection/mobile_basic_handle.dart b/lib/src/render/selection/mobile_basic_handle.dart index 72ea6cb9..39091bc8 100644 --- a/lib/src/render/selection/mobile_basic_handle.dart +++ b/lib/src/render/selection/mobile_basic_handle.dart @@ -102,59 +102,193 @@ class DragHandle extends _IDragHandle { @override Widget build(BuildContext context) { - Widget child; + if (handleType == HandleType.none || handleType == HandleType.collapsed) { + // Collapsed handle still uses the inner per-platform widgets, which + // build their own gesture detectors sized to the (already enlarged) + // outer rect. + Widget child; + if (PlatformExtension.isIOS) { + child = _IOSDragHandle( + handleHeight: handleHeight, + handleColor: handleColor, + handleWidth: handleWidth, + handleBallWidth: handleBallWidth, + handleType: handleType, + debugPaintSizeEnabled: debugPaintSizeEnabled, + onDragging: onDragging, + ); + } else if (PlatformExtension.isAndroid) { + child = _AndroidDragHandle( + handleHeight: handleHeight, + handleColor: handleColor, + handleWidth: handleWidth, + handleBallWidth: handleBallWidth, + handleType: handleType, + debugPaintSizeEnabled: debugPaintSizeEnabled, + onDragging: onDragging, + ); + } else { + throw UnsupportedError('Unsupported platform'); + } + if (debugPaintSizeEnabled) { + child = ColoredBox( + color: Colors.red.withValues(alpha: 0.5), + child: child, + ); + } + return child; + } + + return _SelectionDragHandle( + handleHeight: handleHeight, + handleColor: handleColor, + handleWidth: handleWidth, + handleBallWidth: handleBallWidth, + handleType: handleType, + debugPaintSizeEnabled: debugPaintSizeEnabled, + onDragging: onDragging, + ); + } +} + +/// Selection (left/right) drag handle. Owns its own gesture detector that +/// fills the entire outer touch zone (provided by [Positioned.fromRect] in +/// [MobileSelectionHandle]) and consumes both pans and taps so the editor's +/// tap recognizer below it cannot collapse the selection while the user is +/// trying to grab the handle. +class _SelectionDragHandle extends StatefulWidget { + const _SelectionDragHandle({ + required this.handleHeight, + required this.handleColor, + required this.handleWidth, + required this.handleBallWidth, + required this.handleType, + required this.debugPaintSizeEnabled, + required this.onDragging, + }); + + final double handleHeight; + final Color handleColor; + final double handleWidth; + final double handleBallWidth; + final HandleType handleType; + final bool debugPaintSizeEnabled; + final ValueChanged? onDragging; + + @override + State<_SelectionDragHandle> createState() => _SelectionDragHandleState(); +} + +class _SelectionDragHandleState extends State<_SelectionDragHandle> { + Selection? _selection; + + @override + Widget build(BuildContext context) { + final editorState = context.read(); + + Widget visual; if (PlatformExtension.isIOS) { - child = _IOSDragHandle( - handleHeight: handleHeight, - handleColor: handleColor, - handleWidth: handleWidth, - handleBallWidth: handleBallWidth, - handleType: handleType, - debugPaintSizeEnabled: debugPaintSizeEnabled, - onDragging: onDragging, + visual = _IOSDragHandleVisual( + handleHeight: widget.handleHeight, + handleColor: widget.handleColor, + handleWidth: widget.handleWidth, + handleBallWidth: widget.handleBallWidth, + handleType: widget.handleType, ); } else if (PlatformExtension.isAndroid) { - child = _AndroidDragHandle( - handleHeight: handleHeight, - handleColor: handleColor, - handleWidth: handleWidth, - handleBallWidth: handleBallWidth, - handleType: handleType, - debugPaintSizeEnabled: debugPaintSizeEnabled, - onDragging: onDragging, + visual = _AndroidDragHandleVisual( + handleColor: widget.handleColor, + handleWidth: widget.handleWidth, + handleHeight: widget.handleHeight, + handleBallWidth: widget.handleBallWidth, + handleType: widget.handleType, ); } else { throw UnsupportedError('Unsupported platform'); } - if (debugPaintSizeEnabled) { - child = ColoredBox( + if (widget.debugPaintSizeEnabled) { + visual = ColoredBox( color: Colors.red.withValues(alpha: 0.5), - child: child, + child: visual, ); } - if (handleType != HandleType.none && handleType != HandleType.collapsed) { - final offset = PlatformExtension.isIOS ? -handleWidth : 0.0; - child = Stack( + final visualEdgeOffset = + PlatformExtension.isIOS ? -widget.handleWidth : 0.0; + final ballWidth = widget.handleBallWidth; + double dyOffset = 0.0; + if (PlatformExtension.isIOS) { + if (widget.handleType == HandleType.left) { + dyOffset = ballWidth; + } else if (widget.handleType == HandleType.right) { + dyOffset = -ballWidth; + } + } else if (PlatformExtension.isAndroid) { + dyOffset = -ballWidth * 2; + } + + return RawGestureDetector( + behavior: HitTestBehavior.opaque, + gestures: { + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(), + (recognizer) { + recognizer + ..dragStartBehavior = DragStartBehavior.down + ..onStart = (details) { + _selection = editorState.service.selectionService.onPanStart( + details.translate(0, dyOffset), + widget.handleType.dragMode, + ); + widget.onDragging?.call(true); + } + ..onUpdate = (details) { + final newSelection = + editorState.service.selectionService.onPanUpdate( + details.translate(0, dyOffset), + widget.handleType.dragMode, + ); + if (PlatformExtension.isAndroid && _selection != newSelection) { + HapticFeedback.selectionClick(); + } + _selection = newSelection; + widget.onDragging?.call(true); + } + ..onEnd = (details) { + editorState.service.selectionService.onPanEnd( + details, + widget.handleType.dragMode, + ); + widget.onDragging?.call(false); + }; + }, + ), + // Swallow taps inside the touch zone. The editor's tap recognizer + // sits above us in the gesture arena; without this it wins on lift + // and collapses the selection back to a caret. We register a no-op + // recognizer so the inner (deeper) tap wins instead. + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (recognizer) { + recognizer.onTap = () {}; + }, + ), + }, + child: Stack( clipBehavior: Clip.none, + fit: StackFit.expand, children: [ - if (handleType == HandleType.left) - Positioned( - left: offset, - child: child, - ), - if (handleType == HandleType.right) - Positioned( - right: offset, - child: child, - ), + if (widget.handleType == HandleType.left) + Positioned(left: visualEdgeOffset, top: 0, child: visual), + if (widget.handleType == HandleType.right) + Positioned(right: visualEdgeOffset, top: 0, child: visual), ], - ); - } - - return child; + ), + ); } } @@ -181,7 +315,6 @@ class _IOSDragHandle extends _IDragHandle { ); } else { child = Column( - mainAxisSize: MainAxisSize.max, children: [ if (handleType == HandleType.left) Container( @@ -373,6 +506,122 @@ class _AndroidDragHandle extends _IDragHandle { } } +/// Pure visual half of [_IOSDragHandle] used by [_SelectionDragHandle] for +/// the left/right handles. Mirrors the iOS column layout (ball + bar) without +/// owning any gesture detection. +class _IOSDragHandleVisual extends StatelessWidget { + const _IOSDragHandleVisual({ + required this.handleHeight, + required this.handleColor, + required this.handleWidth, + required this.handleBallWidth, + required this.handleType, + }); + + final double handleHeight; + final Color handleColor; + final double handleWidth; + final double handleBallWidth; + final HandleType handleType; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (handleType == HandleType.left) + Container( + width: handleBallWidth, + height: handleBallWidth, + decoration: BoxDecoration( + color: handleColor, + shape: BoxShape.circle, + ), + ), + if (handleType == HandleType.right) + SizedBox(width: handleBallWidth, height: handleBallWidth), + Container( + width: handleWidth, + color: handleColor, + height: handleHeight - 2.0 * handleBallWidth, + ), + if (handleType == HandleType.right) + Container( + width: handleBallWidth, + height: handleBallWidth, + decoration: BoxDecoration( + color: handleColor, + shape: BoxShape.circle, + ), + ), + if (handleType == HandleType.left) + SizedBox(width: handleBallWidth, height: handleBallWidth), + ], + ); + } +} + +/// Pure visual half of [_AndroidDragHandle] used by [_SelectionDragHandle] for +/// the left/right handles. Mirrors the Android column layout (bar + rounded +/// teardrop ball) without owning any gesture detection. +class _AndroidDragHandleVisual extends StatelessWidget { + const _AndroidDragHandleVisual({ + required this.handleColor, + required this.handleWidth, + required this.handleHeight, + required this.handleBallWidth, + required this.handleType, + }); + + final Color handleColor; + final double handleWidth; + final double handleHeight; + final double handleBallWidth; + final HandleType handleType; + + @override + Widget build(BuildContext context) { + final ballWidth = handleBallWidth * 2.0; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: handleType.crossAxisAlignment, + children: [ + SizedBox( + width: handleWidth, + height: handleHeight - 2.0 * handleBallWidth, + ), + if (handleType == HandleType.left) + Container( + width: ballWidth, + height: ballWidth, + decoration: BoxDecoration( + color: handleColor, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(handleBallWidth), + bottomLeft: Radius.circular(handleBallWidth), + bottomRight: Radius.circular(handleBallWidth), + ), + ), + ), + if (handleType == HandleType.right) + Container( + width: ballWidth, + height: ballWidth, + decoration: BoxDecoration( + color: handleColor, + borderRadius: BorderRadius.only( + topRight: Radius.circular(handleBallWidth), + bottomLeft: Radius.circular(handleBallWidth), + bottomRight: Radius.circular(handleBallWidth), + ), + ), + ), + ], + ); + } +} + extension on DragStartDetails { DragStartDetails translate(double dx, double dy) { return DragStartDetails( diff --git a/lib/src/render/selection/mobile_selection_handle.dart b/lib/src/render/selection/mobile_selection_handle.dart index 01436c65..7e07372b 100644 --- a/lib/src/render/selection/mobile_selection_handle.dart +++ b/lib/src/render/selection/mobile_selection_handle.dart @@ -1,7 +1,15 @@ +import 'dart:math' as math; + import 'package:appflowy_editor/src/editor/util/platform_extension.dart'; import 'package:appflowy_editor/src/render/selection/mobile_basic_handle.dart'; import 'package:flutter/material.dart'; +// Apple HIG / Material minimum recommended touch target. Used to grow the +// handle's hit area without changing the visible ball position, so users can +// grab the drag handle without accidentally tapping the editor below. +const double _kMinIOSTouchTarget = 44.0; +const double _kMinAndroidTouchTarget = 48.0; + class MobileSelectionHandle extends StatelessWidget { const MobileSelectionHandle({ super.key, @@ -26,13 +34,13 @@ class MobileSelectionHandle extends StatelessWidget { Widget build(BuildContext context) { assert(handleType != HandleType.collapsed); - var adjustedRect = rect; + var visualRect = rect; if (handleType != HandleType.none) { if (PlatformExtension.isIOS) { // on iOS, the cursor will still be visible if the selection is not collapsed. // So, adding a threshold padding to avoid row overflow. const threshold = 0.25; - adjustedRect = Rect.fromLTWH( + visualRect = Rect.fromLTWH( rect.left - 2 * (handleWidth + threshold), rect.top - handleBallWidth, rect.width + 4 * (handleWidth + threshold), @@ -41,10 +49,10 @@ class MobileSelectionHandle extends StatelessWidget { } else if (PlatformExtension.isAndroid) { // on Android, normally the cursor will be hidden if the selection is not collapsed. // Extend the click area to make it easier to click. - adjustedRect = Rect.fromLTWH( - rect.left - 2 * (handleBallWidth), + visualRect = Rect.fromLTWH( + rect.left - 2 * handleBallWidth, rect.top, - rect.width + 4 * (handleBallWidth), + rect.width + 4 * handleBallWidth, // Enable clicking in the handle area outside the stack. // https://github.com/flutter/flutter/issues/75747 rect.height + 2 * handleBallWidth, @@ -52,20 +60,57 @@ class MobileSelectionHandle extends StatelessWidget { } } + // Touch zone is grown to meet HIG/Material minimum touch targets, but the + // visible handle column keeps its original height so the ball stays glued + // to the text baseline. + Rect touchRect = visualRect; + if (handleType != HandleType.none) { + final minSize = PlatformExtension.isIOS + ? _kMinIOSTouchTarget + : _kMinAndroidTouchTarget; + touchRect = _expandToMinTouchTarget(visualRect, minSize: minSize); + } + return Positioned.fromRect( - rect: adjustedRect, + rect: touchRect, child: CompositedTransformFollower( link: layerLink, - offset: adjustedRect.topLeft, + offset: touchRect.topLeft, showWhenUnlinked: false, child: DragHandle( handleType: handleType, handleColor: handleColor, - handleHeight: adjustedRect.height, + handleHeight: visualRect.height, handleWidth: handleWidth, handleBallWidth: handleBallWidth, ), ), ); } + + /// Pads [r] outward so that both axes are at least [minSize], anchoring the + /// expansion away from the visible handle ball so the ball stays put on + /// screen. For the left handle the ball sits at the left of [r], so we grow + /// to the right; for the right handle we grow to the left. We always grow + /// downward, since the ball lives below the text baseline. + Rect _expandToMinTouchTarget(Rect r, {required double minSize}) { + final extraW = math.max(0.0, minSize - r.width); + final extraH = math.max(0.0, minSize - r.height); + if (extraW == 0 && extraH == 0) return r; + + double left = r.left; + double width = r.width; + if (extraW > 0) { + if (handleType == HandleType.left) { + // Ball is at r.left, grow rightward. + width += extraW; + } else { + // Ball is at r.right, grow leftward. + left -= extraW; + width += extraW; + } + } + + return Rect.fromLTWH(left, r.top, width, r.height + extraH); + } } diff --git a/lib/src/render/toolbar/toolbar_item.dart b/lib/src/render/toolbar/toolbar_item.dart index 5d0fac35..393a6ed1 100644 --- a/lib/src/render/toolbar/toolbar_item.dart +++ b/lib/src/render/toolbar/toolbar_item.dart @@ -10,6 +10,18 @@ typedef ToolbarItemValidator = bool Function(EditorState editorState); typedef ToolbarItemHighlightCallback = bool Function(EditorState editorState); class ToolbarItem { + factory ToolbarItem.divider() { + return ToolbarItem( + id: 'divider', + type: -1, + group: -1, + iconBuilder: (_) => const EditorSvg(name: 'toolbar/divider'), + validator: (editorState) => true, + handler: (editorState, context) {}, + highlightCallback: (editorState) => false, + ); + } + ToolbarItem({ required this.id, required this.group, @@ -48,18 +60,6 @@ class ToolbarItem { final Widget Function(BuildContext context, EditorState editorState)? itemBuilder; - factory ToolbarItem.divider() { - return ToolbarItem( - id: 'divider', - type: -1, - group: -1, - iconBuilder: (_) => const EditorSvg(name: 'toolbar/divider'), - validator: (editorState) => true, - handler: (editorState, context) {}, - highlightCallback: (editorState) => false, - ); - } - @override bool operator ==(Object other) { if (other is! ToolbarItem) { diff --git a/lib/src/service/default_text_operations/format_rich_text_style.dart b/lib/src/service/default_text_operations/format_rich_text_style.dart index fab5a3d3..1696ddd9 100644 --- a/lib/src/service/default_text_operations/format_rich_text_style.dart +++ b/lib/src/service/default_text_operations/format_rich_text_style.dart @@ -1,44 +1,47 @@ import 'package:appflowy_editor/appflowy_editor.dart'; -void insertHeadingAfterSelection(EditorState editorState, int level) { - insertNodeAfterSelection( +Future insertHeadingAfterSelection( + EditorState editorState, + int level, +) async { + return insertNodeAfterSelection( editorState, headingNode(level: level), ); } -void insertQuoteAfterSelection(EditorState editorState) { - insertNodeAfterSelection( +Future insertQuoteAfterSelection(EditorState editorState) async { + return insertNodeAfterSelection( editorState, quoteNode(), ); } -void insertCheckboxAfterSelection(EditorState editorState) { - insertNodeAfterSelection( +Future insertCheckboxAfterSelection(EditorState editorState) async { + return insertNodeAfterSelection( editorState, todoListNode(checked: false), ); } -void insertBulletedListAfterSelection(EditorState editorState) { - insertNodeAfterSelection( +Future insertBulletedListAfterSelection(EditorState editorState) async { + return insertNodeAfterSelection( editorState, bulletedListNode(), ); } -void insertNumberedListAfterSelection(EditorState editorState) { - insertNodeAfterSelection( +Future insertNumberedListAfterSelection(EditorState editorState) async { + return insertNodeAfterSelection( editorState, numberedListNode(), ); } -bool insertNodeAfterSelection( +Future insertNodeAfterSelection( EditorState editorState, Node node, -) { +) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return false; @@ -68,7 +71,7 @@ bool insertNodeAfterSelection( ..afterSelection = Selection.collapsed(Position(path: next)); } - editorState.apply(transaction); + await editorState.apply(transaction); return true; } diff --git a/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart b/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart index 3aa70c80..ca4509f2 100644 --- a/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart @@ -306,7 +306,7 @@ void handlePaste(EditorState editorState) async { return _pasteRichClipboard(editorState, data); } - deleteSelectedContent(editorState); + await deleteSelectedContent(editorState); WidgetsBinding.instance.addPostFrameCallback((_) { _pasteRichClipboard(editorState, data); diff --git a/lib/src/service/shortcut_event/keybinding.dart b/lib/src/service/shortcut_event/keybinding.dart index 88c4b253..9b9968d6 100644 --- a/lib/src/service/shortcut_event/keybinding.dart +++ b/lib/src/service/shortcut_event/keybinding.dart @@ -22,6 +22,19 @@ extension KeybindingsExtension on List { } class Keybinding { + factory Keybinding.fromMap(Map map) { + return Keybinding( + isAltPressed: map['isAltPressed'] ?? false, + isControlPressed: map['isControlPressed'] ?? false, + isMetaPressed: map['isMetaPressed'] ?? false, + isShiftPressed: map['isShiftPressed'] ?? false, + keyLabel: map['keyLabel'] ?? '', + ); + } + + factory Keybinding.fromJson(String source) => + Keybinding.fromMap(json.decode(source)); + Keybinding({ required this.isAltPressed, required this.isControlPressed, @@ -113,21 +126,8 @@ class Keybinding { }; } - factory Keybinding.fromMap(Map map) { - return Keybinding( - isAltPressed: map['isAltPressed'] ?? false, - isControlPressed: map['isControlPressed'] ?? false, - isMetaPressed: map['isMetaPressed'] ?? false, - isShiftPressed: map['isShiftPressed'] ?? false, - keyLabel: map['keyLabel'] ?? '', - ); - } - String toJson() => json.encode(toMap()); - factory Keybinding.fromJson(String source) => - Keybinding.fromMap(json.decode(source)); - @override String toString() { return 'Keybinding(isAltPressed: $isAltPressed, isControlPressed: $isControlPressed, isMetaPressed: $isMetaPressed, isShiftPressed: $isShiftPressed, keyLabel: $keyLabel)'; diff --git a/test/core/document/attributes_test.dart b/test/core/document/attributes_test.dart index a7b23499..9326a2ee 100644 --- a/test/core/document/attributes_test.dart +++ b/test/core/document/attributes_test.dart @@ -13,7 +13,7 @@ void main() async { 'c': 4, 'd': null, }; - expect(composeAttributes(base, other, keepNull: false), { + expect(composeAttributes(base, other), { 'a': 1, 'b': 3, 'c': 4, @@ -24,11 +24,11 @@ void main() async { 'c': 4, 'd': null, }); - expect(composeAttributes(null, other, keepNull: false), { + expect(composeAttributes(null, other), { 'b': 3, 'c': 4, }); - expect(composeAttributes(base, null, keepNull: false), { + expect(composeAttributes(base, null), { 'a': 1, 'b': 2, }); @@ -75,7 +75,7 @@ void main() async { 'd': 4, }; - var x = hashAttributes(base); + final x = hashAttributes(base); var y = hashAttributes(base); // x & y should have same hash code expect(x == y, true); diff --git a/test/core/document/diff_test.dart b/test/core/document/diff_test.dart index 18bc67c4..70df1a8d 100644 --- a/test/core/document/diff_test.dart +++ b/test/core/document/diff_test.dart @@ -47,6 +47,39 @@ void main() async { ); }); + test('same id type changes', () async { + final id = nanoid(6); + final documentA = Document.blank() + ..insert([0], [buildNodeWithId(id, 'Hello World')]); + final documentB = Document.blank() + ..insert([ + 0, + ], [ + Node( + type: HeadingBlockKeys.type, + id: id, + attributes: { + HeadingBlockKeys.level: 1, + HeadingBlockKeys.delta: (Delta()..insert('Hello World')).toJson(), + }, + ), + ]); + + final ops = diffDocuments(documentA, documentB); + expect(ops.length, 1); + final op = ops.first; + expect(op, isA()); + expect((op as UpdateNodeTypeOperation).nodeId, id); + expect(op.path, [0]); + expect(op.type, HeadingBlockKeys.type); + + final expectation = jsonEncode(documentB.toJson()); + expect( + jsonEncode((await apply(documentA, ops)).toJson()), + expectation, + ); + }); + test('insert', () async { final id1 = nanoid(6); final id2 = nanoid(6); diff --git a/test/core/document/document_test.dart b/test/core/document/document_test.dart index 428a0f42..66ca40d3 100644 --- a/test/core/document/document_test.dart +++ b/test/core/document/document_test.dart @@ -26,8 +26,8 @@ void main() async { test('delete', () { final document = Document(root: Node(type: 'root')); - expect(document.delete([-1], 1), false); - expect(document.delete([100], 1), false); + expect(document.delete([-1]), false); + expect(document.delete([100]), false); for (var i = 0; i < 10; i++) { final node = Node(type: '$i'); diff --git a/test/core/document/node_test.dart b/test/core/document/node_test.dart index 3c21af63..2f9cd685 100644 --- a/test/core/document/node_test.dart +++ b/test/core/document/node_test.dart @@ -120,6 +120,27 @@ void main() async { expect(identical(node.children.first, base.children.first), false); }); + test('copyWith can preserve ids', () { + final child = Node( + type: 'child', + id: 'child-id', + ); + final base = Node( + type: 'base', + id: 'base-id', + children: [child], + ); + + final node = base.copyWith( + id: base.id, + preserveChildIds: true, + ); + + expect(node.id, 'base-id'); + expect(node.children.first.id, 'child-id'); + expect(identical(node.children.first, child), false); + }); + test('test insert', () { final base = Node( type: 'base', diff --git a/test/core/location/selection_test.dart b/test/core/location/selection_test.dart index cb10a7f3..76fc8a93 100644 --- a/test/core/location/selection_test.dart +++ b/test/core/location/selection_test.dart @@ -73,7 +73,7 @@ void main() async { expect(collapsedAtStart.start, start); expect(collapsedAtStart.end, start); - final collapsedAtEnd = selection.collapse(atStart: false); + final collapsedAtEnd = selection.collapse(); expect(collapsedAtEnd.isCollapsed, true); expect(collapsedAtEnd.start, end); expect(collapsedAtEnd.end, end); diff --git a/test/core/transform/operation_test.dart b/test/core/transform/operation_test.dart index 48877e46..14bdb4e7 100644 --- a/test/core/transform/operation_test.dart +++ b/test/core/transform/operation_test.dart @@ -72,6 +72,53 @@ void main() async { expect(op.copyWith(), op); }); + test('test update node type operation', () { + const op = UpdateNodeTypeOperation( + [0], + 'node-id', + 'heading', + 'paragraph', + {'level': 1}, + {'delta': []}, + ); + final json = op.toJson(); + expect(json, { + 'op': 'update_node_type', + 'path': [0], + 'nodeId': 'node-id', + 'type': 'heading', + 'oldType': 'paragraph', + 'attributes': {'level': 1}, + 'oldAttributes': {'delta': []}, + }); + expect(UpdateNodeTypeOperation.fromJson(json), op); + expect(op.invert().invert(), op); + expect(op.copyWith(), op); + }); + + test('UpdateNodeTypeOperation hashCode matches equality', () { + final op1 = UpdateNodeTypeOperation( + [0], + 'node-id', + 'heading', + 'paragraph', + {'level': 1}, + {'delta': 'text'}, + ); + final op2 = UpdateNodeTypeOperation( + [0], + 'node-id', + 'heading', + 'paragraph', + {'level': 1}, + {'delta': 'text'}, + ); + + expect(op1, op2); + expect(op1.hashCode, op2.hashCode); + expect({op1}.contains(op2), true); + }); + test('test delete operation', () { final node = Node(type: 'example'); final op = DeleteOperation([0], [node]); diff --git a/test/core/transform/transaction_test.dart b/test/core/transform/transaction_test.dart index 6b122b8d..b419fb1f 100644 --- a/test/core/transform/transaction_test.dart +++ b/test/core/transform/transaction_test.dart @@ -64,6 +64,104 @@ void main() async { expect(editorState.document.last!.type, 'paragraph-2'); }); + test('remote updateNodeType resolves stale path by node id', () async { + // Remote type updates may be queued with an old path. In that case the + // node id is the stable identity and must win over the stale path, or the + // update can convert a newly inserted sibling by mistake. + final inserted = paragraphNode(text: 'inserted')..id = 'inserted-id'; + final target = paragraphNode(text: 'target')..id = 'target-id'; + final document = Document.blank()..insert([0], [inserted, target]); + final editorState = EditorState(document: document); + final transaction = editorState.transaction + ..add( + UpdateNodeTypeOperation( + [0], + target.id, + HeadingBlockKeys.type, + ParagraphBlockKeys.type, + { + HeadingBlockKeys.level: 1, + HeadingBlockKeys.delta: target.delta!.toJson(), + }, + target.attributes, + ), + ); + + await editorState.apply(transaction, isRemote: true); + + expect(editorState.getNodeAtPath([0])!.id, inserted.id); + expect(editorState.getNodeAtPath([0])!.type, ParagraphBlockKeys.type); + expect(editorState.getNodeAtPath([1])!.id, target.id); + expect(editorState.getNodeAtPath([1])!.type, HeadingBlockKeys.type); + }); + + test('remote updateNodeType skips stale path when node id is missing', + () async { + // If the stable node id cannot be found, the operation should be ignored. + // Applying it to the stale path would mutate the wrong block. + final inserted = paragraphNode(text: 'inserted')..id = 'inserted-id'; + final document = Document.blank()..insert([0], [inserted]); + final editorState = EditorState(document: document); + final transaction = editorState.transaction + ..add( + UpdateNodeTypeOperation( + [0], + 'missing-id', + HeadingBlockKeys.type, + ParagraphBlockKeys.type, + { + HeadingBlockKeys.level: 1, + HeadingBlockKeys.delta: inserted.delta!.toJson(), + }, + inserted.attributes, + ), + ); + + await editorState.apply(transaction, isRemote: true); + + expect(editorState.getNodeAtPath([0])!.id, inserted.id); + expect(editorState.getNodeAtPath([0])!.type, ParagraphBlockKeys.type); + }); + + test('updateNodeType undo and redo preserve identity and children', + () async { + // Same-shape type updates keep the block in place. Undo/redo must only + // switch type/data and must not regenerate the parent or child ids. + final child = paragraphNode(text: 'child')..id = 'child-id'; + final node = paragraphNode( + text: 'parent', + children: [child], + )..id = 'parent-id'; + final document = Document.blank()..insert([0], [node]); + final editorState = EditorState(document: document); + final transaction = editorState.transaction + ..updateNodeType( + node, + BulletedListBlockKeys.type, + { + BulletedListBlockKeys.delta: node.delta!.toJson(), + }, + ); + + await editorState.apply(transaction, skipHistoryDebounce: true); + + expect(editorState.getNodeAtPath([0])!.id, 'parent-id'); + expect(editorState.getNodeAtPath([0])!.type, BulletedListBlockKeys.type); + expect(editorState.getNodeAtPath([0])!.children.first.id, 'child-id'); + + editorState.undoManager.undo(); + + expect(editorState.getNodeAtPath([0])!.id, 'parent-id'); + expect(editorState.getNodeAtPath([0])!.type, ParagraphBlockKeys.type); + expect(editorState.getNodeAtPath([0])!.children.first.id, 'child-id'); + + editorState.undoManager.redo(); + + expect(editorState.getNodeAtPath([0])!.id, 'parent-id'); + expect(editorState.getNodeAtPath([0])!.type, BulletedListBlockKeys.type); + expect(editorState.getNodeAtPath([0])!.children.first.id, 'child-id'); + }); + test('toJson', () { final beforeSelection = Selection.collapsed(Position(path: [0])); final afterSelection = @@ -229,7 +327,7 @@ void main() async { end: Position(path: [0], offset: 10), ); final transaction = editorState.transaction; - var nodes = editorState.getNodesInSelection(selection); + final nodes = editorState.getNodesInSelection(selection); final texts = ['ABC1', 'ABC2', 'ABC3', 'ABC4', 'ABC5']; transaction.replaceTexts(nodes, selection, texts); await editorState.apply(transaction); @@ -338,7 +436,7 @@ void main() async { ); final editorState = EditorState(document: document); final selection = Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [2], offset: text3.length), ); editorState.selection = selection; @@ -363,7 +461,7 @@ void main() async { ); final editorState = EditorState(document: document); final selection = Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [0], offset: text1.length), ); editorState.selection = selection; @@ -402,7 +500,7 @@ void main() async { ); final editorState = EditorState(document: document); final selection = Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [2], offset: text3.length), ); editorState.selection = selection; @@ -442,7 +540,7 @@ void main() async { ); final editorState = EditorState(document: document); final selection = Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [0], offset: 5), ); editorState.selection = selection; @@ -494,7 +592,7 @@ void main() async { ); final editorState = EditorState(document: document); final selection = Selection( - start: Position(path: [0, 0], offset: 0), + start: Position(path: [0, 0]), end: Position(path: [0, 2], offset: 'paragraph 1-3'.length), ); editorState.selection = selection; @@ -561,7 +659,7 @@ void main() async { ); final editorState = EditorState(document: document); final selection = Selection( - start: Position(path: [0, 0], offset: 0), + start: Position(path: [0, 0]), end: Position(path: [0, 3], offset: 'paragraph 1-4'.length), ); editorState.selection = selection; @@ -615,7 +713,7 @@ void main() async { ); final editorState = EditorState(document: document); final selection = Selection( - start: Position(path: [0, 0], offset: 0), + start: Position(path: [0, 0]), end: Position(path: [0, 2], offset: 'paragraph 1-3'.length), ); editorState.selection = selection; diff --git a/test/customer/auto_complete_test.dart b/test/customer/auto_complete_test.dart index d88ccd99..034535f8 100644 --- a/test/customer/auto_complete_test.dart +++ b/test/customer/auto_complete_test.dart @@ -31,7 +31,7 @@ void main() async { ); editorState.selection = Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [0], offset: input.length), ); await tester.pumpAndSettle(); diff --git a/test/customer/custom_action_builder_test.dart b/test/customer/custom_action_builder_test.dart index 89105d38..a455219d 100644 --- a/test/customer/custom_action_builder_test.dart +++ b/test/customer/custom_action_builder_test.dart @@ -60,9 +60,7 @@ class CustomActionBuilder extends StatelessWidget { final editorState = EditorState(document: document); - final paragraphBuilder = ParagraphBlockComponentBuilder( - configuration: standardBlockComponentConfiguration, - ); + final paragraphBuilder = ParagraphBlockComponentBuilder(); paragraphBuilder.showActions = (_) => true; paragraphBuilder.actionBuilder = (blockComponentContext, state) { return Container( diff --git a/test/customer/custom_editor_directionality_test.dart b/test/customer/custom_editor_directionality_test.dart index 5c25e027..4f2ca6f9 100644 --- a/test/customer/custom_editor_directionality_test.dart +++ b/test/customer/custom_editor_directionality_test.dart @@ -17,8 +17,8 @@ void main() async { .widget(find.byType(AppFlowyEditor)) .unwrapOrNull()! .editorState; - Node headerNode = editorState.getNodeAtPath([0])!; - Node textNode = editorState.getNodeAtPath([1])!; + final Node headerNode = editorState.getNodeAtPath([0])!; + final Node textNode = editorState.getNodeAtPath([1])!; expect(headerNode.selectable?.textDirection(), TextDirection.rtl); expect(textNode.selectable?.textDirection(), TextDirection.rtl); diff --git a/test/customer/custom_error_block_test.dart b/test/customer/custom_error_block_test.dart index f5a681c5..0f64eb15 100644 --- a/test/customer/custom_error_block_test.dart +++ b/test/customer/custom_error_block_test.dart @@ -44,7 +44,6 @@ class ErrorEditor extends StatelessWidget { home: Scaffold( body: SafeArea( child: Column( - crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Container( diff --git a/test/customer/custom_toolbar_item_color_test.dart b/test/customer/custom_toolbar_item_color_test.dart index f1b0aea4..aba1d67b 100644 --- a/test/customer/custom_toolbar_item_color_test.dart +++ b/test/customer/custom_toolbar_item_color_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -25,8 +26,10 @@ void main() async { await tester.pumpAndSettle(); // update selection and show the toolbar - editorState.updateSelectionWithReason( - Selection.single(path: [0], startOffset: 0, endOffset: text.length), + unawaited( + editorState.updateSelectionWithReason( + Selection.single(path: [0], startOffset: 0, endOffset: text.length), + ), ); await tester.pumpAndSettle(const Duration(milliseconds: 500)); expect(find.byType(FloatingToolbar), findsOneWidget); @@ -52,7 +55,6 @@ class CustomToolbarItemColor extends StatelessWidget { localizationsDelegates: const [ AppFlowyEditorLocalizations.delegate, ], - supportedLocales: const [Locale('en', 'US')], home: Scaffold( body: SafeArea( child: Container( diff --git a/test/customer/inline_comment_test.dart b/test/customer/inline_comment_test.dart new file mode 100644 index 00000000..d9c28130 --- /dev/null +++ b/test/customer/inline_comment_test.dart @@ -0,0 +1,159 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../new/util/util.dart'; + +void main() async { + await AppFlowyEditorLocalizations.load( + const Locale.fromSubtags(languageCode: 'en'), + ); + + testWidgets('inline comment: render editor with InlineCommentWidget', + (tester) async { + // 1. Build a simple document + final document = Document.blank() + ..addParagraph( + initialText: 'Hello World', + ); + final editorState = EditorState(document: document); + final controller = InlineCommentController( + onCommentAdded: (_, __, ___, ____) async => null, + onCommentDeleted: (_) async {}, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: InlineCommentWidget( + editorState: editorState, + controller: controller, + child: AppFlowyEditor( + editorState: editorState, + editorStyle: EditorStyle.desktop( + textSpanDecorator: buildCommentTextSpanDecorator( + controller: controller, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Should render without error + expect( + find.textContaining('Hello World', findRichText: true), + findsOneWidget, + ); + }); + + testWidgets('inline comment: highlight text with comment-ids attribute', + (tester) async { + // 2. Build a document with pre-existing comment-ids in the delta + final document = Document.blank() + ..addParagraph( + builder: (index) => Delta() + ..insert('Normal text ') + ..insert( + 'commented text', + attributes: {AppFlowyRichTextKeys.commentIds: 'cmt-1'}, + ) + ..insert(' more text'), + ); + final editorState = EditorState(document: document); + + final tappedIds = []; + final controller = InlineCommentController( + onCommentAdded: (_, __, ___, ____) async => null, + onCommentDeleted: (_) async {}, + onCommentTapped: (id, _) => tappedIds.add(id), + initialComments: [ + InlineComment( + id: 'cmt-1', + content: 'Test comment', + authorName: 'Tester', + createdAt: DateTime.now(), + ), + ], + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AppFlowyEditor( + editorState: editorState, + editorStyle: EditorStyle.desktop( + textSpanDecorator: buildCommentTextSpanDecorator( + controller: controller, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Should render all text + expect( + find.textContaining('Normal text', findRichText: true), + findsOneWidget, + ); + expect( + find.textContaining('commented text', findRichText: true), + findsOneWidget, + ); + }); + + testWidgets('inline comment: render with sidebar', (tester) async { + final document = Document.blank() + ..addParagraph( + builder: (index) => Delta() + ..insert( + 'commented text', + attributes: {AppFlowyRichTextKeys.commentIds: 'cmt-1'}, + ), + ); + final editorState = EditorState(document: document); + final controller = InlineCommentController( + onCommentAdded: (_, __, ___, ____) async => null, + onCommentDeleted: (_) async {}, + initialComments: [ + InlineComment( + id: 'cmt-1', + content: 'Sidebar comment', + authorName: 'Tester', + createdAt: DateTime.now(), + ), + ], + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: InlineCommentWidget( + editorState: editorState, + controller: controller, + showSidebar: true, + child: AppFlowyEditor( + editorState: editorState, + editorStyle: EditorStyle.desktop( + textSpanDecorator: buildCommentTextSpanDecorator( + controller: controller, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The editor + sidebar should render without error + expect( + find.textContaining('commented text', findRichText: true), + findsOneWidget, + ); + }); +} diff --git a/test/customer/text_field_and_editor_test.dart b/test/customer/text_field_and_editor_test.dart index 943c1d10..6f80fb93 100644 --- a/test/customer/text_field_and_editor_test.dart +++ b/test/customer/text_field_and_editor_test.dart @@ -76,7 +76,6 @@ class TextFieldAndEditor extends StatelessWidget { home: Scaffold( body: SafeArea( child: Column( - crossAxisAlignment: CrossAxisAlignment.center, children: [ TextField( controller: controller, diff --git a/test/editor/block_component/base_component/insert_newline_in_type_command_test.dart b/test/editor/block_component/base_component/insert_newline_in_type_command_test.dart index 9a83ae78..db885ec8 100644 --- a/test/editor/block_component/base_component/insert_newline_in_type_command_test.dart +++ b/test/editor/block_component/base_component/insert_newline_in_type_command_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -21,12 +22,12 @@ void main() { editorState.selection = Selection.collapsed(Position(path: [0, 1])); - insertNewLineAfterBulletedList.execute(editorState); - Node? node1 = editorState.getNodeAtPath([1]); + unawaited(insertNewLineAfterBulletedList.execute(editorState)); + final Node? node1 = editorState.getNodeAtPath([1]); expect(node1?.type, BulletedListBlockKeys.type); - insertNewLineAfterBulletedList.execute(editorState); - Node? node2 = editorState.getNodeAtPath([1]); + unawaited(insertNewLineAfterBulletedList.execute(editorState)); + final Node? node2 = editorState.getNodeAtPath([1]); expect(node2?.type, ParagraphBlockKeys.type); }); @@ -45,12 +46,12 @@ void main() { editorState.selection = Selection.collapsed(Position(path: [0, 1])); - insertNewLineAfterTodoList.execute(editorState); - Node? node1 = editorState.getNodeAtPath([1]); + unawaited(insertNewLineAfterTodoList.execute(editorState)); + final Node? node1 = editorState.getNodeAtPath([1]); expect(node1?.type, TodoListBlockKeys.type); - insertNewLineAfterTodoList.execute(editorState); - Node? node2 = editorState.getNodeAtPath([1]); + unawaited(insertNewLineAfterTodoList.execute(editorState)); + final Node? node2 = editorState.getNodeAtPath([1]); expect(node2?.type, ParagraphBlockKeys.type); }); @@ -68,12 +69,12 @@ void main() { editorState.selection = Selection.collapsed(Position(path: [0, 1])); - insertNewLineAfterNumberedList.execute(editorState); - Node? node1 = editorState.getNodeAtPath([1]); + unawaited(insertNewLineAfterNumberedList.execute(editorState)); + final Node? node1 = editorState.getNodeAtPath([1]); expect(node1?.type, NumberedListBlockKeys.type); - insertNewLineAfterNumberedList.execute(editorState); - Node? node2 = editorState.getNodeAtPath([1]); + unawaited(insertNewLineAfterNumberedList.execute(editorState)); + final Node? node2 = editorState.getNodeAtPath([1]); expect(node2?.type, ParagraphBlockKeys.type); }); diff --git a/test/editor/block_component/image_block_component/image_upload_widget_test.dart b/test/editor/block_component/image_block_component/image_upload_widget_test.dart index 2f71978b..be744016 100644 --- a/test/editor/block_component/image_block_component/image_upload_widget_test.dart +++ b/test/editor/block_component/image_block_component/image_upload_widget_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -15,8 +16,10 @@ void main() { final editorState = EditorState(document: document); expect(editorState.document.root.children.length, 1); - editorState.updateSelectionWithReason( - Selection.collapsed(Position(path: [0], offset: initialText.length)), + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: [0], offset: initialText.length)), + ), ); await editorState.insertImageNode('https://appflowy.io/image.jpg'); diff --git a/test/editor/command/text_commands_test.dart b/test/editor/command/text_commands_test.dart index 20605e52..ef4c2341 100644 --- a/test/editor/command/text_commands_test.dart +++ b/test/editor/command/text_commands_test.dart @@ -45,7 +45,6 @@ void main() { editorState.selection = Selection.collapsed( Position( path: [0], - offset: 0, ), ); final attributes = editorState.getDeltaAttributesInSelectionStart(); diff --git a/test/editor/editor_component/ime/delta_input_on_non_text_update_impl_test.dart b/test/editor/editor_component/ime/delta_input_on_non_text_update_impl_test.dart index 4003b59a..0b01eac2 100644 --- a/test/editor/editor_component/ime/delta_input_on_non_text_update_impl_test.dart +++ b/test/editor/editor_component/ime/delta_input_on_non_text_update_impl_test.dart @@ -3,6 +3,8 @@ import 'package:appflowy_editor/src/editor/editor_component/service/ime/delta_in import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../../new/infra/testable_editor.dart'; + void main() { group('onNonTextUpdate', () { // Pro-performa test @@ -17,5 +19,43 @@ void main() { [], ); }); + + testWidgets('handles Android IME select all as document select all', + (tester) async { + const text = 'AppFlowy'; + final editor = tester.editor..addParagraphs(3, initialText: text); + await editor.startTesting(); + await editor.updateSelection( + Selection.collapsed(Position(path: [1], offset: 3)), + ); + final oldText = List.filled(3, text).join('\n'); + + final handled = await handleAndroidNonTextUpdate( + TextEditingDeltaNonTextUpdate( + oldText: oldText, + selection: TextSelection( + baseOffset: 0, + extentOffset: oldText.length, + ), + composing: TextRange.empty, + ), + editor.editorState, + ); + + expect(handled, true); + expect( + editor.selection, + Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text.length), + ), + ); + expect( + editor.editorState.selectionUpdateReason, + SelectionUpdateReason.selectAll, + ); + + await editor.dispose(); + }); }); } diff --git a/test/editor/editor_component/renderer/mobile_scroll_service_test.dart b/test/editor/editor_component/renderer/mobile_scroll_service_test.dart deleted file mode 100644 index c6d1f2d3..00000000 --- a/test/editor/editor_component/renderer/mobile_scroll_service_test.dart +++ /dev/null @@ -1,91 +0,0 @@ -// TODO: @LucasXu0 refactor this test -void main() { - // group('MobileScrollService', () { - // testWidgets('can render', (tester) async { - // await tester.buildAndPump( - // MobileScrollService( - // scrollController: ScrollController(), - // autoScroller: AutoScroller(ScrollableState()), - // child: const SizedBox.shrink(), - // ), - // ); - // await tester.pumpAndSettle(); - - // expect(find.byType(MobileScrollService), findsOneWidget); - // }); - - // testWidgets('state', (tester) async { - // final scrollController = ScrollController(debugLabel: 'test'); - // await tester.buildAndPump( - // AutoScrollableWidget( - // scrollController: scrollController, - // builder: (context, scroller) => MobileScrollService( - // scrollController: scrollController, - // autoScroller: AutoScroller(ScrollableState()), - // child: const SizedBox.shrink(), - // ), - // ), - // ); - // await tester.pumpAndSettle(); - - // final state = tester.state(find.byType(MobileScrollService)) - // as MobileScrollServiceState; - - // expect(state.dy, 0); - - // expect(state.onePageHeight, 600.0); - - // expect(state.maxScrollExtent, 0); - - // expect(state.minScrollExtent, 0); - - // expect(state.page, 0); - - // expect(state.scrollController.debugLabel, 'test'); - // }); - - // testWidgets('scroll', (tester) async { - // final scrollController = ScrollController(debugLabel: 'test'); - // await tester.buildAndPump( - // AutoScrollableWidget( - // scrollController: scrollController, - // builder: (context, scroller) => MobileScrollService( - // scrollController: scrollController, - // autoScroller: AutoScroller(ScrollableState()), - // child: const SizedBox.shrink(), - // ), - // ), - // ); - // await tester.pumpAndSettle(); - - // final state = tester.state(find.byType(MobileScrollService)) - // as MobileScrollServiceState; - - // expect(state.dy, 0); - - // state.scrollTo(10); - // await tester.pumpAndSettle(); - // }); - - // testWidgets('enable/disable', (tester) async { - // final scrollController = ScrollController(debugLabel: 'test'); - // await tester.buildAndPump( - // AutoScrollableWidget( - // scrollController: scrollController, - // builder: (context, scroller) => MobileScrollService( - // scrollController: scrollController, - // autoScroller: AutoScroller(ScrollableState()), - // child: const SizedBox.shrink(), - // ), - // ), - // ); - // await tester.pumpAndSettle(); - - // final state = tester.state(find.byType(MobileScrollService)) - // as MobileScrollServiceState; - - // state.enable(); - // state.disable(); - // }); - // }); -} diff --git a/test/editor/toolbar/link_mobile_toolbar_item_test.dart b/test/editor/toolbar/link_mobile_toolbar_item_test.dart index 37ed2e6e..3bdd7f53 100644 --- a/test/editor/toolbar/link_mobile_toolbar_item_test.dart +++ b/test/editor/toolbar/link_mobile_toolbar_item_test.dart @@ -94,8 +94,6 @@ Widget _wrapWithStyle({required Widget child}) => MobileToolbarTheme( toolbarHeight: 55, borderRadius: 8, buttonHeight: 32, - buttonSpacing: 8, - buttonBorderWidth: 1, buttonSelectedBorderWidth: 1, child: child, ); diff --git a/test/editor/util/property_notifier_test.dart b/test/editor/util/property_notifier_test.dart new file mode 100644 index 00000000..3ab2b1c6 --- /dev/null +++ b/test/editor/util/property_notifier_test.dart @@ -0,0 +1,113 @@ +import 'package:appflowy_editor/src/editor/util/property_notifier.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PropertyValueNotifier', () { + test('notifies listeners on assignment even when value is unchanged', () { + final notifier = PropertyValueNotifier(1); + var calls = 0; + notifier.addListener(() => calls++); + + notifier.value = 1; + notifier.value = 1; + expect(calls, 2); + }); + + test('notifies listeners in registration order', () { + final notifier = PropertyValueNotifier(0); + final order = []; + notifier.addListener(() => order.add(1)); + notifier.addListener(() => order.add(2)); + notifier.addListener(() => order.add(3)); + + notifier.value = 1; + expect(order, [1, 2, 3]); + }); + + test('removeListener stops further notifications', () { + final notifier = PropertyValueNotifier(0); + var calls = 0; + void listener() => calls++; + notifier.addListener(listener); + notifier.value = 1; + notifier.removeListener(listener); + notifier.value = 2; + expect(calls, 1); + }); + + test('listener removed during dispatch is not called this round', () { + final notifier = PropertyValueNotifier(0); + final order = []; + late VoidCallback bListener; + void aListener() { + order.add('a'); + notifier.removeListener(bListener); + } + + bListener = () => order.add('b'); + notifier.addListener(aListener); + notifier.addListener(bListener); + + notifier.value = 1; + expect(order, ['a']); + // After dispatch, b is fully removed. + notifier.value = 2; + expect(order, ['a', 'a']); + }); + + test('listener added during dispatch is deferred until next notify', () { + final notifier = PropertyValueNotifier(0); + final order = []; + void late() => order.add('late'); + notifier.addListener(() { + order.add('early'); + notifier.addListener(late); + }); + + notifier.value = 1; + expect(order, ['early']); + notifier.value = 2; + expect(order, ['early', 'early', 'late']); + }); + + test('hasListeners reflects current registration', () { + final notifier = PropertyValueNotifier(0); + void listener() {} + expect(notifier.hasListeners, isFalse); + notifier.addListener(listener); + expect(notifier.hasListeners, isTrue); + notifier.removeListener(listener); + expect(notifier.hasListeners, isFalse); + }); + + test('removeListener is O(1) even at large listener counts', () { + // Regression test for the original O(n²) dispose cost. We just make sure + // a large registration / deregistration pass completes well within a + // second; the ChangeNotifier-backed implementation took multiple seconds + // for this workload (10k * 10k linear scans + array shifts). + final notifier = PropertyValueNotifier(0); + const n = 20000; + final listeners = []; + for (var i = 0; i < n; i++) { + // Each listener must be a distinct closure so Set semantics hold. + void l() {} + listeners.add(l); + notifier.addListener(l); + } + + final stopwatch = Stopwatch()..start(); + for (final l in listeners) { + notifier.removeListener(l); + } + stopwatch.stop(); + expect(notifier.hasListeners, isFalse); + expect( + stopwatch.elapsedMilliseconds, + lessThan(1000), + reason: 'Removing $n listeners should be O(n), not O(n^2). Took ' + '${stopwatch.elapsedMilliseconds} ms.', + ); + }); + }); +} diff --git a/test/infra/clipboard_test.dart b/test/infra/clipboard_test.dart index 34624032..291e0c6f 100644 --- a/test/infra/clipboard_test.dart +++ b/test/infra/clipboard_test.dart @@ -5,11 +5,10 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; class MockClipboard { + const MockClipboard({required this.text, required this.html}); final String? text; final String? html; - const MockClipboard({required this.text, required this.html}); - MockClipboard copyWith({ String? text, String? html, @@ -46,7 +45,7 @@ void main() { group('Clipboard tests', () { test('AppFlowyClipboardData constructor', () { - const data = AppFlowyClipboardData(text: null, html: null); + const data = AppFlowyClipboardData(); expect(data.text, null); expect(data.html, null); @@ -63,8 +62,7 @@ void main() { home: Column( children: [ TextButton( - onPressed: () async => - await AppFlowyClipboard.setData(text: rawText), + onPressed: () async => AppFlowyClipboard.setData(text: rawText), child: const Text('setData'), ), TextButton( diff --git a/test/legacy/flowy_editor_test.dart b/test/legacy/flowy_editor_test.dart index 29c38650..70cfa505 100644 --- a/test/legacy/flowy_editor_test.dart +++ b/test/legacy/flowy_editor_test.dart @@ -86,8 +86,8 @@ void main() { }); test('test position comparator', () { - final pos1 = Position(path: [1], offset: 0); - final pos2 = Position(path: [1], offset: 0); + final pos1 = Position(path: [1]); + final pos2 = Position(path: [1]); expect(pos1 == pos2, true); expect(pos1.hashCode == pos2.hashCode, true); }); @@ -114,14 +114,14 @@ void main() { }); test('test selection comparator', () { - final pos = Position(path: [0], offset: 0); + final pos = Position(path: [0]); final sel = Selection.collapsed(pos); expect(sel.start, sel.end); expect(sel.isCollapsed, true); }); test('test selection collapse', () { - final start = Position(path: [0], offset: 0); + final start = Position(path: [0]); final end = Position(path: [0], offset: 10); final sel = Selection(start: start, end: end); diff --git a/test/mobile/editor/editor_component/style/mobile_text_span_decorator_for_attribute_test.dart b/test/mobile/editor/editor_component/style/mobile_text_span_decorator_for_attribute_test.dart index 13d1fa64..f5527cc6 100644 --- a/test/mobile/editor/editor_component/style/mobile_text_span_decorator_for_attribute_test.dart +++ b/test/mobile/editor/editor_component/style/mobile_text_span_decorator_for_attribute_test.dart @@ -138,12 +138,12 @@ void main() { ); await tester.pumpAndSettle(const Duration(seconds: 1)); - var selection = Selection.single( + final selection = Selection.single( path: [0], startOffset: 0, endOffset: text2.length, ); - var node = editor.editorState.getNodesInSelection(selection); + final node = editor.editorState.getNodesInSelection(selection); // link is changed expect( node.allSatisfyInSelection(selection, (delta) { @@ -188,12 +188,12 @@ void main() { //dialog is closed expect(find.byType(AlertDialog), findsNothing); - var selection = Selection.single( + final selection = Selection.single( path: [0], startOffset: 0, endOffset: text.length, ); - var node = editor.editorState.getNodesInSelection(selection); + final node = editor.editorState.getNodesInSelection(selection); // link is removed expect( node.allSatisfyInSelection(selection, (delta) { diff --git a/test/mobile/toolbar/mobile/mobile_toolbar_style_test.dart b/test/mobile/toolbar/mobile/mobile_toolbar_style_test.dart index 001c21ba..9fbf9b47 100644 --- a/test/mobile/toolbar/mobile/mobile_toolbar_style_test.dart +++ b/test/mobile/toolbar/mobile/mobile_toolbar_style_test.dart @@ -7,39 +7,15 @@ void main() { (WidgetTester tester) async { const backgroundColor = Colors.white; const foregroundColor = Colors.black; - const clearDiagonalLineColor = Color(0xffB3261E); const itemHighlightColor = Color(0xff1F71AC); const itemOutlineColor = Color(0xFFE3E3E3); - const tabbarSelectedBackgroundColor = Color(0x23808080); - const tabbarSelectedForegroundColor = Colors.black; - const primaryColor = Color(0xff1F71AC); - const onPrimaryColor = Colors.white; - const outlineColor = Color(0xFFE3E3E3); const toolbarHeight = 50.0; const borderRadius = 6.0; - const buttonHeight = 40.0; - const buttonSpacing = 8.0; - const buttonBorderWidth = 1.0; - const buttonSelectedBorderWidth = 2.0; await tester.pumpWidget( const MobileToolbarTheme( - backgroundColor: backgroundColor, foregroundColor: foregroundColor, - clearDiagonalLineColor: clearDiagonalLineColor, - itemHighlightColor: itemHighlightColor, - itemOutlineColor: itemOutlineColor, - tabBarSelectedBackgroundColor: tabbarSelectedBackgroundColor, - tabBarSelectedForegroundColor: tabbarSelectedForegroundColor, - primaryColor: primaryColor, - onPrimaryColor: onPrimaryColor, - outlineColor: outlineColor, toolbarHeight: toolbarHeight, - borderRadius: borderRadius, - buttonHeight: buttonHeight, - buttonSpacing: buttonSpacing, - buttonBorderWidth: buttonBorderWidth, - buttonSelectedBorderWidth: buttonSelectedBorderWidth, child: SizedBox(), ), ); diff --git a/test/mobile/toolbar/mobile/mobile_toolbar_test.dart b/test/mobile/toolbar/mobile/mobile_toolbar_test.dart index b6a4cfe4..fe2a9a32 100644 --- a/test/mobile/toolbar/mobile/mobile_toolbar_test.dart +++ b/test/mobile/toolbar/mobile/mobile_toolbar_test.dart @@ -36,7 +36,7 @@ void main() { final editor = tester.editor..addParagraphs(3, initialText: text); await editor.startTesting(); - var selection = Selection.single( + final selection = Selection.single( path: [1], startOffset: 2, endOffset: text.length - 2, diff --git a/test/mobile/toolbar/mobile/toolbar_items/code_mobile_toolbar_item_test.dart b/test/mobile/toolbar/mobile/toolbar_items/code_mobile_toolbar_item_test.dart index 89a1cf58..ca52ab78 100644 --- a/test/mobile/toolbar/mobile/toolbar_items/code_mobile_toolbar_item_test.dart +++ b/test/mobile/toolbar/mobile/toolbar_items/code_mobile_toolbar_item_test.dart @@ -10,7 +10,7 @@ void main() { final editor = tester.editor..addParagraphs(3, initialText: text); await editor.startTesting(); - var selection = Selection.single( + final selection = Selection.single( path: [1], startOffset: 2, endOffset: text.length - 2, diff --git a/test/mobile/toolbar/mobile/toolbar_items/color/background_color_options_widgets_test.dart b/test/mobile/toolbar/mobile/toolbar_items/color/background_color_options_widgets_test.dart index 9d2a82fb..5eeb0866 100644 --- a/test/mobile/toolbar/mobile/toolbar_items/color/background_color_options_widgets_test.dart +++ b/test/mobile/toolbar/mobile/toolbar_items/color/background_color_options_widgets_test.dart @@ -11,7 +11,7 @@ void main() { final editor = tester.editor..addParagraphs(3, initialText: text); await editor.startTesting(); - var selection = Selection.single( + final selection = Selection.single( path: [1], startOffset: 2, endOffset: text.length - 2, diff --git a/test/mobile/toolbar/mobile/toolbar_items/color/text_and_background_color_tool_bar_item_test.dart b/test/mobile/toolbar/mobile/toolbar_items/color/text_and_background_color_tool_bar_item_test.dart index e852b7af..f83e393d 100644 --- a/test/mobile/toolbar/mobile/toolbar_items/color/text_and_background_color_tool_bar_item_test.dart +++ b/test/mobile/toolbar/mobile/toolbar_items/color/text_and_background_color_tool_bar_item_test.dart @@ -12,7 +12,7 @@ void main() { final editor = tester.editor..addParagraphs(3, initialText: text); await editor.startTesting(); - var selection = Selection.single( + final selection = Selection.single( path: [1], startOffset: 2, endOffset: text.length - 2, @@ -52,7 +52,7 @@ void main() { // Tap red color button await tester.tap(find.widgetWithText(ColorButton, 'Red')); await tester.pumpAndSettle(const Duration(milliseconds: 500)); - var node = editor.editorState.getNodeAtPath([1]); + final node = editor.editorState.getNodeAtPath([1]); // Check if the text color is red expect( node?.allSatisfyInSelection(selection, (delta) { diff --git a/test/mobile/toolbar/mobile/toolbar_items/color/text_color_options_widgets_test.dart b/test/mobile/toolbar/mobile/toolbar_items/color/text_color_options_widgets_test.dart index a414a896..61faec45 100644 --- a/test/mobile/toolbar/mobile/toolbar_items/color/text_color_options_widgets_test.dart +++ b/test/mobile/toolbar/mobile/toolbar_items/color/text_color_options_widgets_test.dart @@ -11,7 +11,7 @@ void main() { final editor = tester.editor..addParagraphs(3, initialText: text); await editor.startTesting(); - var selection = Selection.single( + final selection = Selection.single( path: [1], startOffset: 2, endOffset: text.length - 2, diff --git a/test/mobile/toolbar/mobile/toolbar_items/divider_mobile_toolbar_item_test.dart b/test/mobile/toolbar/mobile/toolbar_items/divider_mobile_toolbar_item_test.dart index 879692cb..63928c82 100644 --- a/test/mobile/toolbar/mobile/toolbar_items/divider_mobile_toolbar_item_test.dart +++ b/test/mobile/toolbar/mobile/toolbar_items/divider_mobile_toolbar_item_test.dart @@ -13,7 +13,7 @@ void main() { final editor = tester.editor..addParagraphs(3, initialText: text); await editor.startTesting(); - var selection = Selection.single( + final selection = Selection.single( path: [1], startOffset: 2, endOffset: text.length - 2, @@ -51,7 +51,7 @@ void main() { const originalPath = 1; - var selection = + final selection = Selection.collapsed(Position(path: [originalPath], offset: 2)); await editor.updateSelection(selection); diff --git a/test/mobile/toolbar/mobile/toolbar_items/heading_mobile_toolbar_item_test.dart b/test/mobile/toolbar/mobile/toolbar_items/heading_mobile_toolbar_item_test.dart index 1ad24793..63d10c5d 100644 --- a/test/mobile/toolbar/mobile/toolbar_items/heading_mobile_toolbar_item_test.dart +++ b/test/mobile/toolbar/mobile/toolbar_items/heading_mobile_toolbar_item_test.dart @@ -11,7 +11,7 @@ void main() { final editor = tester.editor..addParagraphs(3, initialText: text); await editor.startTesting(); - var selection = Selection.single( + final selection = Selection.single( path: [1], startOffset: 2, endOffset: text.length - 2, diff --git a/test/mobile/toolbar/mobile/toolbar_items/link_mobile_toolbar_item_test.dart b/test/mobile/toolbar/mobile/toolbar_items/link_mobile_toolbar_item_test.dart index 29863180..69a88c3f 100644 --- a/test/mobile/toolbar/mobile/toolbar_items/link_mobile_toolbar_item_test.dart +++ b/test/mobile/toolbar/mobile/toolbar_items/link_mobile_toolbar_item_test.dart @@ -16,7 +16,7 @@ void main() { final editor = tester.editor..addParagraphs(3, initialText: text); await editor.startTesting(); - var selection = Selection.single( + final selection = Selection.single( path: [1], startOffset: 2, endOffset: text.length - 2, diff --git a/test/mobile/toolbar/mobile/toolbar_items/list_mobile_toobar_item_test.dart b/test/mobile/toolbar/mobile/toolbar_items/list_mobile_toobar_item_test.dart index c0e30ff5..174a6f34 100644 --- a/test/mobile/toolbar/mobile/toolbar_items/list_mobile_toobar_item_test.dart +++ b/test/mobile/toolbar/mobile/toolbar_items/list_mobile_toobar_item_test.dart @@ -11,7 +11,7 @@ void main() { final editor = tester.editor..addParagraphs(3, initialText: text); await editor.startTesting(); - var selection = Selection.single( + final selection = Selection.single( path: [1], startOffset: 2, endOffset: text.length - 2, diff --git a/test/mobile/toolbar/mobile/toolbar_items/quote_mobile_toolbar_item_test.dart b/test/mobile/toolbar/mobile/toolbar_items/quote_mobile_toolbar_item_test.dart index 9c7fc6e5..8a7a72f7 100644 --- a/test/mobile/toolbar/mobile/toolbar_items/quote_mobile_toolbar_item_test.dart +++ b/test/mobile/toolbar/mobile/toolbar_items/quote_mobile_toolbar_item_test.dart @@ -10,7 +10,7 @@ void main() { final editor = tester.editor..addParagraphs(3, initialText: text); await editor.startTesting(); - var selection = Selection.single( + final selection = Selection.single( path: [1], startOffset: 2, endOffset: text.length - 2, diff --git a/test/mobile/toolbar/mobile/toolbar_items/text_decoration_mobile_toolbar_item_test.dart b/test/mobile/toolbar/mobile/toolbar_items/text_decoration_mobile_toolbar_item_test.dart index 3ca77b88..3623080b 100644 --- a/test/mobile/toolbar/mobile/toolbar_items/text_decoration_mobile_toolbar_item_test.dart +++ b/test/mobile/toolbar/mobile/toolbar_items/text_decoration_mobile_toolbar_item_test.dart @@ -13,7 +13,7 @@ void main() { withFloatingToolbar: true, ); - var selection = Selection.single( + final selection = Selection.single( path: [1], startOffset: 2, endOffset: text.length - 2, diff --git a/test/mobile/toolbar/mobile/toolbar_items/todo_list_mobile_toolbar_item_test.dart b/test/mobile/toolbar/mobile/toolbar_items/todo_list_mobile_toolbar_item_test.dart index e23ddd35..98718d08 100644 --- a/test/mobile/toolbar/mobile/toolbar_items/todo_list_mobile_toolbar_item_test.dart +++ b/test/mobile/toolbar/mobile/toolbar_items/todo_list_mobile_toolbar_item_test.dart @@ -10,7 +10,7 @@ void main() { final editor = tester.editor..addParagraphs(3, initialText: text); await editor.startTesting(); - var selection = Selection.single( + final selection = Selection.single( path: [1], startOffset: 2, endOffset: text.length - 2, diff --git a/test/new/block_component/bulleted_list_block_component/bulleted_list_character_shortcut_test.dart b/test/new/block_component/bulleted_list_block_component/bulleted_list_character_shortcut_test.dart index ad2d9e00..eaf3112c 100644 --- a/test/new/block_component/bulleted_list_block_component/bulleted_list_character_shortcut_test.dart +++ b/test/new/block_component/bulleted_list_block_component/bulleted_list_character_shortcut_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -14,16 +15,17 @@ void main() async { test( 'mock inputting a ` ` after asterisk which is located at the front of the text', () async { - testFormatCharacterShortcut( - formatAsteriskToBulletedList, - '*', - 1, - (result, before, after, editorState) { - expect(result, true); - expect(after.delta!.toPlainText(), text); - expect(after.type, BulletedListBlockKeys.type); - }, - text: text, + unawaited( + testFormatCharacterShortcut( + formatAsteriskToBulletedList, + '*', + 1, + (result, before, after, editorState) { + expect(result, true); + expect(after.delta!.toPlainText(), text); + expect(after.type, BulletedListBlockKeys.type); + }, + ), ); }); @@ -32,16 +34,17 @@ void main() async { // After // *W|elcome to AppFlowy Editor 🔥! test('mock inputting a ` ` in the middle of the text - 1', () async { - return testFormatCharacterShortcut( - formatAsteriskToBulletedList, - '*', - 2, - (result, before, after, editorState) { - // nothing happens - expect(result, false); - expect(before.toJson(), after.toJson()); - }, - text: text, + unawaited( + testFormatCharacterShortcut( + formatAsteriskToBulletedList, + '*', + 2, + (result, before, after, editorState) { + // nothing happens + expect(result, false); + expect(before.toJson(), after.toJson()); + }, + ), ); }); diff --git a/test/new/block_component/divider_block_component/divider_character_shortcut_test.dart b/test/new/block_component/divider_block_component/divider_character_shortcut_test.dart index 880a61b7..134407fa 100644 --- a/test/new/block_component/divider_block_component/divider_character_shortcut_test.dart +++ b/test/new/block_component/divider_block_component/divider_character_shortcut_test.dart @@ -13,7 +13,7 @@ void main() async { // [divider] test('--- to divider', () async { const text = ''; - testFormatCharacterShortcut( + await testFormatCharacterShortcut( convertMinusesToDivider, '--', 2, @@ -36,7 +36,7 @@ void main() async { // [divider] test('*** to divider', () async { const text = ''; - testFormatCharacterShortcut( + await testFormatCharacterShortcut( convertStarsToDivider, '**', 2, @@ -55,7 +55,7 @@ void main() async { // [divider] test('___ to divider', () async { const text = ''; - testFormatCharacterShortcut( + await testFormatCharacterShortcut( convertUnderscoreToDivider, '__', 2, diff --git a/test/new/block_component/heading_block_component/heading_character_shortcut_test.dart b/test/new/block_component/heading_block_component/heading_character_shortcut_test.dart index 205a9a11..9dcf5739 100644 --- a/test/new/block_component/heading_block_component/heading_character_shortcut_test.dart +++ b/test/new/block_component/heading_block_component/heading_character_shortcut_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -13,17 +14,19 @@ void main() async { // After // ' ' test('mock inputting a ` ` after the >', () async { - testFormatCharacterShortcut( - formatSignToHeading, - '', - 0, - (result, before, after, editorState) { - expect(result, false); - expect(before.delta!.toPlainText(), ''); - expect(after.delta!.toPlainText(), ''); - expect(after.type != HeadingBlockKeys.type, true); - }, - text: '', + unawaited( + testFormatCharacterShortcut( + formatSignToHeading, + '', + 0, + (result, before, after, editorState) { + expect(result, false); + expect(before.delta!.toPlainText(), ''); + expect(after.delta!.toPlainText(), ''); + expect(after.type != HeadingBlockKeys.type, true); + }, + text: '', + ), ); }); @@ -33,16 +36,17 @@ void main() async { // [heading] Welcome to AppFlowy Editor 🔥! test('mock inputting a ` ` after the #', () async { for (var i = 1; i <= 6; i++) { - testFormatCharacterShortcut( - formatSignToHeading, - '#' * i, - i, - (result, before, after, editorState) { - expect(result, true); - expect(after.delta!.toPlainText(), text); - expect(after.type, 'heading'); - }, - text: text, + unawaited( + testFormatCharacterShortcut( + formatSignToHeading, + '#' * i, + i, + (result, before, after, editorState) { + expect(result, true); + expect(after.delta!.toPlainText(), text); + expect(after.type, 'heading'); + }, + ), ); } }); @@ -52,16 +56,17 @@ void main() async { // After // #######|Welcome to AppFlowy Editor 🔥! test('mock inputting a ` ` after the #', () async { - testFormatCharacterShortcut( - formatSignToHeading, - '#' * 7, - 7, - (result, before, after, editorState) { - // nothing happens - expect(result, false); - expect(before.toJson(), after.toJson()); - }, - text: text, + unawaited( + testFormatCharacterShortcut( + formatSignToHeading, + '#' * 7, + 7, + (result, before, after, editorState) { + // nothing happens + expect(result, false); + expect(before.toJson(), after.toJson()); + }, + ), ); }); @@ -70,16 +75,17 @@ void main() async { // After // >W|elcome to AppFlowy Editor 🔥! test('mock inputting a ` ` in the middle of the node', () async { - testFormatCharacterShortcut( - formatSignToHeading, - '#', - 2, - (result, before, after, editorState) { - // nothing happens - expect(result, false); - expect(before.toJson(), after.toJson()); - }, - text: text, + unawaited( + testFormatCharacterShortcut( + formatSignToHeading, + '#', + 2, + (result, before, after, editorState) { + // nothing happens + expect(result, false); + expect(before.toJson(), after.toJson()); + }, + ), ); }); @@ -120,7 +126,7 @@ void main() async { test('convert bulleted_list to heading', () async { const syntax = '#'; const text = 'Welcome to AppFlowy Editor 🔥!'; - testFormatCharacterShortcut( + await testFormatCharacterShortcut( formatSignToHeading, syntax, syntax.length, @@ -133,7 +139,6 @@ void main() async { expect(after.next!.delta!.toPlainText(), '1 $text'); expect(after.next!.next!.delta!.toPlainText(), '2 $text'); }, - text: text, node: bulletedListNode( text: '$syntax$text', children: [ @@ -143,5 +148,127 @@ void main() async { ), ); }); + + test('paragraph to heading preserves id through undo and redo', () async { + // No children: this is the safe in-place path. The markdown shortcut + // should change the type to heading without replacing the node id. + const syntax = '#'; + const text = 'Welcome to AppFlowy Editor 🔥!'; + final node = paragraphNode(text: '$syntax$text')..id = 'paragraph-id'; + final document = Document.blank()..insert([0], [node]); + final editorState = EditorState(document: document); + editorState.selection = Selection.collapsed( + Position(path: [0], offset: syntax.length), + ); + + final result = await formatSignToHeading.execute(editorState); + + expect(result, true); + expect(editorState.document.root.children.length, 1); + expect(editorState.getNodeAtPath([0])!.id, 'paragraph-id'); + expect(editorState.getNodeAtPath([0])!.type, HeadingBlockKeys.type); + expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), text); + + editorState.undoManager.undo(); + + expect(editorState.document.root.children.length, 1); + expect(editorState.getNodeAtPath([0])!.id, 'paragraph-id'); + expect(editorState.getNodeAtPath([0])!.type, ParagraphBlockKeys.type); + expect( + editorState.getNodeAtPath([0])!.delta!.toPlainText(), + '$syntax$text', + ); + + editorState.undoManager.redo(); + + expect(editorState.document.root.children.length, 1); + expect(editorState.getNodeAtPath([0])!.id, 'paragraph-id'); + expect(editorState.getNodeAtPath([0])!.type, HeadingBlockKeys.type); + expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), text); + }); + + test('convert numbered_list to heading flattens children', () async { + // Historical AppFlowy PR #6516 covered this corner case: heading blocks + // cannot contain children, so nested list children must be flattened into + // siblings instead of being preserved inside the converted heading. + const syntax = '#'; + const text = 'Welcome to AppFlowy Editor 🔥!'; + await testFormatCharacterShortcut( + formatSignToHeading, + syntax, + syntax.length, + (result, before, after, editorState) { + expect(result, true); + expect(after.delta!.toPlainText(), text); + expect(after.type, HeadingBlockKeys.type); + expect(after.attributes[HeadingBlockKeys.level], 1); + expect(after.children.isEmpty, true); + expect(after.next!.delta!.toPlainText(), '1 $text'); + expect(after.next!.type, NumberedListBlockKeys.type); + expect(after.next!.next!.delta!.toPlainText(), '2 $text'); + expect(after.next!.next!.type, NumberedListBlockKeys.type); + }, + node: numberedListNode( + delta: Delta()..insert('$syntax$text'), + children: [ + numberedListNode(delta: Delta()..insert('1 $text')), + numberedListNode(delta: Delta()..insert('2 $text')), + ], + ), + ); + }); + + test('undo and redo numbered_list to heading child flattening', () async { + // The same historical report also called out undo risk. The fallback + // insert/delete path must undo back to one nested list and redo back to a + // childless heading followed by flattened list siblings. + const syntax = '#'; + const text = 'Welcome to AppFlowy Editor 🔥!'; + final child1 = numberedListNode(delta: Delta()..insert('1 $text')) + ..id = 'child-1'; + final child2 = numberedListNode(delta: Delta()..insert('2 $text')) + ..id = 'child-2'; + final node = numberedListNode( + delta: Delta()..insert('$syntax$text'), + children: [child1, child2], + )..id = 'parent'; + final document = Document.blank()..insert([0], [node]); + final editorState = EditorState(document: document); + editorState.selection = Selection.collapsed( + Position(path: [0], offset: syntax.length), + ); + + final result = await formatSignToHeading.execute(editorState); + + expect(result, true); + expect(editorState.document.root.children.length, 3); + expect(editorState.getNodeAtPath([0])!.type, HeadingBlockKeys.type); + expect(editorState.getNodeAtPath([0])!.children, isEmpty); + expect(editorState.getNodeAtPath([1])!.type, NumberedListBlockKeys.type); + expect(editorState.getNodeAtPath([2])!.type, NumberedListBlockKeys.type); + + editorState.undoManager.undo(); + + expect(editorState.document.root.children.length, 1); + expect(editorState.getNodeAtPath([0])!.id, 'parent'); + expect(editorState.getNodeAtPath([0])!.type, NumberedListBlockKeys.type); + expect( + editorState.getNodeAtPath([0])!.delta!.toPlainText(), + '$syntax$text', + ); + expect(editorState.getNodeAtPath([0])!.children.length, 2); + expect(editorState.getNodeAtPath([0, 0])!.id, 'child-1'); + expect(editorState.getNodeAtPath([0, 1])!.id, 'child-2'); + + editorState.undoManager.redo(); + + expect(editorState.document.root.children.length, 3); + expect(editorState.getNodeAtPath([0])!.type, HeadingBlockKeys.type); + expect(editorState.getNodeAtPath([0])!.children, isEmpty); + expect(editorState.getNodeAtPath([1])!.type, NumberedListBlockKeys.type); + expect(editorState.getNodeAtPath([1])!.delta!.toPlainText(), '1 $text'); + expect(editorState.getNodeAtPath([2])!.type, NumberedListBlockKeys.type); + expect(editorState.getNodeAtPath([2])!.delta!.toPlainText(), '2 $text'); + }); }); } diff --git a/test/new/block_component/numbered_list_block_component/numbered_list_character_shortcut_test.dart b/test/new/block_component/numbered_list_block_component/numbered_list_character_shortcut_test.dart index 238e6e11..d0ed8a7c 100644 --- a/test/new/block_component/numbered_list_block_component/numbered_list_character_shortcut_test.dart +++ b/test/new/block_component/numbered_list_block_component/numbered_list_character_shortcut_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -12,16 +13,17 @@ void main() async { // After // 1|Welcome to AppFlowy Editor 🔥! test('mock inputting a ` ` after the number but not dot', () async { - testFormatCharacterShortcut( - formatNumberToNumberedList, - '1', - 1, - (result, before, after, editorState) { - // nothing happens - expect(result, false); - expect(before.toJson(), after.toJson()); - }, - text: text, + unawaited( + testFormatCharacterShortcut( + formatNumberToNumberedList, + '1', + 1, + (result, before, after, editorState) { + // nothing happens + expect(result, false); + expect(before.toJson(), after.toJson()); + }, + ), ); }); @@ -32,16 +34,17 @@ void main() async { test( 'mock inputting a ` ` after the number which is located at the front of the text', () async { - testFormatCharacterShortcut( - formatNumberToNumberedList, - '1.', - 2, - (result, before, after, editorState) { - expect(result, true); - expect(after.delta!.toPlainText(), text); - expect(after.type, NumberedListBlockKeys.type); - }, - text: text, + unawaited( + testFormatCharacterShortcut( + formatNumberToNumberedList, + '1.', + 2, + (result, before, after, editorState) { + expect(result, true); + expect(after.delta!.toPlainText(), text); + expect(after.type, NumberedListBlockKeys.type); + }, + ), ); }); @@ -50,16 +53,17 @@ void main() async { // After // 1.W|elcome to AppFlowy Editor 🔥! test('mock inputting a ` ` in the middle of the node', () async { - testFormatCharacterShortcut( - formatNumberToNumberedList, - '1.', - 3, - (result, before, after, editorState) { - // nothing happens - expect(result, false); - expect(before.toJson(), after.toJson()); - }, - text: text, + unawaited( + testFormatCharacterShortcut( + formatNumberToNumberedList, + '1.', + 3, + (result, before, after, editorState) { + // nothing happens + expect(result, false); + expect(before.toJson(), after.toJson()); + }, + ), ); }); @@ -176,46 +180,100 @@ void main() async { test('convert todo_list to bulleted_list', () async { const syntax = '1.'; const text = 'Welcome to AppFlowy Editor 🔥!'; - testFormatCharacterShortcut( - formatNumberToNumberedList, - syntax, - syntax.length, - (result, before, after, editorState) { - expect(result, true); - expect(after.delta!.toPlainText(), text); - expect(after.type, NumberedListBlockKeys.type); - expect(after.children[0].delta!.toPlainText(), '1 $text'); - expect(after.children[1].delta!.toPlainText(), '2 $text'); - }, - text: text, - node: todoListNode( - text: '$syntax$text', - checked: false, - children: [ - todoListNode(text: '1 $text', checked: false), - todoListNode(text: '2 $text', checked: false), - ], + unawaited( + testFormatCharacterShortcut( + formatNumberToNumberedList, + syntax, + syntax.length, + (result, before, after, editorState) { + expect(result, true); + expect(after.delta!.toPlainText(), text); + expect(after.type, NumberedListBlockKeys.type); + expect(after.children[0].delta!.toPlainText(), '1 $text'); + expect(after.children[1].delta!.toPlainText(), '2 $text'); + }, + node: todoListNode( + text: '$syntax$text', + checked: false, + children: [ + todoListNode(text: '1 $text', checked: false), + todoListNode(text: '2 $text', checked: false), + ], + ), ), ); }); + test( + 'nested todo_list to numbered_list preserves ids through undo and redo', + () async { + // Unlike heading, numbered lists can keep the same child shape. This + // covers the safe in-place path for nested blocks and verifies undo/redo + // does not regenerate parent or child ids. + const syntax = '1.'; + const text = 'Welcome to AppFlowy Editor 🔥!'; + final child1 = todoListNode(text: '1 $text', checked: false) + ..id = 'child-1'; + final child2 = todoListNode(text: '2 $text', checked: false) + ..id = 'child-2'; + final node = todoListNode( + text: '$syntax$text', + checked: false, + children: [child1, child2], + )..id = 'parent'; + final document = Document.blank()..insert([0], [node]); + final editorState = EditorState(document: document); + editorState.selection = Selection.collapsed( + Position(path: [0], offset: syntax.length), + ); + + final result = await formatNumberToNumberedList.execute(editorState); + + expect(result, true); + expect(editorState.document.root.children.length, 1); + expect(editorState.getNodeAtPath([0])!.id, 'parent'); + expect(editorState.getNodeAtPath([0])!.type, NumberedListBlockKeys.type); + expect(editorState.getNodeAtPath([0])!.children.length, 2); + expect(editorState.getNodeAtPath([0, 0])!.id, 'child-1'); + expect(editorState.getNodeAtPath([0, 1])!.id, 'child-2'); + + editorState.undoManager.undo(); + + expect(editorState.document.root.children.length, 1); + expect(editorState.getNodeAtPath([0])!.id, 'parent'); + expect(editorState.getNodeAtPath([0])!.type, TodoListBlockKeys.type); + expect(editorState.getNodeAtPath([0])!.children.length, 2); + expect(editorState.getNodeAtPath([0, 0])!.id, 'child-1'); + expect(editorState.getNodeAtPath([0, 1])!.id, 'child-2'); + + editorState.undoManager.redo(); + + expect(editorState.document.root.children.length, 1); + expect(editorState.getNodeAtPath([0])!.id, 'parent'); + expect(editorState.getNodeAtPath([0])!.type, NumberedListBlockKeys.type); + expect(editorState.getNodeAtPath([0])!.children.length, 2); + expect(editorState.getNodeAtPath([0, 0])!.id, 'child-1'); + expect(editorState.getNodeAtPath([0, 1])!.id, 'child-2'); + }); + test('nothing will happen when converting heading to numbered list', () async { const syntax = '1.'; const text = 'Welcome to AppFlowy Editor 🔥!'; - testFormatCharacterShortcut( - formatNumberToNumberedList, - syntax, - syntax.length, - (result, before, after, editorState) { - expect(result, false); - expect(after.delta!.toPlainText(), '$syntax$text'); - expect(after.type, HeadingBlockKeys.type); - }, - text: text, - node: headingNode( - text: '$syntax$text', - level: 1, + unawaited( + testFormatCharacterShortcut( + formatNumberToNumberedList, + syntax, + syntax.length, + (result, before, after, editorState) { + expect(result, false); + expect(after.delta!.toPlainText(), '$syntax$text'); + expect(after.type, HeadingBlockKeys.type); + }, + node: headingNode( + text: '$syntax$text', + level: 1, + ), ), ); }); diff --git a/test/new/block_component/quote_block_component/quote_character_shortcut_test.dart b/test/new/block_component/quote_block_component/quote_character_shortcut_test.dart index d07978c3..026cfdde 100644 --- a/test/new/block_component/quote_block_component/quote_character_shortcut_test.dart +++ b/test/new/block_component/quote_block_component/quote_character_shortcut_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -12,16 +13,17 @@ void main() async { // After // [quote] Welcome to AppFlowy Editor 🔥! test('mock inputting a ` ` after the > but not dot', () async { - testFormatCharacterShortcut( - formatDoubleQuoteToQuote, - '"', - 1, - (result, before, after, editorState) { - expect(result, true); - expect(after.delta!.toPlainText(), text); - expect(after.type, 'quote'); - }, - text: text, + unawaited( + testFormatCharacterShortcut( + formatDoubleQuoteToQuote, + '"', + 1, + (result, before, after, editorState) { + expect(result, true); + expect(after.delta!.toPlainText(), text); + expect(after.type, 'quote'); + }, + ), ); }); @@ -30,16 +32,17 @@ void main() async { // After // >W|elcome to AppFlowy Editor 🔥! test('mock inputting a ` ` in the middle of the node', () async { - testFormatCharacterShortcut( - formatDoubleQuoteToQuote, - '"', - 2, - (result, before, after, editorState) { - // nothing happens - expect(result, false); - expect(before.toJson(), after.toJson()); - }, - text: text, + unawaited( + testFormatCharacterShortcut( + formatDoubleQuoteToQuote, + '"', + 2, + (result, before, after, editorState) { + // nothing happens + expect(result, false); + expect(before.toJson(), after.toJson()); + }, + ), ); }); diff --git a/test/new/block_component/table_block_component/table_action_test.dart b/test/new/block_component/table_block_component/table_action_test.dart index e3fc089a..c821fa0e 100644 --- a/test/new/block_component/table_block_component/table_action_test.dart +++ b/test/new/block_component/table_block_component/table_action_test.dart @@ -81,7 +81,7 @@ void main() async { }); testWidgets('remove the last column', (tester) async { - var tableNode = TableNode.fromList([ + final tableNode = TableNode.fromList([ ['1', '2'], ]); final editor = tester.editor..addNode(tableNode.node); @@ -102,7 +102,7 @@ void main() async { }); testWidgets('remove the last row', (tester) async { - var tableNode = TableNode.fromList([ + final tableNode = TableNode.fromList([ ['1'], ['3'], ]); @@ -139,7 +139,7 @@ void main() async { editor.editorState, TableDirection.col, ); - await tester.pumpAndSettle(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); tableNode = TableNode(node: tableNode.node); expect(tableNode.colsLen, 3); @@ -168,7 +168,7 @@ void main() async { editor.editorState, TableDirection.row, ); - await tester.pumpAndSettle(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); tableNode = TableNode(node: tableNode.node); expect(tableNode.rowsLen, 3); @@ -197,7 +197,7 @@ void main() async { editor.editorState, TableDirection.col, ); - await tester.pumpAndSettle(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); tableNode = TableNode(node: tableNode.node); expect(tableNode.colsLen, 3); @@ -228,7 +228,7 @@ void main() async { editor.editorState, TableDirection.row, ); - await tester.pumpAndSettle(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); tableNode = TableNode(node: tableNode.node); expect(tableNode.rowsLen, 3); @@ -240,13 +240,13 @@ void main() async { }, ); - var cell12 = getCellNode(tableNode.node, 1, 2)!; + final cell12 = getCellNode(tableNode.node, 1, 2)!; expect(tableNode.getRowHeight(2), cell12.children.first.rect.height + 8); await editor.dispose(); }); testWidgets('set row bg color', (tester) async { - var tableNode = TableNode.fromList([ + final tableNode = TableNode.fromList([ ['', ''], ['', ''], ]); @@ -256,14 +256,14 @@ void main() async { await tester.pumpAndSettle(); final color = Colors.green.toHex(); - TableActions.setBgColor( + await TableActions.setBgColor( tableNode.node, 0, editor.editorState, color, TableDirection.row, ); - await tester.pumpAndSettle(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); for (var i = 0; i < 2; i++) { expect( @@ -287,14 +287,14 @@ void main() async { await tester.pumpAndSettle(); final color = Colors.green.toHex(); - TableActions.setBgColor( + await TableActions.setBgColor( tableNode.node, 0, editor.editorState, color, TableDirection.row, ); - await tester.pumpAndSettle(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); TableActions.add( tableNode.node, @@ -302,7 +302,7 @@ void main() async { editor.editorState, TableDirection.col, ); - await tester.pumpAndSettle(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); tableNode = TableNode(node: tableNode.node); expect(tableNode.colsLen, 3); @@ -326,14 +326,14 @@ void main() async { await tester.pumpAndSettle(); final color = Colors.green.toHex(); - TableActions.setBgColor( + await TableActions.setBgColor( tableNode.node, 0, editor.editorState, color, TableDirection.col, ); - await tester.pumpAndSettle(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); TableActions.add( tableNode.node, diff --git a/test/new/block_component/table_block_component/table_commands_test.dart b/test/new/block_component/table_block_component/table_commands_test.dart index 30c247d4..b875c430 100644 --- a/test/new/block_component/table_block_component/table_commands_test.dart +++ b/test/new/block_component/table_block_component/table_commands_test.dart @@ -21,7 +21,7 @@ void main() async { await editor.startTesting(); await tester.pumpAndSettle(); - var cell00 = getCellNode(tableNode.node, 0, 0)!; + final cell00 = getCellNode(tableNode.node, 0, 0)!; await editor.updateSelection( Selection.single( @@ -31,8 +31,8 @@ void main() async { ); await editor.pressKey(key: LogicalKeyboardKey.enter); - var selection = editor.selection!; - var cell01 = getCellNode(tableNode.node, 0, 1)!; + final selection = editor.selection!; + final cell01 = getCellNode(tableNode.node, 0, 1)!; expect(selection.isCollapsed, true); expect(selection.start.path, cell01.childAtIndexOrNull(0)!.path); @@ -50,7 +50,7 @@ void main() async { await editor.startTesting(); await tester.pumpAndSettle(); - var cell11 = getCellNode(tableNode.node, 1, 1)!; + final cell11 = getCellNode(tableNode.node, 1, 1)!; await editor.updateSelection( Selection.single( @@ -60,7 +60,7 @@ void main() async { ); await editor.pressKey(key: LogicalKeyboardKey.enter); - var selection = editor.selection!; + final selection = editor.selection!; expect(selection.isCollapsed, true); expect(selection.start.path, editor.nodeAtPath([1])!.path); @@ -79,7 +79,7 @@ void main() async { await editor.startTesting(); await tester.pumpAndSettle(); - var cell10 = getCellNode(tableNode.node, 1, 0)!; + final cell10 = getCellNode(tableNode.node, 1, 0)!; await editor.updateSelection( Selection.single( @@ -90,7 +90,7 @@ void main() async { await editor.pressKey(key: LogicalKeyboardKey.backspace); await tester.pumpAndSettle(); - var selection = editor.selection!; + final selection = editor.selection!; expect(selection.isCollapsed, true); expect(selection.start.path, cell10.childAtIndexOrNull(0)!.path); @@ -167,7 +167,7 @@ void main() async { ); await editor.pressKey(key: LogicalKeyboardKey.backspace); - var selection = editor.selection!; + final selection = editor.selection!; expect(selection.isCollapsed, true); expect(selection.start.path, cell11.childAtIndexOrNull(0)!.path); @@ -206,7 +206,7 @@ void main() async { ); await editor.pressKey(key: LogicalKeyboardKey.backspace); - var selection = editor.selection!; + final selection = editor.selection!; expect(selection.isCollapsed, true); expect(selection.start.path, editor.document.first!.path); @@ -228,8 +228,8 @@ void main() async { await editor.startTesting(); await tester.pumpAndSettle(); - var cell01 = getCellNode(tableNode.node, 0, 1)!; - var cell00 = getCellNode(tableNode.node, 0, 0)!; + final cell01 = getCellNode(tableNode.node, 0, 1)!; + final cell00 = getCellNode(tableNode.node, 0, 0)!; await editor.updateSelection( Selection.single( @@ -272,8 +272,8 @@ void main() async { await editor.startTesting(); await tester.pumpAndSettle(); - var cell01 = getCellNode(tableNode.node, 0, 1)!; - var cell00 = getCellNode(tableNode.node, 0, 0)!; + final cell01 = getCellNode(tableNode.node, 0, 1)!; + final cell00 = getCellNode(tableNode.node, 0, 0)!; await editor.updateSelection( Selection.single( @@ -315,8 +315,8 @@ void main() async { await editor.startTesting(); await tester.pumpAndSettle(); - var cell10 = getCellNode(tableNode.node, 1, 0)!; - var cell00 = getCellNode(tableNode.node, 0, 0)!; + final cell10 = getCellNode(tableNode.node, 1, 0)!; + final cell00 = getCellNode(tableNode.node, 0, 0)!; await editor.updateSelection( Selection.single( @@ -361,7 +361,7 @@ void main() async { await editor.startTesting(); await tester.pumpAndSettle(); - var cell10 = getCellNode(tableNode.node, 1, 0)!; + final cell10 = getCellNode(tableNode.node, 1, 0)!; await editor.updateSelection( Selection.single( @@ -372,7 +372,7 @@ void main() async { await editor.pressKey(key: LogicalKeyboardKey.arrowLeft); - var selection = editor.selection!; + final selection = editor.selection!; expect(selection.isCollapsed, true); expect(selection.start.path, cell10.childAtIndexOrNull(0)!.path); @@ -391,7 +391,7 @@ void main() async { await editor.startTesting(); await tester.pumpAndSettle(); - var cell00 = getCellNode(tableNode.node, 0, 0)!; + final cell00 = getCellNode(tableNode.node, 0, 0)!; await editor.updateSelection( Selection.single( @@ -402,7 +402,7 @@ void main() async { await editor.pressKey(key: LogicalKeyboardKey.arrowRight); - var selection = editor.selection!; + final selection = editor.selection!; expect(selection.isCollapsed, true); expect(selection.start.path, cell00.childAtIndexOrNull(0)!.path); @@ -421,9 +421,9 @@ void main() async { await editor.startTesting(); await tester.pumpAndSettle(); - var cell00 = getCellNode(tableNode.node, 0, 0)!; - var cell10 = getCellNode(tableNode.node, 1, 0)!; - var cell11 = getCellNode(tableNode.node, 1, 1)!; + final cell00 = getCellNode(tableNode.node, 0, 0)!; + final cell10 = getCellNode(tableNode.node, 1, 0)!; + final cell11 = getCellNode(tableNode.node, 1, 1)!; await editor.updateSelection( Selection.single( @@ -469,9 +469,9 @@ void main() async { await editor.startTesting(); await tester.pumpAndSettle(); - var cell00 = getCellNode(tableNode.node, 0, 0)!; - var cell10 = getCellNode(tableNode.node, 1, 0)!; - var cell11 = getCellNode(tableNode.node, 1, 1)!; + final cell00 = getCellNode(tableNode.node, 0, 0)!; + final cell10 = getCellNode(tableNode.node, 1, 0)!; + final cell11 = getCellNode(tableNode.node, 1, 1)!; await editor.updateSelection( Selection.single( @@ -532,9 +532,9 @@ void main() async { await editor.startTesting(); await tester.pumpAndSettle(); - var cell00 = getCellNode(tableNode.node, 0, 0)!; - var cell01 = getCellNode(tableNode.node, 0, 1)!; - var cell10 = getCellNode(tableNode.node, 1, 0)!; + final cell00 = getCellNode(tableNode.node, 0, 0)!; + final cell01 = getCellNode(tableNode.node, 0, 1)!; + final cell10 = getCellNode(tableNode.node, 1, 0)!; await editor.updateSelection( Selection.single( diff --git a/test/new/block_component/table_block_component/table_view_test.dart b/test/new/block_component/table_block_component/table_view_test.dart index ca696f6c..9e4815ee 100644 --- a/test/new/block_component/table_block_component/table_view_test.dart +++ b/test/new/block_component/table_block_component/table_view_test.dart @@ -20,11 +20,11 @@ void main() async { await editor.startTesting(); await tester.pumpAndSettle(); - var row0beforeHeight = tableNode.getRowHeight(0); - var row1beforeHeight = tableNode.getRowHeight(1); + final row0beforeHeight = tableNode.getRowHeight(0); + final row1beforeHeight = tableNode.getRowHeight(1); expect(row0beforeHeight == row1beforeHeight, true); - var cell10 = getCellNode(tableNode.node, 1, 0)!; + final cell10 = getCellNode(tableNode.node, 1, 0)!; await editor.updateSelection( Selection.single( path: cell10.childAtIndexOrNull(0)!.path, @@ -54,11 +54,11 @@ void main() async { await editor.startTesting(); await tester.pumpAndSettle(); - var row0beforeHeight = tableNode.getRowHeight(0); - var row1beforeHeight = tableNode.getRowHeight(1); + final row0beforeHeight = tableNode.getRowHeight(0); + final row1beforeHeight = tableNode.getRowHeight(1); expect(row0beforeHeight == row1beforeHeight, true); - var cell10 = getCellNode(tableNode.node, 1, 0)!; + final cell10 = getCellNode(tableNode.node, 1, 0)!; await editor.updateSelection( Selection.single( path: cell10.childAtIndexOrNull(0)!.path, diff --git a/test/new/block_component/todo_list_block_component/todo_list_character_shortcut_test.dart b/test/new/block_component/todo_list_block_component/todo_list_character_shortcut_test.dart index df344c50..f8f30a20 100644 --- a/test/new/block_component/todo_list_block_component/todo_list_character_shortcut_test.dart +++ b/test/new/block_component/todo_list_block_component/todo_list_character_shortcut_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -13,17 +14,18 @@ void main() async { // [uncheckedbox]Welcome to AppFlowy Editor 🔥! test('[] to unchecked todo list ', () async { const text = 'Welcome to AppFlowy Editor 🔥!'; - testFormatCharacterShortcut( - formatEmptyBracketsToUncheckedBox, - '[]', - 2, - (result, before, after, editorState) { - expect(result, true); - expect(after.delta!.toPlainText(), text); - expect(after.type, 'todo_list'); - expect(after.attributes['checked'], false); - }, - text: text, + unawaited( + testFormatCharacterShortcut( + formatEmptyBracketsToUncheckedBox, + '[]', + 2, + (result, before, after, editorState) { + expect(result, true); + expect(after.delta!.toPlainText(), text); + expect(after.type, 'todo_list'); + expect(after.attributes['checked'], false); + }, + ), ); }); @@ -33,17 +35,18 @@ void main() async { // [uncheckedbox]Welcome to AppFlowy Editor 🔥! test('-[] to unchecked todo list ', () async { const text = 'Welcome to AppFlowy Editor 🔥!'; - testFormatCharacterShortcut( - formatHyphenEmptyBracketsToUncheckedBox, - '-[]', - 3, - (result, before, after, editorState) { - expect(result, true); - expect(after.delta!.toPlainText(), text); - expect(after.type, 'todo_list'); - expect(after.attributes['checked'], false); - }, - text: text, + unawaited( + testFormatCharacterShortcut( + formatHyphenEmptyBracketsToUncheckedBox, + '-[]', + 3, + (result, before, after, editorState) { + expect(result, true); + expect(after.delta!.toPlainText(), text); + expect(after.type, 'todo_list'); + expect(after.attributes['checked'], false); + }, + ), ); }); @@ -53,17 +56,18 @@ void main() async { // [checkedbox]Welcome to AppFlowy Editor 🔥! test('[x] to checked todo list ', () async { const text = 'Welcome to AppFlowy Editor 🔥!'; - testFormatCharacterShortcut( - formatFilledBracketsToCheckedBox, - '[x]', - 3, - (result, before, after, editorState) { - expect(result, true); - expect(after.delta!.toPlainText(), text); - expect(after.type, 'todo_list'); - expect(after.attributes['checked'], true); - }, - text: text, + unawaited( + testFormatCharacterShortcut( + formatFilledBracketsToCheckedBox, + '[x]', + 3, + (result, before, after, editorState) { + expect(result, true); + expect(after.delta!.toPlainText(), text); + expect(after.type, 'todo_list'); + expect(after.attributes['checked'], true); + }, + ), ); }); @@ -73,42 +77,44 @@ void main() async { // [checkedbox]Welcome to AppFlowy Editor 🔥! test('-[x] to checked todo list ', () async { const text = 'Welcome to AppFlowy Editor 🔥!'; - testFormatCharacterShortcut( - formatHyphenFilledBracketsToCheckedBox, - '-[x]', - 4, - (result, before, after, editorState) { - expect(result, true); - expect(after.delta!.toPlainText(), text); - expect(after.type, 'todo_list'); - expect(after.attributes['checked'], true); - }, - text: text, + unawaited( + testFormatCharacterShortcut( + formatHyphenFilledBracketsToCheckedBox, + '-[x]', + 4, + (result, before, after, editorState) { + expect(result, true); + expect(after.delta!.toPlainText(), text); + expect(after.type, 'todo_list'); + expect(after.attributes['checked'], true); + }, + ), ); }); test('convert numbered_list to todo_list', () async { const syntax = '-[x]'; const text = 'Welcome to AppFlowy Editor 🔥!'; - testFormatCharacterShortcut( - formatHyphenFilledBracketsToCheckedBox, - syntax, - syntax.length, - (result, before, after, editorState) { - expect(result, true); - expect(after.delta!.toPlainText(), text); - expect(after.type, TodoListBlockKeys.type); - expect(after.attributes[TodoListBlockKeys.checked], true); - expect(after.children[0].delta!.toPlainText(), '1 $text'); - expect(after.children[1].delta!.toPlainText(), '2 $text'); - }, - text: text, - node: bulletedListNode( - text: '$syntax$text', - children: [ - bulletedListNode(text: '1 $text'), - bulletedListNode(text: '2 $text'), - ], + unawaited( + testFormatCharacterShortcut( + formatHyphenFilledBracketsToCheckedBox, + syntax, + syntax.length, + (result, before, after, editorState) { + expect(result, true); + expect(after.delta!.toPlainText(), text); + expect(after.type, TodoListBlockKeys.type); + expect(after.attributes[TodoListBlockKeys.checked], true); + expect(after.children[0].delta!.toPlainText(), '1 $text'); + expect(after.children[1].delta!.toPlainText(), '2 $text'); + }, + node: bulletedListNode( + text: '$syntax$text', + children: [ + bulletedListNode(text: '1 $text'), + bulletedListNode(text: '2 $text'), + ], + ), ), ); }); diff --git a/test/new/command/text_commands_test.dart b/test/new/command/text_commands_test.dart index 39f76701..8c7313a7 100644 --- a/test/new/command/text_commands_test.dart +++ b/test/new/command/text_commands_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -126,7 +127,7 @@ void main() async { Position(path: [0], offset: welcome.length), ); editorState.selection = selection; - editorState.insertNewLine(); + unawaited(editorState.insertNewLine()); expect(editorState.getNodeAtPath([0])?.delta?.toPlainText(), welcome); expect( @@ -159,7 +160,7 @@ void main() async { Position(path: [0], offset: welcome.length), ); editorState.selection = selection; - editorState.insertNewLine(); + unawaited(editorState.insertNewLine()); expect(editorState.getNodeAtPath([0])?.delta?.toPlainText(), welcome); expect(editorState.getNodeAtPath([0, 0]), null); @@ -239,7 +240,7 @@ void main() async { }, ); final selection = Selection.collapsed( - Position(path: [0], offset: 0), + Position(path: [0]), ); final editorState = EditorState(document: document); editorState.selection = selection; diff --git a/test/new/editor_component/service/shortcuts/command_shortcuts/paste_command_test.dart b/test/new/editor_component/service/shortcuts/command_shortcuts/paste_command_test.dart index f1a8a053..292d0e61 100644 --- a/test/new/editor_component/service/shortcuts/command_shortcuts/paste_command_test.dart +++ b/test/new/editor_component/service/shortcuts/command_shortcuts/paste_command_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -37,10 +38,12 @@ void main() async { Selection.collapsed(Position(path: [0], offset: text.length)), ); const pastedText = 'pasted text'; - editor.editorState.pasteMultiLineNodes([ - imageNode(url: ''), - paragraphNode(text: pastedText), - ]); + unawaited( + editor.editorState.pasteMultiLineNodes([ + imageNode(url: ''), + paragraphNode(text: pastedText), + ]), + ); await tester.pumpAndSettle(); expect(editor.nodeAtPath([0])!.delta!.toPlainText(), text); @@ -60,10 +63,12 @@ void main() async { Selection.collapsed(Position(path: [0], offset: text.length)), ); const pastedText = 'pasted text'; - editor.editorState.pasteMultiLineNodes([ - paragraphNode(text: pastedText), - imageNode(url: ''), - ]); + unawaited( + editor.editorState.pasteMultiLineNodes([ + paragraphNode(text: pastedText), + imageNode(url: ''), + ]), + ); await tester.pumpAndSettle(); expect(editor.nodeAtPath([0])!.delta!.toPlainText(), text + pastedText); diff --git a/test/new/extensions/node_extension_test.dart b/test/new/extensions/node_extension_test.dart index 54012ab5..af14ce1c 100644 --- a/test/new/extensions/node_extension_test.dart +++ b/test/new/extensions/node_extension_test.dart @@ -182,7 +182,7 @@ void main() async { // |Welcome to AppFlowy Editor 🔥! // Welcome to AppFlowy Editor 🔥!| final selection = Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position( path: [1], offset: welcome.length + toAppFlowy.length + editor.length, @@ -222,7 +222,7 @@ void main() async { ); final editorState = EditorState(document: document); final selection = Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [2], offset: 5), ); final nodes = editorState.getNodesInSelection(selection); diff --git a/test/new/find_replace_menu/find_replace_menu_find_test.dart b/test/new/find_replace_menu/find_replace_menu_find_test.dart index a2553023..73e4aca6 100644 --- a/test/new/find_replace_menu/find_replace_menu_find_test.dart +++ b/test/new/find_replace_menu/find_replace_menu_find_test.dart @@ -228,7 +228,7 @@ void main() async { expect(find.byType(FindAndReplaceMenuWidget), findsNothing); // We expect that the current selected node is NOT highlighted. - checkIfNotHighlighted(node, selection, expectedResult: true); + checkIfNotHighlighted(node, selection); await editor.dispose(); }); @@ -265,7 +265,7 @@ void main() async { expect(node, isNotNull); // We expect that the current node at path 1 to be NOT highlighted. - checkIfNotHighlighted(node!, selectionAtNode1, expectedResult: true); + checkIfNotHighlighted(node!, selectionAtNode1); // Change the pattern to Flutter and search pattern = 'Flutter'; @@ -283,7 +283,7 @@ void main() async { expect(node, isNotNull); // We expect that the current node at path 0 to be NOT highlighted. - checkIfNotHighlighted(node!, selectionAtNode0, expectedResult: true); + checkIfNotHighlighted(node!, selectionAtNode0); await editor.dispose(); }); diff --git a/test/new/find_replace_menu/search_algorithm_test.dart b/test/new/find_replace_menu/search_algorithm_test.dart index f82bf4b1..a00826df 100644 --- a/test/new/find_replace_menu/search_algorithm_test.dart +++ b/test/new/find_replace_menu/search_algorithm_test.dart @@ -13,7 +13,7 @@ void main() { const pattern = 'Appflowy'; const text = 'Welcome to Appflowy 😁'; - List result = + final List result = algorithm.searchMethod(pattern, text).map((e) => e.start).toList(); expect(result, [11]); }); @@ -30,7 +30,7 @@ grouped by property. Design and modify Appflowy your way with an open core codebase. Appflowy is built with Flutter and Rust. '''; - List result = + final List result = algorithm.searchMethod(pattern, text).map((e) => e.start).toList(); expect(result, [11, 24, 80, 196, 324, 371]); }); @@ -48,7 +48,7 @@ open core codebase. Appflowy is built with Flutter and Rust. const pattern = '😁'; const text = 'Welcome to Appflowy 😁'; - List result = + final List result = algorithm.searchMethod(pattern, text).map((e) => e.start).toList(); expect(result, [20]); }); @@ -59,7 +59,7 @@ open core codebase. Appflowy is built with Flutter and Rust. const pattern = 'App'; const text = 'Welcome to Appflowy 😁'; - List result = + final List result = algorithm.searchMethod(pattern, text).map((e) => e.start).toList(); expect(result, [11]); }); @@ -68,7 +68,7 @@ open core codebase. Appflowy is built with Flutter and Rust. const pattern = 'APPFLOWY'; const text = 'Welcome to Appflowy 😁'; - List result = + final List result = algorithm.searchMethod(pattern, text).map((e) => e.start).toList(); expect(result, []); }); @@ -77,7 +77,7 @@ open core codebase. Appflowy is built with Flutter and Rust. final pattern = RegExp('APPFLOWY', caseSensitive: false); const text = 'Welcome to Appflowy 😁'; - List result = + final List result = algorithm.searchMethod(pattern, text).map((e) => e.start).toList(); expect(result, [11]); }); @@ -86,7 +86,7 @@ open core codebase. Appflowy is built with Flutter and Rust. final pattern = RegExp('a[a-z]p', caseSensitive: false); const text = 'Welcome to Appflowy example app 😁'; - Iterable result = algorithm.searchMethod(pattern, text); + final Iterable result = algorithm.searchMethod(pattern, text); final starts = result.map((e) => e.start).toList(); final ends = result.map((e) => e.end).toList(); expect(starts, [11, 22, 28]); diff --git a/test/new/infra/testable_editor.dart b/test/new/infra/testable_editor.dart index 6d0f8267..773918a3 100644 --- a/test/new/infra/testable_editor.dart +++ b/test/new/infra/testable_editor.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/editor/editor_component/service/ime/text_input_service.dart'; import 'package:flutter/material.dart'; @@ -227,9 +228,11 @@ class TestableEditor { } Future updateSelection(Selection? selection) async { - _editorState.updateSelectionWithReason( - selection, - reason: SelectionUpdateReason.uiEvent, + unawaited( + _editorState.updateSelectionWithReason( + selection, + reason: SelectionUpdateReason.uiEvent, + ), ); await tester.pumpAndSettle(); } diff --git a/test/new/service/shortcuts/character_shortcut_events/format_by_wrapping_with_single_char/format_italic_test.dart b/test/new/service/shortcuts/character_shortcut_events/format_by_wrapping_with_single_char/format_italic_test.dart index 86a1ab6e..e30d3703 100644 --- a/test/new/service/shortcuts/character_shortcut_events/format_by_wrapping_with_single_char/format_italic_test.dart +++ b/test/new/service/shortcuts/character_shortcut_events/format_by_wrapping_with_single_char/format_italic_test.dart @@ -35,9 +35,9 @@ void main() async { // Before // App_Flowy| - // After - // App[italic]Flowy - test('App_Flowy_ to App[italic]Flowy', () async { + // After (unchanged): the opening underscore is not preceded by + // whitespace, so an intra-word underscore like `a_b_` is not italicized. + test('App_Flowy_ does not italicize (intra-word underscore)', () async { const text1 = 'App'; const text2 = 'Flowy'; final document = Document.blank().addParagraphs( @@ -54,11 +54,43 @@ void main() async { final result = await formatUnderscoreToItalic.execute(editorState); + expect(result, false); + final after = editorState.getNodeAtPath([0])!; + expect(after.delta!.toPlainText(), '${text1}_$text2'); + final isItalic = after.delta! + .everyAttributes((element) => element['italic'] == true); + expect(isItalic, false); + }); + + // Before + // hi _AppFlowy| + // After + // hi [italic]AppFlowy + // The opening underscore is preceded by whitespace, so it still formats. + test('hi _AppFlowy_ italicizes AppFlowy (underscore after space)', + () async { + const prefix = 'hi '; + const text = 'AppFlowy'; + final document = Document.blank().addParagraphs( + 1, + builder: (index) => Delta()..insert('${prefix}_$text'), + ); + + final editorState = EditorState(document: document); + + final selection = Selection.collapsed( + Position(path: [0], offset: prefix.length + text.length + 1), + ); + editorState.selection = selection; + + final result = await formatUnderscoreToItalic.execute(editorState); + expect(result, true); final after = editorState.getNodeAtPath([0])!; - expect(after.delta!.toPlainText(), '$text1$text2'); - expect(after.delta!.toList()[0].attributes, null); - expect(after.delta!.toList()[1].attributes, {'italic': true}); + expect(after.delta!.toPlainText(), '$prefix$text'); + final deltaList = after.delta!.toList(); + expect(deltaList[0].attributes, null); + expect(deltaList[1].attributes, {'italic': true}); }); // Before diff --git a/test/new/service/shortcuts/command_shortcut_events/arrow_keys_handler_test.dart b/test/new/service/shortcuts/command_shortcut_events/arrow_keys_handler_test.dart index c37cd6bc..7a033886 100644 --- a/test/new/service/shortcuts/command_shortcut_events/arrow_keys_handler_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/arrow_keys_handler_test.dart @@ -101,7 +101,7 @@ void main() async { expect( editor.selection, selection.copyWith( - end: Position(path: [0], offset: 0), + end: Position(path: [0]), ), ); @@ -401,7 +401,7 @@ void main() async { expect( editor.selection, selection.copyWith( - end: Position(path: [0], offset: 0), + end: Position(path: [0]), ), ); for (int i = 0; i < 7; i++) { @@ -425,7 +425,7 @@ void main() async { expect( editor.selection, selection.copyWith( - end: Position(path: [3], offset: 0), + end: Position(path: [3]), ), ); @@ -510,7 +510,7 @@ void main() async { expect( editor.selection, selection.copyWith( - end: Position(path: [0], offset: 0), + end: Position(path: [0]), ), ); } else { @@ -569,7 +569,7 @@ void main() async { expect( editor.selection, selection.copyWith( - end: Position(path: [1], offset: 0), + end: Position(path: [1]), ), ); await editor.pressKey( @@ -654,7 +654,7 @@ void main() async { expect( editor.selection, selection.copyWith( - end: Position(path: [1], offset: 0), + end: Position(path: [1]), ), ); await editor.dispose(); diff --git a/test/new/service/shortcuts/command_shortcut_events/arrow_right_command_test.dart b/test/new/service/shortcuts/command_shortcut_events/arrow_right_command_test.dart index 3aff9998..3d2ffa30 100644 --- a/test/new/service/shortcuts/command_shortcut_events/arrow_right_command_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/arrow_right_command_test.dart @@ -46,7 +46,7 @@ void main() async { final arrowLeftTest = ArrowTest( text: text, initialSel: selection, - expSel: selection.collapse(atStart: false), + expSel: selection.collapse(), ); await runArrowRightTest(tester, arrowLeftTest); diff --git a/test/new/service/shortcuts/command_shortcut_events/backspace_command_test.dart b/test/new/service/shortcuts/command_shortcut_events/backspace_command_test.dart index 6ab3ef6c..cd5d7f96 100644 --- a/test/new/service/shortcuts/command_shortcut_events/backspace_command_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/backspace_command_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/editor/block_component/table_block_component/util.dart'; import 'package:flutter/material.dart'; @@ -52,7 +53,7 @@ void main() async { // |Welcome to AppFlowy Editor 🔥! final selection = Selection.collapsed( - Position(path: [0], offset: 0), + Position(path: [0]), ); editorState.selection = selection; @@ -82,7 +83,7 @@ void main() async { // Welcome to AppFlowy Editor 🔥! // |Welcome to AppFlowy Editor 🔥! final selection = Selection.collapsed( - Position(path: [1], offset: 0), + Position(path: [1]), ); editorState.selection = selection; @@ -123,7 +124,7 @@ void main() async { // Welcome to AppFlowy Editor 🔥! // |Welcome to AppFlowy Editor 🔥! final selection = Selection.collapsed( - Position(path: [0, 0], offset: 0), + Position(path: [0, 0]), ); editorState.selection = selection; @@ -137,14 +138,14 @@ void main() async { expect( editorState.selection, Selection.collapsed( - Position(path: [1], offset: 0), + Position(path: [1]), ), ); }); test("backspace convert bullet list to paragraph but keep direction", () async { - String rtlText = 'سلام'; + final String rtlText = 'سلام'; final document = Document.blank().addNode( BulletedListBlockKeys.type, initialText: rtlText, @@ -159,7 +160,7 @@ void main() async { // Welcome to AppFlowy Editor 🔥! // |Welcome to AppFlowy Editor 🔥! final selection = Selection.collapsed( - Position(path: [0], offset: 0), + Position(path: [0]), ); editorState.selection = selection; @@ -182,12 +183,14 @@ void main() async { final editorState = EditorState(document: document); final selection = Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [4], offset: text.length), ); - editorState.updateSelectionWithReason( - selection, - reason: SelectionUpdateReason.selectAll, + unawaited( + editorState.updateSelectionWithReason( + selection, + reason: SelectionUpdateReason.selectAll, + ), ); final result = backspaceCommand.execute(editorState); @@ -604,7 +607,7 @@ void main() async { // Ensure the cursor is at the start of the new second row expect( editor.selection, - Selection.collapsed(Position(path: [0, 1, 0], offset: 0)), + Selection.collapsed(Position(path: [0, 1, 0])), ); await editor.dispose(); @@ -672,7 +675,7 @@ void main() async { expect( editor.selection, - Selection.collapsed(Position(path: [0, 1, 0], offset: 0)), + Selection.collapsed(Position(path: [0, 1, 0])), ); await editor.dispose(); diff --git a/test/new/service/shortcuts/command_shortcut_events/checkbox_event_handler_test.dart b/test/new/service/shortcuts/command_shortcut_events/checkbox_event_handler_test.dart index 3a868c60..53eff0e3 100644 --- a/test/new/service/shortcuts/command_shortcut_events/checkbox_event_handler_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/checkbox_event_handler_test.dart @@ -132,7 +132,7 @@ void main() async { await editor.startTesting(); final selection = Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [2], offset: text.length), ); await editor.updateSelection( diff --git a/test/new/service/shortcuts/command_shortcut_events/copy_command_test.dart b/test/new/service/shortcuts/command_shortcut_events/copy_command_test.dart index 3e8c6683..7997b4e0 100644 --- a/test/new/service/shortcuts/command_shortcut_events/copy_command_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/copy_command_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -55,7 +56,7 @@ Future _testHandleCopy( isControlPressed: Platform.isWindows || Platform.isLinux, isMetaPressed: Platform.isMacOS, ); - deleteSelectedContent(editor.editorState); + unawaited(deleteSelectedContent(editor.editorState)); expect(editor.document.root.children.length, 1); expect(editor.document.root.children.first.delta!.isEmpty, true); await editor.pressKey( diff --git a/test/new/service/shortcuts/command_shortcut_events/copy_paste_handler_test.dart b/test/new/service/shortcuts/command_shortcut_events/copy_paste_handler_test.dart index 3a206a75..d2955e0d 100644 --- a/test/new/service/shortcuts/command_shortcut_events/copy_paste_handler_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/copy_paste_handler_test.dart @@ -1,10 +1,8 @@ import 'dart:io'; -import 'package:flutter/services.dart'; - -import 'package:flutter_test/flutter_test.dart'; - import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; import '../../../../infra/clipboard_test.dart'; import '../../../infra/testable_editor.dart'; @@ -44,12 +42,6 @@ void main() async { (tester) async { await _testHandleCopy(tester, Document.fromJson(data)); }); - // TODO: fix this test - // testWidgets( - // 'Presses Command + A in nested document and copy text nestednode', - // (tester) async { - // await _testNestedNodeCopyPaste(tester, Document.fromJson(exampledoc)); - // }); testWidgets('update selection and execute cut command', (tester) async { await _testCutHandle(tester, Document.fromJson(cutData)); @@ -65,7 +57,7 @@ Future _testCutHandle( await editor.updateSelection( Selection( - start: Position(path: [2], offset: 0), + start: Position(path: [2]), end: Position(path: [2], offset: 69), ), ); diff --git a/test/new/service/shortcuts/command_shortcut_events/cut_command_test.dart b/test/new/service/shortcuts/command_shortcut_events/cut_command_test.dart index 322bf2b7..438974fd 100644 --- a/test/new/service/shortcuts/command_shortcut_events/cut_command_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/cut_command_test.dart @@ -52,7 +52,7 @@ Future _testCutHandle( await editor.startTesting(); await editor.updateSelection( Selection( - start: Position(path: [2], offset: 0), + start: Position(path: [2]), end: Position(path: [2], offset: 69), ), ); diff --git a/test/new/service/shortcuts/command_shortcut_events/delete_command_test.dart b/test/new/service/shortcuts/command_shortcut_events/delete_command_test.dart index 1b643608..31a35832 100644 --- a/test/new/service/shortcuts/command_shortcut_events/delete_command_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/delete_command_test.dart @@ -274,7 +274,7 @@ void main() async { // After // | to AppFlowy Editor 🔥! testWidgets('Delete the not collapsed selection', (tester) async { - TestableEditor editor = tester.editor + final TestableEditor editor = tester.editor ..addParagraph( initialText: text, ); diff --git a/test/new/service/shortcuts/command_shortcut_events/enter_without_shift_in_text_node_handler_test.dart b/test/new/service/shortcuts/command_shortcut_events/enter_without_shift_in_text_node_handler_test.dart index 04101c57..cc1d8532 100644 --- a/test/new/service/shortcuts/command_shortcut_events/enter_without_shift_in_text_node_handler_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/enter_without_shift_in_text_node_handler_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -73,7 +74,7 @@ void main() async { editor.selection, Selection.single(path: [lines - 1], startOffset: 0), ); - var lastNode = editor.nodeAtPath([lines - 1]); + final lastNode = editor.nodeAtPath([lines - 1]); expect(lastNode != null, true); expect(lastNode?.type, 'paragraph'); expect(lastNode?.delta?.toPlainText(), text); @@ -121,12 +122,12 @@ void main() async { testWidgets('Presses enter key in multiple selection from top to bottom', (tester) async { - _testMultipleSelection(tester, true); + unawaited(_testMultipleSelection(tester, true)); }); testWidgets('Presses enter key in multiple selection from bottom to top', (tester) async { - _testMultipleSelection(tester, false); + unawaited(_testMultipleSelection(tester, false)); }); testWidgets('Presses enter key in the first line', (tester) async { @@ -289,7 +290,7 @@ Future _testMultipleSelection( // const text = 'Welcome to Appflowy 😁'; final editor = tester.editor; - var lines = 4; + final lines = 4; editor.addParagraphs(lines, initialText: text); diff --git a/test/new/service/shortcuts/command_shortcut_events/exit_editing_mode_handler_test.dart b/test/new/service/shortcuts/command_shortcut_events/exit_editing_mode_handler_test.dart index 2a237803..fde7c81c 100644 --- a/test/new/service/shortcuts/command_shortcut_events/exit_editing_mode_handler_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/exit_editing_mode_handler_test.dart @@ -28,7 +28,7 @@ void main() async { await _testSelection( editor, Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [2], offset: text.length), ), ); diff --git a/test/new/service/shortcuts/command_shortcut_events/format_style_handler_test.dart b/test/new/service/shortcuts/command_shortcut_events/format_style_handler_test.dart index 63740f9c..0b198582 100644 --- a/test/new/service/shortcuts/command_shortcut_events/format_style_handler_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/format_style_handler_test.dart @@ -146,7 +146,7 @@ Future _testUpdateTextStyleByCommandX( ); selection = Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [2], offset: text.length), ); await editor.updateSelection(selection); diff --git a/test/new/service/shortcuts/command_shortcut_events/indent_command_test.dart b/test/new/service/shortcuts/command_shortcut_events/indent_command_test.dart index 4d9c51cf..a90771a4 100644 --- a/test/new/service/shortcuts/command_shortcut_events/indent_command_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/indent_command_test.dart @@ -102,9 +102,6 @@ void main() async { await editor.dispose(); }); - // TODO(.): The purpose of this test is to catch addPostFrameCallback from - // calculateTextDirection but it doesn't catch it. Commenting the callback - // out doesn't make this test fail. testWidgets( "indent AUTO line under AUTO line changing the second line calculated direction", (tester) async { diff --git a/test/new/service/shortcuts/command_shortcut_events/markdown_commands_test.dart b/test/new/service/shortcuts/command_shortcut_events/markdown_commands_test.dart index cbbd1321..e7495e1c 100644 --- a/test/new/service/shortcuts/command_shortcut_events/markdown_commands_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/markdown_commands_test.dart @@ -135,7 +135,7 @@ Future _testUpdateTextStyleByCommandX( true, ); selection = Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [2], offset: text.length), ); await editor.updateSelection(selection); diff --git a/test/new/service/shortcuts/command_shortcut_events/markdown_syntax_to_styled_text_test.dart b/test/new/service/shortcuts/command_shortcut_events/markdown_syntax_to_styled_text_test.dart index e6d37302..e3305ceb 100644 --- a/test/new/service/shortcuts/command_shortcut_events/markdown_syntax_to_styled_text_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/markdown_syntax_to_styled_text_test.dart @@ -289,7 +289,7 @@ void main() async { testWidgets( 'App**Flowy** to bold AppFlowy', - ((tester) async { + (tester) async { const text = 'App**Flowy*'; final editor = tester.editor..addEmptyParagraph(); await editor.startTesting(); @@ -311,12 +311,12 @@ void main() async { expect(result, true); expect(node.delta?.toPlainText(), 'AppFlowy'); await editor.dispose(); - }), + }, ); testWidgets( '***AppFlowy** to bold *AppFlowy', - ((tester) async { + (tester) async { const text = '***AppFlowy*'; final editor = tester.editor..addEmptyParagraph(); await editor.startTesting(); @@ -338,12 +338,12 @@ void main() async { expect(result, true); expect(node.delta?.toPlainText(), '*AppFlowy'); await editor.dispose(); - }), + }, ); testWidgets( '**** nothing changes', - ((tester) async { + (tester) async { const text = '***'; final editor = tester.editor..addEmptyParagraph(); await editor.startTesting(); @@ -365,7 +365,7 @@ void main() async { expect(result, false); expect(node.delta?.toPlainText(), '****'); await editor.dispose(); - }), + }, ); testWidgets( @@ -667,7 +667,7 @@ void main() async { testWidgets( '__AppFlowy__ to bold AppFlowy', - ((tester) async { + (tester) async { const text = '__AppFlowy_'; final editor = tester.editor..addEmptyParagraph(); await editor.startTesting(); @@ -690,12 +690,12 @@ void main() async { expect(result, true); expect(node.delta!.toPlainText(), 'AppFlowy'); await editor.dispose(); - }), + }, ); testWidgets( 'App__Flowy__ to bold AppFlowy', - ((tester) async { + (tester) async { const text = 'App__Flowy_'; final editor = tester.editor..addEmptyParagraph(); await editor.startTesting(); @@ -718,12 +718,12 @@ void main() async { expect(result, true); expect(node.delta!.toPlainText(), 'AppFlowy'); await editor.dispose(); - }), + }, ); testWidgets( '__*AppFlowy__ to bold *AppFlowy', - ((tester) async { + (tester) async { const text = '__*AppFlowy_'; final editor = tester.editor..addEmptyParagraph(); await editor.startTesting(); @@ -746,12 +746,12 @@ void main() async { expect(result, true); expect(node.delta!.toPlainText(), '*AppFlowy'); await editor.dispose(); - }), + }, ); testWidgets( '____ nothing changes', - ((tester) async { + (tester) async { const text = '___'; final editor = tester.editor..addEmptyParagraph(); await editor.startTesting(); @@ -774,7 +774,7 @@ void main() async { expect(result, false); expect(node.delta!.toPlainText(), '____'); await editor.dispose(); - }), + }, ); }); } diff --git a/test/new/service/shortcuts/command_shortcut_events/outdent_command_test.dart b/test/new/service/shortcuts/command_shortcut_events/outdent_command_test.dart index b687146b..09d366ac 100644 --- a/test/new/service/shortcuts/command_shortcut_events/outdent_command_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/outdent_command_test.dart @@ -145,7 +145,7 @@ void main() async { await editor.startTesting(); - var selection = Selection.single(path: [1], startOffset: 0); + final selection = Selection.single(path: [1], startOffset: 0); await editor.updateSelection(selection); await editor.pressKey(key: LogicalKeyboardKey.tab); diff --git a/test/new/service/shortcuts/command_shortcut_events/paste_command_test.dart b/test/new/service/shortcuts/command_shortcut_events/paste_command_test.dart index 746a2b10..4bfb24bb 100644 --- a/test/new/service/shortcuts/command_shortcut_events/paste_command_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/paste_command_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -38,7 +39,7 @@ void main() async { final editor = tester.editor..addParagraph(initialText: ''); await editor.startTesting(); await editor.updateSelection( - Selection.collapsed(Position(path: [0], offset: 0)), + Selection.collapsed(Position(path: [0])), ); const link = 'https://appflowy.io/'; @@ -69,7 +70,7 @@ void main() async { final editor = tester.editor..addParagraph(initialText: ''); await editor.startTesting(); await editor.updateSelection( - Selection.collapsed(Position(path: [0], offset: 0)), + Selection.collapsed(Position(path: [0])), ); const textWithLink = 'click https://appflowy.io/ jump to appflowy'; @@ -262,7 +263,7 @@ void main() async { await editor.dispose(); }); - for (var position in ['start', 'end']) { + for (final position in ['start', 'end']) { testWidgets('paste without format if at $position of formatted text', (tester) async { const pasteText = 'text'; @@ -398,7 +399,7 @@ Future _testHandleCopyMultiplePaste( isMetaPressed: Platform.isMacOS, ); handleCopy(editor.editorState); - deleteSelectedContent(editor.editorState); + unawaited(deleteSelectedContent(editor.editorState)); pasteHTML( editor.editorState, @@ -441,7 +442,7 @@ Future _testHandleCopyPaste( isMetaPressed: Platform.isMacOS, ); handleCopy(editor.editorState); - deleteSelectedContent(editor.editorState); + unawaited(deleteSelectedContent(editor.editorState)); await editor.updateSelection(Selection.collapsed(Position(path: [0]))); await editor.pressKey( key: LogicalKeyboardKey.keyP, diff --git a/test/new/service/shortcuts/command_shortcut_events/redo_undo_handler_test.dart b/test/new/service/shortcuts/command_shortcut_events/redo_undo_handler_test.dart index 77a50a35..1ee3ec14 100644 --- a/test/new/service/shortcuts/command_shortcut_events/redo_undo_handler_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/redo_undo_handler_test.dart @@ -105,7 +105,7 @@ Future _testWithTextFormattingBold(WidgetTester tester) async { expect(node.delta!.toPlainText(), text); expect(result, false); - final start = Position(path: [0], offset: 0); + final start = Position(path: [0]); final end = Position(path: [0], offset: text.length); selection = Selection( start: start, @@ -156,7 +156,7 @@ Future _testWithTextFormattingItalics(WidgetTester tester) async { expect(node.delta!.toPlainText(), text); expect(allItalics, false); - final start = Position(path: [0], offset: 0); + final start = Position(path: [0]); final end = Position(path: [0], offset: text.length); final selection = Selection( start: start, @@ -208,7 +208,7 @@ Future _testWithTextFormattingUnderline(WidgetTester tester) async { expect(node.delta!.toPlainText(), text); expect(allUnderline, false); - final start = Position(path: [0], offset: 0); + final start = Position(path: [0]); final end = Position(path: [0], offset: text.length); final selection = Selection( start: start, diff --git a/test/new/service/shortcuts/command_shortcut_events/remove_word_command_test.dart b/test/new/service/shortcuts/command_shortcut_events/remove_word_command_test.dart index 4c19987b..27e689bf 100644 --- a/test/new/service/shortcuts/command_shortcut_events/remove_word_command_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/remove_word_command_test.dart @@ -125,7 +125,15 @@ void main() async { }); testWidgets('repeatedly till line is empty', (tester) async { - List words = ["Welcome", " ", "to", " ", "Appflowy", " ", "😁"]; + final List words = [ + "Welcome", + " ", + "to", + " ", + "Appflowy", + " ", + "😁", + ]; final text = words.join(); final editor = tester.editor..addParagraph(initialText: text); diff --git a/test/new/service/shortcuts/command_shortcut_events/select_all_command_test.dart b/test/new/service/shortcuts/command_shortcut_events/select_all_command_test.dart index fa49ed03..99bb967b 100644 --- a/test/new/service/shortcuts/command_shortcut_events/select_all_command_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/select_all_command_test.dart @@ -27,7 +27,7 @@ void main() async { expect( editor.selection, Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [count - 1], offset: text.length), ), ); @@ -69,7 +69,7 @@ void main() async { expect( editor.selection, Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [0, 0, 0], offset: text.length), ), ); diff --git a/test/new/service/shortcuts/command_shortcut_events/select_all_handler_test.dart b/test/new/service/shortcuts/command_shortcut_events/select_all_handler_test.dart index 226e62fb..7f161938 100644 --- a/test/new/service/shortcuts/command_shortcut_events/select_all_handler_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/select_all_handler_test.dart @@ -35,7 +35,7 @@ Future _testSelectAllHandler(WidgetTester tester, int lines) async { expect( editor.selection, Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [lines - 1], offset: text.length), ), ); diff --git a/test/new/service/shortcuts/command_shortcut_events/toggle_color_commands_test.dart b/test/new/service/shortcuts/command_shortcut_events/toggle_color_commands_test.dart index f849caac..31809895 100644 --- a/test/new/service/shortcuts/command_shortcut_events/toggle_color_commands_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/toggle_color_commands_test.dart @@ -94,7 +94,7 @@ Future _testUpdateTextColorByCommandX( true, ); selection = Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [2], offset: text.length), ); await editor.updateSelection(selection); diff --git a/test/new/service/shortcuts/command_shortcut_events/undo_redo_command_test.dart b/test/new/service/shortcuts/command_shortcut_events/undo_redo_command_test.dart index 57fd5f87..e04ebb10 100644 --- a/test/new/service/shortcuts/command_shortcut_events/undo_redo_command_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/undo_redo_command_test.dart @@ -149,7 +149,7 @@ void main() async { await editor.startTesting(); await editor.updateSelection( - Selection.collapsed(Position(path: [1], offset: 0)), + Selection.collapsed(Position(path: [1])), ); await editor.pressKey(key: LogicalKeyboardKey.tab); diff --git a/test/new/toolbar/desktop/floating_toolbar_test.dart b/test/new/toolbar/desktop/floating_toolbar_test.dart index ef47c7bb..90d06397 100644 --- a/test/new/toolbar/desktop/floating_toolbar_test.dart +++ b/test/new/toolbar/desktop/floating_toolbar_test.dart @@ -109,8 +109,8 @@ void main() async { await editor.startTesting(withFloatingToolbar: true); final selection = Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [2], offset: 0), + start: Position(path: [0]), + end: Position(path: [2]), ); await editor.updateSelection(selection); diff --git a/test/new/util/arrow_util.dart b/test/new/util/arrow_util.dart index 1c26db91..64c64b6a 100644 --- a/test/new/util/arrow_util.dart +++ b/test/new/util/arrow_util.dart @@ -6,17 +6,16 @@ import '../infra/testable_editor.dart'; import './typedef_util.dart'; class ArrowTest { - String text; - Selection initialSel; - Selection expSel; - NodeDecorator? decorator; - ArrowTest({ required this.text, required this.initialSel, required this.expSel, this.decorator, }); + String text; + Selection initialSel; + Selection expSel; + NodeDecorator? decorator; } Future runArrowLeftTest( diff --git a/test/plugins/html/encoder/document_html_encoder_test.dart b/test/plugins/html/encoder/document_html_encoder_test.dart index f187e419..a0f79008 100644 --- a/test/plugins/html/encoder/document_html_encoder_test.dart +++ b/test/plugins/html/encoder/document_html_encoder_test.dart @@ -3,7 +3,7 @@ import 'package:appflowy_editor/src/plugins/html/encoder/parser/divider_node_par import 'package:flutter_test/flutter_test.dart'; void main() async { - List parser = [ + final List parser = [ const HTMLTextNodeParser(), const HTMLBulletedListNodeParser(), const HTMLNumberedListNodeParser(), diff --git a/test/plugins/html/encoder/parser/image_node_parser_test.dart b/test/plugins/html/encoder/parser/image_node_parser_test.dart index 03dd5dea..c1b608e3 100644 --- a/test/plugins/html/encoder/parser/image_node_parser_test.dart +++ b/test/plugins/html/encoder/parser/image_node_parser_test.dart @@ -2,7 +2,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; void main() async { - List parser = [ + final List parser = [ const HTMLTextNodeParser(), const HTMLBulletedListNodeParser(), const HTMLNumberedListNodeParser(), diff --git a/test/plugins/html/encoder/parser/table_node_parser_test.dart b/test/plugins/html/encoder/parser/table_node_parser_test.dart index 7de61fcc..bcf81451 100644 --- a/test/plugins/html/encoder/parser/table_node_parser_test.dart +++ b/test/plugins/html/encoder/parser/table_node_parser_test.dart @@ -2,7 +2,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; void main() async { - List parser = [ + final List parser = [ const HTMLTextNodeParser(), const HTMLBulletedListNodeParser(), const HTMLNumberedListNodeParser(), diff --git a/test/plugins/html/encoder/parser/text_node_parser_test.dart b/test/plugins/html/encoder/parser/text_node_parser_test.dart index 1a4b494c..5bb367b8 100644 --- a/test/plugins/html/encoder/parser/text_node_parser_test.dart +++ b/test/plugins/html/encoder/parser/text_node_parser_test.dart @@ -3,7 +3,7 @@ import 'package:appflowy_editor/src/plugins/html/encoder/parser/divider_node_par import 'package:flutter_test/flutter_test.dart'; void main() async { - List parser = [ + final List parser = [ const HTMLTextNodeParser(), const HTMLBulletedListNodeParser(), const HTMLNumberedListNodeParser(), diff --git a/test/plugins/markdown/custom_parsers/test_custom_node_parsers.dart b/test/plugins/markdown/custom_parsers/test_custom_node_parsers.dart index d7be4b3a..379a6267 100644 --- a/test/plugins/markdown/custom_parsers/test_custom_node_parsers.dart +++ b/test/plugins/markdown/custom_parsers/test_custom_node_parsers.dart @@ -5,7 +5,7 @@ class TestCustomInlineSyntaxes extends md.InlineSyntax { @override bool onMatch(md.InlineParser parser, Match match) { - md.Element el = md.Element.text('mention_block', "\$"); + final md.Element el = md.Element.text('mention_block', "\$"); el.attributes['mention'] = '''{ "type":"page", "page_id":"${match.group(0)}" diff --git a/test/plugins/word_count/word_counter_test.dart b/test/plugins/word_count/word_counter_test.dart index 6e76aecf..ada3fb3a 100644 --- a/test/plugins/word_count/word_counter_test.dart +++ b/test/plugins/word_count/word_counter_test.dart @@ -46,12 +46,6 @@ void main() async { await tester.pumpAndSettle(const Duration(milliseconds: 300)); - // TODO(Xazin): Fix this test later - // expect(service.documentCounters.wordCount, 3 * 4); - // expect(service.documentCounters.charCount, text.length * 4); - // expect(wordCount, 3 * 4); - // expect(charCount, text.length * 4); - service.stop(); await tester.pumpAndSettle(const Duration(milliseconds: 300)); diff --git a/test/render/image/image_node_builder_test.dart b/test/render/image/image_node_builder_test.dart index 5e9607b7..b32acbb4 100644 --- a/test/render/image/image_node_builder_test.dart +++ b/test/render/image/image_node_builder_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -16,202 +17,213 @@ void main() async { const url = 'https://images.unsplash.com/photo-1682961941145-e73336a53bc6?dl=katsuma-tanaka-cWpkMDSQbWQ-unsplash.jpg'; testWidgets('render image node', (tester) async { - mockNetworkImagesFor(() async { - final editor = tester.editor - ..addParagraph(initialText: text) - ..addNode( - imageNode(url: url), - ) - ..addParagraph(initialText: text); - await editor.startTesting(); - await tester.pumpAndSettle(); + unawaited( + mockNetworkImagesFor(() async { + final editor = tester.editor + ..addParagraph(initialText: text) + ..addNode( + imageNode(url: url), + ) + ..addParagraph(initialText: text); + await editor.startTesting(); + await tester.pumpAndSettle(); - expect(editor.documentRootLen, 3); - expect(find.byType(Image), findsOneWidget); + expect(editor.documentRootLen, 3); + expect(find.byType(Image), findsOneWidget); - await editor.dispose(); - }); + await editor.dispose(); + }), + ); }); testWidgets('cannot see action menu when not editable', (tester) async { - mockNetworkImagesFor(() async { - final editor = tester.editor - ..addParagraph(initialText: text) - ..addNode( - imageNode(url: url), - ) - ..addParagraph(initialText: text); + unawaited( + mockNetworkImagesFor(() async { + final editor = tester.editor + ..addParagraph(initialText: text) + ..addNode( + imageNode(url: url), + ) + ..addParagraph(initialText: text); - await editor.startTesting( - editable: false, - ); - await tester.pumpAndSettle(); + await editor.startTesting( + editable: false, + ); + await tester.pumpAndSettle(); - expect(editor.documentRootLen, 3); - expect(find.byType(Image), findsOneWidget); + expect(editor.documentRootLen, 3); + expect(find.byType(Image), findsOneWidget); - final gesture = await tester.createGesture( - kind: PointerDeviceKind.mouse, - ); - await gesture.addPointer(location: Offset.zero); + final gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); + addTearDown(gesture.removePointer); - await gesture.moveTo(tester.getCenter(find.byType(Image))); - await tester.pumpAndSettle(); + await gesture.moveTo(tester.getCenter(find.byType(Image))); + await tester.pumpAndSettle(); - expect(find.byType(EditorSvg), findsNothing); + expect(find.byType(EditorSvg), findsNothing); - await editor.dispose(); - }); + await editor.dispose(); + }), + ); }); testWidgets('can see action menu when editable', (tester) async { - mockNetworkImagesFor(() async { - final editor = tester.editor - ..addParagraph(initialText: text) - ..addNode( - imageNode(url: url), - ) - ..addParagraph(initialText: text); + unawaited( + mockNetworkImagesFor(() async { + final editor = tester.editor + ..addParagraph(initialText: text) + ..addNode( + imageNode(url: url), + ) + ..addParagraph(initialText: text); - await editor.startTesting(); - await tester.pumpAndSettle(); + await editor.startTesting(); + await tester.pumpAndSettle(); - expect(editor.documentRootLen, 3); - expect(find.byType(Image), findsOneWidget); + expect(editor.documentRootLen, 3); + expect(find.byType(Image), findsOneWidget); - final gesture = await tester.createGesture( - kind: PointerDeviceKind.mouse, - ); - await gesture.addPointer(location: Offset.zero); + final gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); + addTearDown(gesture.removePointer); - await gesture.moveTo(tester.getCenter(find.byType(Image))); - await tester.pumpAndSettle(); + await gesture.moveTo(tester.getCenter(find.byType(Image))); + await tester.pumpAndSettle(); - // expect(find.byType(FlowySvg), findsWidgets); + // expect(find.byType(FlowySvg), findsWidgets); - await editor.dispose(); - }); + await editor.dispose(); + }), + ); }); testWidgets('render image align', (tester) async { - mockNetworkImagesFor(() async { - final editor = tester.editor - ..addParagraph(initialText: text) - ..addNode( - imageNode( - url: url, - align: 'left', - width: 100, - ), - ) - ..addNode( - imageNode( - url: url, - align: 'center', - width: 100, - ), - ) - ..addNode( - imageNode( - url: url, - align: 'right', - width: 100, - ), - ) - ..addParagraph(initialText: text); - await editor.startTesting(); - await tester.pumpAndSettle(); + unawaited( + mockNetworkImagesFor(() async { + final editor = tester.editor + ..addParagraph(initialText: text) + ..addNode( + imageNode( + url: url, + align: 'left', + width: 100, + ), + ) + ..addNode( + imageNode( + url: url, + width: 100, + ), + ) + ..addNode( + imageNode( + url: url, + align: 'right', + width: 100, + ), + ) + ..addParagraph(initialText: text); + await editor.startTesting(); + await tester.pumpAndSettle(); - expect(editor.documentRootLen, 5); - final imageFinder = find.byType(Image); - expect(imageFinder, findsNWidgets(3)); + expect(editor.documentRootLen, 5); + final imageFinder = find.byType(Image); + expect(imageFinder, findsNWidgets(3)); - final editorFinder = find.byType(AppFlowyEditor); - final editorRect = tester.getRect(editorFinder); + final editorFinder = find.byType(AppFlowyEditor); + final editorRect = tester.getRect(editorFinder); - final leftImageRect = tester.getRect(imageFinder.at(0)); - expect( - leftImageRect.left, - editor.editorState.editorStyle.padding.left, - ); - final rightImageRect = tester.getRect(imageFinder.at(2)); - expect( - rightImageRect.right, - editorRect.right - editor.editorState.editorStyle.padding.right, - ); - final centerImageRect = tester.getRect(imageFinder.at(1)); - expect( - centerImageRect.left, - (leftImageRect.left + rightImageRect.left) / 2.0, - ); - expect(leftImageRect.size, centerImageRect.size); - expect(rightImageRect.size, centerImageRect.size); + final leftImageRect = tester.getRect(imageFinder.at(0)); + expect( + leftImageRect.left, + editor.editorState.editorStyle.padding.left, + ); + final rightImageRect = tester.getRect(imageFinder.at(2)); + expect( + rightImageRect.right, + editorRect.right - editor.editorState.editorStyle.padding.right, + ); + final centerImageRect = tester.getRect(imageFinder.at(1)); + expect( + centerImageRect.left, + (leftImageRect.left + rightImageRect.left) / 2.0, + ); + expect(leftImageRect.size, centerImageRect.size); + expect(rightImageRect.size, centerImageRect.size); - // final leftImageNode = editor.document.nodeAtPath([1]); + // final leftImageNode = editor.document.nodeAtPath([1]); - // expect(editor.runAction(1, leftImageNode!), true); // align center - // await tester.pump(); - // expect( - // tester.getRect(imageFinder.at(0)).left, - // centerImageRect.left, - // ); + // expect(editor.runAction(1, leftImageNode!), true); // align center + // await tester.pump(); + // expect( + // tester.getRect(imageFinder.at(0)).left, + // centerImageRect.left, + // ); - // expect(editor.runAction(2, leftImageNode), true); // align right - // await tester.pump(); - // expect( - // tester.getRect(imageFinder.at(0)).right, - // rightImageRect.right, - // ); - }); + // expect(editor.runAction(2, leftImageNode), true); // align right + // await tester.pump(); + // expect( + // tester.getRect(imageFinder.at(0)).right, + // rightImageRect.right, + // ); + }), + ); }); testWidgets('render image copy', (tester) async { - mockNetworkImagesFor(() async { - final editor = tester.editor - ..addParagraph(initialText: text) - ..addNode( - imageNode(url: url), - ) - ..addParagraph(initialText: text); - await editor.startTesting(); + unawaited( + mockNetworkImagesFor(() async { + final editor = tester.editor + ..addParagraph(initialText: text) + ..addNode( + imageNode(url: url), + ) + ..addParagraph(initialText: text); + await editor.startTesting(); - expect(editor.documentRootLen, 3); - final imageFinder = find.byType(Image); - expect(imageFinder, findsOneWidget); + expect(editor.documentRootLen, 3); + final imageFinder = find.byType(Image); + expect(imageFinder, findsOneWidget); - // final node = editor.document.nodeAtPath([1]); + // final node = editor.document.nodeAtPath([1]); - // expect(editor.runAction(3, imageNode!), true); // copy - // await tester.pump(); - }); + // expect(editor.runAction(3, imageNode!), true); // copy + // await tester.pump(); + }), + ); }); testWidgets('render image delete', (tester) async { - mockNetworkImagesFor(() async { - final editor = tester.editor - ..addParagraph(initialText: text) - ..addNode( - imageNode(url: url), - ) - ..addParagraph(initialText: text); - await editor.startTesting(); - await tester.pumpAndSettle(); + unawaited( + mockNetworkImagesFor(() async { + final editor = tester.editor + ..addParagraph(initialText: text) + ..addNode( + imageNode(url: url), + ) + ..addParagraph(initialText: text); + await editor.startTesting(); + await tester.pumpAndSettle(); - expect(editor.documentRootLen, 3); - final imageFinder = find.byType(Image); - expect(imageFinder, findsNWidgets(1)); + expect(editor.documentRootLen, 3); + final imageFinder = find.byType(Image); + expect(imageFinder, findsNWidgets(1)); - // final node = editor.document.nodeAtPath([1]); - // expect(editor.runAction(4, imageNode!), true); // delete + // final node = editor.document.nodeAtPath([1]); + // expect(editor.runAction(4, imageNode!), true); // delete - // await tester.pump(const Duration(milliseconds: 100)); - // expect(editor.documentRootLen, 3); - // expect(find.byType(Image), findsNWidgets(1)); - }); + // await tester.pump(const Duration(milliseconds: 100)); + // expect(editor.documentRootLen, 3); + // expect(find.byType(Image), findsNWidgets(1)); + }), + ); }); }); } diff --git a/test/render/toolbar/toolbar_item_widget_test.dart b/test/render/toolbar/toolbar_item_widget_test.dart index 238a3294..39cc1b89 100644 --- a/test/render/toolbar/toolbar_item_widget_test.dart +++ b/test/render/toolbar/toolbar_item_widget_test.dart @@ -15,7 +15,6 @@ void main() async { var hit = false; final item = ToolbarItem( id: 'appflowy.toolbar.test', - type: 1, group: 0, iconBuilder: (isHighlight) { return Icon( @@ -34,9 +33,9 @@ void main() async { key: key, item: item, isHighlight: true, - onPressed: (() { + onPressed: () { hit = true; - }), + }, ); await tester.pumpWidget( diff --git a/test/service/context_menu_test.dart b/test/service/context_menu_test.dart index e62880e9..ff25ce2b 100644 --- a/test/service/context_menu_test.dart +++ b/test/service/context_menu_test.dart @@ -68,7 +68,7 @@ void main() async { ); await editor.updateSelection( Selection( - start: Position(path: [1], offset: 0), + start: Position(path: [1]), end: Position(path: [1], offset: 5), ), ); @@ -89,7 +89,7 @@ void main() async { expect(clipBoardData.text, copiedText); await editor.updateSelection( Selection( - start: Position(path: [0], offset: 0), + start: Position(path: [0]), end: Position(path: [0], offset: 7), ), ); diff --git a/test/service/editor_service_test.dart b/test/service/editor_service_test.dart index 2f7ccb6b..0e9a8ce3 100644 --- a/test/service/editor_service_test.dart +++ b/test/service/editor_service_test.dart @@ -8,7 +8,7 @@ void main() { group('AppFlowyEditor tests', () { testWidgets('without autoFocus false', (tester) async { final editor = tester.editor..addParagraph(initialText: 'Hello'); - await editor.startTesting(autoFocus: false); + await editor.startTesting(); final selection = editor.selection; expect(selection != null, false); await editor.dispose(); @@ -28,7 +28,7 @@ void main() { 1000, initialText: 'Hello', ); - await editor.startTesting(shrinkWrap: false); + await editor.startTesting(); final size = tester.getSize(find.byType(AppFlowyEditor)); expect(size, const Size(800, 600)); await editor.dispose(); diff --git a/test/service/format_rich_text_style_test.dart b/test/service/format_rich_text_style_test.dart index ad8ac177..836e3ac7 100644 --- a/test/service/format_rich_text_style_test.dart +++ b/test/service/format_rich_text_style_test.dart @@ -1,5 +1,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; + import '../new/infra/testable_editor.dart'; void main() async { @@ -14,7 +15,7 @@ void main() async { await editor.updateSelection(Selection.collapsed(Position(path: [0]))); final inserted = - insertNodeAfterSelection(editor.editorState, paragraphNode()); + await insertNodeAfterSelection(editor.editorState, paragraphNode()); await tester.pumpAndSettle(); expect(inserted, true); @@ -30,7 +31,7 @@ void main() async { await editor.updateSelection(Selection.collapsed(Position(path: [0]))); final inserted = - insertNodeAfterSelection(editor.editorState, paragraphNode()); + await insertNodeAfterSelection(editor.editorState, paragraphNode()); await tester.pumpAndSettle(); expect(inserted, true); @@ -53,7 +54,7 @@ void main() async { await editor.updateSelection(Selection.collapsed(Position(path: [0]))); final inserted = - insertNodeAfterSelection(editor.editorState, paragraphNode()); + await insertNodeAfterSelection(editor.editorState, paragraphNode()); await tester.pumpAndSettle(); final node = editor.nodeAtPath([1])!; @@ -76,7 +77,7 @@ void main() async { await editor.updateSelection(Selection.collapsed(Position(path: [0]))); final inserted = - insertNodeAfterSelection(editor.editorState, paragraphNode()); + await insertNodeAfterSelection(editor.editorState, paragraphNode()); await tester.pumpAndSettle(); final node = editor.nodeAtPath([0])!; @@ -100,7 +101,7 @@ void main() async { await editor.startTesting(); await editor.updateSelection(Selection.collapsed(Position(path: [0]))); - insertHeadingAfterSelection(editor.editorState, 1); + await insertHeadingAfterSelection(editor.editorState, 1); await tester.pumpAndSettle(); final node = editor.nodeAtPath([1])!; diff --git a/test/service/selection_service_test.dart b/test/service/selection_service_test.dart index 9c69870d..14c0f060 100644 --- a/test/service/selection_service_test.dart +++ b/test/service/selection_service_test.dart @@ -59,6 +59,98 @@ void main() async { await editor.dispose(); }); + testWidgets('Test double tap then drag forward selects whole words', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(3, initialText: text); + await editor.startTesting(); + + final secondNode = editor.nodeAtPath([1]); + final finder = find.byKey(secondNode!.key); + final rect = tester.getRect(finder); + + // Press inside the first word "Welcome" (not at its start). + final startPos = rect.centerLeft + const Offset(10.0, 0.0); + + // First tap of the double-tap. + await tester.tapAt(startPos); + // Second tap: press down, hold briefly (so the double-tap is recognized), + // then drag to the end of the line. + final gesture = await tester.startGesture( + startPos, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 150)); + // A small move starts the drag, then extend to the end of the line. + await gesture.moveTo(startPos + const Offset(30.0, 0.0)); + await tester.pump(); + await gesture.moveTo(rect.centerRight); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + // The whole first word stays selected (start at offset 0, not mid-word) + // and the selection extends by whole words to the end of the line. + expect( + editor.selection?.normalized, + Selection.single(path: [1], startOffset: 0, endOffset: text.length), + ); + + await editor.dispose(); + }); + + testWidgets('Test double tap then drag backward keeps the anchor word', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(3, initialText: text); + await editor.startTesting(); + + final secondNode = editor.nodeAtPath([1]); + final finder = find.byKey(secondNode!.key); + final rect = tester.getRect(finder); + + // Probe which word lives further along the line so the assertion does not + // depend on exact glyph widths. + final probePos = rect.centerLeft + const Offset(120.0, 0.0); + await tester.tapAt(probePos); + await tester.tapAt(probePos); + await tester.pump(); + final probeWord = editor.selection!.normalized; + expect(probeWord.start.offset, greaterThan(0)); + + // Reset with a plain tap (also clears any pending word-drag anchor). + await tester.tapAt(rect.centerLeft); + await tester.pump(); + + // Double-tap that later word, then hold and drag back to the start. + await tester.tapAt(probePos); + final gesture = await tester.startGesture( + probePos, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 150)); + // A small move starts the drag, then extend back to the start of the line. + await gesture.moveTo(probePos - const Offset(30.0, 0.0)); + await tester.pump(); + await gesture.moveTo(rect.centerLeft); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + // The double-tapped word's end stays selected and the selection extends + // by whole words back to the start of the line. + expect( + editor.selection?.normalized, + Selection.single( + path: [1], + startOffset: 0, + endOffset: probeWord.end.offset, + ), + ); + + await editor.dispose(); + }); + testWidgets('Test triple tap', (tester) async { const text = 'Welcome to Appflowy 😁'; final editor = tester.editor..addParagraphs(3, initialText: text); @@ -394,7 +486,7 @@ void main() async { ); testWidgets('single tap with horizontal nodes', (tester) async { - var tableNode = TableNode.fromList([ + final tableNode = TableNode.fromList([ ['00', '01', '02', '03', '04'], ['10', '11', '12', '13', '14'], ['20', '21', '22', '23', '24'],