refactor: implement appflowy editor core

This commit is contained in:
Lucas.Xu 2022-10-11 12:02:46 +08:00 committed by GitHub
commit d80a67bdda
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
93 changed files with 2725 additions and 2188 deletions

View File

@ -67,7 +67,7 @@ You can also create an editor from a JSON object in order to configure your init
```dart
final json = ...;
final editorStyle = EditorStyle.defaultStyle();
final editorState = EditorState(StateTree.fromJson(data));
final editorState = EditorState(Document.fromJson(data));
final editor = AppFlowyEditor(
editorState: editorState,
editorStyle: editorStyle,

View File

@ -98,7 +98,7 @@ class _MyHomePageState extends State<MyHomePage> {
if (snapshot.hasData &&
snapshot.connectionState == ConnectionState.done) {
_editorState ??= EditorState(
document: StateTree.fromJson(
document: Document.fromJson(
Map<String, Object>.from(
json.decode(snapshot.data!),
),

View File

@ -26,9 +26,9 @@ ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) {
return KeyEventResult.ignored;
}
if (selection.isCollapsed) {
TransactionBuilder(editorState)
..insertText(codeBlockNode.first, selection.end.offset, '\n')
..commit();
editorState.transaction
.insertText(codeBlockNode.first, selection.end.offset, '\n');
editorState.commit();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
@ -60,21 +60,20 @@ SelectionMenuItem codeBlockMenuItem = SelectionMenuItem(
if (selection == null || textNodes.isEmpty) {
return;
}
if (textNodes.first.toRawString().isEmpty) {
TransactionBuilder(editorState)
if (textNodes.first.toPlainText().isEmpty) {
editorState.transaction
..updateNode(textNodes.first, {
'subtype': 'code_block',
'theme': 'vs',
'language': null,
})
..afterSelection = selection
..commit();
..afterSelection = selection;
editorState.commit();
} else {
TransactionBuilder(editorState)
editorState.transaction
..insertNode(
selection.end.path.next,
TextNode(
type: 'text',
children: LinkedList(),
attributes: {
'subtype': 'code_block',
@ -84,8 +83,8 @@ SelectionMenuItem codeBlockMenuItem = SelectionMenuItem(
delta: Delta()..insert('\n'),
),
)
..afterSelection = selection
..commit();
..afterSelection = selection;
editorState.commit();
}
},
);
@ -149,7 +148,7 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
Widget _buildCodeBlock(BuildContext context) {
final result = highlight.highlight.parse(
widget.textNode.toRawString(),
widget.textNode.toPlainText(),
language: _language,
autoDetection: _language == null,
);
@ -182,11 +181,10 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
child: DropdownButton<String>(
value: _detectLanguage,
onChanged: (value) {
TransactionBuilder(widget.editorState)
..updateNode(widget.textNode, {
'language': value,
})
..commit();
widget.editorState.transaction.updateNode(widget.textNode, {
'language': value,
});
widget.editorState.commit();
},
items: allLanguages.keys.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(

View File

@ -17,8 +17,8 @@ ShortcutEventHandler _insertHorzaontalRule = (editorState, event) {
return KeyEventResult.ignored;
}
final textNode = textNodes.first;
if (textNode.toRawString() == '--') {
TransactionBuilder(editorState)
if (textNode.toPlainText() == '--') {
editorState.transaction
..deleteText(textNode, 0, 2)
..insertNode(
textNode.path,
@ -29,8 +29,8 @@ ShortcutEventHandler _insertHorzaontalRule = (editorState, event) {
),
)
..afterSelection =
Selection.single(path: textNode.path.next, startOffset: 0)
..commit();
Selection.single(path: textNode.path.next, startOffset: 0);
editorState.commit();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
@ -53,8 +53,8 @@ SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
return;
}
final textNode = textNodes.first;
if (textNode.toRawString().isEmpty) {
TransactionBuilder(editorState)
if (textNode.toPlainText().isEmpty) {
editorState.transaction
..insertNode(
textNode.path,
Node(
@ -64,14 +64,13 @@ SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
),
)
..afterSelection =
Selection.single(path: textNode.path.next, startOffset: 0)
..commit();
Selection.single(path: textNode.path.next, startOffset: 0);
editorState.commit();
} else {
TransactionBuilder(editorState)
editorState.transaction
..insertNode(
selection.end.path.next,
TextNode(
type: 'text',
children: LinkedList(),
attributes: {
'subtype': 'horizontal_rule',
@ -79,8 +78,8 @@ SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
delta: Delta()..insert('---'),
),
)
..afterSelection = selection
..commit();
..afterSelection = selection;
editorState.commit();
}
},
);

View File

@ -21,9 +21,9 @@ SelectionMenuItem teXBlockMenuItem = SelectionMenuItem(
return;
}
final Path texNodePath;
if (textNodes.first.toRawString().isEmpty) {
if (textNodes.first.toPlainText().isEmpty) {
texNodePath = selection.end.path;
TransactionBuilder(editorState)
editorState.transaction
..insertNode(
selection.end.path,
Node(
@ -33,11 +33,11 @@ SelectionMenuItem teXBlockMenuItem = SelectionMenuItem(
),
)
..deleteNode(textNodes.first)
..afterSelection = selection
..commit();
..afterSelection = selection;
editorState.commit();
} else {
texNodePath = selection.end.path.next;
TransactionBuilder(editorState)
editorState.transaction
..insertNode(
selection.end.path.next,
Node(
@ -46,8 +46,8 @@ SelectionMenuItem teXBlockMenuItem = SelectionMenuItem(
attributes: {'tex': ''},
),
)
..afterSelection = selection
..commit();
..afterSelection = selection;
editorState.commit();
}
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
final texState =
@ -142,9 +142,8 @@ class __TeXBlockNodeWidgetState extends State<_TeXBlockNodeWidget> {
size: 16,
),
onPressed: () {
TransactionBuilder(widget.editorState)
..deleteNode(widget.node)
..commit();
widget.editorState.transaction.deleteNode(widget.node);
widget.editorState.commit();
},
),
);
@ -175,12 +174,11 @@ class __TeXBlockNodeWidgetState extends State<_TeXBlockNodeWidget> {
onPressed: () {
Navigator.of(context).pop();
if (controller.text != _tex) {
TransactionBuilder(widget.editorState)
..updateNode(
widget.node,
{'tex': controller.text},
)
..commit();
widget.editorState.transaction.updateNode(
widget.node,
{'tex': controller.text},
);
widget.editorState.commit();
}
},
child: const Text('OK'),

View File

@ -18,7 +18,7 @@ ShortcutEventHandler _underscoreToItalicHandler = (editorState, event) {
}
final textNode = textNodes.first;
final text = textNode.toRawString();
final text = textNode.toPlainText();
// Determine if an 'underscore' already exists in the text node and only once.
final firstUnderscore = text.indexOf('_');
final lastUnderscore = text.lastIndexOf('_');
@ -31,7 +31,7 @@ ShortcutEventHandler _underscoreToItalicHandler = (editorState, event) {
// Delete the previous 'underscore',
// update the style of the text surrounded by the two underscores to 'italic',
// and update the cursor position.
TransactionBuilder(editorState)
editorState.transaction
..deleteText(textNode, firstUnderscore, 1)
..formatText(
textNode,
@ -46,8 +46,8 @@ ShortcutEventHandler _underscoreToItalicHandler = (editorState, event) {
path: textNode.path,
offset: selection.end.offset - 1,
),
)
..commit();
);
editorState.commit();
return KeyEventResult.handled;
};

View File

@ -3,18 +3,17 @@ library appflowy_editor;
export 'src/infra/log.dart';
export 'src/render/style/editor_style.dart';
export 'src/document/node.dart';
export 'src/document/path.dart';
export 'src/document/position.dart';
export 'src/document/selection.dart';
export 'src/document/state_tree.dart';
export 'src/document/text_delta.dart';
export 'src/document/attributes.dart';
export 'src/document/built_in_attribute_keys.dart';
export 'src/core/document/node.dart';
export 'src/core/document/path.dart';
export 'src/core/location/position.dart';
export 'src/core/location/selection.dart';
export 'src/core/document/document.dart';
export 'src/core/document/text_delta.dart';
export 'src/core/document/attributes.dart';
export 'src/core/legacy/built_in_attribute_keys.dart';
export 'src/editor_state.dart';
export 'src/operation/operation.dart';
export 'src/operation/transaction.dart';
export 'src/operation/transaction_builder.dart';
export 'src/core/transform/operation.dart';
export 'src/core/transform/transaction.dart';
export 'src/render/selection/selectable.dart';
export 'src/service/editor_service.dart';
export 'src/service/render_plugin_service.dart';
@ -28,7 +27,6 @@ export 'src/service/shortcut_event/keybinding.dart';
export 'src/service/shortcut_event/shortcut_event.dart';
export 'src/service/shortcut_event/shortcut_event_handler.dart';
export 'src/extensions/attributes_extension.dart';
export 'src/extensions/path_extensions.dart';
export 'src/render/rich_text/default_selectable.dart';
export 'src/render/rich_text/flowy_rich_text.dart';
export 'src/render/selection_menu/selection_menu_widget.dart';

View File

@ -1,10 +1,10 @@
import 'dart:async';
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:appflowy_editor/src/core/transform/transaction.dart';
import 'package:flutter/widgets.dart';
Future<void> insertContextInText(
@ -22,9 +22,8 @@ Future<void> insertContextInText(
final completer = Completer<void>();
TransactionBuilder(editorState)
..insertText(result, index, content)
..commit();
editorState.transaction.insertText(result, index, content);
editorState.commit();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
completer.complete();

View File

@ -1,10 +1,10 @@
import 'package:appflowy_editor/src/commands/format_text.dart';
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/core/document/attributes.dart';
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
Future<void> formatBuiltInTextAttributes(

View File

@ -1,12 +1,12 @@
import 'dart:async';
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/core/document/attributes.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:appflowy_editor/src/core/transform/transaction.dart';
import 'package:flutter/widgets.dart';
Future<void> updateTextNodeAttributes(
@ -23,9 +23,8 @@ Future<void> updateTextNodeAttributes(
final completer = Completer<void>();
TransactionBuilder(editorState)
..updateNode(result, attributes)
..commit();
editorState.transaction.updateNode(result, attributes);
editorState.commit();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
completer.complete();
@ -49,15 +48,13 @@ Future<void> updateTextNodeDeltaAttributes(
final newSelection = getSelection(editorState, selection: selection);
final completer = Completer<void>();
TransactionBuilder(editorState)
..formatText(
result,
newSelection.startIndex,
newSelection.length,
attributes,
)
..commit();
editorState.transaction.formatText(
result,
newSelection.startIndex,
newSelection.length,
attributes,
);
editorState.commit();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
completer.complete();

View File

@ -1,6 +1,6 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
// get formatted [TextNode]

View File

@ -0,0 +1,51 @@
/// Attributes is used to describe the Node's information.
///
/// Please note: The keywords in [BuiltInAttributeKey] are reserved.
typedef Attributes = Map<String, dynamic>;
Attributes? composeAttributes(
Attributes? base,
Attributes? other, {
keepNull = false,
}) {
base ??= {};
other ??= {};
Attributes attributes = {
...base,
...other,
};
if (!keepNull) {
attributes = Attributes.from(attributes)
..removeWhere((_, value) => value == null);
}
return attributes.isNotEmpty ? attributes : null;
}
Attributes invertAttributes(Attributes? from, Attributes? to) {
from ??= {};
to ??= {};
final attributes = Attributes.from({});
// key in from but not in to, or value is different
for (final entry in from.entries) {
if ((!to.containsKey(entry.key) && entry.value != null) ||
to[entry.key] != entry.value) {
attributes[entry.key] = entry.value;
}
}
// key in to but not in from, or value is different
for (final entry in to.entries) {
if (!from.containsKey(entry.key) && entry.value != null) {
attributes[entry.key] = null;
}
}
return attributes;
}
int hashAttributes(Attributes base) => Object.hashAllUnordered(
base.entries.map((e) => Object.hash(e.key, e.value)),
);

View File

@ -0,0 +1,118 @@
import 'dart:collection';
import 'package:appflowy_editor/src/core/document/attributes.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/document/text_delta.dart';
/// [Document] reprensents a AppFlowy Editor document structure.
///
/// It stores the root of the document.
///
/// DO NOT directly mutate the properties of a [Document] object.
class Document {
Document({
required this.root,
});
factory Document.fromJson(Map<String, dynamic> json) {
assert(json['document'] is Map);
final document = Map<String, Object>.from(json['document'] as Map);
final root = Node.fromJson(document);
return Document(root: root);
}
/// Creates a empty document with a single text node.
factory Document.empty() {
final root = Node(
type: 'editor',
children: LinkedList<Node>()..add(TextNode.empty()),
);
return Document(
root: root,
);
}
final Node root;
/// Returns the node at the given [path].
Node? nodeAtPath(Path path) {
return root.childAtPath(path);
}
/// Inserts a [Node]s at the given [Path].
bool insert(Path path, Iterable<Node> nodes) {
if (path.isEmpty || nodes.isEmpty) {
return false;
}
final target = nodeAtPath(path);
if (target != null) {
for (final node in nodes) {
target.insertBefore(node);
}
return true;
}
final parent = nodeAtPath(path.parent);
if (parent != null) {
for (final node in nodes) {
parent.insert(node, index: path.last);
}
return true;
}
return false;
}
/// Deletes the [Node]s at the given [Path].
bool delete(Path path, [int length = 1]) {
if (path.isEmpty || length <= 0) {
return false;
}
var target = nodeAtPath(path);
if (target == null) {
return false;
}
while (target != null && length > 0) {
final next = target.next;
target.unlink();
target = next;
length--;
}
return true;
}
/// Updates the [Node] at the given [Path]
bool update(Path path, Attributes attributes) {
if (path.isEmpty) {
return false;
}
final target = nodeAtPath(path);
if (target == null) {
return false;
}
target.updateAttributes(attributes);
return true;
}
/// Updates the [TextNode] at the given [Path]
bool updateText(Path path, Delta delta) {
if (path.isEmpty) {
return false;
}
final target = nodeAtPath(path);
if (target == null || target is! TextNode) {
return false;
}
target.delta = target.delta.compose(delta);
return true;
}
Map<String, Object> toJson() {
return {
'document': root.toJson(),
};
}
}

View File

@ -1,47 +1,21 @@
import 'dart:collection';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/text_delta.dart';
import 'package:flutter/material.dart';
import './attributes.dart';
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';
class Node extends ChangeNotifier with LinkedListEntry<Node> {
Node? parent;
final String type;
final LinkedList<Node> children;
Attributes _attributes;
GlobalKey? key;
// TODO: abstract a selectable node??
final layerLink = LayerLink();
String? get subtype {
// TODO: make 'subtype' as a const value.
if (_attributes.containsKey('subtype')) {
assert(_attributes['subtype'] is String?,
'subtype must be a [String] or [null]');
return _attributes['subtype'] as String?;
}
return null;
}
String get id {
if (subtype != null) {
return '$type/$subtype';
}
return type;
}
Path get path => _path();
Attributes get attributes => _attributes;
Node({
required this.type,
required this.children,
required Attributes attributes,
Attributes? attributes,
this.parent,
}) : _attributes = attributes {
for (final child in children) {
LinkedList<Node>? children,
}) : children = children ?? LinkedList<Node>(),
_attributes = attributes ?? {} {
for (final child in this.children) {
child.parent = this;
}
}
@ -49,14 +23,13 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
factory Node.fromJson(Map<String, Object> json) {
assert(json['type'] is String);
// TODO: check the type that not exist on plugins.
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 LinkedList<Node> children = LinkedList();
final children = LinkedList<Node>();
if (jChildren != null) {
children.addAll(
jChildren.map(
@ -69,14 +42,14 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
Node node;
if (jType == "text") {
if (jType == 'text') {
final jDelta = json['delta'] as List<dynamic>?;
final delta = jDelta == null ? Delta() : Delta.fromJson(jDelta);
node = TextNode(
type: jType,
children: children,
attributes: jAttributes,
delta: delta);
children: children,
attributes: jAttributes,
delta: delta,
);
} else {
node = Node(
type: jType,
@ -92,20 +65,48 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
return node;
}
final String type;
final LinkedList<Node> children;
Node? parent;
Attributes _attributes;
// Renderable
GlobalKey? key;
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 = {..._attributes};
_attributes = composeAttributes(_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.
bool shouldNotifyParent =
_attributes['subtype'] != oldAttributes['subtype'];
this.attributes['subtype'] != oldAttributes['subtype'];
shouldNotifyParent ? parent?.notifyListeners() : notifyListeners();
}
Node? childAtIndex(int index) {
if (children.length <= index) {
if (children.length <= index || index < 0) {
return null;
}
@ -121,7 +122,8 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
}
void insert(Node entry, {int? index}) {
index ??= children.length;
final length = children.length;
index ??= length;
if (children.isEmpty) {
entry.parent = this;
@ -130,8 +132,9 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
return;
}
final length = children.length;
// 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) {
@ -173,28 +176,14 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
};
if (children.isNotEmpty) {
map['children'] =
(children.map((node) => node.toJson())).toList(growable: false);
children.map((node) => node.toJson()).toList(growable: false);
}
if (_attributes.isNotEmpty) {
map['attributes'] = _attributes;
if (attributes.isNotEmpty) {
map['attributes'] = attributes;
}
return map;
}
Path _path([Path previous = const []]) {
if (parent == null) {
return previous;
}
var index = 0;
for (var child in parent!.children) {
if (child == this) {
break;
}
index += 1;
}
return parent!._path([index, ...previous]);
}
Node copyWith({
String? type,
LinkedList<Node>? children,
@ -202,8 +191,8 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
}) {
final node = Node(
type: type ?? this.type,
attributes: attributes ?? {..._attributes},
children: children ?? LinkedList(),
attributes: attributes ?? {...this.attributes},
children: children,
);
if (children == null && this.children.isNotEmpty) {
for (final child in this.children) {
@ -214,34 +203,43 @@ class Node extends ChangeNotifier with LinkedListEntry<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]);
}
}
class TextNode extends Node {
Delta _delta;
TextNode({
required super.type,
required Delta delta,
LinkedList<Node>? children,
Attributes? attributes,
}) : _delta = delta,
super(
children: children ?? LinkedList(),
type: 'text',
children: children,
attributes: attributes ?? {},
);
TextNode.empty({Attributes? attributes})
: _delta = Delta([TextInsert('')]),
: _delta = Delta(operations: [TextInsert('')]),
super(
type: 'text',
children: LinkedList(),
attributes: attributes ?? {},
);
Delta get delta {
return _delta;
}
Delta _delta;
Delta get delta => _delta;
set delta(Delta v) {
_delta = v;
notifyListeners();
@ -250,21 +248,20 @@ class TextNode extends Node {
@override
Map<String, Object> toJson() {
final map = super.toJson();
map['delta'] = _delta.toJson();
map['delta'] = delta.toJson();
return map;
}
@override
TextNode copyWith({
String? type,
String? type = 'text',
LinkedList<Node>? children,
Attributes? attributes,
Delta? delta,
}) {
final textNode = TextNode(
type: type ?? this.type,
children: children,
attributes: attributes ?? _attributes,
attributes: attributes ?? this.attributes,
delta: delta ?? this.delta,
);
if (children == null && this.children.isNotEmpty) {
@ -277,5 +274,28 @@ class TextNode extends Node {
return textNode;
}
String toRawString() => _delta.toRawString();
String toPlainText() => _delta.toPlainText();
}
extension NodeEquality on Iterable<Node> {
bool equals(Iterable<Node> 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 Node &&
other is Node &&
other.type == base.type &&
other.children.equals(base.children);
}
}

View File

@ -1,23 +1,28 @@
import 'package:appflowy_editor/src/document/node.dart';
import './state_tree.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/document.dart';
/// [NodeIterator] is used to traverse the nodes in visual order.
class NodeIterator implements Iterator<Node> {
final StateTree stateTree;
final Node _startNode;
final Node? _endNode;
NodeIterator({
required this.document,
required this.startNode,
this.endNode,
});
final Document document;
final Node startNode;
final Node? endNode;
Node? _currentNode;
bool _began = false;
NodeIterator(this.stateTree, Node startNode, [Node? endNode])
: _startNode = startNode,
_endNode = endNode;
@override
Node get current => _currentNode!;
@override
bool moveNext() {
if (!_began) {
_currentNode = _startNode;
_currentNode = startNode;
_began = true;
return true;
}
@ -27,7 +32,7 @@ class NodeIterator implements Iterator<Node> {
return false;
}
if (_endNode != null && _endNode == node) {
if (endNode != null && endNode == node) {
_currentNode = null;
return false;
}
@ -42,32 +47,25 @@ class NodeIterator implements Iterator<Node> {
if (nextOfParent == null) {
_currentNode = null;
} else {
_currentNode = _findLeadingChild(nextOfParent);
_currentNode = nextOfParent;
}
}
return _currentNode != null;
}
List<Node> toList() {
final result = <Node>[];
while (moveNext()) {
result.add(current);
}
return result;
}
Node _findLeadingChild(Node node) {
while (node.children.isNotEmpty) {
node = node.children.first;
}
return node;
}
@override
Node get current {
return _currentNode!;
}
List<Node> toList() {
final result = <Node>[];
while (moveNext()) {
result.add(current);
}
return result;
}
}

View File

@ -1,17 +1,23 @@
import 'package:appflowy_editor/src/document/path.dart';
import 'dart:math';
import 'package:flutter/foundation.dart';
typedef Path = List<int>;
extension PathExtensions on Path {
bool equals(Path other) {
return listEquals(this, other);
}
bool operator >=(Path other) {
if (pathEquals(this, other)) {
if (equals(other)) {
return true;
}
return this > other;
}
bool operator >(Path other) {
if (pathEquals(this, other)) {
if (equals(other)) {
return false;
}
final length = min(this.length, other.length);
@ -29,14 +35,14 @@ extension PathExtensions on Path {
}
bool operator <=(Path other) {
if (pathEquals(this, other)) {
if (equals(other)) {
return true;
}
return this < other;
}
bool operator <(Path other) {
if (pathEquals(this, other)) {
if (equals(other)) {
return false;
}
final length = min(this.length, other.length);
@ -63,4 +69,22 @@ extension PathExtensions on Path {
..removeLast()
..add(last + 1);
}
Path get previous {
Path previousPath = Path.from(this, growable: true);
if (isEmpty) {
return previousPath;
}
final last = previousPath.last;
return previousPath
..removeLast()
..add(max(0, last - 1));
}
Path get parent {
if (isEmpty) {
return this;
}
return Path.from(this, growable: true)..removeLast();
}
}

View File

@ -1,165 +1,472 @@
import 'dart:collection';
import 'dart:math';
import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:flutter/foundation.dart';
import 'package:appflowy_editor/src/core/document/attributes.dart';
// constant number: 2^53 - 1
const int _maxInt = 9007199254740991;
abstract class TextOperation {
bool get isEmpty => length == 0;
List<int> stringIndexes(String text) {
final indexes = List<int>.filled(text.length, 0);
final iterator = text.runes.iterator;
while (iterator.moveNext()) {
for (var i = 0; i < iterator.currentSize; i++) {
indexes[iterator.rawIndex + i] = iterator.rawIndex;
}
}
return indexes;
}
abstract class TextOperation {
Attributes? get attributes;
int get length;
Attributes? get attributes => null;
bool get isEmpty => length == 0;
Map<String, dynamic> toJson();
}
class TextInsert extends TextOperation {
String content;
TextInsert(
this.text, {
Attributes? attributes,
}) : _attributes = attributes;
String text;
final Attributes? _attributes;
TextInsert(this.content, [Attributes? attrs]) : _attributes = attrs;
@override
int get length => text.length;
@override
int get length {
return content.length;
}
Attributes? get attributes => _attributes != null ? {..._attributes!} : null;
@override
Attributes? get attributes {
return _attributes;
Map<String, dynamic> toJson() {
final result = <String, dynamic>{
'insert': text,
};
if (_attributes != null) {
result['attributes'] = attributes;
}
return result;
}
@override
bool operator ==(Object other) {
if (other is! TextInsert) {
return false;
}
return content == other.content &&
if (identical(this, other)) return true;
return other is TextInsert &&
other.text == text &&
mapEquals(_attributes, other._attributes);
}
@override
int get hashCode {
final contentHash = content.hashCode;
final attrs = _attributes;
return Object.hash(
contentHash, attrs == null ? null : hashAttributes(attrs));
}
@override
Map<String, dynamic> toJson() {
final result = <String, dynamic>{
'insert': content,
};
final attrs = _attributes;
if (attrs != null) {
result['attributes'] = {...attrs};
}
return result;
}
int get hashCode => text.hashCode ^ _attributes.hashCode;
}
class TextRetain extends TextOperation {
int _length;
TextRetain(
this.length, {
Attributes? attributes,
}) : _attributes = attributes;
@override
int length;
final Attributes? _attributes;
TextRetain(length, [Attributes? attributes])
: _length = length,
_attributes = attributes;
@override
bool get isEmpty {
return length == 0;
}
@override
int get length {
return _length;
}
set length(int v) {
_length = v;
}
@override
Attributes? get attributes {
return _attributes;
}
@override
bool operator ==(Object other) {
if (other is! TextRetain) {
return false;
}
return _length == other.length && mapEquals(_attributes, other._attributes);
}
@override
int get hashCode {
final attrs = _attributes;
return Object.hash(_length, attrs == null ? null : hashAttributes(attrs));
}
Attributes? get attributes => _attributes != null ? {..._attributes!} : null;
@override
Map<String, dynamic> toJson() {
final result = <String, dynamic>{
'retain': _length,
'retain': length,
};
final attrs = _attributes;
if (attrs != null) {
result['attributes'] = {...attrs};
if (_attributes != null) {
result['attributes'] = attributes;
}
return result;
}
}
class TextDelete extends TextOperation {
int _length;
TextDelete(int length) : _length = length;
@override
int get length {
return _length;
}
set length(int v) {
_length = v;
}
@override
bool operator ==(Object other) {
if (other is! TextDelete) {
return false;
}
return _length == other.length;
if (identical(this, other)) return true;
return other is TextRetain &&
other.length == length &&
mapEquals(_attributes, other._attributes);
}
@override
int get hashCode {
return _length.hashCode;
}
int get hashCode => length.hashCode ^ _attributes.hashCode;
}
class TextDelete extends TextOperation {
TextDelete({
required this.length,
});
@override
int length;
@override
Attributes? get attributes => null;
@override
Map<String, dynamic> toJson() {
return {
'delete': _length,
'delete': length,
};
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is TextDelete && other.length == length;
}
@override
int get hashCode => length.hashCode;
}
/// Deltas are a simple, yet expressive format that can be used to describe contents and changes.
/// The format is JSON based, and is human readable, yet easily parsible by machines.
/// Deltas can describe any rich text document, includes all text and formatting information, without the ambiguity and complexity of HTML.
///
/// Basically borrowed from: https://github.com/quilljs/delta
class Delta extends Iterable<TextOperation> {
Delta({
List<TextOperation>? operations,
}) : _operations = operations ?? <TextOperation>[];
factory Delta.fromJson(List<dynamic> list) {
final operations = <TextOperation>[];
for (final value in list) {
if (value is Map<String, dynamic>) {
final op = _textOperationFromJson(value);
if (op != null) {
operations.add(op);
}
}
}
return Delta(operations: operations);
}
final List<TextOperation> _operations;
String? _plainText;
List<int>? _runeIndexes;
void addAll(Iterable<TextOperation> textOperations) {
textOperations.forEach(add);
}
void add(TextOperation textOperation) {
if (textOperation.isEmpty) {
return;
}
_plainText = null;
if (_operations.isNotEmpty) {
final lastOp = _operations.last;
if (lastOp is TextDelete && textOperation is TextDelete) {
lastOp.length += textOperation.length;
return;
}
if (mapEquals(lastOp.attributes, textOperation.attributes)) {
if (lastOp is TextInsert && textOperation is TextInsert) {
lastOp.text += textOperation.text;
return;
}
// if there is an delete before the insert
// swap the order
if (lastOp is TextDelete && textOperation is TextInsert) {
_operations.removeLast();
_operations.add(textOperation);
_operations.add(lastOp);
return;
}
if (lastOp is TextRetain && textOperation is TextRetain) {
lastOp.length += textOperation.length;
return;
}
}
}
_operations.add(textOperation);
}
/// The slice() method does not change the original string.
/// The start and end parameters specifies the part of the string to extract.
/// The end position is optional.
Delta slice(int start, [int? end]) {
final result = Delta();
final iterator = _OpIterator(_operations);
int index = 0;
while ((end == null || index < end) && iterator.hasNext) {
TextOperation? nextOp;
if (index < start) {
nextOp = iterator._next(start - index);
} else {
nextOp = iterator._next(end == null ? null : end - index);
result.add(nextOp);
}
index += nextOp.length;
}
return result;
}
/// Insert operations have an `insert` key defined.
/// A String value represents inserting text.
void insert(String text, {Attributes? attributes}) =>
add(TextInsert(text, attributes: attributes));
/// Retain operations have a Number `retain` key defined representing the number of characters to keep (other libraries might use the name keep or skip).
/// An optional `attributes` key can be defined with an Object to describe formatting changes to the character range.
/// A value of `null` in the `attributes` Object represents removal of that key.
///
/// *Note: It is not necessary to retain the last characters of a document as this is implied.*
void retain(int length, {Attributes? attributes}) =>
add(TextRetain(length, attributes: attributes));
/// Delete operations have a Number `delete` key defined representing the number of characters to delete.
void delete(int length) => add(TextDelete(length: length));
/// The length of the string fo the [Delta].
@override
int get length {
return _operations.fold(
0, (previousValue, element) => previousValue + element.length);
}
/// Returns a Delta that is equivalent to applying the operations of own Delta, followed by another Delta.
Delta compose(Delta other) {
final thisIter = _OpIterator(_operations);
final otherIter = _OpIterator(other._operations);
final operations = <TextOperation>[];
final firstOther = otherIter.peek();
if (firstOther != null &&
firstOther is TextRetain &&
firstOther.attributes == null) {
int firstLeft = firstOther.length;
while (
thisIter.peek() is TextInsert && thisIter.peekLength() <= firstLeft) {
firstLeft -= thisIter.peekLength();
final next = thisIter._next();
operations.add(next);
}
if (firstOther.length - firstLeft > 0) {
otherIter._next(firstOther.length - firstLeft);
}
}
final delta = Delta(operations: operations);
while (thisIter.hasNext || otherIter.hasNext) {
if (otherIter.peek() is TextInsert) {
final next = otherIter._next();
delta.add(next);
} else if (thisIter.peek() is TextDelete) {
final next = thisIter._next();
delta.add(next);
} else {
// otherIs
final length = min(thisIter.peekLength(), otherIter.peekLength());
final thisOp = thisIter._next(length);
final otherOp = otherIter._next(length);
final attributes = composeAttributes(
thisOp.attributes,
otherOp.attributes,
keepNull: thisOp is TextRetain,
);
if (otherOp is TextRetain && otherOp.length > 0) {
TextOperation? newOp;
if (thisOp is TextRetain) {
newOp = TextRetain(length, attributes: attributes);
} else if (thisOp is TextInsert) {
newOp = TextInsert(thisOp.text, attributes: attributes);
}
if (newOp != null) {
delta.add(newOp);
}
// Optimization if rest of other is just retain
if (!otherIter.hasNext &&
delta._operations.isNotEmpty &&
delta._operations.last == newOp) {
final rest = Delta(operations: thisIter.rest());
return (delta + rest)..chop();
}
} else if (otherOp is TextDelete && (thisOp is TextRetain)) {
delta.add(otherOp);
}
}
}
return delta..chop();
}
/// This method joins two Delta together.
Delta operator +(Delta other) {
var operations = [..._operations];
if (other._operations.isNotEmpty) {
operations.add(other._operations[0]);
operations.addAll(other._operations.sublist(1));
}
return Delta(operations: operations);
}
void chop() {
if (_operations.isEmpty) {
return;
}
_plainText = null;
final lastOp = _operations.last;
if (lastOp is TextRetain && (lastOp.attributes?.length ?? 0) == 0) {
_operations.removeLast();
}
}
@override
bool operator ==(Object other) {
if (other is! Delta) {
return false;
}
return listEquals(_operations, other._operations);
}
@override
int get hashCode {
return Object.hashAll(_operations);
}
/// Returned an inverted delta that has the opposite effect of against a base document delta.
Delta invert(Delta base) {
final inverted = Delta();
_operations.fold(0, (int previousValue, op) {
if (op is TextInsert) {
inverted.delete(op.length);
} else if (op is TextRetain && op.attributes == null) {
inverted.retain(op.length);
return previousValue + op.length;
} else if (op is TextDelete || op is TextRetain) {
final length = op.length;
final slice = base.slice(previousValue, previousValue + length);
for (final baseOp in slice._operations) {
if (op is TextDelete) {
inverted.add(baseOp);
} else if (op is TextRetain && op.attributes != null) {
inverted.retain(
baseOp.length,
attributes: invertAttributes(baseOp.attributes, op.attributes),
);
}
}
return previousValue + length;
}
return previousValue;
});
return inverted..chop();
}
List<dynamic> toJson() {
return _operations.map((e) => e.toJson()).toList();
}
/// This method will return the position of the previous rune.
///
/// Since the encoding of the [String] in Dart is UTF-16.
/// If you want to find the previous character of a position,
/// you can' just use the `position - 1` simply.
///
/// This method can help you to compute the position of the previous character.
int prevRunePosition(int pos) {
if (pos == 0) {
return pos - 1;
}
_plainText ??=
_operations.whereType<TextInsert>().map((op) => op.text).join();
_runeIndexes ??= stringIndexes(_plainText!);
return _runeIndexes![pos - 1];
}
/// This method will return the position of the next rune.
///
/// Since the encoding of the [String] in Dart is UTF-16.
/// If you want to find the previous character of a position,
/// you can' just use the `position + 1` simply.
///
/// This method can help you to compute the position of the next character.
int nextRunePosition(int pos) {
final stringContent = toPlainText();
if (pos >= stringContent.length - 1) {
return stringContent.length;
}
_runeIndexes ??= stringIndexes(_plainText!);
for (var i = pos + 1; i < _runeIndexes!.length; i++) {
if (_runeIndexes![i] != pos) {
return _runeIndexes![i];
}
}
return stringContent.length;
}
String toPlainText() {
_plainText ??=
_operations.whereType<TextInsert>().map((op) => op.text).join();
return _plainText!;
}
@override
Iterator<TextOperation> get iterator => _operations.iterator;
static TextOperation? _textOperationFromJson(Map<String, dynamic> json) {
TextOperation? operation;
if (json['insert'] is String) {
final attributes = json['attributes'] as Map<String, dynamic>?;
operation = TextInsert(
json['insert'] as String,
attributes: attributes != null ? {...attributes} : null,
);
} else if (json['retain'] is int) {
final attrs = json['attributes'] as Map<String, dynamic>?;
operation = TextRetain(
json['retain'] as int,
attributes: attrs != null ? {...attrs} : null,
);
} else if (json['delete'] is int) {
operation = TextDelete(length: json['delete'] as int);
}
return operation;
}
}
class _OpIterator {
_OpIterator(
Iterable<TextOperation> operations,
) : _operations = UnmodifiableListView(operations);
final UnmodifiableListView<TextOperation> _operations;
int _index = 0;
int _offset = 0;
_OpIterator(List<TextOperation> operations)
: _operations = UnmodifiableListView(operations);
bool get hasNext {
return peekLength() < _maxInt;
}
@ -199,20 +506,17 @@ class _OpIterator {
_offset += length;
}
if (nextOp is TextDelete) {
return TextDelete(length);
return TextDelete(length: length);
}
if (nextOp is TextRetain) {
return TextRetain(
length,
nextOp.attributes,
);
return TextRetain(length, attributes: nextOp.attributes);
}
if (nextOp is TextInsert) {
return TextInsert(
nextOp.content.substring(offset, offset + length),
nextOp.attributes,
nextOp.text.substring(offset, offset + length),
attributes: nextOp.attributes,
);
}
@ -235,325 +539,3 @@ class _OpIterator {
}
}
}
TextOperation? _textOperationFromJson(Map<String, dynamic> json) {
TextOperation? result;
if (json['insert'] is String) {
final attrs = json['attributes'] as Map<String, dynamic>?;
result =
TextInsert(json['insert'] as String, attrs == null ? null : {...attrs});
} else if (json['retain'] is int) {
final attrs = json['attributes'] as Map<String, dynamic>?;
result =
TextRetain(json['retain'] as int, attrs == null ? null : {...attrs});
} else if (json['delete'] is int) {
result = TextDelete(json['delete'] as int);
}
return result;
}
/// Deltas are a simple, yet expressive format that can be used to describe contents and changes.
/// The format is JSON based, and is human readable, yet easily parsible by machines.
/// Deltas can describe any rich text document, includes all text and formatting information, without the ambiguity and complexity of HTML.
///
/// Basically borrowed from: https://github.com/quilljs/delta
class Delta extends Iterable<TextOperation> {
final List<TextOperation> _operations;
String? _rawString;
List<int>? _runeIndexes;
factory Delta.fromJson(List<dynamic> list) {
final operations = <TextOperation>[];
for (final obj in list) {
final op = _textOperationFromJson(obj as Map<String, dynamic>);
if (op != null) {
operations.add(op);
}
}
return Delta(operations);
}
Delta([List<TextOperation>? ops]) : _operations = ops ?? <TextOperation>[];
void addAll(Iterable<TextOperation> textOps) {
textOps.forEach(add);
}
void add(TextOperation textOp) {
if (textOp.isEmpty) {
return;
}
_rawString = null;
if (_operations.isNotEmpty) {
final lastOp = _operations.last;
if (lastOp is TextDelete && textOp is TextDelete) {
lastOp.length += textOp.length;
return;
}
if (mapEquals(lastOp.attributes, textOp.attributes)) {
if (lastOp is TextInsert && textOp is TextInsert) {
lastOp.content += textOp.content;
return;
}
// if there is an delete before the insert
// swap the order
if (lastOp is TextDelete && textOp is TextInsert) {
_operations.removeLast();
_operations.add(textOp);
_operations.add(lastOp);
return;
}
if (lastOp is TextRetain && textOp is TextRetain) {
lastOp.length += textOp.length;
return;
}
}
}
_operations.add(textOp);
}
/// The slice() method does not change the original string.
/// The start and end parameters specifies the part of the string to extract.
/// The end position is optional.
Delta slice(int start, [int? end]) {
final result = Delta();
final iterator = _OpIterator(_operations);
int index = 0;
while ((end == null || index < end) && iterator.hasNext) {
TextOperation? nextOp;
if (index < start) {
nextOp = iterator._next(start - index);
} else {
nextOp = iterator._next(end == null ? null : end - index);
result.add(nextOp);
}
index += nextOp.length;
}
return result;
}
/// Insert operations have an `insert` key defined.
/// A String value represents inserting text.
void insert(String content, [Attributes? attributes]) =>
add(TextInsert(content, attributes));
/// Retain operations have a Number `retain` key defined representing the number of characters to keep (other libraries might use the name keep or skip).
/// An optional `attributes` key can be defined with an Object to describe formatting changes to the character range.
/// A value of `null` in the `attributes` Object represents removal of that key.
///
/// *Note: It is not necessary to retain the last characters of a document as this is implied.*
void retain(int length, [Attributes? attributes]) =>
add(TextRetain(length, attributes));
/// Delete operations have a Number `delete` key defined representing the number of characters to delete.
void delete(int length) => add(TextDelete(length));
/// The length of the string fo the [Delta].
@override
int get length {
return _operations.fold(
0, (previousValue, element) => previousValue + element.length);
}
/// Returns a Delta that is equivalent to applying the operations of own Delta, followed by another Delta.
Delta compose(Delta other) {
final thisIter = _OpIterator(_operations);
final otherIter = _OpIterator(other._operations);
final ops = <TextOperation>[];
final firstOther = otherIter.peek();
if (firstOther != null &&
firstOther is TextRetain &&
firstOther.attributes == null) {
int firstLeft = firstOther.length;
while (
thisIter.peek() is TextInsert && thisIter.peekLength() <= firstLeft) {
firstLeft -= thisIter.peekLength();
final next = thisIter._next();
ops.add(next);
}
if (firstOther.length - firstLeft > 0) {
otherIter._next(firstOther.length - firstLeft);
}
}
final delta = Delta(ops);
while (thisIter.hasNext || otherIter.hasNext) {
if (otherIter.peek() is TextInsert) {
final next = otherIter._next();
delta.add(next);
} else if (thisIter.peek() is TextDelete) {
final next = thisIter._next();
delta.add(next);
} else {
// otherIs
final length = min(thisIter.peekLength(), otherIter.peekLength());
final thisOp = thisIter._next(length);
final otherOp = otherIter._next(length);
final attributes = composeAttributes(
thisOp.attributes, otherOp.attributes, thisOp is TextRetain);
if (otherOp is TextRetain && otherOp.length > 0) {
TextOperation? newOp;
if (thisOp is TextRetain) {
newOp = TextRetain(length, attributes);
} else if (thisOp is TextInsert) {
newOp = TextInsert(thisOp.content, attributes);
}
if (newOp != null) {
delta.add(newOp);
}
// Optimization if rest of other is just retain
if (!otherIter.hasNext &&
delta._operations.isNotEmpty &&
delta._operations.last == newOp) {
final rest = Delta(thisIter.rest());
return (delta + rest)..chop();
}
} else if (otherOp is TextDelete && (thisOp is TextRetain)) {
delta.add(otherOp);
}
}
}
return delta..chop();
}
/// This method joins two Delta together.
Delta operator +(Delta other) {
var ops = [..._operations];
if (other._operations.isNotEmpty) {
ops.add(other._operations[0]);
ops.addAll(other._operations.sublist(1));
}
return Delta(ops);
}
void chop() {
if (_operations.isEmpty) {
return;
}
_rawString = null;
final lastOp = _operations.last;
if (lastOp is TextRetain && (lastOp.attributes?.length ?? 0) == 0) {
_operations.removeLast();
}
}
@override
bool operator ==(Object other) {
if (other is! Delta) {
return false;
}
return listEquals(_operations, other._operations);
}
@override
int get hashCode {
return Object.hashAll(_operations);
}
/// Returned an inverted delta that has the opposite effect of against a base document delta.
Delta invert(Delta base) {
final inverted = Delta();
_operations.fold(0, (int previousValue, op) {
if (op is TextInsert) {
inverted.delete(op.length);
} else if (op is TextRetain && op.attributes == null) {
inverted.retain(op.length);
return previousValue + op.length;
} else if (op is TextDelete || op is TextRetain) {
final length = op.length;
final slice = base.slice(previousValue, previousValue + length);
for (final baseOp in slice._operations) {
if (op is TextDelete) {
inverted.add(baseOp);
} else if (op is TextRetain && op.attributes != null) {
inverted.retain(baseOp.length,
invertAttributes(op.attributes, baseOp.attributes));
}
}
return previousValue + length;
}
return previousValue;
});
return inverted..chop();
}
List<dynamic> toJson() {
return _operations.map((e) => e.toJson()).toList();
}
/// This method will return the position of the previous rune.
///
/// Since the encoding of the [String] in Dart is UTF-16.
/// If you want to find the previous character of a position,
/// you can' just use the `position - 1` simply.
///
/// This method can help you to compute the position of the previous character.
int prevRunePosition(int pos) {
if (pos == 0) {
return pos - 1;
}
_rawString ??=
_operations.whereType<TextInsert>().map((op) => op.content).join();
_runeIndexes ??= stringIndexes(_rawString!);
return _runeIndexes![pos - 1];
}
/// This method will return the position of the next rune.
///
/// Since the encoding of the [String] in Dart is UTF-16.
/// If you want to find the previous character of a position,
/// you can' just use the `position + 1` simply.
///
/// This method can help you to compute the position of the next character.
int nextRunePosition(int pos) {
final stringContent = toRawString();
if (pos >= stringContent.length - 1) {
return stringContent.length;
}
_runeIndexes ??= stringIndexes(_rawString!);
for (var i = pos + 1; i < _runeIndexes!.length; i++) {
if (_runeIndexes![i] != pos) {
return _runeIndexes![i];
}
}
return stringContent.length;
}
String toRawString() {
_rawString ??=
_operations.whereType<TextInsert>().map((op) => op.content).join();
return _rawString!;
}
@override
Iterator<TextOperation> get iterator => _operations.iterator;
}
List<int> stringIndexes(String content) {
final indexes = List<int>.filled(content.length, 0);
final iterator = content.runes.iterator;
while (iterator.moveNext()) {
for (var i = 0; i < iterator.currentSize; i++) {
indexes[iterator.rawIndex + i] = iterator.rawIndex;
}
}
return indexes;
}

View File

@ -1,4 +1,4 @@
import './path.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
class Position {
final Path path;
@ -11,17 +11,18 @@ class Position {
@override
bool operator ==(Object other) {
if (other is! Position) {
return false;
}
return pathEquals(path, other.path) && offset == other.offset;
if (identical(this, other)) return true;
return other is Position &&
other.path.equals(path) &&
other.offset == offset;
}
@override
int get hashCode {
final pathHash = Object.hashAll(path);
return Object.hash(pathHash, offset);
}
int get hashCode => Object.hash(offset, Object.hashAll(path));
@override
String toString() => 'path = $path, offset = $offset';
Position copyWith({Path? path, int? offset}) {
return Position(
@ -30,13 +31,10 @@ class Position {
);
}
@override
String toString() => 'path = $path, offset = $offset';
Map<String, dynamic> toJson() {
return {
"path": path.toList(),
"offset": offset,
'path': path,
'offset': offset,
};
}
}

View File

@ -1,6 +1,5 @@
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/position.dart';
import 'package:appflowy_editor/src/extensions/path_extensions.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/location/position.dart';
/// Selection represents the selected area or the cursor area in the editor.
///
@ -37,31 +36,58 @@ class Selection {
final Position start;
final Position end;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Selection && other.start == start && other.end == end;
}
@override
int get hashCode => start.hashCode ^ end.hashCode;
@override
String toString() => 'start = $start, end = $end';
/// Returns a Boolean indicating whether the selection's start and end points
/// are at the same position.
bool get isCollapsed => start == end;
bool get isSingle => pathEquals(start.path, end.path);
/// Returns a Boolean indicating whether the selection's start and end points
/// are at the same path.
bool get isSingle => start.path.equals(end.path);
/// Returns a Boolean indicating whether the selection is forward.
bool get isForward =>
(start.path > end.path) || (isSingle && start.offset > end.offset);
/// Returns a Boolean indicating whether the selection is backward.
bool get isBackward =>
(start.path < end.path) || (isSingle && start.offset < end.offset);
Selection get normalize {
if (isForward) {
return reversed;
}
return this;
}
/// Returns a normalized selection that direction is forward.
Selection get normalized => isBackward ? copyWith() : reversed.copyWith();
/// Returns a reversed selection.
Selection get reversed => copyWith(start: end, end: start);
int get startIndex => normalize.start.offset;
int get endIndex => normalize.end.offset;
/// Returns the offset in the starting position under the normalized selection.
int get startIndex => normalized.start.offset;
/// Returns the offset in the ending position under the normalized selection.
int get endIndex => normalized.end.offset;
int get length => endIndex - startIndex;
/// Collapses the current selection to a single point.
///
/// If [atStart] is true, the selection will be collapsed to the start point.
/// If [atStart] is false, the selection will be collapsed to the end point.
Selection collapse({bool atStart = false}) {
if (atStart) {
return Selection(start: start, end: start);
return copyWith(end: start);
} else {
return Selection(start: end, end: end);
return copyWith(start: end);
}
}
@ -72,29 +98,10 @@ class Selection {
);
}
Selection copy() => Selection(start: start, end: end);
Map<String, dynamic> toJson() {
return {
'start': start.toJson(),
'end': end.toJson(),
};
}
@override
bool operator ==(Object other) {
if (other is! Selection) {
return false;
}
if (identical(this, other)) {
return true;
}
return start == other.start && end == other.end;
}
@override
int get hashCode => Object.hash(start, end);
@override
String toString() => '[Selection] start = $start, end = $end';
}

View File

@ -0,0 +1,273 @@
import 'package:flutter/foundation.dart';
import 'package:appflowy_editor/src/core/document/attributes.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/document/text_delta.dart';
/// [Operation] represents a change to a [Document].
abstract class Operation {
Operation(
this.path,
);
factory Operation.fromJson() => throw UnimplementedError();
final Path path;
/// Inverts the operation.
///
/// Returns the inverted operation.
Operation invert();
/// Returns the JSON representation of the operation.
Map<String, dynamic> toJson();
Operation copyWith({Path? path});
}
/// [InsertOperation] represents an insert operation.
class InsertOperation extends Operation {
InsertOperation(
super.path,
this.nodes,
);
factory InsertOperation.fromJson(Map<String, dynamic> json) {
final path = json['path'] as Path;
final nodes = (json['nodes'] as List)
.map((n) => Node.fromJson(n))
.toList(growable: false);
return InsertOperation(path, nodes);
}
final Iterable<Node> nodes;
@override
Operation invert() => DeleteOperation(path, nodes);
@override
Map<String, dynamic> toJson() {
return {
'op': 'insert',
'path': path,
'nodes': nodes.map((n) => n.toJson()).toList(growable: false),
};
}
@override
Operation copyWith({Path? path}) {
return InsertOperation(path ?? this.path, nodes);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is InsertOperation &&
other.path.equals(path) &&
other.nodes.equals(nodes);
}
@override
int get hashCode => path.hashCode ^ Object.hashAll(nodes);
}
/// [DeleteOperation] represents a delete operation.
class DeleteOperation extends Operation {
DeleteOperation(
super.path,
this.nodes,
);
factory DeleteOperation.fromJson(Map<String, dynamic> json) {
final path = json['path'] as Path;
final nodes = (json['nodes'] as List)
.map((n) => Node.fromJson(n))
.toList(growable: false);
return DeleteOperation(path, nodes);
}
final Iterable<Node> nodes;
@override
Operation invert() => InsertOperation(path, nodes);
@override
Map<String, dynamic> toJson() {
return {
'op': 'delete',
'path': path,
'nodes': nodes.map((n) => n.toJson()).toList(growable: false),
};
}
@override
Operation copyWith({Path? path}) {
return DeleteOperation(path ?? this.path, nodes);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is DeleteOperation &&
other.path.equals(path) &&
other.nodes.equals(nodes);
}
@override
int get hashCode => path.hashCode ^ Object.hashAll(nodes);
}
/// [UpdateOperation] represents an attributes update operation.
class UpdateOperation extends Operation {
UpdateOperation(
super.path,
this.attributes,
this.oldAttributes,
);
factory UpdateOperation.fromJson(Map<String, dynamic> json) {
final path = json['path'] as Path;
final oldAttributes = json['oldAttributes'] as Attributes;
final attributes = json['attributes'] as Attributes;
return UpdateOperation(
path,
attributes,
oldAttributes,
);
}
final Attributes attributes;
final Attributes oldAttributes;
@override
Operation invert() => UpdateOperation(
path,
oldAttributes,
attributes,
);
@override
Map<String, dynamic> toJson() {
return {
'op': 'update',
'path': path,
'attributes': {...attributes},
'oldAttributes': {...oldAttributes},
};
}
@override
Operation copyWith({Path? path}) {
return UpdateOperation(
path ?? this.path,
{...attributes},
{...oldAttributes},
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UpdateOperation &&
other.path.equals(path) &&
mapEquals(other.attributes, attributes) &&
mapEquals(other.oldAttributes, oldAttributes);
}
@override
int get hashCode =>
path.hashCode ^ attributes.hashCode ^ oldAttributes.hashCode;
}
/// [UpdateTextOperation] represents a text update operation.
class UpdateTextOperation extends Operation {
UpdateTextOperation(
super.path,
this.delta,
this.inverted,
);
factory UpdateTextOperation.fromJson(Map<String, dynamic> json) {
final path = json['path'] as Path;
final delta = Delta.fromJson(json['delta']);
final inverted = Delta.fromJson(json['inverted']);
return UpdateTextOperation(path, delta, inverted);
}
final Delta delta;
final Delta inverted;
@override
Operation invert() => UpdateTextOperation(path, inverted, delta);
@override
Map<String, dynamic> toJson() {
return {
'op': 'update_text',
'path': path,
'delta': delta.toJson(),
'inverted': inverted.toJson(),
};
}
@override
Operation copyWith({Path? path}) {
return UpdateTextOperation(path ?? this.path, delta, inverted);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UpdateTextOperation &&
other.path.equals(path) &&
other.delta == delta &&
other.inverted == inverted;
}
@override
int get hashCode => delta.hashCode ^ inverted.hashCode;
}
// TODO(Lucas.Xu): refactor this part
Path transformPath(Path preInsertPath, Path b, [int delta = 1]) {
if (preInsertPath.length > b.length) {
return b;
}
if (preInsertPath.isEmpty || b.isEmpty) {
return b;
}
// check the prefix
for (var i = 0; i < preInsertPath.length - 1; i++) {
if (preInsertPath[i] != b[i]) {
return b;
}
}
final prefix = preInsertPath.sublist(0, preInsertPath.length - 1);
final suffix = b.sublist(preInsertPath.length);
final preInsertLast = preInsertPath.last;
final bAtIndex = b[preInsertPath.length - 1];
if (preInsertLast <= bAtIndex) {
prefix.add(bAtIndex + delta);
} else {
prefix.add(bAtIndex);
}
prefix.addAll(suffix);
return prefix;
}
Operation transformOperation(Operation a, Operation b) {
if (a is InsertOperation) {
final newPath = transformPath(a.path, b.path, a.nodes.length);
return b.copyWith(path: newPath);
} else if (a is DeleteOperation) {
final newPath = transformPath(a.path, b.path, -1 * a.nodes.length);
return b.copyWith(path: newPath);
}
// TODO: transform update and textedit
return b;
}

View File

@ -0,0 +1,267 @@
import 'dart:math';
import 'package:appflowy_editor/src/core/document/attributes.dart';
import 'package:appflowy_editor/src/core/document/document.dart';
import 'package:appflowy_editor/src/core/document/node.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/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/core/transform/operation.dart';
/// A [Transaction] has a list of [Operation] objects that will be applied
/// to the editor.
///
/// There will be several ways to consume the transaction:
/// 1. Apply to the state to update the UI.
/// 2. Send to the backend to store and do operation transforming.
class Transaction {
Transaction({
required this.document,
});
final Document document;
/// The operations to be applied.
final List<Operation> operations = [];
/// The selection to be applied.
Selection? afterSelection;
/// The before selection is to be recovered if needed.
Selection? beforeSelection;
/// Inserts the [Node] at the given [Path].
void insertNode(
Path path,
Node node, {
bool deepCopy = true,
}) {
insertNodes(path, [node], deepCopy: deepCopy);
}
/// Inserts a sequence of [Node]s at the given [Path].
void insertNodes(
Path path,
Iterable<Node> nodes, {
bool deepCopy = true,
}) {
if (deepCopy) {
add(InsertOperation(path, nodes.map((e) => e.copyWith())));
} else {
add(InsertOperation(path, nodes));
}
}
/// Updates the attributes of the [Node].
///
/// The [attributes] will be merged into the existing attributes.
void updateNode(Node node, Attributes attributes) {
final inverted = invertAttributes(node.attributes, attributes);
add(UpdateOperation(
node.path,
{...attributes},
inverted,
));
}
/// Deletes the [Node] in the document.
void deleteNode(Node node) {
deleteNodesAtPath(node.path);
}
/// Deletes the [Node]s in the document.
void deleteNodes(Iterable<Node> nodes) {
nodes.forEach(deleteNode);
}
/// Deletes the [Node]s at the given [Path].
///
/// The [length] indicates the number of consecutive deletions,
/// including the node of the current path.
void deleteNodesAtPath(Path path, [int length = 1]) {
if (path.isEmpty) return;
final nodes = <Node>[];
final parent = path.parent;
for (var i = 0; i < length; i++) {
final node = document.nodeAtPath(parent + [path.last + i]);
if (node == null) {
break;
}
nodes.add(node);
}
add(DeleteOperation(path, nodes));
}
/// Update the [TextNode]s with the given [Delta].
void updateText(TextNode textNode, Delta delta) {
final inverted = delta.invert(textNode.delta);
add(UpdateTextOperation(textNode.path, delta, inverted));
}
/// Returns the JSON representation of the transaction.
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (operations.isNotEmpty) {
json['operations'] = operations.map((o) => o.toJson());
}
if (afterSelection != null) {
json['after_selection'] = afterSelection!.toJson();
}
if (beforeSelection != null) {
json['before_selection'] = beforeSelection!.toJson();
}
return json;
}
/// Adds an operation to the transaction.
/// This method will merge operations if they are both TextEdits.
///
/// Also, this method will transform the path of the operations
/// to avoid conflicts.
add(Operation op, {bool transform = true}) {
final Operation? last = operations.isEmpty ? null : operations.last;
if (last != null) {
if (op is UpdateTextOperation &&
last is UpdateTextOperation &&
op.path.equals(last.path)) {
final newOp = UpdateTextOperation(
op.path,
last.delta.compose(op.delta),
op.inverted.compose(last.inverted),
);
operations[operations.length - 1] = newOp;
return;
}
}
if (transform) {
for (var i = 0; i < operations.length; i++) {
op = transformOperation(operations[i], op);
}
}
if (op is UpdateTextOperation && op.delta.isEmpty) {
return;
}
operations.add(op);
}
}
extension TextTransaction on Transaction {
void mergeText(
TextNode first,
TextNode second, {
int? firstOffset,
int secondOffset = 0,
}) {
final firstLength = first.delta.length;
final secondLength = second.delta.length;
firstOffset ??= firstLength;
updateText(
first,
Delta()
..retain(firstOffset)
..delete(firstLength - firstOffset)
..addAll(second.delta.slice(secondOffset, secondLength)),
);
afterSelection = Selection.collapsed(Position(
path: first.path,
offset: firstOffset,
));
}
/// Inserts the text content at a specified index.
///
/// Optionally, you may specify formatting attributes that are applied to the inserted string.
/// By default, the formatting attributes before the insert position will be reused.
void insertText(
TextNode textNode,
int index,
String text, {
Attributes? attributes,
}) {
var newAttributes = attributes;
if (index != 0 && attributes == null) {
newAttributes =
textNode.delta.slice(max(index - 1, 0), index).first.attributes;
if (newAttributes != null) {
newAttributes = {...newAttributes}; // make a copy
}
}
updateText(
textNode,
Delta()
..retain(index)
..insert(text, attributes: newAttributes),
);
afterSelection = Selection.collapsed(
Position(path: textNode.path, offset: index + text.length),
);
}
/// Assigns a formatting attributes to a range of text.
formatText(
TextNode textNode,
int index,
int length,
Attributes attributes,
) {
afterSelection = beforeSelection;
updateText(
textNode,
Delta()
..retain(index)
..retain(length, attributes: attributes),
);
}
/// Deletes the text of specified length starting at index.
deleteText(
TextNode textNode,
int index,
int length,
) {
updateText(
textNode,
Delta()
..retain(index)
..delete(length),
);
afterSelection = Selection.collapsed(
Position(path: textNode.path, offset: index),
);
}
/// Replaces the text of specified length starting at index.
///
/// Optionally, you may specify formatting attributes that are applied to the inserted string.
/// By default, the formatting attributes before the insert position will be reused.
replaceText(
TextNode textNode,
int index,
int length,
String text, {
Attributes? attributes,
}) {
var newAttributes = attributes;
if (index != 0 && attributes == null) {
newAttributes =
textNode.delta.slice(max(index - 1, 0), index).first.attributes;
if (newAttributes != null) {
newAttributes = {...newAttributes}; // make a copy
}
}
updateText(
textNode,
Delta()
..retain(index)
..delete(length)
..insert(text, attributes: newAttributes),
);
afterSelection = Selection.collapsed(
Position(
path: textNode.path,
offset: index + text.length,
),
);
}
}

View File

@ -1,42 +0,0 @@
typedef Attributes = Map<String, dynamic>;
int hashAttributes(Attributes attributes) {
return Object.hashAllUnordered(
attributes.entries.map((e) => Object.hash(e.key, e.value)));
}
Attributes invertAttributes(Attributes? attr, Attributes? base) {
attr ??= {};
base ??= {};
final Attributes baseInverted = base.keys.fold({}, (memo, key) {
if (base![key] != attr![key] && attr.containsKey(key)) {
memo[key] = base[key];
}
return memo;
});
return attr.keys.fold(baseInverted, (memo, key) {
if (attr![key] != base![key] && !base.containsKey(key)) {
memo[key] = null;
}
return memo;
});
}
Attributes? composeAttributes(Attributes? a, Attributes? b,
[bool keepNull = false]) {
a ??= {};
b ??= {};
Attributes attributes = {...b};
if (!keepNull) {
attributes = Map.from(attributes)..removeWhere((_, value) => value == null);
}
for (final entry in a.entries) {
if (!b.containsKey(entry.key)) {
attributes[entry.key] = entry.value;
}
}
return attributes.isNotEmpty ? attributes : null;
}

View File

@ -1,7 +0,0 @@
import 'package:flutter/foundation.dart';
typedef Path = List<int>;
bool pathEquals(Path path1, Path path2) {
return listEquals(path1, path2);
}

View File

@ -1,116 +0,0 @@
import 'dart:math';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/text_delta.dart';
import './attributes.dart';
class StateTree {
final Node root;
StateTree({
required this.root,
});
factory StateTree.empty() {
return StateTree(
root: Node.fromJson({
'type': 'editor',
'children': [
{
'type': 'text',
}
]
}),
);
}
factory StateTree.fromJson(Attributes json) {
assert(json['document'] is Map);
final document = Map<String, Object>.from(json['document'] as Map);
final root = Node.fromJson(document);
return StateTree(root: root);
}
Map<String, Object> toJson() {
return {
'document': root.toJson(),
};
}
Node? nodeAtPath(Path path) {
return root.childAtPath(path);
}
bool insert(Path path, List<Node> nodes) {
if (path.isEmpty) {
return false;
}
Node? insertedNode = root.childAtPath(
path.sublist(0, path.length - 1) + [max(0, path.last - 1)],
);
if (insertedNode == null) {
final insertedNode = root.childAtPath(
path.sublist(0, path.length - 1),
);
if (insertedNode != null) {
for (final node in nodes) {
insertedNode.insert(node);
}
return true;
}
return false;
}
if (path.last <= 0) {
for (var i = 0; i < nodes.length; i++) {
final node = nodes[i];
insertedNode.insertBefore(node);
}
} else {
for (var i = 0; i < nodes.length; i++) {
final node = nodes[i];
insertedNode!.insertAfter(node);
insertedNode = node;
}
}
return true;
}
bool textEdit(Path path, Delta delta) {
if (path.isEmpty) {
return false;
}
final node = root.childAtPath(path);
if (node == null || node is! TextNode) {
return false;
}
node.delta = node.delta.compose(delta);
return false;
}
delete(Path path, [int length = 1]) {
if (path.isEmpty) {
return null;
}
var deletedNode = root.childAtPath(path);
while (deletedNode != null && length > 0) {
final next = deletedNode.next;
deletedNode.unlink();
length--;
deletedNode = next;
}
}
bool update(Path path, Attributes attributes) {
if (path.isEmpty) {
return false;
}
final updatedNode = root.childAtPath(path);
if (updatedNode == null) {
return false;
}
updatedNode.updateAttributes(attributes);
return true;
}
}

View File

@ -5,10 +5,10 @@ import 'package:appflowy_editor/src/render/style/editor_style.dart';
import 'package:appflowy_editor/src/service/service.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/document/state_tree.dart';
import 'package:appflowy_editor/src/operation/operation.dart';
import 'package:appflowy_editor/src/operation/transaction.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/core/document/document.dart';
import 'package:appflowy_editor/src/core/transform/operation.dart';
import 'package:appflowy_editor/src/core/transform/transaction.dart';
import 'package:appflowy_editor/src/undo_manager.dart';
class ApplyOptions {
@ -46,7 +46,7 @@ enum CursorUpdateReason {
///
/// Mutating the document with document's API is not recommended.
class EditorState {
final StateTree document;
final Document document;
// Service reference.
final service = FlowyService();
@ -74,6 +74,24 @@ class EditorState {
bool editable = true;
Transaction get transaction {
if (_transaction != null) {
return _transaction!;
}
_transaction = Transaction(document: document);
_transaction!.beforeSelection = _cursorSelection;
return _transaction!;
}
Transaction? _transaction;
void commit() {
if (_transaction != null) {
apply(_transaction!, const ApplyOptions(recordUndo: true));
_transaction = null;
}
}
Selection? get cursorSelection {
return _cursorSelection;
}
@ -105,7 +123,7 @@ class EditorState {
}
factory EditorState.empty() {
return EditorState(document: StateTree.empty());
return EditorState(document: Document.empty());
}
/// Apply the transaction to the state.
@ -166,8 +184,8 @@ class EditorState {
document.update(op.path, op.attributes);
} else if (op is DeleteOperation) {
document.delete(op.path, op.nodes.length);
} else if (op is TextEditOperation) {
document.textEdit(op.path, op.delta);
} else if (op is UpdateTextOperation) {
document.updateText(op.path, op.delta);
}
_observer.add(op);
}

View File

@ -1,5 +1,5 @@
import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
import 'package:appflowy_editor/src/core/document/attributes.dart';
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
import 'package:flutter/material.dart';
extension NodeAttributesExtensions on Attributes {

View File

@ -1,7 +1,7 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/extensions/object_extensions.dart';
import 'package:appflowy_editor/src/extensions/path_extensions.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:flutter/material.dart';

View File

@ -1,9 +1,9 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/position.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/document/text_delta.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/core/document/text_delta.dart';
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
extension TextNodeExtension on TextNode {
T? getAttributeInSelection<T>(Selection selection, String styleKey) {
@ -168,18 +168,17 @@ extension TextNodesExtension on List<TextNode> {
for (var i = 0; i < length; i++) {
final node = this[i];
final Selection newSelection;
if (i == 0 && pathEquals(node.path, selection.start.path)) {
if (i == 0 && node.path.equals(selection.start.path)) {
if (selection.isBackward) {
newSelection = selection.copyWith(
end: Position(path: node.path, offset: node.toRawString().length),
end: Position(path: node.path, offset: node.toPlainText().length),
);
} else {
newSelection = selection.copyWith(
end: Position(path: node.path, offset: 0),
);
}
} else if (i == length - 1 &&
pathEquals(node.path, selection.end.path)) {
} else if (i == length - 1 && node.path.equals(selection.end.path)) {
if (selection.isBackward) {
newSelection = selection.copyWith(
start: Position(path: node.path, offset: 0),
@ -187,13 +186,13 @@ extension TextNodesExtension on List<TextNode> {
} else {
newSelection = selection.copyWith(
start:
Position(path: node.path, offset: node.toRawString().length),
Position(path: node.path, offset: node.toPlainText().length),
);
}
} else {
newSelection = Selection(
start: Position(path: node.path, offset: 0),
end: Position(path: node.path, offset: node.toRawString().length),
end: Position(path: node.path, offset: node.toPlainText().length),
);
}
if (!node.allSatisfyInSelection(newSelection, styleKey, test)) {

View File

@ -1,14 +1,14 @@
import 'dart:collection';
import 'dart:ui';
import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/text_delta.dart';
import 'package:appflowy_editor/src/core/document/attributes.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/text_delta.dart';
import 'package:appflowy_editor/src/extensions/color_extension.dart';
import 'package:flutter/material.dart';
import 'package:html/parser.dart' show parse;
import 'package:html/dom.dart' as html;
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
class HTMLTag {
static const h1 = "h1";
@ -89,7 +89,7 @@ class HTMLToNodesConverter {
}
}
if (delta.isNotEmpty) {
result.add(TextNode(type: "text", delta: delta));
result.add(TextNode(delta: delta));
}
return result;
}
@ -134,7 +134,7 @@ class HTMLToNodesConverter {
final delta = Delta();
delta.insert(element.text);
if (delta.isNotEmpty) {
return [TextNode(type: "text", delta: delta)];
return [TextNode(delta: delta)];
}
}
return [];
@ -218,24 +218,29 @@ class HTMLToNodesConverter {
_handleRichTextElement(Delta delta, html.Element element) {
if (element.localName == HTMLTag.span) {
delta.insert(element.text,
_getDeltaAttributesFromHtmlAttributes(element.attributes));
delta.insert(
element.text,
attributes: _getDeltaAttributesFromHtmlAttributes(element.attributes),
);
} else if (element.localName == HTMLTag.anchor) {
final hyperLink = element.attributes["href"];
Map<String, dynamic>? attributes;
if (hyperLink != null) {
attributes = {"href": hyperLink};
}
delta.insert(element.text, attributes);
delta.insert(element.text, attributes: attributes);
} else if (element.localName == HTMLTag.strong ||
element.localName == HTMLTag.bold) {
delta.insert(element.text, {BuiltInAttributeKey.bold: true});
delta.insert(element.text, attributes: {BuiltInAttributeKey.bold: true});
} else if (element.localName == HTMLTag.underline) {
delta.insert(element.text, {BuiltInAttributeKey.underline: true});
delta.insert(element.text,
attributes: {BuiltInAttributeKey.underline: true});
} else if (element.localName == HTMLTag.italic) {
delta.insert(element.text, {BuiltInAttributeKey.italic: true});
delta
.insert(element.text, attributes: {BuiltInAttributeKey.italic: true});
} else if (element.localName == HTMLTag.del) {
delta.insert(element.text, {BuiltInAttributeKey.strikethrough: true});
delta.insert(element.text,
attributes: {BuiltInAttributeKey.strikethrough: true});
} else {
delta.insert(element.text);
}
@ -271,8 +276,7 @@ class HTMLToNodesConverter {
}
}
final textNode =
TextNode(type: "text", delta: delta, attributes: attributes);
final textNode = TextNode(delta: delta, attributes: attributes);
if (isCheckbox) {
textNode.attributes["subtype"] = BuiltInAttributeKey.checkbox;
textNode.attributes["checkbox"] = checked;
@ -315,7 +319,6 @@ class HTMLToNodesConverter {
final delta = Delta();
delta.insert(element.text);
return TextNode(
type: "text",
attributes: {"subtype": "heading", "heading": headingStyle},
delta: delta);
}
@ -537,22 +540,22 @@ class NodesToHTMLConverter {
if (attributes.length == 1 &&
attributes[BuiltInAttributeKey.bold] == true) {
final strong = html.Element.tag(HTMLTag.strong);
strong.append(html.Text(op.content));
strong.append(html.Text(op.text));
childNodes.add(strong);
} else if (attributes.length == 1 &&
attributes[BuiltInAttributeKey.underline] == true) {
final strong = html.Element.tag(HTMLTag.underline);
strong.append(html.Text(op.content));
strong.append(html.Text(op.text));
childNodes.add(strong);
} else if (attributes.length == 1 &&
attributes[BuiltInAttributeKey.italic] == true) {
final strong = html.Element.tag(HTMLTag.italic);
strong.append(html.Text(op.content));
strong.append(html.Text(op.text));
childNodes.add(strong);
} else if (attributes.length == 1 &&
attributes[BuiltInAttributeKey.strikethrough] == true) {
final strong = html.Element.tag(HTMLTag.del);
strong.append(html.Text(op.content));
strong.append(html.Text(op.text));
childNodes.add(strong);
} else {
final span = html.Element.tag(HTMLTag.span);
@ -560,11 +563,11 @@ class NodesToHTMLConverter {
if (cssString.isNotEmpty) {
span.attributes["style"] = cssString;
}
span.append(html.Text(op.content));
span.append(html.Text(op.text));
childNodes.add(span);
}
} else {
childNodes.add(html.Text(op.content));
childNodes.add(html.Text(op.text));
}
}
}

View File

@ -1,4 +1,4 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
class Infra {
// find the forward nearest text node

View File

@ -1,218 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
abstract class Operation {
factory Operation.fromJson(Map<String, dynamic> map) {
String t = map["op"] as String;
if (t == "insert") {
return InsertOperation.fromJson(map);
} else if (t == "update") {
return UpdateOperation.fromJson(map);
} else if (t == "delete") {
return DeleteOperation.fromJson(map);
} else if (t == "text-edit") {
return TextEditOperation.fromJson(map);
}
throw ArgumentError('unexpected type $t');
}
final Path path;
Operation(this.path);
Operation copyWithPath(Path path);
Operation invert();
Map<String, dynamic> toJson();
}
class InsertOperation extends Operation {
final List<Node> nodes;
factory InsertOperation.fromJson(Map<String, dynamic> map) {
final path = map["path"] as List<int>;
final value =
(map["nodes"] as List<dynamic>).map((n) => Node.fromJson(n)).toList();
return InsertOperation(path, value);
}
InsertOperation(Path path, this.nodes) : super(path);
InsertOperation copyWith({Path? path, List<Node>? nodes}) =>
InsertOperation(path ?? this.path, nodes ?? this.nodes);
@override
Operation copyWithPath(Path path) => copyWith(path: path);
@override
Operation invert() {
return DeleteOperation(
path,
nodes,
);
}
@override
Map<String, dynamic> toJson() {
return {
"op": "insert",
"path": path.toList(),
"nodes": nodes.map((n) => n.toJson()),
};
}
}
class UpdateOperation extends Operation {
final Attributes attributes;
final Attributes oldAttributes;
factory UpdateOperation.fromJson(Map<String, dynamic> map) {
final path = map["path"] as List<int>;
final attributes = map["attributes"] as Map<String, dynamic>;
final oldAttributes = map["oldAttributes"] as Map<String, dynamic>;
return UpdateOperation(path, attributes, oldAttributes);
}
UpdateOperation(
Path path,
this.attributes,
this.oldAttributes,
) : super(path);
UpdateOperation copyWith(
{Path? path, Attributes? attributes, Attributes? oldAttributes}) =>
UpdateOperation(path ?? this.path, attributes ?? this.attributes,
oldAttributes ?? this.oldAttributes);
@override
Operation copyWithPath(Path path) => copyWith(path: path);
@override
Operation invert() {
return UpdateOperation(
path,
oldAttributes,
attributes,
);
}
@override
Map<String, dynamic> toJson() {
return {
"op": "update",
"path": path.toList(),
"attributes": {...attributes},
"oldAttributes": {...oldAttributes},
};
}
}
class DeleteOperation extends Operation {
final List<Node> nodes;
factory DeleteOperation.fromJson(Map<String, dynamic> map) {
final path = map["path"] as List<int>;
final List<Node> nodes =
(map["nodes"] as List<dynamic>).map((e) => Node.fromJson(e)).toList();
return DeleteOperation(path, nodes);
}
DeleteOperation(
Path path,
this.nodes,
) : super(path);
DeleteOperation copyWith({Path? path, List<Node>? nodes}) =>
DeleteOperation(path ?? this.path, nodes ?? this.nodes);
@override
Operation copyWithPath(Path path) => copyWith(path: path);
@override
Operation invert() {
return InsertOperation(path, nodes);
}
@override
Map<String, dynamic> toJson() {
return {
"op": "delete",
"path": path.toList(),
"nodes": nodes.map((n) => n.toJson()),
};
}
}
class TextEditOperation extends Operation {
final Delta delta;
final Delta inverted;
factory TextEditOperation.fromJson(Map<String, dynamic> map) {
final path = map["path"] as List<int>;
final delta = Delta.fromJson(map["delta"]);
final invert = Delta.fromJson(map["invert"]);
return TextEditOperation(path, delta, invert);
}
TextEditOperation(
Path path,
this.delta,
this.inverted,
) : super(path);
TextEditOperation copyWith({Path? path, Delta? delta, Delta? inverted}) =>
TextEditOperation(
path ?? this.path, delta ?? this.delta, inverted ?? this.inverted);
@override
Operation copyWithPath(Path path) => copyWith(path: path);
@override
Operation invert() {
return TextEditOperation(path, inverted, delta);
}
@override
Map<String, dynamic> toJson() {
return {
"op": "text-edit",
"path": path.toList(),
"delta": delta.toJson(),
"invert": inverted.toJson(),
};
}
}
Path transformPath(Path preInsertPath, Path b, [int delta = 1]) {
if (preInsertPath.length > b.length) {
return b;
}
if (preInsertPath.isEmpty || b.isEmpty) {
return b;
}
// check the prefix
for (var i = 0; i < preInsertPath.length - 1; i++) {
if (preInsertPath[i] != b[i]) {
return b;
}
}
final prefix = preInsertPath.sublist(0, preInsertPath.length - 1);
final suffix = b.sublist(preInsertPath.length);
final preInsertLast = preInsertPath.last;
final bAtIndex = b[preInsertPath.length - 1];
if (preInsertLast <= bAtIndex) {
prefix.add(bAtIndex + delta);
} else {
prefix.add(bAtIndex);
}
prefix.addAll(suffix);
return prefix;
}
Operation transformOperation(Operation a, Operation b) {
if (a is InsertOperation) {
final newPath = transformPath(a.path, b.path, a.nodes.length);
return b.copyWithPath(newPath);
} else if (a is DeleteOperation) {
final newPath = transformPath(a.path, b.path, -1 * a.nodes.length);
return b.copyWithPath(newPath);
}
// TODO: transform update and textedit
return b;
}

View File

@ -1,39 +0,0 @@
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import './operation.dart';
/// A [Transaction] has a list of [Operation] objects that will be applied
/// to the editor. It is an immutable class and used to store and transmit.
///
/// If you want to build a new [Transaction], use [TransactionBuilder] directly.
///
/// There will be several ways to consume the transaction:
/// 1. Apply to the state to update the UI.
/// 2. Send to the backend to store and do operation transforming.
/// 3. Used by the UndoManager to implement redo/undo.
@immutable
class Transaction {
final UnmodifiableListView<Operation> operations;
final Selection? beforeSelection;
final Selection? afterSelection;
const Transaction({
required this.operations,
this.beforeSelection,
this.afterSelection,
});
Map<String, dynamic> toJson() {
final Map<String, dynamic> result = {
"operations": operations.map((e) => e.toJson()),
};
if (beforeSelection != null) {
result["beforeSelection"] = beforeSelection!.toJson();
}
if (afterSelection != null) {
result["afterSelection"] = afterSelection!.toJson();
}
return result;
}
}

View File

@ -1,230 +0,0 @@
import 'dart:collection';
import 'dart:math';
import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/position.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/document/text_delta.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/operation/operation.dart';
import 'package:appflowy_editor/src/operation/transaction.dart';
/// A [TransactionBuilder] is used to build the transaction from the state.
/// It will save a snapshot of the cursor selection state automatically.
/// The cursor can be restored if the transaction is undo.
class TransactionBuilder {
final List<Operation> operations = [];
EditorState state;
Selection? beforeSelection;
Selection? afterSelection;
TransactionBuilder(this.state);
/// Commits the operations to the state
Future<void> commit() async {
final transaction = finish();
state.apply(transaction);
}
/// Inserts the nodes at the position of path.
insertNode(Path path, Node node) {
insertNodes(path, [node]);
}
/// Inserts a sequence of nodes at the position of path.
insertNodes(Path path, List<Node> nodes) {
beforeSelection = state.cursorSelection;
add(InsertOperation(path, nodes.map((node) => node.copyWith()).toList()));
}
/// Updates the attributes of nodes.
updateNode(Node node, Attributes attributes) {
beforeSelection = state.cursorSelection;
final inverted = invertAttributes(attributes, node.attributes);
add(UpdateOperation(
node.path,
{...attributes},
inverted,
));
}
/// Deletes a node in the document.
deleteNode(Node node) {
deleteNodesAtPath(node.path);
}
deleteNodes(List<Node> nodes) {
nodes.forEach(deleteNode);
}
/// Deletes a sequence of nodes at the path of the document.
/// The length specifies the length of the following nodes to delete(
/// including the start one).
deleteNodesAtPath(Path path, [int length = 1]) {
if (path.isEmpty) {
return;
}
final nodes = <Node>[];
final prefix = path.sublist(0, path.length - 1);
final last = path.last;
for (var i = 0; i < length; i++) {
final node = state.document.nodeAtPath(prefix + [last + i])!;
nodes.add(node);
}
add(DeleteOperation(path, nodes.map((node) => node.copyWith()).toList()));
}
textEdit(TextNode node, Delta Function() f) {
beforeSelection = state.cursorSelection;
final path = node.path;
final delta = f();
final inverted = delta.invert(node.delta);
add(TextEditOperation(path, delta, inverted));
}
setAfterSelection(Selection sel) {
afterSelection = sel;
}
mergeText(TextNode firstNode, TextNode secondNode,
{int? firstOffset, int secondOffset = 0}) {
final firstLength = firstNode.delta.length;
final secondLength = secondNode.delta.length;
textEdit(
firstNode,
() => Delta()
..retain(firstOffset ?? firstLength)
..delete(firstLength - (firstOffset ?? firstLength))
..addAll(secondNode.delta.slice(secondOffset, secondLength)),
);
afterSelection = Selection.collapsed(
Position(
path: firstNode.path,
offset: firstOffset ?? firstLength,
),
);
}
/// Inserts content at a specified index.
/// Optionally, you may specify formatting attributes that are applied to the inserted string.
/// By default, the formatting attributes before the insert position will be used.
insertText(
TextNode node,
int index,
String content, {
Attributes? attributes,
}) {
var newAttributes = attributes;
if (index != 0 && attributes == null) {
newAttributes =
node.delta.slice(max(index - 1, 0), index).first.attributes;
if (newAttributes != null) {
newAttributes = Attributes.from(newAttributes);
}
}
textEdit(
node,
() => Delta()
..retain(index)
..insert(
content,
newAttributes,
),
);
afterSelection = Selection.collapsed(
Position(path: node.path, offset: index + content.length),
);
}
/// Assigns formatting attributes to a range of text.
formatText(TextNode node, int index, int length, Attributes attributes) {
textEdit(
node,
() => Delta()
..retain(index)
..retain(length, attributes));
afterSelection = beforeSelection;
}
/// Deletes length characters starting from index.
deleteText(TextNode node, int index, int length) {
textEdit(
node,
() => Delta()
..retain(index)
..delete(length));
afterSelection =
Selection.collapsed(Position(path: node.path, offset: index));
}
replaceText(TextNode node, int index, int length, String content,
[Attributes? attributes]) {
var newAttributes = attributes;
if (attributes == null) {
final ops = node.delta.slice(index, index + length);
if (ops.isNotEmpty) {
newAttributes = ops.first.attributes;
}
}
textEdit(
node,
() => Delta()
..retain(index)
..delete(length)
..insert(content, newAttributes),
);
afterSelection = Selection.collapsed(
Position(
path: node.path,
offset: index + content.length,
),
);
}
/// Adds an operation to the transaction.
/// This method will merge operations if they are both TextEdits.
///
/// Also, this method will transform the path of the operations
/// to avoid conflicts.
add(Operation op, {bool transform = true}) {
final Operation? last = operations.isEmpty ? null : operations.last;
if (last != null) {
if (op is TextEditOperation &&
last is TextEditOperation &&
pathEquals(op.path, last.path)) {
final newOp = TextEditOperation(
op.path,
last.delta.compose(op.delta),
op.inverted.compose(last.inverted),
);
operations[operations.length - 1] = newOp;
return;
}
}
if (transform) {
for (var i = 0; i < operations.length; i++) {
op = transformOperation(operations[i], op);
}
}
if (op is TextEditOperation && op.delta.isEmpty) {
return;
}
operations.add(op);
}
/// Generates a immutable [Transaction] to apply or transmit.
Transaction finish() {
return Transaction(
operations: UnmodifiableListView(operations),
beforeSelection: beforeSelection,
afterSelection: afterSelection,
);
}
}

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/service/render_plugin_service.dart';

View File

@ -1,5 +1,4 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
import 'package:flutter/material.dart';
import 'package:rich_clipboard/rich_clipboard.dart';
@ -25,23 +24,20 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
RichClipboard.setData(RichClipboardData(text: src));
},
onDelete: () {
TransactionBuilder(context.editorState)
..deleteNode(context.node)
..commit();
context.editorState.transaction.deleteNode(context.node);
context.editorState.commit();
},
onAlign: (alignment) {
TransactionBuilder(context.editorState)
..updateNode(context.node, {
'align': _alignmentToText(alignment),
})
..commit();
context.editorState.transaction.updateNode(context.node, {
'align': _alignmentToText(alignment),
});
context.editorState.commit();
},
onResize: (width) {
TransactionBuilder(context.editorState)
..updateNode(context.node, {
'width': width,
})
..commit();
context.editorState.transaction.updateNode(context.node, {
'width': width,
});
context.editorState.commit();
},
);
}

View File

@ -1,7 +1,7 @@
import 'package:appflowy_editor/src/extensions/object_extensions.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/position.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:flutter/material.dart';

View File

@ -1,9 +1,8 @@
import 'dart:collection';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
import 'package:flutter/material.dart';
@ -192,11 +191,10 @@ extension on EditorState {
'align': 'center',
},
);
TransactionBuilder(this)
..insertNode(
selection.start.path,
imageNode,
)
..commit();
transaction.insertNode(
selection.start.path,
imageNode,
);
commit();
}
}

View File

@ -1,4 +1,4 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';

View File

@ -1,5 +1,5 @@
import 'package:appflowy_editor/src/document/position.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:flutter/material.dart';

View File

@ -5,11 +5,11 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/position.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/document/text_delta.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/core/document/text_delta.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
@ -123,7 +123,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
@override
List<Rect> getRectsInSelection(Selection selection) {
assert(selection.isSingle &&
pathEquals(selection.start.path, widget.textNode.path));
selection.start.path.equals(widget.textNode.path));
final textSelection = TextSelection(
baseOffset: selection.start.offset,
@ -163,7 +163,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
Widget _buildRichText(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.text,
child: widget.textNode.toRawString().isEmpty
child: widget.textNode.toPlainText().isEmpty
? Stack(
children: [
_buildPlaceholderText(context),
@ -257,7 +257,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
offset += textInsert.length;
textSpans.add(
TextSpan(
text: textInsert.content,
text: textInsert.text,
style: textStyle,
recognizer: recognizer,
),

View File

@ -1,4 +1,4 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';

View File

@ -1,4 +1,4 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';

View File

@ -1,4 +1,4 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';

View File

@ -1,4 +1,4 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';

View File

@ -1,5 +1,5 @@
import 'package:appflowy_editor/src/document/position.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:flutter/material.dart';
enum CursorStyle {

View File

@ -6,7 +6,7 @@ import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
abstract class SelectionMenuService {
Offset get topLeft;

View File

@ -44,14 +44,13 @@ class SelectionMenuItem {
if (selection != null && nodes.length == 1) {
final node = nodes.first as TextNode;
final end = selection.start.offset;
final start = node.toRawString().substring(0, end).lastIndexOf('/');
TransactionBuilder(editorState)
..deleteText(
node,
start,
selection.start.offset - start,
)
..commit();
final start = node.toPlainText().substring(0, end).lastIndexOf('/');
editorState.transaction.deleteText(
node,
start,
selection.start.offset - start,
);
editorState.commit();
}
}
}
@ -278,13 +277,12 @@ class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
final nodes = selectionService.currentSelectedNodes;
if (selection != null && nodes.length == 1) {
widget.onSelectionUpdate();
TransactionBuilder(widget.editorState)
..deleteText(
nodes.first as TextNode,
selection.start.offset - length,
length,
)
..commit();
widget.editorState.transaction.deleteText(
nodes.first as TextNode,
selection.start.offset - length,
length,
);
widget.editorState.commit();
}
}
@ -295,13 +293,12 @@ class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
widget.editorState.service.selectionService.currentSelectedNodes;
if (selection != null && nodes.length == 1) {
widget.onSelectionUpdate();
TransactionBuilder(widget.editorState)
..insertText(
nodes.first as TextNode,
selection.end.offset,
text,
)
..commit();
widget.editorState.transaction.insertText(
nodes.first as TextNode,
selection.end.offset,
text,
);
widget.editorState.commit();
}
}
}

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/extensions/attributes_extension.dart';

View File

@ -357,10 +357,9 @@ void showLinkMenu(
_dismissLinkMenu();
},
onRemoveLink: () {
TransactionBuilder(editorState)
..formatText(
textNode, index, length, {BuiltInAttributeKey.href: null})
..commit();
editorState.transaction.formatText(
textNode, index, length, {BuiltInAttributeKey.href: null});
editorState.commit();
_dismissLinkMenu();
},
onFocusChange: (value) {

View File

@ -1,12 +1,5 @@
import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/position.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
import 'package:appflowy_editor/src/extensions/path_extensions.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
void insertHeadingAfterSelection(EditorState editorState, String heading) {
insertTextNodeAfterSelection(editorState, {
@ -54,16 +47,15 @@ bool insertTextNodeAfterSelection(
formatTextNodes(editorState, attributes);
} else {
final next = selection.end.path.next;
final builder = TransactionBuilder(editorState);
builder
editorState.transaction
..insertNode(
next,
TextNode.empty(attributes: attributes),
)
..afterSelection = Selection.collapsed(
Position(path: next, offset: 0),
)
..commit();
);
editorState.commit();
}
return true;
@ -107,7 +99,7 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
return false;
}
final builder = TransactionBuilder(editorState);
final transaction = editorState.transaction;
for (final textNode in textNodes) {
var newAttributes = {...textNode.attributes};
@ -117,7 +109,7 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
}
}
newAttributes.addAll(attributes);
builder
transaction
..updateNode(
textNode,
newAttributes,
@ -125,12 +117,12 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
..afterSelection = Selection.collapsed(
Position(
path: textNode.path,
offset: textNode.toRawString().length,
offset: textNode.toPlainText().length,
),
);
}
builder.commit();
editorState.commit();
return true;
}
@ -216,13 +208,13 @@ bool formatRichTextStyle(EditorState editorState, Attributes attributes) {
return false;
}
final builder = TransactionBuilder(editorState);
final transaction = editorState.transaction;
// 1. All nodes are text nodes.
// 2. The first node is not TextNode.
// 3. The last node is not TextNode.
if (nodes.length == textNodes.length && textNodes.length == 1) {
builder.formatText(
transaction.formatText(
textNodes.first,
selection.start.offset,
selection.end.offset - selection.start.offset,
@ -232,14 +224,14 @@ bool formatRichTextStyle(EditorState editorState, Attributes attributes) {
for (var i = 0; i < textNodes.length; i++) {
final textNode = textNodes[i];
var index = 0;
var length = textNode.toRawString().length;
var length = textNode.toPlainText().length;
if (i == 0 && textNode == nodes.first) {
index = selection.start.offset;
length = textNode.toRawString().length - selection.start.offset;
length = textNode.toPlainText().length - selection.start.offset;
} else if (i == textNodes.length - 1 && textNode == nodes.last) {
length = selection.end.offset;
}
builder.formatText(
transaction.formatText(
textNode,
index,
length,
@ -248,7 +240,7 @@ bool formatRichTextStyle(EditorState editorState, Attributes attributes) {
}
}
builder.commit();
editorState.commit();
return true;
}

View File

@ -1,13 +1,13 @@
import 'package:appflowy_editor/src/infra/log.dart';
import 'package:appflowy_editor/src/core/transform/transaction.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/extensions/node_extensions.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
/// [AppFlowyInputService] is responsible for processing text input,
/// including text insertion, deletion and replacement.
@ -160,13 +160,12 @@ class _AppFlowyInputState extends State<AppFlowyInput>
}
if (currentSelection.isSingle) {
final textNode = selectionService.currentSelectedNodes.first as TextNode;
TransactionBuilder(_editorState)
..insertText(
textNode,
delta.insertionOffset,
delta.textInserted,
)
..commit();
_editorState.transaction.insertText(
textNode,
delta.insertionOffset,
delta.textInserted,
);
_editorState.commit();
} else {
// TODO: implement
}
@ -181,9 +180,9 @@ class _AppFlowyInputState extends State<AppFlowyInput>
if (currentSelection.isSingle) {
final textNode = selectionService.currentSelectedNodes.first as TextNode;
final length = delta.deletedRange.end - delta.deletedRange.start;
TransactionBuilder(_editorState)
..deleteText(textNode, delta.deletedRange.start, length)
..commit();
_editorState.transaction
.deleteText(textNode, delta.deletedRange.start, length);
_editorState.commit();
} else {
// TODO: implement
}
@ -198,10 +197,9 @@ class _AppFlowyInputState extends State<AppFlowyInput>
if (currentSelection.isSingle) {
final textNode = selectionService.currentSelectedNodes.first as TextNode;
final length = delta.replacedRange.end - delta.replacedRange.start;
TransactionBuilder(_editorState)
..replaceText(
textNode, delta.replacedRange.start, length, delta.replacementText)
..commit();
_editorState.transaction.replaceText(
textNode, delta.replacedRange.start, length, delta.replacementText);
_editorState.commit();
} else {
// TODO: implement
}
@ -282,7 +280,7 @@ class _AppFlowyInputState extends State<AppFlowyInput>
// FIXME: upward and selection update.
if (textNodes.isNotEmpty && selection != null) {
final text = textNodes.fold<String>(
'', (sum, textNode) => '$sum${textNode.toRawString()}\n');
'', (sum, textNode) => '$sum${textNode.toPlainText()}\n');
attach(
TextEditingValue(
text: text,

View File

@ -220,7 +220,7 @@ ShortcutEventHandler cursorEndSelect = (editorState, event) {
KeyEventResult cursorUp(EditorState editorState, RawKeyEvent event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection =
editorState.service.selectionService.currentSelection.value?.normalize;
editorState.service.selectionService.currentSelection.value?.normalized;
if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored;
}
@ -234,7 +234,7 @@ KeyEventResult cursorUp(EditorState editorState, RawKeyEvent event) {
KeyEventResult cursorDown(EditorState editorState, RawKeyEvent event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection =
editorState.service.selectionService.currentSelection.value?.normalize;
editorState.service.selectionService.currentSelection.value?.normalized;
if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored;
}
@ -248,7 +248,7 @@ KeyEventResult cursorDown(EditorState editorState, RawKeyEvent event) {
KeyEventResult cursorLeft(EditorState editorState, RawKeyEvent event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection =
editorState.service.selectionService.currentSelection.value?.normalize;
editorState.service.selectionService.currentSelection.value?.normalized;
if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored;
}
@ -270,7 +270,7 @@ KeyEventResult cursorLeft(EditorState editorState, RawKeyEvent event) {
KeyEventResult cursorRight(EditorState editorState, RawKeyEvent event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection =
editorState.service.selectionService.currentSelection.value?.normalize;
editorState.service.selectionService.currentSelection.value?.normalized;
if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored;
}

View File

@ -28,11 +28,11 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
final List<Node> nonTextNodes =
nodes.where((node) => node is! TextNode).toList(growable: false);
final transactionBuilder = TransactionBuilder(editorState);
final transaction = editorState.transaction;
List<int>? cancelNumberListPath;
if (nonTextNodes.isNotEmpty) {
transactionBuilder.deleteNodes(nonTextNodes);
transaction.deleteNodes(nonTextNodes);
}
if (textNodes.length == 1) {
@ -44,7 +44,7 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
if (textNode.subtype == BuiltInAttributeKey.numberList) {
cancelNumberListPath = textNode.path;
}
transactionBuilder
transaction
..updateNode(textNode, {
BuiltInAttributeKey.subtype: null,
textNode.subtype!: null,
@ -61,20 +61,20 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
return _backDeleteToPreviousTextNode(
editorState,
textNode,
transactionBuilder,
transaction,
nonTextNodes,
selection,
);
}
} else {
if (selection.isCollapsed) {
transactionBuilder.deleteText(
transaction.deleteText(
textNode,
index,
selection.start.offset - index,
);
} else {
transactionBuilder.deleteText(
transaction.deleteText(
textNode,
selection.start.offset,
selection.end.offset - selection.start.offset,
@ -84,33 +84,32 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
} else {
if (textNodes.isEmpty) {
if (nonTextNodes.isNotEmpty) {
transactionBuilder.afterSelection =
Selection.collapsed(selection.start);
transaction.afterSelection = Selection.collapsed(selection.start);
}
transactionBuilder.commit();
editorState.commit();
return KeyEventResult.handled;
}
final startPosition = selection.start;
final nodeAtStart = editorState.document.nodeAtPath(startPosition.path)!;
_deleteTextNodes(transactionBuilder, textNodes, selection);
transactionBuilder.commit();
_deleteTextNodes(transaction, textNodes, selection);
editorState.commit();
if (nodeAtStart is TextNode &&
nodeAtStart.subtype == BuiltInAttributeKey.numberList) {
makeFollowingNodesIncremental(
editorState,
startPosition.path,
transactionBuilder.afterSelection!,
transaction.afterSelection!,
);
}
return KeyEventResult.handled;
}
if (transactionBuilder.operations.isNotEmpty) {
if (transaction.operations.isNotEmpty) {
if (nonTextNodes.isNotEmpty) {
transactionBuilder.afterSelection = Selection.collapsed(selection.start);
transaction.afterSelection = Selection.collapsed(selection.start);
}
transactionBuilder.commit();
editorState.commit();
}
if (cancelNumberListPath != null) {
@ -128,20 +127,20 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
KeyEventResult _backDeleteToPreviousTextNode(
EditorState editorState,
TextNode textNode,
TransactionBuilder transactionBuilder,
Transaction transaction,
List<Node> nonTextNodes,
Selection selection,
) {
if (textNode.next == null &&
textNode.children.isEmpty &&
textNode.parent?.parent != null) {
transactionBuilder
transaction
..deleteNode(textNode)
..insertNode(textNode.parent!.path.next, textNode)
..afterSelection = Selection.collapsed(
Position(path: textNode.parent!.path.next, offset: 0),
)
..commit();
);
editorState.commit();
return KeyEventResult.handled;
}
@ -152,32 +151,32 @@ KeyEventResult _backDeleteToPreviousTextNode(
prevIsNumberList = true;
}
transactionBuilder.mergeText(previousTextNode, textNode);
transaction.mergeText(previousTextNode, textNode);
if (textNode.children.isNotEmpty) {
transactionBuilder.insertNodes(
transaction.insertNodes(
previousTextNode.path.next,
textNode.children.toList(growable: false),
);
}
transactionBuilder.deleteNode(textNode);
transactionBuilder.afterSelection = Selection.collapsed(
transaction.deleteNode(textNode);
transaction.afterSelection = Selection.collapsed(
Position(
path: previousTextNode.path,
offset: previousTextNode.toRawString().length,
offset: previousTextNode.toPlainText().length,
),
);
}
if (transactionBuilder.operations.isNotEmpty) {
if (transaction.operations.isNotEmpty) {
if (nonTextNodes.isNotEmpty) {
transactionBuilder.afterSelection = Selection.collapsed(selection.start);
transaction.afterSelection = Selection.collapsed(selection.start);
}
transactionBuilder.commit();
editorState.commit();
}
if (prevIsNumberList) {
makeFollowingNodesIncremental(editorState, previousTextNode!.path,
transactionBuilder.afterSelection!);
makeFollowingNodesIncremental(
editorState, previousTextNode!.path, transaction.afterSelection!);
}
return KeyEventResult.handled;
@ -197,7 +196,7 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
return KeyEventResult.ignored;
}
final transactionBuilder = TransactionBuilder(editorState);
final transaction = editorState.transaction;
if (textNodes.length == 1) {
final textNode = textNodes.first;
// The cursor is at the end of the line,
@ -206,55 +205,52 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
return _mergeNextLineIntoThisLine(
editorState,
textNode,
transactionBuilder,
transaction,
selection,
);
}
final index = textNode.delta.nextRunePosition(selection.start.offset);
if (selection.isCollapsed) {
transactionBuilder.deleteText(
transaction.deleteText(
textNode,
selection.start.offset,
index - selection.start.offset,
);
} else {
transactionBuilder.deleteText(
transaction.deleteText(
textNode,
selection.start.offset,
selection.end.offset - selection.start.offset,
);
}
transactionBuilder.commit();
editorState.commit();
} else {
final startPosition = selection.start;
final nodeAtStart = editorState.document.nodeAtPath(startPosition.path)!;
_deleteTextNodes(transactionBuilder, textNodes, selection);
transactionBuilder.commit();
_deleteTextNodes(transaction, textNodes, selection);
editorState.commit();
if (nodeAtStart is TextNode &&
nodeAtStart.subtype == BuiltInAttributeKey.numberList) {
makeFollowingNodesIncremental(
editorState, startPosition.path, transactionBuilder.afterSelection!);
editorState, startPosition.path, transaction.afterSelection!);
}
}
return KeyEventResult.handled;
}
KeyEventResult _mergeNextLineIntoThisLine(
EditorState editorState,
TextNode textNode,
TransactionBuilder transactionBuilder,
Selection selection) {
KeyEventResult _mergeNextLineIntoThisLine(EditorState editorState,
TextNode textNode, Transaction transaction, Selection selection) {
final nextNode = textNode.next;
if (nextNode == null) {
return KeyEventResult.ignored;
}
if (nextNode is TextNode) {
transactionBuilder.mergeText(textNode, nextNode);
transaction.mergeText(textNode, nextNode);
}
transactionBuilder.deleteNode(nextNode);
transactionBuilder.commit();
transaction.deleteNode(nextNode);
editorState.commit();
if (textNode.subtype == BuiltInAttributeKey.numberList) {
makeFollowingNodesIncremental(editorState, textNode.path, selection);
@ -263,15 +259,15 @@ KeyEventResult _mergeNextLineIntoThisLine(
return KeyEventResult.handled;
}
void _deleteTextNodes(TransactionBuilder transactionBuilder,
List<TextNode> textNodes, Selection selection) {
void _deleteTextNodes(
Transaction transaction, List<TextNode> textNodes, Selection selection) {
final first = textNodes.first;
final last = textNodes.last;
var content = textNodes.last.toRawString();
var content = textNodes.last.toPlainText();
content = content.substring(selection.end.offset, content.length);
// Merge the fist and the last text node content,
// and delete the all nodes expect for the first.
transactionBuilder
transaction
..deleteNodes(textNodes.sublist(1))
..mergeText(
first,

View File

@ -1,6 +1,6 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/infra/html_converter.dart';
import 'package:appflowy_editor/src/document/node_iterator.dart';
import 'package:appflowy_editor/src/core/document/node_iterator.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart';
import 'package:flutter/material.dart';
import 'package:rich_clipboard/rich_clipboard.dart';
@ -25,11 +25,11 @@ Selection _computeSelectionAfterPasteMultipleNodes(
}
void _handleCopy(EditorState editorState) async {
final selection = editorState.cursorSelection?.normalize;
final selection = editorState.cursorSelection?.normalized;
if (selection == null || selection.isCollapsed) {
return;
}
if (pathEquals(selection.start.path, selection.end.path)) {
if (selection.start.path.equals(selection.end.path)) {
final nodeAtPath = editorState.document.nodeAtPath(selection.end.path)!;
if (nodeAtPath.type == "text") {
final textNode = nodeAtPath as TextNode;
@ -49,7 +49,11 @@ void _handleCopy(EditorState editorState) async {
final beginNode = editorState.document.nodeAtPath(selection.start.path)!;
final endNode = editorState.document.nodeAtPath(selection.end.path)!;
final nodes = NodeIterator(editorState.document, beginNode, endNode).toList();
final nodes = NodeIterator(
document: editorState.document,
startNode: beginNode,
endNode: endNode,
).toList();
final copyString = NodesToHTMLConverter(
nodes: nodes,
@ -61,7 +65,7 @@ void _handleCopy(EditorState editorState) async {
}
void _pasteHTML(EditorState editorState, String html) {
final selection = editorState.cursorSelection?.normalize;
final selection = editorState.cursorSelection?.normalized;
if (selection == null) {
return;
}
@ -81,16 +85,16 @@ void _pasteHTML(EditorState editorState, String html) {
} else if (nodes.length == 1) {
final firstNode = nodes[0];
final nodeAtPath = editorState.document.nodeAtPath(path)!;
final tb = TransactionBuilder(editorState);
final tb = editorState.transaction;
final startOffset = selection.start.offset;
if (nodeAtPath.type == "text" && firstNode.type == "text") {
final textNodeAtPath = nodeAtPath as TextNode;
final firstTextNode = firstNode as TextNode;
tb.textEdit(textNodeAtPath,
() => (Delta()..retain(startOffset)) + firstTextNode.delta);
tb.setAfterSelection(Selection.collapsed(Position(
tb.updateText(
textNodeAtPath, (Delta()..retain(startOffset)) + firstTextNode.delta);
tb.afterSelection = (Selection.collapsed(Position(
path: path, offset: startOffset + firstTextNode.delta.length)));
tb.commit();
editorState.commit();
return;
}
}
@ -100,7 +104,7 @@ void _pasteHTML(EditorState editorState, String html) {
void _pasteMultipleLinesInText(
EditorState editorState, List<int> path, int offset, List<Node> nodes) {
final tb = TransactionBuilder(editorState);
final tb = editorState.transaction;
final firstNode = nodes[0];
final nodeAtPath = editorState.document.nodeAtPath(path)!;
@ -116,10 +120,9 @@ void _pasteMultipleLinesInText(
final firstTextNode = firstNode as TextNode;
final remain = textNodeAtPath.delta.slice(offset);
tb.textEdit(
tb.updateText(
textNodeAtPath,
() =>
(Delta()
(Delta()
..retain(offset)
..delete(remain.length)) +
firstTextNode.delta);
@ -136,15 +139,15 @@ void _pasteMultipleLinesInText(
final tailTextNode = tailNodes.last as TextNode;
tailTextNode.delta = tailTextNode.delta + remain;
} else if (remain.isNotEmpty) {
tailNodes.add(TextNode(type: "text", delta: remain));
tailNodes.add(TextNode(delta: remain));
}
} else {
tailNodes.add(TextNode(type: "text", delta: remain));
tailNodes.add(TextNode(delta: remain));
}
tb.setAfterSelection(afterSelection);
tb.afterSelection = afterSelection;
tb.insertNodes(path, tailNodes);
tb.commit();
editorState.commit();
if (startNumber != null) {
makeFollowingNodesIncremental(editorState, originalPath, afterSelection,
@ -157,9 +160,9 @@ void _pasteMultipleLinesInText(
_computeSelectionAfterPasteMultipleNodes(editorState, nodes);
path[path.length - 1]++;
tb.setAfterSelection(afterSelection);
tb.afterSelection = afterSelection;
tb.insertNodes(path, nodes);
tb.commit();
editorState.commit();
}
void _handlePaste(EditorState editorState) async {
@ -192,15 +195,15 @@ void _pasteSingleLine(
EditorState editorState, Selection selection, String line) {
final node = editorState.document.nodeAtPath(selection.end.path)! as TextNode;
final beginOffset = selection.end.offset;
TransactionBuilder(editorState)
..textEdit(
editorState.transaction
..updateText(
node,
() => Delta()
Delta()
..retain(beginOffset)
..addAll(_lineContentToDelta(line)))
..setAfterSelection(Selection.collapsed(
Position(path: selection.end.path, offset: beginOffset + line.length)))
..commit();
..afterSelection = (Selection.collapsed(
Position(path: selection.end.path, offset: beginOffset + line.length)));
editorState.commit();
}
/// parse url from the line text
@ -218,7 +221,7 @@ Delta _lineContentToDelta(String lineContent) {
delta.insert(lineContent.substring(lastUrlEndOffset, match.start));
}
final linkContent = lineContent.substring(match.start, match.end);
delta.insert(linkContent, {"href": linkContent});
delta.insert(linkContent, attributes: {"href": linkContent});
lastUrlEndOffset = match.end;
}
@ -230,7 +233,7 @@ Delta _lineContentToDelta(String lineContent) {
}
void _handlePastePlainText(EditorState editorState, String plainText) {
final selection = editorState.cursorSelection?.normalize;
final selection = editorState.cursorSelection?.normalized;
if (selection == null) {
return;
}
@ -260,10 +263,9 @@ void _handlePastePlainText(EditorState editorState, String plainText) {
final insertedLineSuffix = node.delta.slice(beginOffset);
path[path.length - 1]++;
final tb = TransactionBuilder(editorState);
final List<TextNode> nodes = remains
.map((e) => TextNode(type: "text", delta: _lineContentToDelta(e)))
.toList();
final tb = editorState.transaction;
final List<TextNode> nodes =
remains.map((e) => TextNode(delta: _lineContentToDelta(e))).toList();
final afterSelection =
_computeSelectionAfterPasteMultipleNodes(editorState, nodes);
@ -272,20 +274,20 @@ void _handlePastePlainText(EditorState editorState, String plainText) {
if (nodes.isNotEmpty) {
final last = nodes.last;
nodes[nodes.length - 1] =
TextNode(type: "text", delta: last.delta..addAll(insertedLineSuffix));
TextNode(delta: last.delta..addAll(insertedLineSuffix));
}
// insert first line
tb.textEdit(
tb.updateText(
node,
() => Delta()
Delta()
..retain(beginOffset)
..insert(firstLine)
..delete(node.delta.length - beginOffset));
// insert remains
tb.insertNodes(path, nodes);
tb.setAfterSelection(afterSelection);
tb.commit();
tb.afterSelection = afterSelection;
editorState.commit();
}
}
@ -297,35 +299,38 @@ void _handleCut(EditorState editorState) {
}
void _deleteSelectedContent(EditorState editorState) {
final selection = editorState.cursorSelection?.normalize;
final selection = editorState.cursorSelection?.normalized;
if (selection == null || selection.isCollapsed) {
return;
}
final beginNode = editorState.document.nodeAtPath(selection.start.path)!;
final endNode = editorState.document.nodeAtPath(selection.end.path)!;
if (pathEquals(selection.start.path, selection.end.path) &&
if (selection.start.path.equals(selection.end.path) &&
beginNode.type == "text") {
final textItem = beginNode as TextNode;
final tb = TransactionBuilder(editorState);
final tb = editorState.transaction;
final len = selection.end.offset - selection.start.offset;
tb.textEdit(
tb.updateText(
textItem,
() => Delta()
Delta()
..retain(selection.start.offset)
..delete(len));
tb.setAfterSelection(Selection.collapsed(selection.start));
tb.commit();
tb.afterSelection = Selection.collapsed(selection.start);
editorState.commit();
return;
}
final traverser = NodeIterator(editorState.document, beginNode, endNode);
final tb = TransactionBuilder(editorState);
final traverser = NodeIterator(
document: editorState.document,
startNode: beginNode,
endNode: endNode,
);
final tb = editorState.transaction;
while (traverser.moveNext()) {
final item = traverser.current;
if (item.type == "text" && beginNode == item) {
final textItem = item as TextNode;
final deleteLen = textItem.delta.length - selection.start.offset;
tb.textEdit(textItem, () {
tb.updateText(textItem, () {
final delta = Delta()
..retain(selection.start.offset)
..delete(deleteLen);
@ -336,13 +341,13 @@ void _deleteSelectedContent(EditorState editorState) {
}
return delta;
});
}());
} else {
tb.deleteNode(item);
}
}
tb.setAfterSelection(Selection.collapsed(selection.start));
tb.commit();
tb.afterSelection = Selection.collapsed(selection.start);
editorState.commit();
}
ShortcutEventHandler copyEventHandler = (editorState, event) {

View File

@ -39,11 +39,11 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
final afterSelection = Selection.collapsed(
Position(path: textNodes.first.path.next, offset: 0),
);
TransactionBuilder(editorState)
editorState.transaction
..deleteText(
textNodes.first,
selection.start.offset,
textNodes.first.toRawString().length,
textNodes.first.toPlainText().length,
)
..deleteNodes(subTextNodes)
..deleteText(
@ -51,8 +51,8 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
0,
selection.end.offset,
)
..afterSelection = afterSelection
..commit();
..afterSelection = afterSelection;
editorState.commit();
if (startNode is TextNode &&
startNode.subtype == BuiltInAttributeKey.numberList) {
@ -73,16 +73,16 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
// If selection is collapsed and position.start.offset == 0,
// insert a empty text node before.
if (selection.isCollapsed && selection.start.offset == 0) {
if (textNode.toRawString().isEmpty && textNode.subtype != null) {
if (textNode.toPlainText().isEmpty && textNode.subtype != null) {
final afterSelection = Selection.collapsed(
Position(path: textNode.path, offset: 0),
);
TransactionBuilder(editorState)
editorState.transaction
..updateNode(textNode, {
BuiltInAttributeKey.subtype: null,
})
..afterSelection = afterSelection
..commit();
..afterSelection = afterSelection;
editorState.commit();
final nextNode = textNode.next;
if (nextNode is TextNode &&
@ -105,13 +105,13 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
BuiltInAttributeKey.numberList;
newNode.attributes[BuiltInAttributeKey.number] = prevNumber;
final insertPath = textNode.path;
TransactionBuilder(editorState)
editorState.transaction
..insertNode(
insertPath,
newNode,
)
..afterSelection = afterSelection
..commit();
..afterSelection = afterSelection;
editorState.commit();
makeFollowingNodesIncremental(editorState, insertPath, afterSelection,
beginNum: prevNumber);
@ -120,7 +120,7 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
BuiltInAttributeKey.heading,
BuiltInAttributeKey.quote,
].contains(subtype);
TransactionBuilder(editorState)
editorState.transaction
..insertNode(
textNode.path,
textNode.copyWith(
@ -129,8 +129,8 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
attributes: needCopyAttributes ? null : {},
),
)
..afterSelection = afterSelection
..commit();
..afterSelection = afterSelection;
editorState.commit();
}
}
return KeyEventResult.handled;
@ -145,25 +145,25 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
Position(path: nextPath, offset: 0),
);
final transactionBuilder = TransactionBuilder(editorState);
transactionBuilder.insertNode(
final transaction = editorState.transaction;
transaction.insertNode(
textNode.path.next,
textNode.copyWith(
attributes: attributes,
delta: textNode.delta.slice(selection.end.offset),
),
);
transactionBuilder.deleteText(
transaction.deleteText(
textNode,
selection.start.offset,
textNode.toRawString().length - selection.start.offset,
textNode.toPlainText().length - selection.start.offset,
);
if (textNode.children.isNotEmpty) {
final children = textNode.children.toList(growable: false);
transactionBuilder.deleteNodes(children);
transaction.deleteNodes(children);
}
transactionBuilder.afterSelection = afterSelection;
transactionBuilder.commit();
transaction.afterSelection = afterSelection;
editorState.commit();
// If the new type of a text node is number list,
// the numbers of the following nodes should be incremental.

View File

@ -2,7 +2,7 @@ import 'package:appflowy_editor/src/service/default_text_operations/format_rich_
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
ShortcutEventHandler formatBoldEventHandler = (editorState, event) {
final selection = editorState.service.selectionService.currentSelection.value;

View File

@ -1,6 +1,7 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
import 'package:flutter/material.dart';
bool _isCodeStyle(TextNode textNode, int index) {
@ -44,7 +45,7 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
final textNode = textNodes.first;
final selectionText = textNode
.toRawString()
.toPlainText()
.substring(selection.start.offset, selection.end.offset);
// toggle code style when selected some text
@ -53,7 +54,7 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
return KeyEventResult.handled;
}
final text = textNode.toRawString().substring(0, selection.end.offset);
final text = textNode.toPlainText().substring(0, selection.end.offset);
final backquoteIndexes = _findBackquoteIndexes(text, textNode);
if (backquoteIndexes.isEmpty) {
return KeyEventResult.ignored;
@ -72,7 +73,7 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
return KeyEventResult.ignored;
}
TransactionBuilder(editorState)
editorState.transaction
..deleteText(textNode, lastBackquoteIndex, 1)
..deleteText(textNode, firstBackquoteIndex, 2)
..formatText(
@ -88,8 +89,8 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
path: textNode.path,
offset: endIndex - 3,
),
)
..commit();
);
editorState.commit();
return KeyEventResult.handled;
}
@ -103,7 +104,7 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
// delete the backquote.
// update the style of the text surround by ` ` to code.
// and update the cursor position.
TransactionBuilder(editorState)
editorState.transaction
..deleteText(textNode, startIndex, 1)
..formatText(
textNode,
@ -118,8 +119,8 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
path: textNode.path,
offset: endIndex - 1,
),
)
..commit();
);
editorState.commit();
return KeyEventResult.handled;
};
@ -134,7 +135,7 @@ ShortcutEventHandler doubleTildeToStrikethrough = (editorState, event) {
}
final textNode = textNodes.first;
final text = textNode.toRawString().substring(0, selection.end.offset);
final text = textNode.toPlainText().substring(0, selection.end.offset);
// make sure the last two characters are ~~.
if (text.length < 2 || text[selection.end.offset - 1] != '~') {
@ -165,7 +166,7 @@ ShortcutEventHandler doubleTildeToStrikethrough = (editorState, event) {
// delete the last three tildes.
// update the style of the text surround by `~~ ~~` to strikethrough.
// and update the cursor position.
TransactionBuilder(editorState)
editorState.transaction
..deleteText(textNode, lastTildeIndex, 1)
..deleteText(textNode, thirdToLastTildeIndex, 2)
..formatText(
@ -181,8 +182,8 @@ ShortcutEventHandler doubleTildeToStrikethrough = (editorState, event) {
path: textNode.path,
offset: selection.end.offset - 3,
),
)
..commit();
);
editorState.commit();
return KeyEventResult.handled;
};
@ -199,7 +200,7 @@ ShortcutEventHandler markdownLinkToLinkHandler = (editorState, event) {
// find all of the indexs for important characters
final textNode = textNodes.first;
final text = textNode.toRawString();
final text = textNode.toPlainText();
final firstOpeningBracket = text.indexOf('[');
final firstClosingBracket = text.indexOf(']');
@ -219,7 +220,7 @@ ShortcutEventHandler markdownLinkToLinkHandler = (editorState, event) {
// update the href attribute of the text surrounded by [ ] to the url,
// delete everything after the text,
// and update the cursor position.
TransactionBuilder(editorState)
editorState.transaction
..deleteText(textNode, firstOpeningBracket, 1)
..formatText(
textNode,
@ -236,8 +237,8 @@ ShortcutEventHandler markdownLinkToLinkHandler = (editorState, event) {
path: textNode.path,
offset: firstOpeningBracket + linkText!.length,
),
)
..commit();
);
editorState.commit();
return KeyEventResult.handled;
};

View File

@ -11,7 +11,7 @@ ShortcutEventHandler doubleAsterisksToBold = (editorState, event) {
}
final textNode = textNodes.first;
final text = textNode.toRawString().substring(0, selection.end.offset);
final text = textNode.toPlainText().substring(0, selection.end.offset);
// make sure the last two characters are **.
if (text.length < 2 || text[selection.end.offset - 1] != '*') {
@ -42,7 +42,7 @@ ShortcutEventHandler doubleAsterisksToBold = (editorState, event) {
// delete the last three asterisks.
// update the style of the text surround by `** **` to bold.
// and update the cursor position.
TransactionBuilder(editorState)
editorState.transaction
..deleteText(textNode, lastAsterisIndex, 1)
..deleteText(textNode, thirdToLastAsteriskIndex, 2)
..formatText(
@ -59,8 +59,8 @@ ShortcutEventHandler doubleAsterisksToBold = (editorState, event) {
path: textNode.path,
offset: selection.end.offset - 3,
),
)
..commit();
);
editorState.commit();
return KeyEventResult.handled;
};
@ -75,7 +75,7 @@ ShortcutEventHandler doubleUnderscoresToBold = (editorState, event) {
}
final textNode = textNodes.first;
final text = textNode.toRawString().substring(0, selection.end.offset);
final text = textNode.toPlainText().substring(0, selection.end.offset);
// make sure the last two characters are __.
if (text.length < 2 || text[selection.end.offset - 1] != '_') {
@ -108,7 +108,7 @@ ShortcutEventHandler doubleUnderscoresToBold = (editorState, event) {
// delete the last three underscores.
// update the style of the text surround by `__ __` to bold.
// and update the cursor position.
TransactionBuilder(editorState)
editorState.transaction
..deleteText(textNode, lastAsterisIndex, 1)
..deleteText(textNode, thirdToLastUnderscoreIndex, 2)
..formatText(
@ -125,8 +125,8 @@ ShortcutEventHandler doubleUnderscoresToBold = (editorState, event) {
path: textNode.path,
offset: selection.end.offset - 3,
),
)
..commit();
);
editorState.commit();
return KeyEventResult.handled;
};

View File

@ -1,8 +1,7 @@
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
import 'package:appflowy_editor/src/core/document/attributes.dart';
void makeFollowingNodesIncremental(
EditorState editorState, List<int> insertPath, Selection afterSelection,
@ -16,7 +15,7 @@ void makeFollowingNodesIncremental(
int numPtr = beginNum + 1;
var ptr = insertNode.next;
final builder = TransactionBuilder(editorState);
final builder = editorState.transaction;
while (ptr != null) {
if (ptr.subtype != BuiltInAttributeKey.numberList) {
@ -34,5 +33,5 @@ void makeFollowingNodesIncremental(
}
builder.afterSelection = afterSelection;
builder.commit();
editorState.commit();
}

View File

@ -1,6 +1,6 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/position.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
import 'package:flutter/material.dart';

View File

@ -1,5 +1,5 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/transform/transaction.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
import 'package:appflowy_editor/src/extensions/node_extensions.dart';
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
@ -25,10 +25,9 @@ ShortcutEventHandler slashShortcutHandler = (editorState, event) {
if (selection == null || context == null || selectable == null) {
return KeyEventResult.ignored;
}
TransactionBuilder(editorState)
..replaceText(textNode, selection.start.offset,
selection.end.offset - selection.start.offset, event.character ?? '')
..commit();
editorState.transaction.replaceText(textNode, selection.start.offset,
selection.end.offset - selection.start.offset, event.character ?? '');
editorState.commit();
WidgetsBinding.instance.addPostFrameCallback((_) {
_selectionMenuService =

View File

@ -15,9 +15,8 @@ ShortcutEventHandler tabHandler = (editorState, event) {
final previous = textNode.previous;
if (textNode.subtype != BuiltInAttributeKey.bulletedList) {
TransactionBuilder(editorState)
..insertText(textNode, selection.end.offset, ' ' * 4)
..commit();
editorState.transaction.insertText(textNode, selection.end.offset, ' ' * 4);
editorState.commit();
return KeyEventResult.handled;
}
@ -31,11 +30,11 @@ ShortcutEventHandler tabHandler = (editorState, event) {
start: selection.start.copyWith(path: path),
end: selection.end.copyWith(path: path),
);
TransactionBuilder(editorState)
editorState.transaction
..deleteNode(textNode)
..insertNode(path, textNode)
..setAfterSelection(afterSelection)
..commit();
..afterSelection = afterSelection;
editorState.commit();
return KeyEventResult.handled;
};

View File

@ -1,12 +1,12 @@
import 'package:appflowy_editor/src/core/transform/transaction.dart';
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/position.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import './number_list_helper.dart';
import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
@ -44,7 +44,7 @@ ShortcutEventHandler whiteSpaceHandler = (editorState, event) {
}
final textNode = textNodes.first;
final text = textNode.toRawString().substring(0, selection.end.offset);
final text = textNode.toPlainText().substring(0, selection.end.offset);
final numberMatch = _numberRegex.firstMatch(text);
@ -99,15 +99,14 @@ KeyEventResult _toNumberList(EditorState editorState, TextNode textNode,
));
final insertPath = textNode.path;
TransactionBuilder(editorState)
editorState.transaction
..deleteText(textNode, 0, matchText.length)
..updateNode(textNode, {
BuiltInAttributeKey.subtype: BuiltInAttributeKey.numberList,
BuiltInAttributeKey.number: numValue
})
..afterSelection = afterSelection
..commit();
..afterSelection = afterSelection;
editorState.commit();
makeFollowingNodesIncremental(editorState, insertPath, afterSelection);
@ -118,7 +117,7 @@ KeyEventResult _toBulletedList(EditorState editorState, TextNode textNode) {
if (textNode.subtype == BuiltInAttributeKey.bulletedList) {
return KeyEventResult.ignored;
}
TransactionBuilder(editorState)
editorState.transaction
..deleteText(textNode, 0, 1)
..updateNode(textNode, {
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
@ -128,8 +127,8 @@ KeyEventResult _toBulletedList(EditorState editorState, TextNode textNode) {
path: textNode.path,
offset: 0,
),
)
..commit();
);
editorState.commit();
return KeyEventResult.handled;
}
@ -140,18 +139,18 @@ KeyEventResult _toCheckboxList(EditorState editorState, TextNode textNode) {
final String symbol;
bool check = false;
final symbols = List<String>.from(_checkboxListSymbols)
..retainWhere(textNode.toRawString().startsWith);
..retainWhere(textNode.toPlainText().startsWith);
if (symbols.isNotEmpty) {
symbol = symbols.first;
check = true;
} else {
symbol = (List<String>.from(_unCheckboxListSymbols)
..retainWhere(textNode.toRawString().startsWith))
..retainWhere(textNode.toPlainText().startsWith))
.first;
check = false;
}
TransactionBuilder(editorState)
editorState.transaction
..deleteText(textNode, 0, symbol.length)
..updateNode(textNode, {
BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
@ -162,22 +161,22 @@ KeyEventResult _toCheckboxList(EditorState editorState, TextNode textNode) {
path: textNode.path,
offset: 0,
),
)
..commit();
);
editorState.commit();
return KeyEventResult.handled;
}
KeyEventResult _toHeadingStyle(
EditorState editorState, TextNode textNode, Selection selection) {
final x = _countOfSign(
textNode.toRawString(),
textNode.toPlainText(),
selection,
);
final hX = 'h$x';
if (textNode.attributes.heading == hX) {
return KeyEventResult.ignored;
}
TransactionBuilder(editorState)
editorState.transaction
..deleteText(textNode, 0, x)
..updateNode(textNode, {
BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
@ -188,8 +187,8 @@ KeyEventResult _toHeadingStyle(
path: textNode.path,
offset: 0,
),
)
..commit();
);
editorState.commit();
return KeyEventResult.handled;
}

View File

@ -1,4 +1,4 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/log.dart';
import 'package:flutter/material.dart';

View File

@ -1,14 +1,14 @@
import 'package:appflowy_editor/src/infra/log.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/node_iterator.dart';
import 'package:appflowy_editor/src/document/position.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/node_iterator.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/extensions/node_extensions.dart';
import 'package:appflowy_editor/src/extensions/object_extensions.dart';
import 'package:appflowy_editor/src/extensions/path_extensions.dart';
import 'package:appflowy_editor/src/render/selection/cursor_widget.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:appflowy_editor/src/render/selection/selection_widget.dart';
@ -179,8 +179,11 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
final startNode = editorState.document.nodeAtPath(start);
final endNode = editorState.document.nodeAtPath(end);
if (startNode != null && endNode != null) {
final nodes =
NodeIterator(editorState.document, startNode, endNode).toList();
final nodes = NodeIterator(
document: editorState.document,
startNode: startNode,
endNode: endNode,
).toList();
if (selection.isBackward) {
return nodes;
} else {
@ -363,7 +366,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
final backwardNodes =
selection.isBackward ? nodes : nodes.reversed.toList(growable: false);
final normalizedSelection = selection.normalize;
final normalizedSelection = selection.normalized;
assert(normalizedSelection.isBackward);
Log.selection.debug('update selection areas, $normalizedSelection');
@ -375,7 +378,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
continue;
}
var newSelection = normalizedSelection.copy();
var newSelection = normalizedSelection.copyWith();
/// In the case of multiple selections,
/// we need to return a new selection for each selected node individually.

View File

@ -1,10 +1,9 @@
import 'dart:collection';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/infra/log.dart';
import 'package:appflowy_editor/src/operation/operation.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:appflowy_editor/src/operation/transaction.dart';
import 'package:appflowy_editor/src/core/transform/operation.dart';
import 'package:appflowy_editor/src/core/transform/transaction.dart';
import 'package:appflowy_editor/src/editor_state.dart';
/// A [HistoryItem] contains list of operations committed by users.
@ -39,7 +38,7 @@ class HistoryItem extends LinkedListEntry<HistoryItem> {
/// Create a new [Transaction] by inverting the operations.
Transaction toTransaction(EditorState state) {
final builder = TransactionBuilder(state);
final builder = Transaction(document: state.document);
for (var i = operations.length - 1; i >= 0; i--) {
final operation = operations[i];
final inverted = operation.invert();
@ -47,7 +46,7 @@ class HistoryItem extends LinkedListEntry<HistoryItem> {
}
builder.afterSelection = beforeSelection;
builder.beforeSelection = afterSelection;
return builder.finish();
return builder;
}
}

View File

@ -0,0 +1,59 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {
group('attributes.dart', () {
test('composeAttributes', () {
final base = {
'a': 1,
'b': 2,
};
final other = {
'b': 3,
'c': 4,
'd': null,
};
expect(composeAttributes(base, other, keepNull: false), {
'a': 1,
'b': 3,
'c': 4,
});
expect(composeAttributes(base, other, keepNull: true), {
'a': 1,
'b': 3,
'c': 4,
'd': null,
});
expect(composeAttributes(null, other, keepNull: false), {
'b': 3,
'c': 4,
});
expect(composeAttributes(base, null, keepNull: false), {
'a': 1,
'b': 2,
});
});
test('invertAttributes', () {
final base = {
'a': 1,
'b': 2,
};
final other = {
'b': 3,
'c': 4,
'd': null,
};
expect(invertAttributes(base, other), {
'a': 1,
'b': 2,
'c': null,
});
expect(invertAttributes(other, base), {
'a': null,
'b': 3,
'c': 4,
});
});
});
}

View File

@ -0,0 +1,77 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {
group('documemnt.dart', () {
test('insert', () {
final document = Document.empty();
expect(document.insert([-1], []), false);
expect(document.insert([100], []), false);
final node0 = Node(type: '0');
final node1 = Node(type: '1');
expect(document.insert([0], [node0, node1]), true);
expect(document.nodeAtPath([0])?.type, '0');
expect(document.nodeAtPath([1])?.type, '1');
});
test('delete', () {
final document = Document(root: Node(type: 'root'));
expect(document.delete([-1], 1), false);
expect(document.delete([100], 1), false);
for (var i = 0; i < 10; i++) {
final node = Node(type: '$i');
document.insert([i], [node]);
}
document.delete([0], 10);
expect(document.root.children.isEmpty, true);
});
test('update', () {
final node = Node(type: 'example', attributes: {'a': 'a'});
final document = Document(root: Node(type: 'root'));
document.insert([0], [node]);
final attributes = {
'a': 'b',
'b': 'c',
};
expect(document.update([0], attributes), true);
expect(document.nodeAtPath([0])?.attributes, attributes);
expect(document.update([-1], attributes), false);
});
test('updateText', () {
final delta = Delta()..insert('Editor');
final textNode = TextNode(delta: delta);
final document = Document(root: Node(type: 'root'));
document.insert([0], [textNode]);
document.updateText([0], Delta()..insert('AppFlowy'));
expect((document.nodeAtPath([0]) as TextNode).toPlainText(),
'AppFlowyEditor');
});
test('serialize', () {
final json = {
'document': {
'type': 'editor',
'children': [
{
'type': 'text',
'delta': [],
}
],
'attributes': {'a': 'a'}
}
};
final document = Document.fromJson(json);
expect(document.toJson(), json);
});
});
}

View File

@ -0,0 +1,33 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/core/document/node_iterator.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {
group('node_iterator.dart', () {
test('', () {
final root = Node(type: 'root');
for (var i = 1; i <= 10; i++) {
final node = Node(type: 'node_$i');
for (var j = 1; j <= i; j++) {
node.insert(Node(type: 'node_${i}_$j'));
}
root.insert(node);
}
final nodes = NodeIterator(
document: Document(root: root),
startNode: root.childAtPath([0])!,
endNode: root.childAtPath([10, 10]),
);
for (var i = 1; i <= 10; i++) {
nodes.moveNext();
expect(nodes.current.type, 'node_$i');
for (var j = 1; j <= i; j++) {
nodes.moveNext();
expect(nodes.current.type, 'node_${i}_$j');
}
}
expect(nodes.moveNext(), false);
});
});
}

View File

@ -4,10 +4,6 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('node.dart', () {
test('test node copyWith', () {
final node = Node(
@ -57,7 +53,6 @@ void main() async {
test('test textNode copyWith', () {
final textNode = TextNode(
type: 'example',
children: LinkedList(),
attributes: {
'example': 'example',
@ -65,7 +60,7 @@ void main() async {
delta: Delta()..insert('AppFlowy'),
);
expect(textNode.toJson(), {
'type': 'example',
'type': 'text',
'attributes': {
'example': 'example',
},
@ -79,7 +74,6 @@ void main() async {
);
final textNodeWithChildren = TextNode(
type: 'example',
children: LinkedList()..add(textNode),
attributes: {
'example': 'example',
@ -87,7 +81,7 @@ void main() async {
delta: Delta()..insert('AppFlowy'),
);
expect(textNodeWithChildren.toJson(), {
'type': 'example',
'type': 'text',
'attributes': {
'example': 'example',
},
@ -96,7 +90,7 @@ void main() async {
],
'children': [
{
'type': 'example',
'type': 'text',
'attributes': {
'example': 'example',
},
@ -149,5 +143,90 @@ void main() async {
expect(identical(node.children, base.children), false);
expect(identical(node.children.first, base.children.first), false);
});
test('test insert', () {
final base = Node(
type: 'base',
);
// insert at the front when node's children is empty
final childA = Node(
type: 'child',
);
base.insert(childA);
expect(
identical(base.childAtIndex(0), childA),
true,
);
// insert at the front
final childB = Node(
type: 'child',
);
base.insert(childB, index: -1);
expect(
identical(base.childAtIndex(0), childB),
true,
);
// insert at the last
final childC = Node(
type: 'child',
);
base.insert(childC, index: 1000);
expect(
identical(base.childAtIndex(base.children.length - 1), childC),
true,
);
// insert at the last
final childD = Node(
type: 'child',
);
base.insert(childD);
expect(
identical(base.childAtIndex(base.children.length - 1), childD),
true,
);
// insert at the second
final childE = Node(
type: 'child',
);
base.insert(childE, index: 1);
expect(
identical(base.childAtIndex(1), childE),
true,
);
});
test('test fromJson', () {
final node = Node.fromJson({
'type': 'text',
'delta': [
{'insert': 'example'},
],
'children': [
{
'type': 'example',
'attributes': {
'example': 'example',
},
},
],
});
expect(node.type, 'text');
expect(node is TextNode, true);
expect((node as TextNode).delta.toPlainText(), 'example');
expect(node.attributes, {});
expect(node.children.length, 1);
expect(node.children.first.type, 'example');
expect(node.children.first.attributes, {'example': 'example'});
});
test('test toPlainText', () {
final textNode = TextNode.empty()..delta = (Delta()..insert('AppFlowy'));
expect(textNode.toPlainText(), 'AppFlowy');
});
});
}

View File

@ -0,0 +1,33 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {
group('path.dart', () {
test('test path equality', () {
var p1 = [0, 0];
var p2 = [0];
expect(p1 > p2, true);
expect(p1 >= p2, true);
expect(p1 < p2, false);
expect(p1 <= p2, false);
p1 = [1, 1, 2];
p2 = [1, 1, 3];
expect(p2 > p1, true);
expect(p2 >= p1, true);
expect(p2 < p1, false);
expect(p2 <= p1, false);
p1 = [2, 0, 1];
p2 = [2, 0, 1];
expect(p2 > p1, false);
expect(p1 > p2, false);
expect(p2 >= p1, true);
expect(p2 <= p1, true);
expect(p1.equals(p2), true);
});
});
}

View File

@ -0,0 +1,332 @@
import 'package:appflowy_editor/src/core/document/attributes.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/src/core/document/text_delta.dart';
void main() {
group('text_delta.dart', () {
group('compose', () {
test('test delta', () {
final delta = Delta(operations: <TextOperation>[
TextInsert('Gandalf', attributes: {
'bold': true,
}),
TextInsert(' the '),
TextInsert('Grey', attributes: {
'color': '#ccc',
})
]);
final death = Delta()
..retain(12)
..insert("White", attributes: {
'color': '#fff',
})
..delete(4);
final restores = delta.compose(death);
expect(restores.toList(), <TextOperation>[
TextInsert('Gandalf', attributes: {'bold': true}),
TextInsert(' the '),
TextInsert('White', attributes: {'color': '#fff'}),
]);
});
test('compose()', () {
final a = Delta()..insert('A');
final b = Delta()..insert('B');
final expected = Delta()
..insert('B')
..insert('A');
expect(a.compose(b), expected);
});
test('insert + retain', () {
final a = Delta()..insert('A');
final b = Delta()
..retain(1, attributes: {
'bold': true,
'color': 'red',
});
final expected = Delta()
..insert('A', attributes: {
'bold': true,
'color': 'red',
});
expect(a.compose(b), expected);
});
test('insert + delete', () {
final a = Delta()..insert('A');
final b = Delta()..delete(1);
final expected = Delta();
expect(a.compose(b), expected);
});
test('delete + insert', () {
final a = Delta()..delete(1);
final b = Delta()..insert('B');
final expected = Delta()
..insert('B')
..delete(1);
expect(a.compose(b), expected);
});
test('delete + retain', () {
final a = Delta()..delete(1);
final b = Delta()
..retain(1, attributes: {
'bold': true,
'color': 'red',
});
final expected = Delta()
..delete(1)
..retain(1, attributes: {
'bold': true,
'color': 'red',
});
expect(a.compose(b), expected);
});
test('delete + delete', () {
final a = Delta()..delete(1);
final b = Delta()..delete(1);
final expected = Delta()..delete(2);
expect(a.compose(b), expected);
});
test('retain + insert', () {
final a = Delta()..retain(1, attributes: {'color': 'blue'});
final b = Delta()..insert('B');
final expected = Delta()
..insert('B')
..retain(1, attributes: {
'color': 'blue',
});
expect(a.compose(b), expected);
});
test('retain + retain', () {
final a = Delta()
..retain(1, attributes: {
'color': 'blue',
});
final b = Delta()
..retain(1, attributes: {
'bold': true,
'color': 'red',
});
final expected = Delta()
..retain(1, attributes: {
'bold': true,
'color': 'red',
});
expect(a.compose(b), expected);
});
test('retain + delete', () {
final a = Delta()
..retain(1, attributes: {
'color': 'blue',
});
final b = Delta()..delete(1);
final expected = Delta()..delete(1);
expect(a.compose(b), expected);
});
test('insert in middle of text', () {
final a = Delta()..insert('Hello');
final b = Delta()
..retain(3)
..insert('X');
final expected = Delta()..insert('HelXlo');
expect(a.compose(b), expected);
});
test('insert and delete ordering', () {
final a = Delta()..insert('Hello');
final b = Delta()..insert('Hello');
final insertFirst = Delta()
..retain(3)
..insert('X')
..delete(1);
final deleteFirst = Delta()
..retain(3)
..delete(1)
..insert('X');
final expected = Delta()..insert('HelXo');
expect(a.compose(insertFirst), expected);
expect(b.compose(deleteFirst), expected);
});
test('delete entire text', () {
final a = Delta()
..retain(4)
..insert('Hello');
final b = Delta()..delete(9);
final expected = Delta()..delete(4);
expect(a.compose(b), expected);
});
test('retain more than length of text', () {
final a = Delta()..insert('Hello');
final b = Delta()..retain(10);
final expected = Delta()..insert('Hello');
expect(a.compose(b), expected);
});
test('retain start optimization', () {
final a = Delta()
..insert('A', attributes: {'bold': true})
..insert('B')
..insert('C', attributes: {'bold': true})
..delete(1);
final b = Delta()
..retain(3)
..insert('D');
final expected = Delta()
..insert('A', attributes: {'bold': true})
..insert('B')
..insert('C', attributes: {'bold': true})
..insert('D')
..delete(1);
expect(a.compose(b), expected);
});
test('retain end optimization', () {
final a = Delta()
..insert('A', attributes: {'bold': true})
..insert('B')
..insert('C', attributes: {'bold': true});
final b = Delta()..delete(1);
final expected = Delta()
..insert('B')
..insert('C', attributes: {'bold': true});
expect(a.compose(b), expected);
});
test('retain end optimization join', () {
final a = Delta()
..insert('A', attributes: {'bold': true})
..insert('B')
..insert('C', attributes: {'bold': true})
..insert('D')
..insert('E', attributes: {'bold': true})
..insert('F');
final b = Delta()
..retain(1)
..delete(1);
final expected = Delta()
..insert('AC', attributes: {'bold': true})
..insert('D')
..insert('E', attributes: {'bold': true})
..insert('F');
expect(a.compose(b), expected);
});
});
group('invert', () {
test('insert', () {
final delta = Delta()
..retain(2)
..insert('A');
final base = Delta()..insert('12346');
final expected = Delta()
..retain(2)
..delete(1);
final inverted = delta.invert(base);
expect(expected, inverted);
expect(base.compose(delta).compose(inverted), base);
});
test('delete', () {
final delta = Delta()
..retain(2)
..delete(3);
final base = Delta()..insert('123456');
final expected = Delta()
..retain(2)
..insert('345');
final inverted = delta.invert(base);
expect(expected, inverted);
expect(base.compose(delta).compose(inverted), base);
});
test('retain', () {
final delta = Delta()
..retain(2)
..retain(3, attributes: {'bold': true});
final base = Delta()..insert('123456');
final expected = Delta()
..retain(2)
..retain(3, attributes: {'bold': null});
final inverted = delta.invert(base);
expect(expected, inverted);
final t = base.compose(delta).compose(inverted);
expect(t, base);
});
});
group('json', () {
test('toJson()', () {
final delta = Delta()
..retain(2)
..insert('A')
..delete(3);
expect(delta.toJson(), [
{'retain': 2},
{'insert': 'A'},
{'delete': 3}
]);
});
test('attributes', () {
final delta = Delta()
..retain(2, attributes: {'bold': true})
..insert('A', attributes: {'italic': true});
expect(delta.toJson(), [
{
'retain': 2,
'attributes': {'bold': true},
},
{
'insert': 'A',
'attributes': {'italic': true},
},
]);
});
test('fromJson()', () {
final delta = Delta.fromJson([
{'retain': 2},
{'insert': 'A'},
{'delete': 3},
]);
final expected = Delta()
..retain(2)
..insert('A')
..delete(3);
expect(delta, expected);
});
});
group('runes', () {
test("stringIndexes", () {
final indexes = stringIndexes('😊');
expect(indexes[0], 0);
expect(indexes[1], 0);
});
test("next rune 1", () {
final delta = Delta()..insert('😊');
expect(delta.nextRunePosition(0), 2);
});
test("next rune 2", () {
final delta = Delta()..insert('😊a');
expect(delta.nextRunePosition(0), 2);
});
test("next rune 3", () {
final delta = Delta()..insert('😊陈');
expect(delta.nextRunePosition(2), 3);
});
test("prev rune 1", () {
final delta = Delta()..insert('😊陈');
expect(delta.prevRunePosition(2), 0);
});
test("prev rune 2", () {
final delta = Delta()..insert('😊');
expect(delta.prevRunePosition(2), 0);
});
test("prev rune 3", () {
final delta = Delta()..insert('😊');
expect(delta.prevRunePosition(0), -1);
});
});
group("attributes", () {
test("compose", () {
final attrs =
composeAttributes({'a': null}, {'b': null}, keepNull: true);
expect(attrs != null, true);
expect(attrs?.containsKey("a"), true);
expect(attrs?.containsKey("b"), true);
expect(attrs?["a"], null);
expect(attrs?["b"], null);
});
});
});
}

View File

@ -0,0 +1,26 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {
group('position.dart', () {
test('test position equality', () {
final positionA = Position(path: [0, 1, 2], offset: 3);
final positionB = Position(path: [0, 1, 2], offset: 3);
expect(positionA, positionB);
final positionC = positionA.copyWith(offset: 4);
final positionD = positionB.copyWith(path: [1, 2, 3]);
expect(positionC.offset, 4);
expect(positionD.path, [1, 2, 3]);
expect(positionA.toJson(), {
'path': [0, 1, 2],
'offset': 3,
});
expect(positionC.toJson(), {
'path': [0, 1, 2],
'offset': 4,
});
});
});
}

View File

@ -0,0 +1,77 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {
group('selection.dart', () {
test('test selection equality', () {
final position = Position(path: [0, 1, 2], offset: 3);
final selectionA = Selection(start: position, end: position);
final selectionB = Selection.collapsed(position);
expect(selectionA, selectionB);
expect(selectionA.hashCode, selectionB.hashCode);
final newPosition = Position(path: [1, 2, 3], offset: 4);
final selectionC = selectionA.copyWith(start: newPosition);
expect(selectionC.start, newPosition);
expect(selectionC.end, position);
expect(selectionC.isCollapsed, false);
final selectionD = selectionA.copyWith(end: newPosition);
expect(selectionD.start, position);
expect(selectionD.end, newPosition);
expect(selectionD.isCollapsed, false);
final selectionE = Selection.single(path: [0, 1, 2], startOffset: 3);
expect(selectionE, selectionA);
expect(selectionE.isSingle, true);
expect(selectionE.isCollapsed, true);
});
test('test selection direction', () {
final start = Position(path: [0, 1, 2], offset: 3);
final end = Position(path: [1, 2, 3], offset: 3);
final backwardSelection = Selection(start: start, end: end);
expect(backwardSelection.isBackward, true);
final forwardSelection = Selection(start: end, end: start);
expect(forwardSelection.isForward, true);
expect(backwardSelection.reversed, forwardSelection);
expect(forwardSelection.normalized, backwardSelection);
expect(backwardSelection.startIndex, 3);
expect(backwardSelection.endIndex, 3);
});
test('test selection collapsed', () {
final start = Position(path: [0, 1, 2], offset: 3);
final end = Position(path: [1, 2, 3], offset: 3);
final selection = Selection(start: start, end: end);
final collapsedAtStart = selection.collapse(atStart: true);
expect(collapsedAtStart.isCollapsed, true);
expect(collapsedAtStart.start, start);
expect(collapsedAtStart.end, start);
final collapsedAtEnd = selection.collapse(atStart: false);
expect(collapsedAtEnd.isCollapsed, true);
expect(collapsedAtEnd.start, end);
expect(collapsedAtEnd.end, end);
});
test('test selection toJson', () {
final start = Position(path: [0, 1, 2], offset: 3);
final end = Position(path: [1, 2, 3], offset: 3);
final selection = Selection(start: start, end: end);
expect(selection.toJson(), {
'start': {
'path': [0, 1, 2],
'offset': 3
},
'end': {
'path': [1, 2, 3],
'offset': 3
}
});
});
});
}

View File

@ -0,0 +1,79 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {
group('operation.dart', () {
test('test insert operation', () {
final node = Node(type: 'example');
final op = InsertOperation([0], [node]);
final json = op.toJson();
expect(json, {
'op': 'insert',
'path': [0],
'nodes': [
{
'type': 'example',
}
]
});
expect(InsertOperation.fromJson(json), op);
expect(op.invert().invert(), op);
expect(op.copyWith(), op);
});
test('test update operation', () {
final op = UpdateOperation([0], {'a': 1}, {'a': 0});
final json = op.toJson();
expect(json, {
'op': 'update',
'path': [0],
'attributes': {'a': 1},
'oldAttributes': {'a': 0}
});
expect(UpdateOperation.fromJson(json), op);
expect(op.invert().invert(), op);
expect(op.copyWith(), op);
});
test('test delete operation', () {
final node = Node(type: 'example');
final op = DeleteOperation([0], [node]);
final json = op.toJson();
expect(json, {
'op': 'delete',
'path': [0],
'nodes': [
{
'type': 'example',
}
]
});
expect(DeleteOperation.fromJson(json), op);
expect(op.invert().invert(), op);
expect(op.copyWith(), op);
});
test('test update text operation', () {
final app = Delta()..insert('App');
final appflowy = Delta()
..retain(3)
..insert('Flowy');
final op = UpdateTextOperation([0], app, appflowy.invert(app));
final json = op.toJson();
expect(json, {
'op': 'update_text',
'path': [0],
'delta': [
{'insert': 'App'}
],
'inverted': [
{'retain': 3},
{'delete': 5}
]
});
expect(UpdateTextOperation.fromJson(json), op);
expect(op.invert().invert(), op);
expect(op.copyWith(), op);
});
});
}

View File

@ -31,7 +31,7 @@ void main() async {
expect(p1 > p2, false);
expect(p2 >= p1, true);
expect(p2 <= p1, true);
expect(pathEquals(p1, p2), true);
expect(p1.equals(p2), true);
});
});
}

View File

@ -15,7 +15,6 @@ void main() async {
const text = 'Welcome to Appflowy 😁';
TextNode textNode() {
return TextNode(
type: 'text',
delta: Delta()..insert(text),
);
}

View File

@ -19,7 +19,7 @@ class EditorWidgetTester {
EditorState get editorState => _editorState;
Node get root => _editorState.document.root;
StateTree get document => _editorState.document;
Document get document => _editorState.document;
int get documentLength => _editorState.document.root.children.length;
Selection? get documentSelection =>
_editorState.service.selectionService.currentSelection.value;
@ -63,8 +63,7 @@ class EditorWidgetTester {
void insertTextNode(String? text, {Attributes? attributes, Delta? delta}) {
insert(
TextNode(
type: 'text',
delta: delta ?? Delta([TextInsert(text ?? 'Test')]),
delta: delta ?? Delta(operations: [TextInsert(text ?? 'Test')]),
attributes: attributes,
),
);
@ -103,7 +102,7 @@ class EditorWidgetTester {
{Selection? selection}) async {
await apply([
TextEditingDeltaInsertion(
oldText: textNode.toRawString(),
oldText: textNode.toPlainText(),
textInserted: text,
insertionOffset: offset,
selection: selection != null
@ -156,7 +155,7 @@ class EditorWidgetTester {
EditorState _createEmptyDocument() {
return EditorState(
document: StateTree(
document: Document(
root: _createEmptyEditorRoot(),
),
)..disableSealTimer = true;

View File

@ -1,329 +0,0 @@
import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/src/document/text_delta.dart';
void main() {
group('compose', () {
test('test delta', () {
final delta = Delta(<TextOperation>[
TextInsert('Gandalf', {
'bold': true,
}),
TextInsert(' the '),
TextInsert('Grey', {
'color': '#ccc',
})
]);
final death = Delta()
..retain(12)
..insert("White", {
'color': '#fff',
})
..delete(4);
final restores = delta.compose(death);
expect(restores.toList(), <TextOperation>[
TextInsert('Gandalf', {'bold': true}),
TextInsert(' the '),
TextInsert('White', {'color': '#fff'}),
]);
});
test('compose()', () {
final a = Delta()..insert('A');
final b = Delta()..insert('B');
final expected = Delta()
..insert('B')
..insert('A');
expect(a.compose(b), expected);
});
test('insert + retain', () {
final a = Delta()..insert('A');
final b = Delta()
..retain(1, {
'bold': true,
'color': 'red',
});
final expected = Delta()
..insert('A', {
'bold': true,
'color': 'red',
});
expect(a.compose(b), expected);
});
test('insert + delete', () {
final a = Delta()..insert('A');
final b = Delta()..delete(1);
final expected = Delta();
expect(a.compose(b), expected);
});
test('delete + insert', () {
final a = Delta()..delete(1);
final b = Delta()..insert('B');
final expected = Delta()
..insert('B')
..delete(1);
expect(a.compose(b), expected);
});
test('delete + retain', () {
final a = Delta()..delete(1);
final b = Delta()
..retain(1, {
'bold': true,
'color': 'red',
});
final expected = Delta()
..delete(1)
..retain(1, {
'bold': true,
'color': 'red',
});
expect(a.compose(b), expected);
});
test('delete + delete', () {
final a = Delta()..delete(1);
final b = Delta()..delete(1);
final expected = Delta()..delete(2);
expect(a.compose(b), expected);
});
test('retain + insert', () {
final a = Delta()..retain(1, {'color': 'blue'});
final b = Delta()..insert('B');
final expected = Delta()
..insert('B')
..retain(1, {
'color': 'blue',
});
expect(a.compose(b), expected);
});
test('retain + retain', () {
final a = Delta()
..retain(1, {
'color': 'blue',
});
final b = Delta()
..retain(1, {
'bold': true,
'color': 'red',
});
final expected = Delta()
..retain(1, {
'bold': true,
'color': 'red',
});
expect(a.compose(b), expected);
});
test('retain + delete', () {
final a = Delta()
..retain(1, {
'color': 'blue',
});
final b = Delta()..delete(1);
final expected = Delta()..delete(1);
expect(a.compose(b), expected);
});
test('insert in middle of text', () {
final a = Delta()..insert('Hello');
final b = Delta()
..retain(3)
..insert('X');
final expected = Delta()..insert('HelXlo');
expect(a.compose(b), expected);
});
test('insert and delete ordering', () {
final a = Delta()..insert('Hello');
final b = Delta()..insert('Hello');
final insertFirst = Delta()
..retain(3)
..insert('X')
..delete(1);
final deleteFirst = Delta()
..retain(3)
..delete(1)
..insert('X');
final expected = Delta()..insert('HelXo');
expect(a.compose(insertFirst), expected);
expect(b.compose(deleteFirst), expected);
});
test('delete entire text', () {
final a = Delta()
..retain(4)
..insert('Hello');
final b = Delta()..delete(9);
final expected = Delta()..delete(4);
expect(a.compose(b), expected);
});
test('retain more than length of text', () {
final a = Delta()..insert('Hello');
final b = Delta()..retain(10);
final expected = Delta()..insert('Hello');
expect(a.compose(b), expected);
});
test('retain start optimization', () {
final a = Delta()
..insert('A', {'bold': true})
..insert('B')
..insert('C', {'bold': true})
..delete(1);
final b = Delta()
..retain(3)
..insert('D');
final expected = Delta()
..insert('A', {'bold': true})
..insert('B')
..insert('C', {'bold': true})
..insert('D')
..delete(1);
expect(a.compose(b), expected);
});
test('retain end optimization', () {
final a = Delta()
..insert('A', {'bold': true})
..insert('B')
..insert('C', {'bold': true});
final b = Delta()..delete(1);
final expected = Delta()
..insert('B')
..insert('C', {'bold': true});
expect(a.compose(b), expected);
});
test('retain end optimization join', () {
final a = Delta()
..insert('A', {'bold': true})
..insert('B')
..insert('C', {'bold': true})
..insert('D')
..insert('E', {'bold': true})
..insert('F');
final b = Delta()
..retain(1)
..delete(1);
final expected = Delta()
..insert('AC', {'bold': true})
..insert('D')
..insert('E', {'bold': true})
..insert('F');
expect(a.compose(b), expected);
});
});
group('invert', () {
test('insert', () {
final delta = Delta()
..retain(2)
..insert('A');
final base = Delta()..insert('12346');
final expected = Delta()
..retain(2)
..delete(1);
final inverted = delta.invert(base);
expect(expected, inverted);
expect(base.compose(delta).compose(inverted), base);
});
test('delete', () {
final delta = Delta()
..retain(2)
..delete(3);
final base = Delta()..insert('123456');
final expected = Delta()
..retain(2)
..insert('345');
final inverted = delta.invert(base);
expect(expected, inverted);
expect(base.compose(delta).compose(inverted), base);
});
test('retain', () {
final delta = Delta()
..retain(2)
..retain(3, {'bold': true});
final base = Delta()..insert('123456');
final expected = Delta()
..retain(2)
..retain(3, {'bold': null});
final inverted = delta.invert(base);
expect(expected, inverted);
final t = base.compose(delta).compose(inverted);
expect(t, base);
});
});
group('json', () {
test('toJson()', () {
final delta = Delta()
..retain(2)
..insert('A')
..delete(3);
expect(delta.toJson(), [
{'retain': 2},
{'insert': 'A'},
{'delete': 3}
]);
});
test('attributes', () {
final delta = Delta()
..retain(2, {'bold': true})
..insert('A', {'italic': true});
expect(delta.toJson(), [
{
'retain': 2,
'attributes': {'bold': true},
},
{
'insert': 'A',
'attributes': {'italic': true},
},
]);
});
test('fromJson()', () {
final delta = Delta.fromJson([
{'retain': 2},
{'insert': 'A'},
{'delete': 3},
]);
final expected = Delta()
..retain(2)
..insert('A')
..delete(3);
expect(delta, expected);
});
});
group('runes', () {
test("stringIndexes", () {
final indexes = stringIndexes('😊');
expect(indexes[0], 0);
expect(indexes[1], 0);
});
test("next rune 1", () {
final delta = Delta()..insert('😊');
expect(delta.nextRunePosition(0), 2);
});
test("next rune 2", () {
final delta = Delta()..insert('😊a');
expect(delta.nextRunePosition(0), 2);
});
test("next rune 3", () {
final delta = Delta()..insert('😊陈');
expect(delta.nextRunePosition(2), 3);
});
test("prev rune 1", () {
final delta = Delta()..insert('😊陈');
expect(delta.prevRunePosition(2), 0);
});
test("prev rune 2", () {
final delta = Delta()..insert('😊');
expect(delta.prevRunePosition(2), 0);
});
test("prev rune 3", () {
final delta = Delta()..insert('😊');
expect(delta.prevRunePosition(0), -1);
});
});
group("attributes", () {
test("compose", () {
final attrs = composeAttributes({"a": null}, {"b": null}, true);
expect(attrs != null, true);
expect(attrs!.containsKey("a"), true);
expect(attrs.containsKey("b"), true);
expect(attrs["a"], null);
expect(attrs["b"], null);
});
});
}

View File

@ -1,6 +1,6 @@
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/position.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
@ -9,16 +9,16 @@ void main() {
test('create state tree', () async {
// final String response = await rootBundle.loadString('assets/document.json');
// final data = Map<String, Object>.from(json.decode(response));
// final stateTree = StateTree.fromJson(data);
// expect(stateTree.root.type, 'root');
// expect(stateTree.root.toJson(), data['document']);
// final document = Document.fromJson(data);
// expect(document.root.type, 'root');
// expect(document.root.toJson(), data['document']);
});
test('search node by Path in state tree', () async {
// final String response = await rootBundle.loadString('assets/document.json');
// final data = Map<String, Object>.from(json.decode(response));
// final stateTree = StateTree.fromJson(data);
// final checkBoxNode = stateTree.root.childAtPath([1, 0]);
// final document = Document.fromJson(data);
// final checkBoxNode = document.root.childAtPath([1, 0]);
// expect(checkBoxNode != null, true);
// final textType = checkBoxNode!.attributes['text-type'];
// expect(textType != null, true);
@ -27,8 +27,8 @@ void main() {
test('search node by Self in state tree', () async {
// final String response = await rootBundle.loadString('assets/document.json');
// final data = Map<String, Object>.from(json.decode(response));
// final stateTree = StateTree.fromJson(data);
// final checkBoxNode = stateTree.root.childAtPath([1, 0]);
// final document = Document.fromJson(data);
// final checkBoxNode = document.root.childAtPath([1, 0]);
// expect(checkBoxNode != null, true);
// final textType = checkBoxNode!.attributes['text-type'];
// expect(textType != null, true);
@ -39,21 +39,21 @@ void main() {
test('insert node in state tree', () async {
// final String response = await rootBundle.loadString('assets/document.json');
// final data = Map<String, Object>.from(json.decode(response));
// final stateTree = StateTree.fromJson(data);
// final document = Document.fromJson(data);
// final insertNode = Node.fromJson({
// 'type': 'text',
// });
// bool result = stateTree.insert([1, 1], [insertNode]);
// bool result = document.insert([1, 1], [insertNode]);
// expect(result, true);
// expect(identical(insertNode, stateTree.nodeAtPath([1, 1])), true);
// expect(identical(insertNode, document.nodeAtPath([1, 1])), true);
});
test('delete node in state tree', () async {
// final String response = await rootBundle.loadString('assets/document.json');
// final data = Map<String, Object>.from(json.decode(response));
// final stateTree = StateTree.fromJson(data);
// stateTree.delete([1, 1], 1);
// final node = stateTree.nodeAtPath([1, 1]);
// final document = Document.fromJson(data);
// document.delete([1, 1], 1);
// final node = document.nodeAtPath([1, 1]);
// expect(node != null, true);
// expect(node!.attributes['tag'], '**');
});
@ -61,10 +61,10 @@ void main() {
test('update node in state tree', () async {
// final String response = await rootBundle.loadString('assets/document.json');
// final data = Map<String, Object>.from(json.decode(response));
// final stateTree = StateTree.fromJson(data);
// final test = stateTree.update([1, 1], {'text-type': 'heading1'});
// final document = Document.fromJson(data);
// final test = document.update([1, 1], {'text-type': 'heading1'});
// expect(test, true);
// final updatedNode = stateTree.nodeAtPath([1, 1]);
// final updatedNode = document.nodeAtPath([1, 1]);
// expect(updatedNode != null, true);
// expect(updatedNode!.attributes['text-type'], 'heading1');
});
@ -72,7 +72,7 @@ void main() {
test('test path utils 1', () {
final path1 = <int>[1];
final path2 = <int>[1];
expect(pathEquals(path1, path2), true);
expect(path1.equals(path2), true);
expect(Object.hashAll(path1), Object.hashAll(path2));
});
@ -80,7 +80,7 @@ void main() {
test('test path utils 2', () {
final path1 = <int>[1];
final path2 = <int>[2];
expect(pathEquals(path1, path2), false);
expect(path1.equals(path2), false);
expect(Object.hashAll(path1) != Object.hashAll(path2), true);
});

View File

@ -1,11 +1,10 @@
import 'dart:collection';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/src/operation/operation.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:appflowy_editor/src/core/transform/operation.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/document/state_tree.dart';
import 'package:appflowy_editor/src/core/document/document.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -48,25 +47,26 @@ void main() {
final item2 = Node(type: "node", attributes: {}, children: LinkedList());
final item3 = Node(type: "node", attributes: {}, children: LinkedList());
final root = Node(
type: "root",
attributes: {},
children: LinkedList()
..addAll([
item1,
item2,
item3,
]));
final state = EditorState(document: StateTree(root: root));
type: "root",
attributes: {},
children: LinkedList()
..addAll([
item1,
item2,
item3,
]),
);
final state = EditorState(document: Document(root: root));
expect(item1.path, [0]);
expect(item2.path, [1]);
expect(item3.path, [2]);
final tb = TransactionBuilder(state);
tb.deleteNode(item1);
tb.deleteNode(item2);
tb.deleteNode(item3);
final transaction = tb.finish();
final transaction = state.transaction;
transaction.deleteNode(item1);
transaction.deleteNode(item2);
transaction.deleteNode(item3);
state.commit();
expect(transaction.operations[0].path, [0]);
expect(transaction.operations[1].path, [0]);
expect(transaction.operations[2].path, [0]);
@ -74,13 +74,12 @@ void main() {
group("toJson", () {
test("insert", () {
final root = Node(type: "root", attributes: {}, children: LinkedList());
final state = EditorState(document: StateTree(root: root));
final state = EditorState(document: Document(root: root));
final item1 = Node(type: "node", attributes: {}, children: LinkedList());
final tb = TransactionBuilder(state);
tb.insertNode([0], item1);
final transaction = tb.finish();
final transaction = state.transaction;
transaction.insertNode([0], item1);
state.commit();
expect(transaction.toJson(), {
"operations": [
{
@ -94,16 +93,17 @@ void main() {
test("delete", () {
final item1 = Node(type: "node", attributes: {}, children: LinkedList());
final root = Node(
type: "root",
attributes: {},
children: LinkedList()
..addAll([
item1,
]));
final state = EditorState(document: StateTree(root: root));
final tb = TransactionBuilder(state);
tb.deleteNode(item1);
final transaction = tb.finish();
type: "root",
attributes: {},
children: LinkedList()
..addAll([
item1,
]),
);
final state = EditorState(document: Document(root: root));
final transaction = state.transaction;
transaction.deleteNode(item1);
state.commit();
expect(transaction.toJson(), {
"operations": [
{

View File

@ -17,16 +17,16 @@ void main() async {
}
test("HistoryItem #1", () {
final document = StateTree(root: _createEmptyEditorRoot());
final document = Document(root: _createEmptyEditorRoot());
final editorState = EditorState(document: document);
final historyItem = HistoryItem();
historyItem.add(DeleteOperation(
[0], [TextNode(type: 'text', delta: Delta()..insert('0'))]));
historyItem.add(DeleteOperation(
[0], [TextNode(type: 'text', delta: Delta()..insert('1'))]));
historyItem.add(DeleteOperation(
[0], [TextNode(type: 'text', delta: Delta()..insert('2'))]));
historyItem
.add(DeleteOperation([0], [TextNode(delta: Delta()..insert('0'))]));
historyItem
.add(DeleteOperation([0], [TextNode(delta: Delta()..insert('1'))]));
historyItem
.add(DeleteOperation([0], [TextNode(delta: Delta()..insert('2'))]));
final transaction = historyItem.toTransaction(editorState);
assert(isInsertAndPathEqual(transaction.operations[0], [0], '2'));
@ -35,12 +35,12 @@ void main() async {
});
test("HistoryItem #2", () {
final document = StateTree(root: _createEmptyEditorRoot());
final document = Document(root: _createEmptyEditorRoot());
final editorState = EditorState(document: document);
final historyItem = HistoryItem();
historyItem.add(DeleteOperation(
[0], [TextNode(type: 'text', delta: Delta()..insert('0'))]));
historyItem
.add(DeleteOperation([0], [TextNode(delta: Delta()..insert('0'))]));
historyItem
.add(UpdateOperation([0], {"subType": "number"}, {"subType": null}));
historyItem.add(DeleteOperation([0], [TextNode.empty(), TextNode.empty()]));
@ -59,11 +59,11 @@ bool isInsertAndPathEqual(Operation operation, Path path, [String? content]) {
return false;
}
if (!pathEquals(operation.path, path)) {
if (!operation.path.equals(path)) {
return false;
}
final firstNode = operation.nodes[0];
final firstNode = operation.nodes.first;
if (firstNode is! TextNode) {
return false;
}
@ -72,5 +72,5 @@ bool isInsertAndPathEqual(Operation operation, Path path, [String? content]) {
return true;
}
return firstNode.delta.toRawString() == content;
return firstNode.delta.toPlainText() == content;
}

View File

@ -26,8 +26,8 @@ void main() async {
BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
BuiltInAttributeKey.checkbox: false,
},
delta: Delta([
TextInsert(text, {
delta: Delta(operations: [
TextInsert(text, attributes: {
BuiltInAttributeKey.bold: true,
BuiltInAttributeKey.italic: true,
BuiltInAttributeKey.underline: true,

View File

@ -147,7 +147,7 @@ Future<void> _testDefaultSelectionMenuItems(
int index, EditorWidgetTester editor) async {
expect(editor.documentLength, 4);
expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0));
expect((editor.nodeAtPath([1]) as TextNode).toRawString(),
expect((editor.nodeAtPath([1]) as TextNode).toPlainText(),
'Welcome to Appflowy 😁');
final node = editor.nodeAtPath([2]);
final item = defaultSelectionMenuItems[index];

View File

@ -117,7 +117,7 @@ void main() async {
expect(editor.documentLength, 1);
expect(editor.documentSelection,
Selection.single(path: [0], startOffset: text.length));
expect((editor.nodeAtPath([0]) as TextNode).toRawString(), text * 2);
expect((editor.nodeAtPath([0]) as TextNode).toPlainText(), text * 2);
});
// Before
@ -275,7 +275,6 @@ void main() async {
// * Welcome to Appflowy 😁
const text = 'Welcome to Appflowy 😁';
final node = TextNode(
type: 'text',
delta: Delta()..insert(text),
attributes: {
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
@ -320,7 +319,7 @@ void main() async {
editor.documentSelection,
Selection.single(path: [0, 0], startOffset: text.length),
);
expect((editor.nodeAtPath([0, 0]) as TextNode).toRawString(), text * 2);
expect((editor.nodeAtPath([0, 0]) as TextNode).toPlainText(), text * 2);
});
testWidgets('Delete the complicated nested bulleted list', (tester) async {
@ -331,7 +330,6 @@ void main() async {
// * Welcome to Appflowy 😁
const text = 'Welcome to Appflowy 😁';
final node = TextNode(
type: 'text',
delta: Delta()..insert(text),
attributes: {
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
@ -390,7 +388,7 @@ void main() async {
true,
);
expect(
(editor.nodeAtPath([0, 0]) as TextNode).toRawString() == text * 2,
(editor.nodeAtPath([0, 0]) as TextNode).toPlainText() == text * 2,
true,
);
expect(
@ -496,7 +494,7 @@ Future<void> _deleteStyledTextByBackspace(
expect(editor.documentSelection,
Selection.single(path: [1], startOffset: text.length));
expect(editor.nodeAtPath([1])?.subtype, style);
expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text * 2);
expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), text * 2);
await editor.updateSelection(
Selection.single(path: [1], startOffset: 0),
@ -538,7 +536,7 @@ Future<void> _deleteStyledTextByDelete(
expect(
editor.documentSelection, Selection.single(path: [1], startOffset: 0));
expect(editor.nodeAtPath([1])?.subtype, style);
expect((editor.nodeAtPath([1]) as TextNode).toRawString(),
expect((editor.nodeAtPath([1]) as TextNode).toPlainText(),
text.safeSubString(i));
}
@ -548,7 +546,7 @@ Future<void> _deleteStyledTextByDelete(
expect(editor.documentLength, 2);
expect(editor.documentSelection, Selection.single(path: [1], startOffset: 0));
expect(editor.nodeAtPath([1])?.subtype, style);
expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text);
expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), text);
}
Future<void> _deleteTextByBackspace(
@ -568,7 +566,7 @@ Future<void> _deleteTextByBackspace(
expect(editor.documentLength, 3);
expect(editor.documentSelection, Selection.single(path: [1], startOffset: 9));
expect((editor.nodeAtPath([1]) as TextNode).toRawString(),
expect((editor.nodeAtPath([1]) as TextNode).toPlainText(),
'Welcome t Appflowy 😁');
// delete 'to '
@ -578,7 +576,7 @@ Future<void> _deleteTextByBackspace(
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
expect(editor.documentLength, 3);
expect(editor.documentSelection, Selection.single(path: [2], startOffset: 8));
expect((editor.nodeAtPath([2]) as TextNode).toRawString(),
expect((editor.nodeAtPath([2]) as TextNode).toPlainText(),
'Welcome Appflowy 😁');
// delete 'Appflowy 😁
@ -593,7 +591,7 @@ Future<void> _deleteTextByBackspace(
expect(editor.documentLength, 1);
expect(
editor.documentSelection, Selection.single(path: [0], startOffset: 11));
expect((editor.nodeAtPath([0]) as TextNode).toRawString(),
expect((editor.nodeAtPath([0]) as TextNode).toPlainText(),
'Welcome to Appflowy 😁');
}
@ -614,7 +612,7 @@ Future<void> _deleteTextByDelete(
expect(editor.documentLength, 3);
expect(editor.documentSelection, Selection.single(path: [1], startOffset: 9));
expect((editor.nodeAtPath([1]) as TextNode).toRawString(),
expect((editor.nodeAtPath([1]) as TextNode).toPlainText(),
'Welcome t Appflowy 😁');
// delete 'to '
@ -624,7 +622,7 @@ Future<void> _deleteTextByDelete(
await editor.pressLogicKey(LogicalKeyboardKey.delete);
expect(editor.documentLength, 3);
expect(editor.documentSelection, Selection.single(path: [2], startOffset: 8));
expect((editor.nodeAtPath([2]) as TextNode).toRawString(),
expect((editor.nodeAtPath([2]) as TextNode).toPlainText(),
'Welcome Appflowy 😁');
// delete 'Appflowy 😁
@ -639,6 +637,6 @@ Future<void> _deleteTextByDelete(
expect(editor.documentLength, 1);
expect(
editor.documentSelection, Selection.single(path: [0], startOffset: 11));
expect((editor.nodeAtPath([0]) as TextNode).toRawString(),
expect((editor.nodeAtPath([0]) as TextNode).toPlainText(),
'Welcome to Appflowy 😁');
}

View File

@ -74,10 +74,10 @@ void main() async {
expect(lastNode != null, true);
expect(lastNode is TextNode, true);
lastNode = lastNode as TextNode;
expect(lastNode.delta.toRawString(), text);
expect((lastNode.previous as TextNode).delta.toRawString(), '');
expect(lastNode.delta.toPlainText(), text);
expect((lastNode.previous as TextNode).delta.toPlainText(), '');
expect(
(lastNode.previous!.previous as TextNode).delta.toRawString(), text);
(lastNode.previous!.previous as TextNode).delta.toPlainText(), text);
});
// Before
@ -134,7 +134,7 @@ void main() async {
);
await editor.pressLogicKey(LogicalKeyboardKey.enter);
expect(editor.documentLength, 2);
expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text);
expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), text);
});
});
}
@ -227,6 +227,6 @@ Future<void> _testMultipleSelection(
);
expect(editor.documentLength, 2);
expect((editor.nodeAtPath([0]) as TextNode).toRawString(), 'Welcome');
expect((editor.nodeAtPath([1]) as TextNode).toRawString(), 'to Appflowy 😁');
expect((editor.nodeAtPath([0]) as TextNode).toPlainText(), 'Welcome');
expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), 'to Appflowy 😁');
}

View File

@ -39,11 +39,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 0,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allBold, true);
expect(textNode.toRawString(), 'AppFlowy');
expect(textNode.toPlainText(), 'AppFlowy');
});
testWidgets('App**Flowy** to bold AppFlowy', (tester) async {
@ -62,11 +62,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 3,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allBold, true);
expect(textNode.toRawString(), 'AppFlowy');
expect(textNode.toPlainText(), 'AppFlowy');
});
testWidgets('***AppFlowy** to bold *AppFlowy', (tester) async {
@ -85,11 +85,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 1,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allBold, true);
expect(textNode.toRawString(), '*AppFlowy');
expect(textNode.toPlainText(), '*AppFlowy');
});
testWidgets('**AppFlowy** application to bold AppFlowy only',
@ -115,7 +115,7 @@ void main() async {
),
);
expect(appFlowyBold, true);
expect(textNode.toRawString(), 'AppFlowy');
expect(textNode.toPlainText(), 'AppFlowy');
});
testWidgets('**** nothing changes', (tester) async {
@ -134,11 +134,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 0,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allBold, false);
expect(textNode.toRawString(), text);
expect(textNode.toPlainText(), text);
});
});
@ -171,11 +171,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 0,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allBold, true);
expect(textNode.toRawString(), 'AppFlowy');
expect(textNode.toPlainText(), 'AppFlowy');
});
testWidgets('App__Flowy__ to bold AppFlowy', (tester) async {
@ -194,11 +194,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 3,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allBold, true);
expect(textNode.toRawString(), 'AppFlowy');
expect(textNode.toPlainText(), 'AppFlowy');
});
testWidgets('___AppFlowy__ to bold _AppFlowy', (tester) async {
@ -217,11 +217,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 1,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allBold, true);
expect(textNode.toRawString(), '_AppFlowy');
expect(textNode.toPlainText(), '_AppFlowy');
});
testWidgets('__AppFlowy__ application to bold AppFlowy only',
@ -247,7 +247,7 @@ void main() async {
),
);
expect(appFlowyBold, true);
expect(textNode.toRawString(), 'AppFlowy');
expect(textNode.toPlainText(), 'AppFlowy');
});
testWidgets('____ nothing changes', (tester) async {
@ -266,11 +266,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 0,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allBold, false);
expect(textNode.toRawString(), text);
expect(textNode.toPlainText(), text);
});
});
});

View File

@ -38,11 +38,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 0,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allCode, true);
expect(textNode.toRawString(), 'AppFlowy');
expect(textNode.toPlainText(), 'AppFlowy');
});
testWidgets('App`Flowy` to code AppFlowy', (tester) async {
@ -61,11 +61,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 3,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allCode, true);
expect(textNode.toRawString(), 'AppFlowy');
expect(textNode.toPlainText(), 'AppFlowy');
});
testWidgets('`` nothing changes', (tester) async {
@ -84,11 +84,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 0,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allCode, false);
expect(textNode.toRawString(), text);
expect(textNode.toPlainText(), text);
});
});
@ -120,11 +120,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 1,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allCode, true);
expect(textNode.toRawString(), '`AppFlowy');
expect(textNode.toPlainText(), '`AppFlowy');
});
testWidgets('```` nothing changes', (tester) async {
@ -143,11 +143,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 0,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allCode, false);
expect(textNode.toRawString(), text);
expect(textNode.toPlainText(), text);
});
});
@ -180,11 +180,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 0,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allStrikethrough, true);
expect(textNode.toRawString(), 'AppFlowy');
expect(textNode.toPlainText(), 'AppFlowy');
});
testWidgets('App~~Flowy~~ to strikethrough AppFlowy', (tester) async {
@ -203,11 +203,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 3,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allStrikethrough, true);
expect(textNode.toRawString(), 'AppFlowy');
expect(textNode.toPlainText(), 'AppFlowy');
});
testWidgets('~~~AppFlowy~~ to bold ~AppFlowy', (tester) async {
@ -226,11 +226,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 1,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allStrikethrough, true);
expect(textNode.toRawString(), '~AppFlowy');
expect(textNode.toPlainText(), '~AppFlowy');
});
testWidgets('~~~~ nothing changes', (tester) async {
@ -249,11 +249,11 @@ void main() async {
Selection.single(
path: [0],
startOffset: 0,
endOffset: textNode.toRawString().length,
endOffset: textNode.toPlainText().length,
),
);
expect(allStrikethrough, false);
expect(textNode.toRawString(), text);
expect(textNode.toPlainText(), text);
});
});
});

