mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
refactor: implement appflowy editor core
This commit is contained in:
commit
d80a67bdda
@ -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,
|
||||
|
@ -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!),
|
||||
),
|
||||
|
@ -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>(
|
||||
|
@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
@ -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'),
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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';
|
||||
|
@ -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();
|
||||
|
@ -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(
|
||||
|
@ -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();
|
||||
|
@ -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]
|
||||
|
@ -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)),
|
||||
);
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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';
|
||||
}
|
@ -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;
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
typedef Path = List<int>;
|
||||
|
||||
bool pathEquals(Path path1, Path path2) {
|
||||
return listEquals(path1, path2);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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)) {
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
||||
|
@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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 =
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -15,7 +15,6 @@ void main() async {
|
||||
const text = 'Welcome to Appflowy 😁';
|
||||
TextNode textNode() {
|
||||
return TextNode(
|
||||
type: 'text',
|
||||
delta: Delta()..insert(text),
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
|
@ -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": [
|
||||
{
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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];
|
||||
|
@ -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 😁');
|
||||
}
|
||||
|
@ -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 😁');
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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) {
|
||||
|
@ -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 😁 ',
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -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,
|
||||
})
|
||||
]),
|
||||
|
Loading…
Reference in New Issue
Block a user