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:
parent
b43e53469e
commit
41335c54ae
236 changed files with 4793 additions and 1418 deletions
|
|
@ -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/**
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
_buildSeparator(context, 'Export To X Demo'),
|
||||
_buildListTile(context, 'Export To JSON', () {
|
||||
|
|
|
|||
193
example/lib/pages/inline_comment_editor.dart
Normal file
193
example/lib/pages/inline_comment_editor.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
50
example/lib/pages/ten_thousand_links_showcase.dart
Normal file
50
example/lib/pages/ten_thousand_links_showcase.dart
Normal 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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
317
lib/src/core/document/deprecated/node.dart
Normal file
317
lib/src/core/document/deprecated/node.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,20 @@ List<Operation> diffDocuments(Document oldDocument, Document newDocument) {
|
|||
}
|
||||
|
||||
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(
|
||||
UpdateOperation(oldNode.path, newNode.attributes, oldNode.attributes),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Node copyWith({
|
||||
String? type,
|
||||
String? id,
|
||||
bool preserveChildIds = false,
|
||||
Iterable<Node>? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,6 @@ class AtLeastOneEditableNodeRule extends DocumentRule {
|
|||
..afterSelection = Selection.collapsed(
|
||||
Position(path: [0]),
|
||||
);
|
||||
editorState.apply(transaction);
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -487,7 +487,7 @@ class Delta extends Iterable<TextOperation> {
|
|||
|
||||
/// 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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
class UpdateTextOperation extends Operation {
|
||||
const UpdateTextOperation(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ class _BlockComponentActionWrapperState
|
|||
opaque: false,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ValueListenableBuilder<bool>(
|
||||
|
|
|
|||
|
|
@ -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<T extends StatefulWidget> on State<T> {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ CommandShortcutEventHandler _convertToParagraphCommandHandler = (editorState) {
|
|||
textDirection: textDirection,
|
||||
children: node.children.map((e) => e.deepCopy()).toList(),
|
||||
),
|
||||
deepCopy: true,
|
||||
)
|
||||
..deleteNode(node)
|
||||
..afterSelection = transaction.beforeSelection;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<bool> 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<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
Loading…
Reference in a new issue