chore: merge flutter 3_27_4 into main branch (#1228)

* chore: merge flutter/3_27_4 to main branch

* chore: code format

* fix: unit tests
This commit is contained in:
Lucas 2026-07-27 15:37:27 +08:00 • committed by GitHub
commit 41335c54ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
236 changed files with 4793 additions and 1418 deletions

View file

@ -4,11 +4,29 @@ include: package:flutter_lints/flutter.yaml
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options
linter: linter:
rules: rules:
- always_declare_return_types
- require_trailing_commas - 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 - 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: analyzer:
exclude: exclude:
- lib/src/l10n/** - lib/src/l10n/**

View file

@ -16,7 +16,9 @@ import 'package:example/pages/editor.dart';
import 'package:example/pages/editor_list.dart'; import 'package:example/pages/editor_list.dart';
import 'package:example/pages/fixed_toolbar_editor.dart'; import 'package:example/pages/fixed_toolbar_editor.dart';
import 'package:example/pages/focus_example_for_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/markdown_editor.dart';
import 'package:example/pages/ten_thousand_links_showcase.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -281,6 +283,24 @@ class _HomePageState extends State<HomePage> {
); );
}), }),
_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 // Encoder Demo
_buildSeparator(context, 'Export To X Demo'), _buildSeparator(context, 'Export To X Demo'),
_buildListTile(context, 'Export To JSON', () { _buildListTile(context, 'Export To JSON', () {

View file

@ -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<InlineCommentEditor> createState() => _InlineCommentEditorState();
}
class _InlineCommentEditorState extends State<InlineCommentEditor> {
late final EditorState editorState;
late final EditorScrollController editorScrollController;
late final InlineCommentController commentController;
final List<InlineComment> _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<String?> _onCommentAdded(
String nodeId,
int startOffset,
int endOffset,
String? initialText,
) async {
// Show a dialog for the user to enter a comment
final content = await showDialog<String>(
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<void> _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,
),
),
),
),
),
);
}
}

View file

@ -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<TenThousandLinksShowcase> createState() =>
_TenThousandLinksShowcaseState();
}
class _TenThousandLinksShowcaseState extends State<TenThousandLinksShowcase> {
late final Future<String> _jsonString = _buildLinksDocument();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('10,000 Links Showcase')),
body: Editor(
jsonString: _jsonString,
onEditorStateChange: (_) {},
),
);
}
Future<String> _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()));
}
}

View file

@ -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'; export 'src/editor/block_component/table_block_component/table.dart';
// editor part, including editor component, block component, etc. // editor part, including editor component, block component, etc.
export 'src/editor/editor.dart'; 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/find_replace_menu/find_and_replace.dart';
export 'src/editor/l10n/appflowy_editor_l10n.dart'; export 'src/editor/l10n/appflowy_editor_l10n.dart';
export 'src/editor/selection_menu/selection_menu.dart'; export 'src/editor/selection_menu/selection_menu.dart';

View file

@ -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> {
NodeV0({
required this.type,
Attributes? attributes,
this.parent,
LinkedList<NodeV0>? children,
}) : children = children ?? LinkedList<NodeV0>(),
_attributes = attributes ?? {} {
for (final child in this.children) {
child.parent = this;
}
}
factory NodeV0.fromJson(Map<String, Object> 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<NodeV0>();
if (jChildren != null) {
children.addAll(
jChildren.map(
(jChild) => NodeV0.fromJson(
Map<String, Object>.from(jChild),
),
),
);
}
NodeV0 node;
if (jType == 'text') {
final jDelta = json['delta'] as List<dynamic>?;
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<NodeV0> 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<String, Object> toJson() {
final map = <String, Object>{
'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<NodeV0>? 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<String, Object> toJson() {
final map = super.toJson();
map['delta'] = delta.toJson();
return map;
}
@override
TextNodeV0 copyWith({
String? type = 'text',
LinkedList<NodeV0>? 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<NodeV0> {
bool equals(Iterable<NodeV0> 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, U>(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);
}
}

View file

@ -8,9 +8,20 @@ List<Operation> diffDocuments(Document oldDocument, Document newDocument) {
} }
List<Operation> diffNodes(Node oldNode, Node newNode) { List<Operation> diffNodes(Node oldNode, Node newNode) {
List<Operation> operations = []; final List<Operation> 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( operations.add(
UpdateOperation(oldNode.path, newNode.attributes, oldNode.attributes), UpdateOperation(oldNode.path, newNode.attributes, oldNode.attributes),
); );

View file

@ -153,6 +153,35 @@ class Document {
return true; 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] /// Updates the [Node] with [Delta] at the given [Path]
bool updateText(Path path, Delta delta) { bool updateText(Path path, Delta delta) {
if (path.isEmpty) { if (path.isEmpty) {
@ -163,7 +192,8 @@ class Document {
if (target == null || targetDelta == null) { if (target == null || targetDelta == null) {
return false; return false;
} }
target.updateAttributes({'delta': (targetDelta.compose(delta)).toJson()});
target.updateAttributes({'delta': targetDelta.compose(delta).toJson()});
return true; return true;
} }
@ -196,4 +226,8 @@ class Document {
'document': root.toJson(), 'document': root.toJson(),
}; };
} }
Document deepCopy() {
return Document.fromJson(toJson());
}
} }

View file

@ -271,19 +271,24 @@ final class Node extends ChangeNotifier with LinkedListEntry<Node> {
/// Be careful of the children, they will be deep copied if not provided. /// Be careful of the children, they will be deep copied if not provided.
Node copyWith({ Node copyWith({
String? type, String? type,
String? id,
bool preserveChildIds = false,
Iterable<Node>? children, Iterable<Node>? children,
Attributes? attributes, Attributes? attributes,
}) { }) {
final node = Node( final node = Node(
type: type ?? this.type, type: type ?? this.type,
id: nanoid(6), id: id ?? nanoid(6),
attributes: attributes ?? {...this.attributes}, attributes: attributes ?? {...this.attributes},
children: children ?? [], children: children ?? [],
); );
if (children == null && _children.isNotEmpty) { if (children == null && _children.isNotEmpty) {
for (final child in _children) { for (final child in _children) {
node._children.add( 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, Attributes? attributes,
Delta? delta, Delta? delta,
String? id, String? id,
bool preserveChildIds = false,
}) { }) {
final textNode = TextNode( final textNode = TextNode(
children: children ?? [], children: children ?? [],
attributes: attributes ?? this.attributes, attributes: attributes ?? this.attributes,
delta: delta ?? this.delta, delta: delta ?? this.delta,
); );
if (id != null) {
textNode.id = id;
}
if (children == null && this.children.isNotEmpty) { if (children == null && this.children.isNotEmpty) {
for (final child in this.children) { for (final child in this.children) {
textNode._children.add( textNode._children.add(
child.copyWith()..parent = textNode, child.copyWith(
id: preserveChildIds ? child.id : null,
preserveChildIds: preserveChildIds,
)..parent = textNode,
); );
} }
} }

View file

@ -65,7 +65,7 @@ extension PathExtensions on Path {
} }
Path get next { Path get next {
Path nextPath = Path.from(this, growable: true); final Path nextPath = Path.from(this);
if (isEmpty) { if (isEmpty) {
return nextPath; return nextPath;
} }
@ -77,7 +77,7 @@ extension PathExtensions on Path {
} }
Path nextNPath(int n) { Path nextNPath(int n) {
Path nextPath = Path.from(this, growable: true); final Path nextPath = Path.from(this);
if (isEmpty) { if (isEmpty) {
return nextPath; return nextPath;
} }
@ -89,11 +89,11 @@ extension PathExtensions on Path {
} }
Path child(int index) { Path child(int index) {
return Path.from(this, growable: true)..add(index); return Path.from(this)..add(index);
} }
Path get previous { Path get previous {
Path previousPath = Path.from(this, growable: true); final Path previousPath = Path.from(this);
if (isEmpty) { if (isEmpty) {
return previousPath; return previousPath;
} }
@ -105,7 +105,7 @@ extension PathExtensions on Path {
} }
Path previousNPath(int n) { Path previousNPath(int n) {
Path previousPath = Path.from(this, growable: true); final Path previousPath = Path.from(this);
if (isEmpty) { if (isEmpty) {
return previousPath; return previousPath;
} }
@ -121,7 +121,7 @@ extension PathExtensions on Path {
return this; return this;
} }
return Path.from(this, growable: true)..removeLast(); return Path.from(this)..removeLast();
} }
bool isAncestorOf(Path other) { bool isAncestorOf(Path other) {
@ -149,7 +149,7 @@ extension PathExtensions on Path {
bool isSameDepth = false, bool isSameDepth = false,
}) { }) {
selection = selection?.normalized; selection = selection?.normalized;
bool result = selection != null && final bool result = selection != null &&
selection.start.path <= this && selection.start.path <= this &&
this <= selection.end.path; this <= selection.end.path;
if (isSameDepth) { if (isSameDepth) {

View file

@ -31,6 +31,6 @@ class AtLeastOneEditableNodeRule extends DocumentRule {
..afterSelection = Selection.collapsed( ..afterSelection = Selection.collapsed(
Position(path: [0]), Position(path: [0]),
); );
editorState.apply(transaction); await editorState.apply(transaction);
} }
} }

View file

@ -487,7 +487,7 @@ class Delta extends Iterable<TextOperation> {
/// This method joins two Delta together. /// This method joins two Delta together.
Delta operator +(Delta other) { Delta operator +(Delta other) {
var operations = [..._operations]; final operations = [..._operations];
if (other._operations.isNotEmpty) { if (other._operations.isNotEmpty) {
operations.add(other._operations[0]); operations.add(other._operations[0]);
operations.addAll(other._operations.sublist(1)); operations.addAll(other._operations.sublist(1));

View file

@ -1,9 +1,6 @@
import 'package:appflowy_editor/src/core/document/path.dart'; import 'package:appflowy_editor/src/core/document/path.dart';
class Position { class Position {
final Path path;
final int offset;
Position({ Position({
required this.path, required this.path,
this.offset = 0, this.offset = 0,
@ -22,6 +19,8 @@ class Position {
offset: offset ?? 0, offset: offset ?? 0,
); );
} }
final Path path;
final int offset;
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {

View file

@ -182,6 +182,101 @@ class UpdateOperation extends Operation {
path.hashCode ^ attributes.hashCode ^ oldAttributes.hashCode; 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<String, dynamic> 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<String, dynamic> 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. /// [UpdateTextOperation] represents a text update operation.
class UpdateTextOperation extends Operation { class UpdateTextOperation extends Operation {
const UpdateTextOperation( const UpdateTextOperation(

View file

@ -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. /// Deletes the [Node] in the document.
void deleteNode(Node node) { void deleteNode(Node node) {
deleteNodesAtPath(node.path); deleteNodesAtPath(node.path);
if (beforeSelection != null) { if (beforeSelection != null) {
final nodePath = node.path; final nodePath = node.path;
final selectionPath = beforeSelection!.start.path; final selectionPath = beforeSelection!.start.path;
if (!(nodePath.equals(selectionPath))) { if (!nodePath.equals(selectionPath)) {
afterSelection = beforeSelection; afterSelection = beforeSelection;
} }
} }

View file

@ -80,7 +80,6 @@ class _BlockComponentActionWrapperState
opaque: false, opaque: false,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ValueListenableBuilder<bool>( ValueListenableBuilder<bool>(

View file

@ -10,6 +10,7 @@ typedef BlockComponentTextStyleBuilder = TextStyle Function(
class BlockComponentConfiguration { class BlockComponentConfiguration {
const BlockComponentConfiguration({ const BlockComponentConfiguration({
this.padding = _padding, this.padding = _padding,
this.margin = _margin,
this.indentPadding = _indentPadding, this.indentPadding = _indentPadding,
this.placeholderText = _placeholderText, this.placeholderText = _placeholderText,
this.textStyle = _textStyle, this.textStyle = _textStyle,
@ -23,6 +24,11 @@ class BlockComponentConfiguration {
/// It works only for the block component itself, not for the children. /// It works only for the block component itself, not for the children.
final EdgeInsets Function(Node node) padding; 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. /// The padding of a block component.
/// ///
/// It works only for the block that needs to be indented. /// It works only for the block that needs to be indented.
@ -53,6 +59,7 @@ class BlockComponentConfiguration {
BlockComponentConfiguration copyWith({ BlockComponentConfiguration copyWith({
EdgeInsets Function(Node node)? padding, EdgeInsets Function(Node node)? padding,
EdgeInsets Function(Node node)? margin,
BlockComponentTextStyleBuilder? textStyle, BlockComponentTextStyleBuilder? textStyle,
String Function(Node node)? placeholderText, String Function(Node node)? placeholderText,
BlockComponentTextStyleBuilder? placeholderTextStyle, BlockComponentTextStyleBuilder? placeholderTextStyle,
@ -62,6 +69,7 @@ class BlockComponentConfiguration {
}) { }) {
return BlockComponentConfiguration( return BlockComponentConfiguration(
padding: padding ?? this.padding, padding: padding ?? this.padding,
margin: margin ?? this.margin,
textStyle: textStyle ?? this.textStyle, textStyle: textStyle ?? this.textStyle,
placeholderText: placeholderText ?? this.placeholderText, placeholderText: placeholderText ?? this.placeholderText,
placeholderTextStyle: placeholderTextStyle ?? this.placeholderTextStyle, placeholderTextStyle: placeholderTextStyle ?? this.placeholderTextStyle,
@ -78,6 +86,7 @@ mixin BlockComponentConfigurable<T extends StatefulWidget> on State<T> {
Node get node; Node get node;
EdgeInsets get padding => configuration.padding(node); EdgeInsets get padding => configuration.padding(node);
EdgeInsets get margin => configuration.margin(node);
TextStyle textStyleWithTextSpan({TextSpan? textSpan}) => TextStyle textStyleWithTextSpan({TextSpan? textSpan}) =>
configuration.textStyle(node, textSpan: textSpan); configuration.textStyle(node, textSpan: textSpan);
@ -94,6 +103,10 @@ EdgeInsets _padding(Node node) {
return const EdgeInsets.symmetric(vertical: 4.0); return const EdgeInsets.symmetric(vertical: 4.0);
} }
EdgeInsets _margin(Node node) {
return EdgeInsets.zero;
}
EdgeInsets _indentPadding(Node node, TextDirection textDirection) { EdgeInsets _indentPadding(Node node, TextDirection textDirection) {
switch (textDirection) { switch (textDirection) {
case TextDirection.ltr: case TextDirection.ltr:
@ -119,7 +132,7 @@ TextStyle _placeholderTextStyle(Node node, {TextSpan? textSpan}) {
} }
EdgeInsets _blockSelectionAreaPadding(Node node) { EdgeInsets _blockSelectionAreaPadding(Node node) {
return const EdgeInsets.symmetric(vertical: 0.0); return const EdgeInsets.symmetric();
} }
TextAlign _textAlign(Node node) { TextAlign _textAlign(Node node) {

View file

@ -52,7 +52,6 @@ CommandShortcutEventHandler _convertToParagraphCommandHandler = (editorState) {
textDirection: textDirection, textDirection: textDirection,
children: node.children.map((e) => e.deepCopy()).toList(), children: node.children.map((e) => e.deepCopy()).toList(),
), ),
deepCopy: true,
) )
..deleteNode(node) ..deleteNode(node)
..afterSelection = transaction.beforeSelection; ..afterSelection = transaction.beforeSelection;

View file

@ -85,7 +85,7 @@ CommandShortcutEventHandler _indentCommandHandler = (editorState) {
final transaction = editorState.transaction final transaction = editorState.transaction
..deleteNodes(nodes) ..deleteNodes(nodes)
..insertNodes(startPath, nodes, deepCopy: true) ..insertNodes(startPath, nodes)
..afterSelection = afterSelection; ..afterSelection = afterSelection;
editorState.apply(transaction); editorState.apply(transaction);

View file

@ -1,4 +1,7 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
const _nodeEquality = DeepCollectionEquality();
/// Formats the current node to specified markdown style. /// Formats the current node to specified markdown style.
/// ///
@ -55,23 +58,58 @@ Future<bool> formatMarkdownSymbol(
final afterSelection = Selection.collapsed( final afterSelection = Selection.collapsed(
Position( Position(
path: node.path, path: node.path,
offset: 0,
), ),
); );
final formattedNodes = nodesBuilder(text, node, delta); final formattedNodes = nodesBuilder(text, node, delta);
// Create a transaction that replaces the current node with the final transaction = editorState.transaction;
// formatted node. if (_canUpdateNodeTypeInPlace(node, formattedNodes)) {
final transaction = editorState.transaction final formattedNode = formattedNodes.single;
..insertNodes( transaction.updateNodeType(
node.path, node,
formattedNodes, formattedNode.type,
) formattedNode.attributes,
..deleteNode(node) );
..afterSelection = afterSelection; } 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); await editorState.apply(transaction);
return true; return true;
} }
bool _canUpdateNodeTypeInPlace(Node node, List<Node> 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;
}

Some files were not shown because too many files have changed in this diff Show more