View File

@ -56,7 +56,7 @@ Future<void> _testBackspaceUndoRedo(
}
expect(editor.documentLength, 3);
expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text);
expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), text);
expect(editor.documentSelection, selection);
if (Platform.isWindows || Platform.isLinux) {

View File

@ -26,7 +26,7 @@ void main() async {
);
await editor.pressLogicKey(LogicalKeyboardKey.space);
expect(
(editor.nodeAtPath([i]) as TextNode).toRawString(),
(editor.nodeAtPath([i]) as TextNode).toPlainText(),
'W elcome to Appflowy 😁',
);
}
@ -36,7 +36,7 @@ void main() async {
);
await editor.pressLogicKey(LogicalKeyboardKey.space);
expect(
(editor.nodeAtPath([i]) as TextNode).toRawString(),
(editor.nodeAtPath([i]) as TextNode).toPlainText(),
'W elcome to Appflowy 😁 ',
);
}

View File

@ -87,7 +87,7 @@ void main() async {
expect(textNode.subtype, BuiltInAttributeKey.heading);
// BuiltInAttributeKey.h1 ~ BuiltInAttributeKey.h6
expect(textNode.attributes.heading, 'h$i');
expect(textNode.toRawString().startsWith('##'), true);
expect(textNode.toPlainText().startsWith('##'), true);
}
});
@ -211,7 +211,7 @@ void main() async {
await editor.pressLogicKey(LogicalKeyboardKey.space);
expect(textNode.subtype, BuiltInAttributeKey.checkbox);
expect(textNode.attributes.check, true);
expect(textNode.toRawString(), insertedText);
expect(textNode.toPlainText(), insertedText);
});
});
}

View File

@ -63,9 +63,9 @@ void main() async {
..insertTextNode(text)
..insertTextNode(
null,
delta: Delta([
delta: Delta(operations: [
TextInsert(text),
TextInsert(text, attributes),
TextInsert(text, attributes: attributes),
TextInsert(text),
]),
);
@ -171,8 +171,8 @@ void main() async {
BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
BuiltInAttributeKey.heading: BuiltInAttributeKey.h1,
},
delta: Delta([
TextInsert(text, {
delta: Delta(operations: [
TextInsert(text, attributes: {
BuiltInAttributeKey.bold: true,
})
]),