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
|
# 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/**
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
||||||
|
|
|
||||||
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';
|
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';
|
||||||
|
|
|
||||||
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> 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),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>(
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue