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
|
```dart
|
||||||
final json = ...;
|
final json = ...;
|
||||||
final editorStyle = EditorStyle.defaultStyle();
|
final editorStyle = EditorStyle.defaultStyle();
|
||||||
final editorState = EditorState(StateTree.fromJson(data));
|
final editorState = EditorState(Document.fromJson(data));
|
||||||
final editor = AppFlowyEditor(
|
final editor = AppFlowyEditor(
|
||||||
editorState: editorState,
|
editorState: editorState,
|
||||||
editorStyle: editorStyle,
|
editorStyle: editorStyle,
|
||||||
|
@ -98,7 +98,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
if (snapshot.hasData &&
|
if (snapshot.hasData &&
|
||||||
snapshot.connectionState == ConnectionState.done) {
|
snapshot.connectionState == ConnectionState.done) {
|
||||||
_editorState ??= EditorState(
|
_editorState ??= EditorState(
|
||||||
document: StateTree.fromJson(
|
document: Document.fromJson(
|
||||||
Map<String, Object>.from(
|
Map<String, Object>.from(
|
||||||
json.decode(snapshot.data!),
|
json.decode(snapshot.data!),
|
||||||
),
|
),
|
||||||
|
@ -26,9 +26,9 @@ ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) {
|
|||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
if (selection.isCollapsed) {
|
if (selection.isCollapsed) {
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..insertText(codeBlockNode.first, selection.end.offset, '\n')
|
.insertText(codeBlockNode.first, selection.end.offset, '\n');
|
||||||
..commit();
|
editorState.commit();
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
@ -60,21 +60,20 @@ SelectionMenuItem codeBlockMenuItem = SelectionMenuItem(
|
|||||||
if (selection == null || textNodes.isEmpty) {
|
if (selection == null || textNodes.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (textNodes.first.toRawString().isEmpty) {
|
if (textNodes.first.toPlainText().isEmpty) {
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..updateNode(textNodes.first, {
|
..updateNode(textNodes.first, {
|
||||||
'subtype': 'code_block',
|
'subtype': 'code_block',
|
||||||
'theme': 'vs',
|
'theme': 'vs',
|
||||||
'language': null,
|
'language': null,
|
||||||
})
|
})
|
||||||
..afterSelection = selection
|
..afterSelection = selection;
|
||||||
..commit();
|
editorState.commit();
|
||||||
} else {
|
} else {
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..insertNode(
|
..insertNode(
|
||||||
selection.end.path.next,
|
selection.end.path.next,
|
||||||
TextNode(
|
TextNode(
|
||||||
type: 'text',
|
|
||||||
children: LinkedList(),
|
children: LinkedList(),
|
||||||
attributes: {
|
attributes: {
|
||||||
'subtype': 'code_block',
|
'subtype': 'code_block',
|
||||||
@ -84,8 +83,8 @@ SelectionMenuItem codeBlockMenuItem = SelectionMenuItem(
|
|||||||
delta: Delta()..insert('\n'),
|
delta: Delta()..insert('\n'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
..afterSelection = selection
|
..afterSelection = selection;
|
||||||
..commit();
|
editorState.commit();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -149,7 +148,7 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
|
|||||||
|
|
||||||
Widget _buildCodeBlock(BuildContext context) {
|
Widget _buildCodeBlock(BuildContext context) {
|
||||||
final result = highlight.highlight.parse(
|
final result = highlight.highlight.parse(
|
||||||
widget.textNode.toRawString(),
|
widget.textNode.toPlainText(),
|
||||||
language: _language,
|
language: _language,
|
||||||
autoDetection: _language == null,
|
autoDetection: _language == null,
|
||||||
);
|
);
|
||||||
@ -182,11 +181,10 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
|
|||||||
child: DropdownButton<String>(
|
child: DropdownButton<String>(
|
||||||
value: _detectLanguage,
|
value: _detectLanguage,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
TransactionBuilder(widget.editorState)
|
widget.editorState.transaction.updateNode(widget.textNode, {
|
||||||
..updateNode(widget.textNode, {
|
|
||||||
'language': value,
|
'language': value,
|
||||||
})
|
});
|
||||||
..commit();
|
widget.editorState.commit();
|
||||||
},
|
},
|
||||||
items: allLanguages.keys.map<DropdownMenuItem<String>>((String value) {
|
items: allLanguages.keys.map<DropdownMenuItem<String>>((String value) {
|
||||||
return DropdownMenuItem<String>(
|
return DropdownMenuItem<String>(
|
||||||
|
@ -17,8 +17,8 @@ ShortcutEventHandler _insertHorzaontalRule = (editorState, event) {
|
|||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
final textNode = textNodes.first;
|
final textNode = textNodes.first;
|
||||||
if (textNode.toRawString() == '--') {
|
if (textNode.toPlainText() == '--') {
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..deleteText(textNode, 0, 2)
|
..deleteText(textNode, 0, 2)
|
||||||
..insertNode(
|
..insertNode(
|
||||||
textNode.path,
|
textNode.path,
|
||||||
@ -29,8 +29,8 @@ ShortcutEventHandler _insertHorzaontalRule = (editorState, event) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
..afterSelection =
|
..afterSelection =
|
||||||
Selection.single(path: textNode.path.next, startOffset: 0)
|
Selection.single(path: textNode.path.next, startOffset: 0);
|
||||||
..commit();
|
editorState.commit();
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
@ -53,8 +53,8 @@ SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final textNode = textNodes.first;
|
final textNode = textNodes.first;
|
||||||
if (textNode.toRawString().isEmpty) {
|
if (textNode.toPlainText().isEmpty) {
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..insertNode(
|
..insertNode(
|
||||||
textNode.path,
|
textNode.path,
|
||||||
Node(
|
Node(
|
||||||
@ -64,14 +64,13 @@ SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
..afterSelection =
|
..afterSelection =
|
||||||
Selection.single(path: textNode.path.next, startOffset: 0)
|
Selection.single(path: textNode.path.next, startOffset: 0);
|
||||||
..commit();
|
editorState.commit();
|
||||||
} else {
|
} else {
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..insertNode(
|
..insertNode(
|
||||||
selection.end.path.next,
|
selection.end.path.next,
|
||||||
TextNode(
|
TextNode(
|
||||||
type: 'text',
|
|
||||||
children: LinkedList(),
|
children: LinkedList(),
|
||||||
attributes: {
|
attributes: {
|
||||||
'subtype': 'horizontal_rule',
|
'subtype': 'horizontal_rule',
|
||||||
@ -79,8 +78,8 @@ SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
|
|||||||
delta: Delta()..insert('---'),
|
delta: Delta()..insert('---'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
..afterSelection = selection
|
..afterSelection = selection;
|
||||||
..commit();
|
editorState.commit();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -21,9 +21,9 @@ SelectionMenuItem teXBlockMenuItem = SelectionMenuItem(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final Path texNodePath;
|
final Path texNodePath;
|
||||||
if (textNodes.first.toRawString().isEmpty) {
|
if (textNodes.first.toPlainText().isEmpty) {
|
||||||
texNodePath = selection.end.path;
|
texNodePath = selection.end.path;
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..insertNode(
|
..insertNode(
|
||||||
selection.end.path,
|
selection.end.path,
|
||||||
Node(
|
Node(
|
||||||
@ -33,11 +33,11 @@ SelectionMenuItem teXBlockMenuItem = SelectionMenuItem(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
..deleteNode(textNodes.first)
|
..deleteNode(textNodes.first)
|
||||||
..afterSelection = selection
|
..afterSelection = selection;
|
||||||
..commit();
|
editorState.commit();
|
||||||
} else {
|
} else {
|
||||||
texNodePath = selection.end.path.next;
|
texNodePath = selection.end.path.next;
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..insertNode(
|
..insertNode(
|
||||||
selection.end.path.next,
|
selection.end.path.next,
|
||||||
Node(
|
Node(
|
||||||
@ -46,8 +46,8 @@ SelectionMenuItem teXBlockMenuItem = SelectionMenuItem(
|
|||||||
attributes: {'tex': ''},
|
attributes: {'tex': ''},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
..afterSelection = selection
|
..afterSelection = selection;
|
||||||
..commit();
|
editorState.commit();
|
||||||
}
|
}
|
||||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||||
final texState =
|
final texState =
|
||||||
@ -142,9 +142,8 @@ class __TeXBlockNodeWidgetState extends State<_TeXBlockNodeWidget> {
|
|||||||
size: 16,
|
size: 16,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
TransactionBuilder(widget.editorState)
|
widget.editorState.transaction.deleteNode(widget.node);
|
||||||
..deleteNode(widget.node)
|
widget.editorState.commit();
|
||||||
..commit();
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -175,12 +174,11 @@ class __TeXBlockNodeWidgetState extends State<_TeXBlockNodeWidget> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
if (controller.text != _tex) {
|
if (controller.text != _tex) {
|
||||||
TransactionBuilder(widget.editorState)
|
widget.editorState.transaction.updateNode(
|
||||||
..updateNode(
|
|
||||||
widget.node,
|
widget.node,
|
||||||
{'tex': controller.text},
|
{'tex': controller.text},
|
||||||
)
|
);
|
||||||
..commit();
|
widget.editorState.commit();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('OK'),
|
child: const Text('OK'),
|
||||||
|
@ -18,7 +18,7 @@ ShortcutEventHandler _underscoreToItalicHandler = (editorState, event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final textNode = textNodes.first;
|
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.
|
// Determine if an 'underscore' already exists in the text node and only once.
|
||||||
final firstUnderscore = text.indexOf('_');
|
final firstUnderscore = text.indexOf('_');
|
||||||
final lastUnderscore = text.lastIndexOf('_');
|
final lastUnderscore = text.lastIndexOf('_');
|
||||||
@ -31,7 +31,7 @@ ShortcutEventHandler _underscoreToItalicHandler = (editorState, event) {
|
|||||||
// Delete the previous 'underscore',
|
// Delete the previous 'underscore',
|
||||||
// update the style of the text surrounded by the two underscores to 'italic',
|
// update the style of the text surrounded by the two underscores to 'italic',
|
||||||
// and update the cursor position.
|
// and update the cursor position.
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..deleteText(textNode, firstUnderscore, 1)
|
..deleteText(textNode, firstUnderscore, 1)
|
||||||
..formatText(
|
..formatText(
|
||||||
textNode,
|
textNode,
|
||||||
@ -46,8 +46,8 @@ ShortcutEventHandler _underscoreToItalicHandler = (editorState, event) {
|
|||||||
path: textNode.path,
|
path: textNode.path,
|
||||||
offset: selection.end.offset - 1,
|
offset: selection.end.offset - 1,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
..commit();
|
editorState.commit();
|
||||||
|
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
};
|
};
|
||||||
|
@ -3,18 +3,17 @@ library appflowy_editor;
|
|||||||
|
|
||||||
export 'src/infra/log.dart';
|
export 'src/infra/log.dart';
|
||||||
export 'src/render/style/editor_style.dart';
|
export 'src/render/style/editor_style.dart';
|
||||||
export 'src/document/node.dart';
|
export 'src/core/document/node.dart';
|
||||||
export 'src/document/path.dart';
|
export 'src/core/document/path.dart';
|
||||||
export 'src/document/position.dart';
|
export 'src/core/location/position.dart';
|
||||||
export 'src/document/selection.dart';
|
export 'src/core/location/selection.dart';
|
||||||
export 'src/document/state_tree.dart';
|
export 'src/core/document/document.dart';
|
||||||
export 'src/document/text_delta.dart';
|
export 'src/core/document/text_delta.dart';
|
||||||
export 'src/document/attributes.dart';
|
export 'src/core/document/attributes.dart';
|
||||||
export 'src/document/built_in_attribute_keys.dart';
|
export 'src/core/legacy/built_in_attribute_keys.dart';
|
||||||
export 'src/editor_state.dart';
|
export 'src/editor_state.dart';
|
||||||
export 'src/operation/operation.dart';
|
export 'src/core/transform/operation.dart';
|
||||||
export 'src/operation/transaction.dart';
|
export 'src/core/transform/transaction.dart';
|
||||||
export 'src/operation/transaction_builder.dart';
|
|
||||||
export 'src/render/selection/selectable.dart';
|
export 'src/render/selection/selectable.dart';
|
||||||
export 'src/service/editor_service.dart';
|
export 'src/service/editor_service.dart';
|
||||||
export 'src/service/render_plugin_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.dart';
|
||||||
export 'src/service/shortcut_event/shortcut_event_handler.dart';
|
export 'src/service/shortcut_event/shortcut_event_handler.dart';
|
||||||
export 'src/extensions/attributes_extension.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/default_selectable.dart';
|
||||||
export 'src/render/rich_text/flowy_rich_text.dart';
|
export 'src/render/rich_text/flowy_rich_text.dart';
|
||||||
export 'src/render/selection_menu/selection_menu_widget.dart';
|
export 'src/render/selection_menu/selection_menu_widget.dart';
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
|
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
|
||||||
import 'package:appflowy_editor/src/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
import 'package:appflowy_editor/src/document/path.dart';
|
import 'package:appflowy_editor/src/core/document/path.dart';
|
||||||
import 'package:appflowy_editor/src/editor_state.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';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
Future<void> insertContextInText(
|
Future<void> insertContextInText(
|
||||||
@ -22,9 +22,8 @@ Future<void> insertContextInText(
|
|||||||
|
|
||||||
final completer = Completer<void>();
|
final completer = Completer<void>();
|
||||||
|
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction.insertText(result, index, content);
|
||||||
..insertText(result, index, content)
|
editorState.commit();
|
||||||
..commit();
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||||
completer.complete();
|
completer.complete();
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import 'package:appflowy_editor/src/commands/format_text.dart';
|
import 'package:appflowy_editor/src/commands/format_text.dart';
|
||||||
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
|
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
|
||||||
import 'package:appflowy_editor/src/document/attributes.dart';
|
import 'package:appflowy_editor/src/core/document/attributes.dart';
|
||||||
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
|
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
|
||||||
import 'package:appflowy_editor/src/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
import 'package:appflowy_editor/src/document/path.dart';
|
import 'package:appflowy_editor/src/core/document/path.dart';
|
||||||
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/editor_state.dart';
|
||||||
|
|
||||||
Future<void> formatBuiltInTextAttributes(
|
Future<void> formatBuiltInTextAttributes(
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
|
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
|
||||||
import 'package:appflowy_editor/src/document/attributes.dart';
|
import 'package:appflowy_editor/src/core/document/attributes.dart';
|
||||||
import 'package:appflowy_editor/src/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
import 'package:appflowy_editor/src/document/path.dart';
|
import 'package:appflowy_editor/src/core/document/path.dart';
|
||||||
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/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';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
Future<void> updateTextNodeAttributes(
|
Future<void> updateTextNodeAttributes(
|
||||||
@ -23,9 +23,8 @@ Future<void> updateTextNodeAttributes(
|
|||||||
|
|
||||||
final completer = Completer<void>();
|
final completer = Completer<void>();
|
||||||
|
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction.updateNode(result, attributes);
|
||||||
..updateNode(result, attributes)
|
editorState.commit();
|
||||||
..commit();
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||||
completer.complete();
|
completer.complete();
|
||||||
@ -49,15 +48,13 @@ Future<void> updateTextNodeDeltaAttributes(
|
|||||||
final newSelection = getSelection(editorState, selection: selection);
|
final newSelection = getSelection(editorState, selection: selection);
|
||||||
|
|
||||||
final completer = Completer<void>();
|
final completer = Completer<void>();
|
||||||
|
editorState.transaction.formatText(
|
||||||
TransactionBuilder(editorState)
|
|
||||||
..formatText(
|
|
||||||
result,
|
result,
|
||||||
newSelection.startIndex,
|
newSelection.startIndex,
|
||||||
newSelection.length,
|
newSelection.length,
|
||||||
attributes,
|
attributes,
|
||||||
)
|
);
|
||||||
..commit();
|
editorState.commit();
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||||
completer.complete();
|
completer.complete();
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import 'package:appflowy_editor/src/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
import 'package:appflowy_editor/src/document/path.dart';
|
import 'package:appflowy_editor/src/core/document/path.dart';
|
||||||
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/editor_state.dart';
|
||||||
|
|
||||||
// get formatted [TextNode]
|
// 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 '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 '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> {
|
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({
|
Node({
|
||||||
required this.type,
|
required this.type,
|
||||||
required this.children,
|
Attributes? attributes,
|
||||||
required Attributes attributes,
|
|
||||||
this.parent,
|
this.parent,
|
||||||
}) : _attributes = attributes {
|
LinkedList<Node>? children,
|
||||||
for (final child in children) {
|
}) : children = children ?? LinkedList<Node>(),
|
||||||
|
_attributes = attributes ?? {} {
|
||||||
|
for (final child in this.children) {
|
||||||
child.parent = this;
|
child.parent = this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -49,14 +23,13 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
|||||||
factory Node.fromJson(Map<String, Object> json) {
|
factory Node.fromJson(Map<String, Object> json) {
|
||||||
assert(json['type'] is String);
|
assert(json['type'] is String);
|
||||||
|
|
||||||
// TODO: check the type that not exist on plugins.
|
|
||||||
final jType = json['type'] as String;
|
final jType = json['type'] as String;
|
||||||
final jChildren = json['children'] as List?;
|
final jChildren = json['children'] as List?;
|
||||||
final jAttributes = json['attributes'] != null
|
final jAttributes = json['attributes'] != null
|
||||||
? Attributes.from(json['attributes'] as Map)
|
? Attributes.from(json['attributes'] as Map)
|
||||||
: Attributes.from({});
|
: Attributes.from({});
|
||||||
|
|
||||||
final LinkedList<Node> children = LinkedList();
|
final children = LinkedList<Node>();
|
||||||
if (jChildren != null) {
|
if (jChildren != null) {
|
||||||
children.addAll(
|
children.addAll(
|
||||||
jChildren.map(
|
jChildren.map(
|
||||||
@ -69,14 +42,14 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
|||||||
|
|
||||||
Node node;
|
Node node;
|
||||||
|
|
||||||
if (jType == "text") {
|
if (jType == 'text') {
|
||||||
final jDelta = json['delta'] as List<dynamic>?;
|
final jDelta = json['delta'] as List<dynamic>?;
|
||||||
final delta = jDelta == null ? Delta() : Delta.fromJson(jDelta);
|
final delta = jDelta == null ? Delta() : Delta.fromJson(jDelta);
|
||||||
node = TextNode(
|
node = TextNode(
|
||||||
type: jType,
|
|
||||||
children: children,
|
children: children,
|
||||||
attributes: jAttributes,
|
attributes: jAttributes,
|
||||||
delta: delta);
|
delta: delta,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
node = Node(
|
node = Node(
|
||||||
type: jType,
|
type: jType,
|
||||||
@ -92,20 +65,48 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
|||||||
return 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) {
|
void updateAttributes(Attributes attributes) {
|
||||||
final oldAttributes = {..._attributes};
|
final oldAttributes = this.attributes;
|
||||||
_attributes = composeAttributes(_attributes, attributes) ?? {};
|
|
||||||
|
_attributes = composeAttributes(this.attributes, attributes) ?? {};
|
||||||
|
|
||||||
// Notifies the new attributes
|
// Notifies the new attributes
|
||||||
// if attributes contains 'subtype', should notify parent to rebuild node
|
// if attributes contains 'subtype', should notify parent to rebuild node
|
||||||
// else, just notify current node.
|
// else, just notify current node.
|
||||||
bool shouldNotifyParent =
|
bool shouldNotifyParent =
|
||||||
_attributes['subtype'] != oldAttributes['subtype'];
|
this.attributes['subtype'] != oldAttributes['subtype'];
|
||||||
shouldNotifyParent ? parent?.notifyListeners() : notifyListeners();
|
shouldNotifyParent ? parent?.notifyListeners() : notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Node? childAtIndex(int index) {
|
Node? childAtIndex(int index) {
|
||||||
if (children.length <= index) {
|
if (children.length <= index || index < 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,7 +122,8 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void insert(Node entry, {int? index}) {
|
void insert(Node entry, {int? index}) {
|
||||||
index ??= children.length;
|
final length = children.length;
|
||||||
|
index ??= length;
|
||||||
|
|
||||||
if (children.isEmpty) {
|
if (children.isEmpty) {
|
||||||
entry.parent = this;
|
entry.parent = this;
|
||||||
@ -130,8 +132,9 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
|||||||
return;
|
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) {
|
if (index >= length) {
|
||||||
children.last.insertAfter(entry);
|
children.last.insertAfter(entry);
|
||||||
} else if (index <= 0) {
|
} else if (index <= 0) {
|
||||||
@ -173,28 +176,14 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
|||||||
};
|
};
|
||||||
if (children.isNotEmpty) {
|
if (children.isNotEmpty) {
|
||||||
map['children'] =
|
map['children'] =
|
||||||
(children.map((node) => node.toJson())).toList(growable: false);
|
children.map((node) => node.toJson()).toList(growable: false);
|
||||||
}
|
}
|
||||||
if (_attributes.isNotEmpty) {
|
if (attributes.isNotEmpty) {
|
||||||
map['attributes'] = _attributes;
|
map['attributes'] = attributes;
|
||||||
}
|
}
|
||||||
return map;
|
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({
|
Node copyWith({
|
||||||
String? type,
|
String? type,
|
||||||
LinkedList<Node>? children,
|
LinkedList<Node>? children,
|
||||||
@ -202,8 +191,8 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
|||||||
}) {
|
}) {
|
||||||
final node = Node(
|
final node = Node(
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
attributes: attributes ?? {..._attributes},
|
attributes: attributes ?? {...this.attributes},
|
||||||
children: children ?? LinkedList(),
|
children: children,
|
||||||
);
|
);
|
||||||
if (children == null && this.children.isNotEmpty) {
|
if (children == null && this.children.isNotEmpty) {
|
||||||
for (final child in this.children) {
|
for (final child in this.children) {
|
||||||
@ -214,34 +203,43 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
|||||||
}
|
}
|
||||||
return 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 {
|
class TextNode extends Node {
|
||||||
Delta _delta;
|
|
||||||
|
|
||||||
TextNode({
|
TextNode({
|
||||||
required super.type,
|
|
||||||
required Delta delta,
|
required Delta delta,
|
||||||
LinkedList<Node>? children,
|
LinkedList<Node>? children,
|
||||||
Attributes? attributes,
|
Attributes? attributes,
|
||||||
}) : _delta = delta,
|
}) : _delta = delta,
|
||||||
super(
|
super(
|
||||||
children: children ?? LinkedList(),
|
type: 'text',
|
||||||
|
children: children,
|
||||||
attributes: attributes ?? {},
|
attributes: attributes ?? {},
|
||||||
);
|
);
|
||||||
|
|
||||||
TextNode.empty({Attributes? attributes})
|
TextNode.empty({Attributes? attributes})
|
||||||
: _delta = Delta([TextInsert('')]),
|
: _delta = Delta(operations: [TextInsert('')]),
|
||||||
super(
|
super(
|
||||||
type: 'text',
|
type: 'text',
|
||||||
children: LinkedList(),
|
|
||||||
attributes: attributes ?? {},
|
attributes: attributes ?? {},
|
||||||
);
|
);
|
||||||
|
|
||||||
Delta get delta {
|
Delta _delta;
|
||||||
return _delta;
|
Delta get delta => _delta;
|
||||||
}
|
|
||||||
|
|
||||||
set delta(Delta v) {
|
set delta(Delta v) {
|
||||||
_delta = v;
|
_delta = v;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@ -250,21 +248,20 @@ class TextNode extends Node {
|
|||||||
@override
|
@override
|
||||||
Map<String, Object> toJson() {
|
Map<String, Object> toJson() {
|
||||||
final map = super.toJson();
|
final map = super.toJson();
|
||||||
map['delta'] = _delta.toJson();
|
map['delta'] = delta.toJson();
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TextNode copyWith({
|
TextNode copyWith({
|
||||||
String? type,
|
String? type = 'text',
|
||||||
LinkedList<Node>? children,
|
LinkedList<Node>? children,
|
||||||
Attributes? attributes,
|
Attributes? attributes,
|
||||||
Delta? delta,
|
Delta? delta,
|
||||||
}) {
|
}) {
|
||||||
final textNode = TextNode(
|
final textNode = TextNode(
|
||||||
type: type ?? this.type,
|
|
||||||
children: children,
|
children: children,
|
||||||
attributes: attributes ?? _attributes,
|
attributes: attributes ?? this.attributes,
|
||||||
delta: delta ?? this.delta,
|
delta: delta ?? this.delta,
|
||||||
);
|
);
|
||||||
if (children == null && this.children.isNotEmpty) {
|
if (children == null && this.children.isNotEmpty) {
|
||||||
@ -277,5 +274,28 @@ class TextNode extends Node {
|
|||||||
return textNode;
|
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 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
|
import 'package:appflowy_editor/src/core/document/document.dart';
|
||||||
import './state_tree.dart';
|
|
||||||
|
|
||||||
/// [NodeIterator] is used to traverse the nodes in visual order.
|
/// [NodeIterator] is used to traverse the nodes in visual order.
|
||||||
class NodeIterator implements Iterator<Node> {
|
class NodeIterator implements Iterator<Node> {
|
||||||
final StateTree stateTree;
|
NodeIterator({
|
||||||
final Node _startNode;
|
required this.document,
|
||||||
final Node? _endNode;
|
required this.startNode,
|
||||||
|
this.endNode,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Document document;
|
||||||
|
final Node startNode;
|
||||||
|
final Node? endNode;
|
||||||
|
|
||||||
Node? _currentNode;
|
Node? _currentNode;
|
||||||
bool _began = false;
|
bool _began = false;
|
||||||
|
|
||||||
NodeIterator(this.stateTree, Node startNode, [Node? endNode])
|
@override
|
||||||
: _startNode = startNode,
|
Node get current => _currentNode!;
|
||||||
_endNode = endNode;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool moveNext() {
|
bool moveNext() {
|
||||||
if (!_began) {
|
if (!_began) {
|
||||||
_currentNode = _startNode;
|
_currentNode = startNode;
|
||||||
_began = true;
|
_began = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -27,7 +32,7 @@ class NodeIterator implements Iterator<Node> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_endNode != null && _endNode == node) {
|
if (endNode != null && endNode == node) {
|
||||||
_currentNode = null;
|
_currentNode = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -42,32 +47,25 @@ class NodeIterator implements Iterator<Node> {
|
|||||||
if (nextOfParent == null) {
|
if (nextOfParent == null) {
|
||||||
_currentNode = null;
|
_currentNode = null;
|
||||||
} else {
|
} else {
|
||||||
_currentNode = _findLeadingChild(nextOfParent);
|
_currentNode = nextOfParent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return _currentNode != null;
|
return _currentNode != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Node> toList() {
|
||||||
|
final result = <Node>[];
|
||||||
|
while (moveNext()) {
|
||||||
|
result.add(current);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
Node _findLeadingChild(Node node) {
|
Node _findLeadingChild(Node node) {
|
||||||
while (node.children.isNotEmpty) {
|
while (node.children.isNotEmpty) {
|
||||||
node = node.children.first;
|
node = node.children.first;
|
||||||
}
|
}
|
||||||
return node;
|
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 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
typedef Path = List<int>;
|
||||||
|
|
||||||
extension PathExtensions on Path {
|
extension PathExtensions on Path {
|
||||||
|
bool equals(Path other) {
|
||||||
|
return listEquals(this, other);
|
||||||
|
}
|
||||||
|
|
||||||
bool operator >=(Path other) {
|
bool operator >=(Path other) {
|
||||||
if (pathEquals(this, other)) {
|
if (equals(other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return this > other;
|
return this > other;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool operator >(Path other) {
|
bool operator >(Path other) {
|
||||||
if (pathEquals(this, other)) {
|
if (equals(other)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final length = min(this.length, other.length);
|
final length = min(this.length, other.length);
|
||||||
@ -29,14 +35,14 @@ extension PathExtensions on Path {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool operator <=(Path other) {
|
bool operator <=(Path other) {
|
||||||
if (pathEquals(this, other)) {
|
if (equals(other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return this < other;
|
return this < other;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool operator <(Path other) {
|
bool operator <(Path other) {
|
||||||
if (pathEquals(this, other)) {
|
if (equals(other)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final length = min(this.length, other.length);
|
final length = min(this.length, other.length);
|
||||||
@ -63,4 +69,22 @@ extension PathExtensions on Path {
|
|||||||
..removeLast()
|
..removeLast()
|
||||||
..add(last + 1);
|
..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:collection';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:appflowy_editor/src/document/attributes.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:appflowy_editor/src/core/document/attributes.dart';
|
||||||
|
|
||||||
// constant number: 2^53 - 1
|
// constant number: 2^53 - 1
|
||||||
const int _maxInt = 9007199254740991;
|
const int _maxInt = 9007199254740991;
|
||||||
|
|
||||||
abstract class TextOperation {
|
List<int> stringIndexes(String text) {
|
||||||
bool get isEmpty => length == 0;
|
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;
|
int get length;
|
||||||
|
|
||||||
Attributes? get attributes => null;
|
bool get isEmpty => length == 0;
|
||||||
|
|
||||||
Map<String, dynamic> toJson();
|
Map<String, dynamic> toJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
class TextInsert extends TextOperation {
|
class TextInsert extends TextOperation {
|
||||||
String content;
|
TextInsert(
|
||||||
|
this.text, {
|
||||||
|
Attributes? attributes,
|
||||||
|
}) : _attributes = attributes;
|
||||||
|
|
||||||
|
String text;
|
||||||
final Attributes? _attributes;
|
final Attributes? _attributes;
|
||||||
|
|
||||||
TextInsert(this.content, [Attributes? attrs]) : _attributes = attrs;
|
@override
|
||||||
|
int get length => text.length;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get length {
|
Attributes? get attributes => _attributes != null ? {..._attributes!} : null;
|
||||||
return content.length;
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final result = <String, dynamic>{
|
||||||
|
'insert': text,
|
||||||
|
};
|
||||||
|
if (_attributes != null) {
|
||||||
|
result['attributes'] = attributes;
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
@override
|
|
||||||
Attributes? get attributes {
|
|
||||||
return _attributes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other is! TextInsert) {
|
if (identical(this, other)) return true;
|
||||||
return false;
|
|
||||||
}
|
return other is TextInsert &&
|
||||||
return content == other.content &&
|
other.text == text &&
|
||||||
mapEquals(_attributes, other._attributes);
|
mapEquals(_attributes, other._attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode {
|
int get hashCode => text.hashCode ^ _attributes.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class TextRetain extends TextOperation {
|
class TextRetain extends TextOperation {
|
||||||
int _length;
|
TextRetain(
|
||||||
|
this.length, {
|
||||||
|
Attributes? attributes,
|
||||||
|
}) : _attributes = attributes;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int length;
|
||||||
final Attributes? _attributes;
|
final Attributes? _attributes;
|
||||||
|
|
||||||
TextRetain(length, [Attributes? attributes])
|
|
||||||
: _length = length,
|
|
||||||
_attributes = attributes;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get isEmpty {
|
Attributes? get attributes => _attributes != null ? {..._attributes!} : null;
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final result = <String, dynamic>{
|
final result = <String, dynamic>{
|
||||||
'retain': _length,
|
'retain': length,
|
||||||
};
|
};
|
||||||
final attrs = _attributes;
|
if (_attributes != null) {
|
||||||
if (attrs != null) {
|
result['attributes'] = attributes;
|
||||||
result['attributes'] = {...attrs};
|
|
||||||
}
|
}
|
||||||
return result;
|
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
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other is! TextDelete) {
|
if (identical(this, other)) return true;
|
||||||
return false;
|
|
||||||
}
|
return other is TextRetain &&
|
||||||
return _length == other.length;
|
other.length == length &&
|
||||||
|
mapEquals(_attributes, other._attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode {
|
int get hashCode => length.hashCode ^ _attributes.hashCode;
|
||||||
return _length.hashCode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TextDelete extends TextOperation {
|
||||||
|
TextDelete({
|
||||||
|
required this.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
int length;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Attributes? get attributes => null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
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 {
|
class _OpIterator {
|
||||||
|
_OpIterator(
|
||||||
|
Iterable<TextOperation> operations,
|
||||||
|
) : _operations = UnmodifiableListView(operations);
|
||||||
|
|
||||||
final UnmodifiableListView<TextOperation> _operations;
|
final UnmodifiableListView<TextOperation> _operations;
|
||||||
int _index = 0;
|
int _index = 0;
|
||||||
int _offset = 0;
|
int _offset = 0;
|
||||||
|
|
||||||
_OpIterator(List<TextOperation> operations)
|
|
||||||
: _operations = UnmodifiableListView(operations);
|
|
||||||
|
|
||||||
bool get hasNext {
|
bool get hasNext {
|
||||||
return peekLength() < _maxInt;
|
return peekLength() < _maxInt;
|
||||||
}
|
}
|
||||||
@ -199,20 +506,17 @@ class _OpIterator {
|
|||||||
_offset += length;
|
_offset += length;
|
||||||
}
|
}
|
||||||
if (nextOp is TextDelete) {
|
if (nextOp is TextDelete) {
|
||||||
return TextDelete(length);
|
return TextDelete(length: length);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextOp is TextRetain) {
|
if (nextOp is TextRetain) {
|
||||||
return TextRetain(
|
return TextRetain(length, attributes: nextOp.attributes);
|
||||||
length,
|
|
||||||
nextOp.attributes,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextOp is TextInsert) {
|
if (nextOp is TextInsert) {
|
||||||
return TextInsert(
|
return TextInsert(
|
||||||
nextOp.content.substring(offset, offset + length),
|
nextOp.text.substring(offset, offset + length),
|
||||||
nextOp.attributes,
|
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 {
|
class Position {
|
||||||
final Path path;
|
final Path path;
|
||||||
@ -11,17 +11,18 @@ class Position {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other is! Position) {
|
if (identical(this, other)) return true;
|
||||||
return false;
|
|
||||||
}
|
return other is Position &&
|
||||||
return pathEquals(path, other.path) && offset == other.offset;
|
other.path.equals(path) &&
|
||||||
|
other.offset == offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode {
|
int get hashCode => Object.hash(offset, Object.hashAll(path));
|
||||||
final pathHash = Object.hashAll(path);
|
|
||||||
return Object.hash(pathHash, offset);
|
@override
|
||||||
}
|
String toString() => 'path = $path, offset = $offset';
|
||||||
|
|
||||||
Position copyWith({Path? path, int? offset}) {
|
Position copyWith({Path? path, int? offset}) {
|
||||||
return Position(
|
return Position(
|
||||||
@ -30,13 +31,10 @@ class Position {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'path = $path, offset = $offset';
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
"path": path.toList(),
|
'path': path,
|
||||||
"offset": offset,
|
'offset': offset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,6 +1,5 @@
|
|||||||
import 'package:appflowy_editor/src/document/path.dart';
|
import 'package:appflowy_editor/src/core/document/path.dart';
|
||||||
import 'package:appflowy_editor/src/document/position.dart';
|
import 'package:appflowy_editor/src/core/location/position.dart';
|
||||||
import 'package:appflowy_editor/src/extensions/path_extensions.dart';
|
|
||||||
|
|
||||||
/// Selection represents the selected area or the cursor area in the editor.
|
/// Selection represents the selected area or the cursor area in the editor.
|
||||||
///
|
///
|
||||||
@ -37,31 +36,58 @@ class Selection {
|
|||||||
final Position start;
|
final Position start;
|
||||||
final Position end;
|
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 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 =>
|
bool get isForward =>
|
||||||
(start.path > end.path) || (isSingle && start.offset > end.offset);
|
(start.path > end.path) || (isSingle && start.offset > end.offset);
|
||||||
|
|
||||||
|
/// Returns a Boolean indicating whether the selection is backward.
|
||||||
bool get isBackward =>
|
bool get isBackward =>
|
||||||
(start.path < end.path) || (isSingle && start.offset < end.offset);
|
(start.path < end.path) || (isSingle && start.offset < end.offset);
|
||||||
|
|
||||||
Selection get normalize {
|
/// Returns a normalized selection that direction is forward.
|
||||||
if (isForward) {
|
Selection get normalized => isBackward ? copyWith() : reversed.copyWith();
|
||||||
return reversed;
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// Returns a reversed selection.
|
||||||
Selection get reversed => copyWith(start: end, end: start);
|
Selection get reversed => copyWith(start: end, end: start);
|
||||||
|
|
||||||
int get startIndex => normalize.start.offset;
|
/// Returns the offset in the starting position under the normalized selection.
|
||||||
int get endIndex => normalize.end.offset;
|
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;
|
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}) {
|
Selection collapse({bool atStart = false}) {
|
||||||
if (atStart) {
|
if (atStart) {
|
||||||
return Selection(start: start, end: start);
|
return copyWith(end: start);
|
||||||
} else {
|
} 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() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
'start': start.toJson(),
|
'start': start.toJson(),
|
||||||
'end': end.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:appflowy_editor/src/service/service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:appflowy_editor/src/document/selection.dart';
|
import 'package:appflowy_editor/src/core/location/selection.dart';
|
||||||
import 'package:appflowy_editor/src/document/state_tree.dart';
|
import 'package:appflowy_editor/src/core/document/document.dart';
|
||||||
import 'package:appflowy_editor/src/operation/operation.dart';
|
import 'package:appflowy_editor/src/core/transform/operation.dart';
|
||||||
import 'package:appflowy_editor/src/operation/transaction.dart';
|
import 'package:appflowy_editor/src/core/transform/transaction.dart';
|
||||||
import 'package:appflowy_editor/src/undo_manager.dart';
|
import 'package:appflowy_editor/src/undo_manager.dart';
|
||||||
|
|
||||||
class ApplyOptions {
|
class ApplyOptions {
|
||||||
@ -46,7 +46,7 @@ enum CursorUpdateReason {
|
|||||||
///
|
///
|
||||||
/// Mutating the document with document's API is not recommended.
|
/// Mutating the document with document's API is not recommended.
|
||||||
class EditorState {
|
class EditorState {
|
||||||
final StateTree document;
|
final Document document;
|
||||||
|
|
||||||
// Service reference.
|
// Service reference.
|
||||||
final service = FlowyService();
|
final service = FlowyService();
|
||||||
@ -74,6 +74,24 @@ class EditorState {
|
|||||||
|
|
||||||
bool editable = true;
|
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 {
|
Selection? get cursorSelection {
|
||||||
return _cursorSelection;
|
return _cursorSelection;
|
||||||
}
|
}
|
||||||
@ -105,7 +123,7 @@ class EditorState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
factory EditorState.empty() {
|
factory EditorState.empty() {
|
||||||
return EditorState(document: StateTree.empty());
|
return EditorState(document: Document.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the transaction to the state.
|
/// Apply the transaction to the state.
|
||||||
@ -166,8 +184,8 @@ class EditorState {
|
|||||||
document.update(op.path, op.attributes);
|
document.update(op.path, op.attributes);
|
||||||
} else if (op is DeleteOperation) {
|
} else if (op is DeleteOperation) {
|
||||||
document.delete(op.path, op.nodes.length);
|
document.delete(op.path, op.nodes.length);
|
||||||
} else if (op is TextEditOperation) {
|
} else if (op is UpdateTextOperation) {
|
||||||
document.textEdit(op.path, op.delta);
|
document.updateText(op.path, op.delta);
|
||||||
}
|
}
|
||||||
_observer.add(op);
|
_observer.add(op);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import 'package:appflowy_editor/src/document/attributes.dart';
|
import 'package:appflowy_editor/src/core/document/attributes.dart';
|
||||||
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
|
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
extension NodeAttributesExtensions on Attributes {
|
extension NodeAttributesExtensions on Attributes {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import 'package:appflowy_editor/src/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.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/selection.dart';
|
||||||
import 'package:appflowy_editor/src/extensions/object_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/selectable.dart';
|
import 'package:appflowy_editor/src/render/selection/selectable.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import 'package:appflowy_editor/src/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
import 'package:appflowy_editor/src/document/path.dart';
|
import 'package:appflowy_editor/src/core/document/path.dart';
|
||||||
import 'package:appflowy_editor/src/document/position.dart';
|
import 'package:appflowy_editor/src/core/location/position.dart';
|
||||||
import 'package:appflowy_editor/src/document/selection.dart';
|
import 'package:appflowy_editor/src/core/location/selection.dart';
|
||||||
import 'package:appflowy_editor/src/document/text_delta.dart';
|
import 'package:appflowy_editor/src/core/document/text_delta.dart';
|
||||||
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
|
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
|
||||||
|
|
||||||
extension TextNodeExtension on TextNode {
|
extension TextNodeExtension on TextNode {
|
||||||
T? getAttributeInSelection<T>(Selection selection, String styleKey) {
|
T? getAttributeInSelection<T>(Selection selection, String styleKey) {
|
||||||
@ -168,18 +168,17 @@ extension TextNodesExtension on List<TextNode> {
|
|||||||
for (var i = 0; i < length; i++) {
|
for (var i = 0; i < length; i++) {
|
||||||
final node = this[i];
|
final node = this[i];
|
||||||
final Selection newSelection;
|
final Selection newSelection;
|
||||||
if (i == 0 && pathEquals(node.path, selection.start.path)) {
|
if (i == 0 && node.path.equals(selection.start.path)) {
|
||||||
if (selection.isBackward) {
|
if (selection.isBackward) {
|
||||||
newSelection = selection.copyWith(
|
newSelection = selection.copyWith(
|
||||||
end: Position(path: node.path, offset: node.toRawString().length),
|
end: Position(path: node.path, offset: node.toPlainText().length),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
newSelection = selection.copyWith(
|
newSelection = selection.copyWith(
|
||||||
end: Position(path: node.path, offset: 0),
|
end: Position(path: node.path, offset: 0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (i == length - 1 &&
|
} else if (i == length - 1 && node.path.equals(selection.end.path)) {
|
||||||
pathEquals(node.path, selection.end.path)) {
|
|
||||||
if (selection.isBackward) {
|
if (selection.isBackward) {
|
||||||
newSelection = selection.copyWith(
|
newSelection = selection.copyWith(
|
||||||
start: Position(path: node.path, offset: 0),
|
start: Position(path: node.path, offset: 0),
|
||||||
@ -187,13 +186,13 @@ extension TextNodesExtension on List<TextNode> {
|
|||||||
} else {
|
} else {
|
||||||
newSelection = selection.copyWith(
|
newSelection = selection.copyWith(
|
||||||
start:
|
start:
|
||||||
Position(path: node.path, offset: node.toRawString().length),
|
Position(path: node.path, offset: node.toPlainText().length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newSelection = Selection(
|
newSelection = Selection(
|
||||||
start: Position(path: node.path, offset: 0),
|
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)) {
|
if (!node.allSatisfyInSelection(newSelection, styleKey, test)) {
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:appflowy_editor/src/document/attributes.dart';
|
import 'package:appflowy_editor/src/core/document/attributes.dart';
|
||||||
import 'package:appflowy_editor/src/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
import 'package:appflowy_editor/src/document/text_delta.dart';
|
import 'package:appflowy_editor/src/core/document/text_delta.dart';
|
||||||
import 'package:appflowy_editor/src/extensions/color_extension.dart';
|
import 'package:appflowy_editor/src/extensions/color_extension.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:html/parser.dart' show parse;
|
import 'package:html/parser.dart' show parse;
|
||||||
import 'package:html/dom.dart' as html;
|
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 {
|
class HTMLTag {
|
||||||
static const h1 = "h1";
|
static const h1 = "h1";
|
||||||
@ -89,7 +89,7 @@ class HTMLToNodesConverter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (delta.isNotEmpty) {
|
if (delta.isNotEmpty) {
|
||||||
result.add(TextNode(type: "text", delta: delta));
|
result.add(TextNode(delta: delta));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -134,7 +134,7 @@ class HTMLToNodesConverter {
|
|||||||
final delta = Delta();
|
final delta = Delta();
|
||||||
delta.insert(element.text);
|
delta.insert(element.text);
|
||||||
if (delta.isNotEmpty) {
|
if (delta.isNotEmpty) {
|
||||||
return [TextNode(type: "text", delta: delta)];
|
return [TextNode(delta: delta)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
@ -218,24 +218,29 @@ class HTMLToNodesConverter {
|
|||||||
|
|
||||||
_handleRichTextElement(Delta delta, html.Element element) {
|
_handleRichTextElement(Delta delta, html.Element element) {
|
||||||
if (element.localName == HTMLTag.span) {
|
if (element.localName == HTMLTag.span) {
|
||||||
delta.insert(element.text,
|
delta.insert(
|
||||||
_getDeltaAttributesFromHtmlAttributes(element.attributes));
|
element.text,
|
||||||
|
attributes: _getDeltaAttributesFromHtmlAttributes(element.attributes),
|
||||||
|
);
|
||||||
} else if (element.localName == HTMLTag.anchor) {
|
} else if (element.localName == HTMLTag.anchor) {
|
||||||
final hyperLink = element.attributes["href"];
|
final hyperLink = element.attributes["href"];
|
||||||
Map<String, dynamic>? attributes;
|
Map<String, dynamic>? attributes;
|
||||||
if (hyperLink != null) {
|
if (hyperLink != null) {
|
||||||
attributes = {"href": hyperLink};
|
attributes = {"href": hyperLink};
|
||||||
}
|
}
|
||||||
delta.insert(element.text, attributes);
|
delta.insert(element.text, attributes: attributes);
|
||||||
} else if (element.localName == HTMLTag.strong ||
|
} else if (element.localName == HTMLTag.strong ||
|
||||||
element.localName == HTMLTag.bold) {
|
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) {
|
} 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) {
|
} 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) {
|
} else if (element.localName == HTMLTag.del) {
|
||||||
delta.insert(element.text, {BuiltInAttributeKey.strikethrough: true});
|
delta.insert(element.text,
|
||||||
|
attributes: {BuiltInAttributeKey.strikethrough: true});
|
||||||
} else {
|
} else {
|
||||||
delta.insert(element.text);
|
delta.insert(element.text);
|
||||||
}
|
}
|
||||||
@ -271,8 +276,7 @@ class HTMLToNodesConverter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final textNode =
|
final textNode = TextNode(delta: delta, attributes: attributes);
|
||||||
TextNode(type: "text", delta: delta, attributes: attributes);
|
|
||||||
if (isCheckbox) {
|
if (isCheckbox) {
|
||||||
textNode.attributes["subtype"] = BuiltInAttributeKey.checkbox;
|
textNode.attributes["subtype"] = BuiltInAttributeKey.checkbox;
|
||||||
textNode.attributes["checkbox"] = checked;
|
textNode.attributes["checkbox"] = checked;
|
||||||
@ -315,7 +319,6 @@ class HTMLToNodesConverter {
|
|||||||
final delta = Delta();
|
final delta = Delta();
|
||||||
delta.insert(element.text);
|
delta.insert(element.text);
|
||||||
return TextNode(
|
return TextNode(
|
||||||
type: "text",
|
|
||||||
attributes: {"subtype": "heading", "heading": headingStyle},
|
attributes: {"subtype": "heading", "heading": headingStyle},
|
||||||
delta: delta);
|
delta: delta);
|
||||||
}
|
}
|
||||||
@ -537,22 +540,22 @@ class NodesToHTMLConverter {
|
|||||||
if (attributes.length == 1 &&
|
if (attributes.length == 1 &&
|
||||||
attributes[BuiltInAttributeKey.bold] == true) {
|
attributes[BuiltInAttributeKey.bold] == true) {
|
||||||
final strong = html.Element.tag(HTMLTag.strong);
|
final strong = html.Element.tag(HTMLTag.strong);
|
||||||
strong.append(html.Text(op.content));
|
strong.append(html.Text(op.text));
|
||||||
childNodes.add(strong);
|
childNodes.add(strong);
|
||||||
} else if (attributes.length == 1 &&
|
} else if (attributes.length == 1 &&
|
||||||
attributes[BuiltInAttributeKey.underline] == true) {
|
attributes[BuiltInAttributeKey.underline] == true) {
|
||||||
final strong = html.Element.tag(HTMLTag.underline);
|
final strong = html.Element.tag(HTMLTag.underline);
|
||||||
strong.append(html.Text(op.content));
|
strong.append(html.Text(op.text));
|
||||||
childNodes.add(strong);
|
childNodes.add(strong);
|
||||||
} else if (attributes.length == 1 &&
|
} else if (attributes.length == 1 &&
|
||||||
attributes[BuiltInAttributeKey.italic] == true) {
|
attributes[BuiltInAttributeKey.italic] == true) {
|
||||||
final strong = html.Element.tag(HTMLTag.italic);
|
final strong = html.Element.tag(HTMLTag.italic);
|
||||||
strong.append(html.Text(op.content));
|
strong.append(html.Text(op.text));
|
||||||
childNodes.add(strong);
|
childNodes.add(strong);
|
||||||
} else if (attributes.length == 1 &&
|
} else if (attributes.length == 1 &&
|
||||||
attributes[BuiltInAttributeKey.strikethrough] == true) {
|
attributes[BuiltInAttributeKey.strikethrough] == true) {
|
||||||
final strong = html.Element.tag(HTMLTag.del);
|
final strong = html.Element.tag(HTMLTag.del);
|
||||||
strong.append(html.Text(op.content));
|
strong.append(html.Text(op.text));
|
||||||
childNodes.add(strong);
|
childNodes.add(strong);
|
||||||
} else {
|
} else {
|
||||||
final span = html.Element.tag(HTMLTag.span);
|
final span = html.Element.tag(HTMLTag.span);
|
||||||
@ -560,11 +563,11 @@ class NodesToHTMLConverter {
|
|||||||
if (cssString.isNotEmpty) {
|
if (cssString.isNotEmpty) {
|
||||||
span.attributes["style"] = cssString;
|
span.attributes["style"] = cssString;
|
||||||
}
|
}
|
||||||
span.append(html.Text(op.content));
|
span.append(html.Text(op.text));
|
||||||
childNodes.add(span);
|
childNodes.add(span);
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
class Infra {
|
||||||
// find the forward nearest text node
|
// 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: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/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/service/render_plugin_service.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/core/document/node.dart';
|
||||||
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
|
|
||||||
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
|
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:rich_clipboard/rich_clipboard.dart';
|
import 'package:rich_clipboard/rich_clipboard.dart';
|
||||||
@ -25,23 +24,20 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
|
|||||||
RichClipboard.setData(RichClipboardData(text: src));
|
RichClipboard.setData(RichClipboardData(text: src));
|
||||||
},
|
},
|
||||||
onDelete: () {
|
onDelete: () {
|
||||||
TransactionBuilder(context.editorState)
|
context.editorState.transaction.deleteNode(context.node);
|
||||||
..deleteNode(context.node)
|
context.editorState.commit();
|
||||||
..commit();
|
|
||||||
},
|
},
|
||||||
onAlign: (alignment) {
|
onAlign: (alignment) {
|
||||||
TransactionBuilder(context.editorState)
|
context.editorState.transaction.updateNode(context.node, {
|
||||||
..updateNode(context.node, {
|
|
||||||
'align': _alignmentToText(alignment),
|
'align': _alignmentToText(alignment),
|
||||||
})
|
});
|
||||||
..commit();
|
context.editorState.commit();
|
||||||
},
|
},
|
||||||
onResize: (width) {
|
onResize: (width) {
|
||||||
TransactionBuilder(context.editorState)
|
context.editorState.transaction.updateNode(context.node, {
|
||||||
..updateNode(context.node, {
|
|
||||||
'width': width,
|
'width': width,
|
||||||
})
|
});
|
||||||
..commit();
|
context.editorState.commit();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import 'package:appflowy_editor/src/extensions/object_extensions.dart';
|
import 'package:appflowy_editor/src/extensions/object_extensions.dart';
|
||||||
import 'package:appflowy_editor/src/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
import 'package:appflowy_editor/src/document/position.dart';
|
import 'package:appflowy_editor/src/core/location/position.dart';
|
||||||
import 'package:appflowy_editor/src/document/selection.dart';
|
import 'package:appflowy_editor/src/core/location/selection.dart';
|
||||||
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
|
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
|
||||||
import 'package:appflowy_editor/src/render/selection/selectable.dart';
|
import 'package:appflowy_editor/src/render/selection/selectable.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import 'dart:collection';
|
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/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/infra/flowy_svg.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:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@ -192,11 +191,10 @@ extension on EditorState {
|
|||||||
'align': 'center',
|
'align': 'center',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
TransactionBuilder(this)
|
transaction.insertNode(
|
||||||
..insertNode(
|
|
||||||
selection.start.path,
|
selection.start.path,
|
||||||
imageNode,
|
imageNode,
|
||||||
)
|
);
|
||||||
..commit();
|
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/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
|
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
|
||||||
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.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/core/location/position.dart';
|
||||||
import 'package:appflowy_editor/src/document/selection.dart';
|
import 'package:appflowy_editor/src/core/location/selection.dart';
|
||||||
import 'package:appflowy_editor/src/render/selection/selectable.dart';
|
import 'package:appflowy_editor/src/render/selection/selectable.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
@ -5,11 +5,11 @@ import 'package:flutter/gestures.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
|
|
||||||
import 'package:appflowy_editor/src/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
import 'package:appflowy_editor/src/document/path.dart';
|
import 'package:appflowy_editor/src/core/document/path.dart';
|
||||||
import 'package:appflowy_editor/src/document/position.dart';
|
import 'package:appflowy_editor/src/core/location/position.dart';
|
||||||
import 'package:appflowy_editor/src/document/selection.dart';
|
import 'package:appflowy_editor/src/core/location/selection.dart';
|
||||||
import 'package:appflowy_editor/src/document/text_delta.dart';
|
import 'package:appflowy_editor/src/core/document/text_delta.dart';
|
||||||
import 'package:appflowy_editor/src/editor_state.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/url_launcher_extension.dart';
|
||||||
import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
|
import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
|
||||||
@ -123,7 +123,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||||||
@override
|
@override
|
||||||
List<Rect> getRectsInSelection(Selection selection) {
|
List<Rect> getRectsInSelection(Selection selection) {
|
||||||
assert(selection.isSingle &&
|
assert(selection.isSingle &&
|
||||||
pathEquals(selection.start.path, widget.textNode.path));
|
selection.start.path.equals(widget.textNode.path));
|
||||||
|
|
||||||
final textSelection = TextSelection(
|
final textSelection = TextSelection(
|
||||||
baseOffset: selection.start.offset,
|
baseOffset: selection.start.offset,
|
||||||
@ -163,7 +163,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||||||
Widget _buildRichText(BuildContext context) {
|
Widget _buildRichText(BuildContext context) {
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
cursor: SystemMouseCursors.text,
|
cursor: SystemMouseCursors.text,
|
||||||
child: widget.textNode.toRawString().isEmpty
|
child: widget.textNode.toPlainText().isEmpty
|
||||||
? Stack(
|
? Stack(
|
||||||
children: [
|
children: [
|
||||||
_buildPlaceholderText(context),
|
_buildPlaceholderText(context),
|
||||||
@ -257,7 +257,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||||||
offset += textInsert.length;
|
offset += textInsert.length;
|
||||||
textSpans.add(
|
textSpans.add(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: textInsert.content,
|
text: textInsert.text,
|
||||||
style: textStyle,
|
style: textStyle,
|
||||||
recognizer: recognizer,
|
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/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.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';
|
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/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.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';
|
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/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
|
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
|
||||||
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.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/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.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';
|
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/core/location/position.dart';
|
||||||
import 'package:appflowy_editor/src/document/selection.dart';
|
import 'package:appflowy_editor/src/core/location/selection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
enum CursorStyle {
|
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:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
|
||||||
|
|
||||||
import 'package:flutter/material.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 {
|
abstract class SelectionMenuService {
|
||||||
Offset get topLeft;
|
Offset get topLeft;
|
||||||
|
@ -44,14 +44,13 @@ class SelectionMenuItem {
|
|||||||
if (selection != null && nodes.length == 1) {
|
if (selection != null && nodes.length == 1) {
|
||||||
final node = nodes.first as TextNode;
|
final node = nodes.first as TextNode;
|
||||||
final end = selection.start.offset;
|
final end = selection.start.offset;
|
||||||
final start = node.toRawString().substring(0, end).lastIndexOf('/');
|
final start = node.toPlainText().substring(0, end).lastIndexOf('/');
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction.deleteText(
|
||||||
..deleteText(
|
|
||||||
node,
|
node,
|
||||||
start,
|
start,
|
||||||
selection.start.offset - start,
|
selection.start.offset - start,
|
||||||
)
|
);
|
||||||
..commit();
|
editorState.commit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -278,13 +277,12 @@ class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
|
|||||||
final nodes = selectionService.currentSelectedNodes;
|
final nodes = selectionService.currentSelectedNodes;
|
||||||
if (selection != null && nodes.length == 1) {
|
if (selection != null && nodes.length == 1) {
|
||||||
widget.onSelectionUpdate();
|
widget.onSelectionUpdate();
|
||||||
TransactionBuilder(widget.editorState)
|
widget.editorState.transaction.deleteText(
|
||||||
..deleteText(
|
|
||||||
nodes.first as TextNode,
|
nodes.first as TextNode,
|
||||||
selection.start.offset - length,
|
selection.start.offset - length,
|
||||||
length,
|
length,
|
||||||
)
|
);
|
||||||
..commit();
|
widget.editorState.commit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -295,13 +293,12 @@ class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
|
|||||||
widget.editorState.service.selectionService.currentSelectedNodes;
|
widget.editorState.service.selectionService.currentSelectedNodes;
|
||||||
if (selection != null && nodes.length == 1) {
|
if (selection != null && nodes.length == 1) {
|
||||||
widget.onSelectionUpdate();
|
widget.onSelectionUpdate();
|
||||||
TransactionBuilder(widget.editorState)
|
widget.editorState.transaction.insertText(
|
||||||
..insertText(
|
|
||||||
nodes.first as TextNode,
|
nodes.first as TextNode,
|
||||||
selection.end.offset,
|
selection.end.offset,
|
||||||
text,
|
text,
|
||||||
)
|
);
|
||||||
..commit();
|
widget.editorState.commit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
|
import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
|
||||||
|
|
||||||
|
@ -357,10 +357,9 @@ void showLinkMenu(
|
|||||||
_dismissLinkMenu();
|
_dismissLinkMenu();
|
||||||
},
|
},
|
||||||
onRemoveLink: () {
|
onRemoveLink: () {
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction.formatText(
|
||||||
..formatText(
|
textNode, index, length, {BuiltInAttributeKey.href: null});
|
||||||
textNode, index, length, {BuiltInAttributeKey.href: null})
|
editorState.commit();
|
||||||
..commit();
|
|
||||||
_dismissLinkMenu();
|
_dismissLinkMenu();
|
||||||
},
|
},
|
||||||
onFocusChange: (value) {
|
onFocusChange: (value) {
|
||||||
|
@ -1,12 +1,5 @@
|
|||||||
import 'package:appflowy_editor/src/document/attributes.dart';
|
import 'package:appflowy_editor/appflowy_editor.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/src/extensions/text_node_extensions.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) {
|
void insertHeadingAfterSelection(EditorState editorState, String heading) {
|
||||||
insertTextNodeAfterSelection(editorState, {
|
insertTextNodeAfterSelection(editorState, {
|
||||||
@ -54,16 +47,15 @@ bool insertTextNodeAfterSelection(
|
|||||||
formatTextNodes(editorState, attributes);
|
formatTextNodes(editorState, attributes);
|
||||||
} else {
|
} else {
|
||||||
final next = selection.end.path.next;
|
final next = selection.end.path.next;
|
||||||
final builder = TransactionBuilder(editorState);
|
editorState.transaction
|
||||||
builder
|
|
||||||
..insertNode(
|
..insertNode(
|
||||||
next,
|
next,
|
||||||
TextNode.empty(attributes: attributes),
|
TextNode.empty(attributes: attributes),
|
||||||
)
|
)
|
||||||
..afterSelection = Selection.collapsed(
|
..afterSelection = Selection.collapsed(
|
||||||
Position(path: next, offset: 0),
|
Position(path: next, offset: 0),
|
||||||
)
|
);
|
||||||
..commit();
|
editorState.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@ -107,7 +99,7 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final builder = TransactionBuilder(editorState);
|
final transaction = editorState.transaction;
|
||||||
|
|
||||||
for (final textNode in textNodes) {
|
for (final textNode in textNodes) {
|
||||||
var newAttributes = {...textNode.attributes};
|
var newAttributes = {...textNode.attributes};
|
||||||
@ -117,7 +109,7 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
newAttributes.addAll(attributes);
|
newAttributes.addAll(attributes);
|
||||||
builder
|
transaction
|
||||||
..updateNode(
|
..updateNode(
|
||||||
textNode,
|
textNode,
|
||||||
newAttributes,
|
newAttributes,
|
||||||
@ -125,12 +117,12 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
|
|||||||
..afterSelection = Selection.collapsed(
|
..afterSelection = Selection.collapsed(
|
||||||
Position(
|
Position(
|
||||||
path: textNode.path,
|
path: textNode.path,
|
||||||
offset: textNode.toRawString().length,
|
offset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.commit();
|
editorState.commit();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -216,13 +208,13 @@ bool formatRichTextStyle(EditorState editorState, Attributes attributes) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final builder = TransactionBuilder(editorState);
|
final transaction = editorState.transaction;
|
||||||
|
|
||||||
// 1. All nodes are text nodes.
|
// 1. All nodes are text nodes.
|
||||||
// 2. The first node is not TextNode.
|
// 2. The first node is not TextNode.
|
||||||
// 3. The last node is not TextNode.
|
// 3. The last node is not TextNode.
|
||||||
if (nodes.length == textNodes.length && textNodes.length == 1) {
|
if (nodes.length == textNodes.length && textNodes.length == 1) {
|
||||||
builder.formatText(
|
transaction.formatText(
|
||||||
textNodes.first,
|
textNodes.first,
|
||||||
selection.start.offset,
|
selection.start.offset,
|
||||||
selection.end.offset - 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++) {
|
for (var i = 0; i < textNodes.length; i++) {
|
||||||
final textNode = textNodes[i];
|
final textNode = textNodes[i];
|
||||||
var index = 0;
|
var index = 0;
|
||||||
var length = textNode.toRawString().length;
|
var length = textNode.toPlainText().length;
|
||||||
if (i == 0 && textNode == nodes.first) {
|
if (i == 0 && textNode == nodes.first) {
|
||||||
index = selection.start.offset;
|
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) {
|
} else if (i == textNodes.length - 1 && textNode == nodes.last) {
|
||||||
length = selection.end.offset;
|
length = selection.end.offset;
|
||||||
}
|
}
|
||||||
builder.formatText(
|
transaction.formatText(
|
||||||
textNode,
|
textNode,
|
||||||
index,
|
index,
|
||||||
length,
|
length,
|
||||||
@ -248,7 +240,7 @@ bool formatRichTextStyle(EditorState editorState, Attributes attributes) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.commit();
|
editorState.commit();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import 'package:appflowy_editor/src/infra/log.dart';
|
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/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'package:appflowy_editor/src/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
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/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/extensions/node_extensions.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,
|
/// [AppFlowyInputService] is responsible for processing text input,
|
||||||
/// including text insertion, deletion and replacement.
|
/// including text insertion, deletion and replacement.
|
||||||
@ -160,13 +160,12 @@ class _AppFlowyInputState extends State<AppFlowyInput>
|
|||||||
}
|
}
|
||||||
if (currentSelection.isSingle) {
|
if (currentSelection.isSingle) {
|
||||||
final textNode = selectionService.currentSelectedNodes.first as TextNode;
|
final textNode = selectionService.currentSelectedNodes.first as TextNode;
|
||||||
TransactionBuilder(_editorState)
|
_editorState.transaction.insertText(
|
||||||
..insertText(
|
|
||||||
textNode,
|
textNode,
|
||||||
delta.insertionOffset,
|
delta.insertionOffset,
|
||||||
delta.textInserted,
|
delta.textInserted,
|
||||||
)
|
);
|
||||||
..commit();
|
_editorState.commit();
|
||||||
} else {
|
} else {
|
||||||
// TODO: implement
|
// TODO: implement
|
||||||
}
|
}
|
||||||
@ -181,9 +180,9 @@ class _AppFlowyInputState extends State<AppFlowyInput>
|
|||||||
if (currentSelection.isSingle) {
|
if (currentSelection.isSingle) {
|
||||||
final textNode = selectionService.currentSelectedNodes.first as TextNode;
|
final textNode = selectionService.currentSelectedNodes.first as TextNode;
|
||||||
final length = delta.deletedRange.end - delta.deletedRange.start;
|
final length = delta.deletedRange.end - delta.deletedRange.start;
|
||||||
TransactionBuilder(_editorState)
|
_editorState.transaction
|
||||||
..deleteText(textNode, delta.deletedRange.start, length)
|
.deleteText(textNode, delta.deletedRange.start, length);
|
||||||
..commit();
|
_editorState.commit();
|
||||||
} else {
|
} else {
|
||||||
// TODO: implement
|
// TODO: implement
|
||||||
}
|
}
|
||||||
@ -198,10 +197,9 @@ class _AppFlowyInputState extends State<AppFlowyInput>
|
|||||||
if (currentSelection.isSingle) {
|
if (currentSelection.isSingle) {
|
||||||
final textNode = selectionService.currentSelectedNodes.first as TextNode;
|
final textNode = selectionService.currentSelectedNodes.first as TextNode;
|
||||||
final length = delta.replacedRange.end - delta.replacedRange.start;
|
final length = delta.replacedRange.end - delta.replacedRange.start;
|
||||||
TransactionBuilder(_editorState)
|
_editorState.transaction.replaceText(
|
||||||
..replaceText(
|
textNode, delta.replacedRange.start, length, delta.replacementText);
|
||||||
textNode, delta.replacedRange.start, length, delta.replacementText)
|
_editorState.commit();
|
||||||
..commit();
|
|
||||||
} else {
|
} else {
|
||||||
// TODO: implement
|
// TODO: implement
|
||||||
}
|
}
|
||||||
@ -282,7 +280,7 @@ class _AppFlowyInputState extends State<AppFlowyInput>
|
|||||||
// FIXME: upward and selection update.
|
// FIXME: upward and selection update.
|
||||||
if (textNodes.isNotEmpty && selection != null) {
|
if (textNodes.isNotEmpty && selection != null) {
|
||||||
final text = textNodes.fold<String>(
|
final text = textNodes.fold<String>(
|
||||||
'', (sum, textNode) => '$sum${textNode.toRawString()}\n');
|
'', (sum, textNode) => '$sum${textNode.toPlainText()}\n');
|
||||||
attach(
|
attach(
|
||||||
TextEditingValue(
|
TextEditingValue(
|
||||||
text: text,
|
text: text,
|
||||||
|
@ -220,7 +220,7 @@ ShortcutEventHandler cursorEndSelect = (editorState, event) {
|
|||||||
KeyEventResult cursorUp(EditorState editorState, RawKeyEvent event) {
|
KeyEventResult cursorUp(EditorState editorState, RawKeyEvent event) {
|
||||||
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
||||||
final selection =
|
final selection =
|
||||||
editorState.service.selectionService.currentSelection.value?.normalize;
|
editorState.service.selectionService.currentSelection.value?.normalized;
|
||||||
if (nodes.isEmpty || selection == null) {
|
if (nodes.isEmpty || selection == null) {
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
@ -234,7 +234,7 @@ KeyEventResult cursorUp(EditorState editorState, RawKeyEvent event) {
|
|||||||
KeyEventResult cursorDown(EditorState editorState, RawKeyEvent event) {
|
KeyEventResult cursorDown(EditorState editorState, RawKeyEvent event) {
|
||||||
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
||||||
final selection =
|
final selection =
|
||||||
editorState.service.selectionService.currentSelection.value?.normalize;
|
editorState.service.selectionService.currentSelection.value?.normalized;
|
||||||
if (nodes.isEmpty || selection == null) {
|
if (nodes.isEmpty || selection == null) {
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
@ -248,7 +248,7 @@ KeyEventResult cursorDown(EditorState editorState, RawKeyEvent event) {
|
|||||||
KeyEventResult cursorLeft(EditorState editorState, RawKeyEvent event) {
|
KeyEventResult cursorLeft(EditorState editorState, RawKeyEvent event) {
|
||||||
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
||||||
final selection =
|
final selection =
|
||||||
editorState.service.selectionService.currentSelection.value?.normalize;
|
editorState.service.selectionService.currentSelection.value?.normalized;
|
||||||
if (nodes.isEmpty || selection == null) {
|
if (nodes.isEmpty || selection == null) {
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
@ -270,7 +270,7 @@ KeyEventResult cursorLeft(EditorState editorState, RawKeyEvent event) {
|
|||||||
KeyEventResult cursorRight(EditorState editorState, RawKeyEvent event) {
|
KeyEventResult cursorRight(EditorState editorState, RawKeyEvent event) {
|
||||||
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
||||||
final selection =
|
final selection =
|
||||||
editorState.service.selectionService.currentSelection.value?.normalize;
|
editorState.service.selectionService.currentSelection.value?.normalized;
|
||||||
if (nodes.isEmpty || selection == null) {
|
if (nodes.isEmpty || selection == null) {
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
@ -28,11 +28,11 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
|
|||||||
final List<Node> nonTextNodes =
|
final List<Node> nonTextNodes =
|
||||||
nodes.where((node) => node is! TextNode).toList(growable: false);
|
nodes.where((node) => node is! TextNode).toList(growable: false);
|
||||||
|
|
||||||
final transactionBuilder = TransactionBuilder(editorState);
|
final transaction = editorState.transaction;
|
||||||
List<int>? cancelNumberListPath;
|
List<int>? cancelNumberListPath;
|
||||||
|
|
||||||
if (nonTextNodes.isNotEmpty) {
|
if (nonTextNodes.isNotEmpty) {
|
||||||
transactionBuilder.deleteNodes(nonTextNodes);
|
transaction.deleteNodes(nonTextNodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (textNodes.length == 1) {
|
if (textNodes.length == 1) {
|
||||||
@ -44,7 +44,7 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
|
|||||||
if (textNode.subtype == BuiltInAttributeKey.numberList) {
|
if (textNode.subtype == BuiltInAttributeKey.numberList) {
|
||||||
cancelNumberListPath = textNode.path;
|
cancelNumberListPath = textNode.path;
|
||||||
}
|
}
|
||||||
transactionBuilder
|
transaction
|
||||||
..updateNode(textNode, {
|
..updateNode(textNode, {
|
||||||
BuiltInAttributeKey.subtype: null,
|
BuiltInAttributeKey.subtype: null,
|
||||||
textNode.subtype!: null,
|
textNode.subtype!: null,
|
||||||
@ -61,20 +61,20 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
|
|||||||
return _backDeleteToPreviousTextNode(
|
return _backDeleteToPreviousTextNode(
|
||||||
editorState,
|
editorState,
|
||||||
textNode,
|
textNode,
|
||||||
transactionBuilder,
|
transaction,
|
||||||
nonTextNodes,
|
nonTextNodes,
|
||||||
selection,
|
selection,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (selection.isCollapsed) {
|
if (selection.isCollapsed) {
|
||||||
transactionBuilder.deleteText(
|
transaction.deleteText(
|
||||||
textNode,
|
textNode,
|
||||||
index,
|
index,
|
||||||
selection.start.offset - index,
|
selection.start.offset - index,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
transactionBuilder.deleteText(
|
transaction.deleteText(
|
||||||
textNode,
|
textNode,
|
||||||
selection.start.offset,
|
selection.start.offset,
|
||||||
selection.end.offset - selection.start.offset,
|
selection.end.offset - selection.start.offset,
|
||||||
@ -84,33 +84,32 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
|
|||||||
} else {
|
} else {
|
||||||
if (textNodes.isEmpty) {
|
if (textNodes.isEmpty) {
|
||||||
if (nonTextNodes.isNotEmpty) {
|
if (nonTextNodes.isNotEmpty) {
|
||||||
transactionBuilder.afterSelection =
|
transaction.afterSelection = Selection.collapsed(selection.start);
|
||||||
Selection.collapsed(selection.start);
|
|
||||||
}
|
}
|
||||||
transactionBuilder.commit();
|
editorState.commit();
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
final startPosition = selection.start;
|
final startPosition = selection.start;
|
||||||
final nodeAtStart = editorState.document.nodeAtPath(startPosition.path)!;
|
final nodeAtStart = editorState.document.nodeAtPath(startPosition.path)!;
|
||||||
_deleteTextNodes(transactionBuilder, textNodes, selection);
|
_deleteTextNodes(transaction, textNodes, selection);
|
||||||
transactionBuilder.commit();
|
editorState.commit();
|
||||||
|
|
||||||
if (nodeAtStart is TextNode &&
|
if (nodeAtStart is TextNode &&
|
||||||
nodeAtStart.subtype == BuiltInAttributeKey.numberList) {
|
nodeAtStart.subtype == BuiltInAttributeKey.numberList) {
|
||||||
makeFollowingNodesIncremental(
|
makeFollowingNodesIncremental(
|
||||||
editorState,
|
editorState,
|
||||||
startPosition.path,
|
startPosition.path,
|
||||||
transactionBuilder.afterSelection!,
|
transaction.afterSelection!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transactionBuilder.operations.isNotEmpty) {
|
if (transaction.operations.isNotEmpty) {
|
||||||
if (nonTextNodes.isNotEmpty) {
|
if (nonTextNodes.isNotEmpty) {
|
||||||
transactionBuilder.afterSelection = Selection.collapsed(selection.start);
|
transaction.afterSelection = Selection.collapsed(selection.start);
|
||||||
}
|
}
|
||||||
transactionBuilder.commit();
|
editorState.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cancelNumberListPath != null) {
|
if (cancelNumberListPath != null) {
|
||||||
@ -128,20 +127,20 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
|
|||||||
KeyEventResult _backDeleteToPreviousTextNode(
|
KeyEventResult _backDeleteToPreviousTextNode(
|
||||||
EditorState editorState,
|
EditorState editorState,
|
||||||
TextNode textNode,
|
TextNode textNode,
|
||||||
TransactionBuilder transactionBuilder,
|
Transaction transaction,
|
||||||
List<Node> nonTextNodes,
|
List<Node> nonTextNodes,
|
||||||
Selection selection,
|
Selection selection,
|
||||||
) {
|
) {
|
||||||
if (textNode.next == null &&
|
if (textNode.next == null &&
|
||||||
textNode.children.isEmpty &&
|
textNode.children.isEmpty &&
|
||||||
textNode.parent?.parent != null) {
|
textNode.parent?.parent != null) {
|
||||||
transactionBuilder
|
transaction
|
||||||
..deleteNode(textNode)
|
..deleteNode(textNode)
|
||||||
..insertNode(textNode.parent!.path.next, textNode)
|
..insertNode(textNode.parent!.path.next, textNode)
|
||||||
..afterSelection = Selection.collapsed(
|
..afterSelection = Selection.collapsed(
|
||||||
Position(path: textNode.parent!.path.next, offset: 0),
|
Position(path: textNode.parent!.path.next, offset: 0),
|
||||||
)
|
);
|
||||||
..commit();
|
editorState.commit();
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,32 +151,32 @@ KeyEventResult _backDeleteToPreviousTextNode(
|
|||||||
prevIsNumberList = true;
|
prevIsNumberList = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionBuilder.mergeText(previousTextNode, textNode);
|
transaction.mergeText(previousTextNode, textNode);
|
||||||
if (textNode.children.isNotEmpty) {
|
if (textNode.children.isNotEmpty) {
|
||||||
transactionBuilder.insertNodes(
|
transaction.insertNodes(
|
||||||
previousTextNode.path.next,
|
previousTextNode.path.next,
|
||||||
textNode.children.toList(growable: false),
|
textNode.children.toList(growable: false),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
transactionBuilder.deleteNode(textNode);
|
transaction.deleteNode(textNode);
|
||||||
transactionBuilder.afterSelection = Selection.collapsed(
|
transaction.afterSelection = Selection.collapsed(
|
||||||
Position(
|
Position(
|
||||||
path: previousTextNode.path,
|
path: previousTextNode.path,
|
||||||
offset: previousTextNode.toRawString().length,
|
offset: previousTextNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transactionBuilder.operations.isNotEmpty) {
|
if (transaction.operations.isNotEmpty) {
|
||||||
if (nonTextNodes.isNotEmpty) {
|
if (nonTextNodes.isNotEmpty) {
|
||||||
transactionBuilder.afterSelection = Selection.collapsed(selection.start);
|
transaction.afterSelection = Selection.collapsed(selection.start);
|
||||||
}
|
}
|
||||||
transactionBuilder.commit();
|
editorState.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevIsNumberList) {
|
if (prevIsNumberList) {
|
||||||
makeFollowingNodesIncremental(editorState, previousTextNode!.path,
|
makeFollowingNodesIncremental(
|
||||||
transactionBuilder.afterSelection!);
|
editorState, previousTextNode!.path, transaction.afterSelection!);
|
||||||
}
|
}
|
||||||
|
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
@ -197,7 +196,7 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
|
|||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
|
||||||
final transactionBuilder = TransactionBuilder(editorState);
|
final transaction = editorState.transaction;
|
||||||
if (textNodes.length == 1) {
|
if (textNodes.length == 1) {
|
||||||
final textNode = textNodes.first;
|
final textNode = textNodes.first;
|
||||||
// The cursor is at the end of the line,
|
// The cursor is at the end of the line,
|
||||||
@ -206,55 +205,52 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
|
|||||||
return _mergeNextLineIntoThisLine(
|
return _mergeNextLineIntoThisLine(
|
||||||
editorState,
|
editorState,
|
||||||
textNode,
|
textNode,
|
||||||
transactionBuilder,
|
transaction,
|
||||||
selection,
|
selection,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final index = textNode.delta.nextRunePosition(selection.start.offset);
|
final index = textNode.delta.nextRunePosition(selection.start.offset);
|
||||||
if (selection.isCollapsed) {
|
if (selection.isCollapsed) {
|
||||||
transactionBuilder.deleteText(
|
transaction.deleteText(
|
||||||
textNode,
|
textNode,
|
||||||
selection.start.offset,
|
selection.start.offset,
|
||||||
index - selection.start.offset,
|
index - selection.start.offset,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
transactionBuilder.deleteText(
|
transaction.deleteText(
|
||||||
textNode,
|
textNode,
|
||||||
selection.start.offset,
|
selection.start.offset,
|
||||||
selection.end.offset - selection.start.offset,
|
selection.end.offset - selection.start.offset,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
transactionBuilder.commit();
|
editorState.commit();
|
||||||
} else {
|
} else {
|
||||||
final startPosition = selection.start;
|
final startPosition = selection.start;
|
||||||
final nodeAtStart = editorState.document.nodeAtPath(startPosition.path)!;
|
final nodeAtStart = editorState.document.nodeAtPath(startPosition.path)!;
|
||||||
_deleteTextNodes(transactionBuilder, textNodes, selection);
|
_deleteTextNodes(transaction, textNodes, selection);
|
||||||
transactionBuilder.commit();
|
editorState.commit();
|
||||||
|
|
||||||
if (nodeAtStart is TextNode &&
|
if (nodeAtStart is TextNode &&
|
||||||
nodeAtStart.subtype == BuiltInAttributeKey.numberList) {
|
nodeAtStart.subtype == BuiltInAttributeKey.numberList) {
|
||||||
makeFollowingNodesIncremental(
|
makeFollowingNodesIncremental(
|
||||||
editorState, startPosition.path, transactionBuilder.afterSelection!);
|
editorState, startPosition.path, transaction.afterSelection!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEventResult _mergeNextLineIntoThisLine(
|
KeyEventResult _mergeNextLineIntoThisLine(EditorState editorState,
|
||||||
EditorState editorState,
|
TextNode textNode, Transaction transaction, Selection selection) {
|
||||||
TextNode textNode,
|
|
||||||
TransactionBuilder transactionBuilder,
|
|
||||||
Selection selection) {
|
|
||||||
final nextNode = textNode.next;
|
final nextNode = textNode.next;
|
||||||
if (nextNode == null) {
|
if (nextNode == null) {
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
if (nextNode is TextNode) {
|
if (nextNode is TextNode) {
|
||||||
transactionBuilder.mergeText(textNode, nextNode);
|
transaction.mergeText(textNode, nextNode);
|
||||||
}
|
}
|
||||||
transactionBuilder.deleteNode(nextNode);
|
transaction.deleteNode(nextNode);
|
||||||
transactionBuilder.commit();
|
editorState.commit();
|
||||||
|
|
||||||
if (textNode.subtype == BuiltInAttributeKey.numberList) {
|
if (textNode.subtype == BuiltInAttributeKey.numberList) {
|
||||||
makeFollowingNodesIncremental(editorState, textNode.path, selection);
|
makeFollowingNodesIncremental(editorState, textNode.path, selection);
|
||||||
@ -263,15 +259,15 @@ KeyEventResult _mergeNextLineIntoThisLine(
|
|||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _deleteTextNodes(TransactionBuilder transactionBuilder,
|
void _deleteTextNodes(
|
||||||
List<TextNode> textNodes, Selection selection) {
|
Transaction transaction, List<TextNode> textNodes, Selection selection) {
|
||||||
final first = textNodes.first;
|
final first = textNodes.first;
|
||||||
final last = textNodes.last;
|
final last = textNodes.last;
|
||||||
var content = textNodes.last.toRawString();
|
var content = textNodes.last.toPlainText();
|
||||||
content = content.substring(selection.end.offset, content.length);
|
content = content.substring(selection.end.offset, content.length);
|
||||||
// Merge the fist and the last text node content,
|
// Merge the fist and the last text node content,
|
||||||
// and delete the all nodes expect for the first.
|
// and delete the all nodes expect for the first.
|
||||||
transactionBuilder
|
transaction
|
||||||
..deleteNodes(textNodes.sublist(1))
|
..deleteNodes(textNodes.sublist(1))
|
||||||
..mergeText(
|
..mergeText(
|
||||||
first,
|
first,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:appflowy_editor/src/infra/html_converter.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:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:rich_clipboard/rich_clipboard.dart';
|
import 'package:rich_clipboard/rich_clipboard.dart';
|
||||||
@ -25,11 +25,11 @@ Selection _computeSelectionAfterPasteMultipleNodes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleCopy(EditorState editorState) async {
|
void _handleCopy(EditorState editorState) async {
|
||||||
final selection = editorState.cursorSelection?.normalize;
|
final selection = editorState.cursorSelection?.normalized;
|
||||||
if (selection == null || selection.isCollapsed) {
|
if (selection == null || selection.isCollapsed) {
|
||||||
return;
|
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)!;
|
final nodeAtPath = editorState.document.nodeAtPath(selection.end.path)!;
|
||||||
if (nodeAtPath.type == "text") {
|
if (nodeAtPath.type == "text") {
|
||||||
final textNode = nodeAtPath as TextNode;
|
final textNode = nodeAtPath as TextNode;
|
||||||
@ -49,7 +49,11 @@ void _handleCopy(EditorState editorState) async {
|
|||||||
final beginNode = editorState.document.nodeAtPath(selection.start.path)!;
|
final beginNode = editorState.document.nodeAtPath(selection.start.path)!;
|
||||||
final endNode = editorState.document.nodeAtPath(selection.end.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(
|
final copyString = NodesToHTMLConverter(
|
||||||
nodes: nodes,
|
nodes: nodes,
|
||||||
@ -61,7 +65,7 @@ void _handleCopy(EditorState editorState) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _pasteHTML(EditorState editorState, String html) {
|
void _pasteHTML(EditorState editorState, String html) {
|
||||||
final selection = editorState.cursorSelection?.normalize;
|
final selection = editorState.cursorSelection?.normalized;
|
||||||
if (selection == null) {
|
if (selection == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -81,16 +85,16 @@ void _pasteHTML(EditorState editorState, String html) {
|
|||||||
} else if (nodes.length == 1) {
|
} else if (nodes.length == 1) {
|
||||||
final firstNode = nodes[0];
|
final firstNode = nodes[0];
|
||||||
final nodeAtPath = editorState.document.nodeAtPath(path)!;
|
final nodeAtPath = editorState.document.nodeAtPath(path)!;
|
||||||
final tb = TransactionBuilder(editorState);
|
final tb = editorState.transaction;
|
||||||
final startOffset = selection.start.offset;
|
final startOffset = selection.start.offset;
|
||||||
if (nodeAtPath.type == "text" && firstNode.type == "text") {
|
if (nodeAtPath.type == "text" && firstNode.type == "text") {
|
||||||
final textNodeAtPath = nodeAtPath as TextNode;
|
final textNodeAtPath = nodeAtPath as TextNode;
|
||||||
final firstTextNode = firstNode as TextNode;
|
final firstTextNode = firstNode as TextNode;
|
||||||
tb.textEdit(textNodeAtPath,
|
tb.updateText(
|
||||||
() => (Delta()..retain(startOffset)) + firstTextNode.delta);
|
textNodeAtPath, (Delta()..retain(startOffset)) + firstTextNode.delta);
|
||||||
tb.setAfterSelection(Selection.collapsed(Position(
|
tb.afterSelection = (Selection.collapsed(Position(
|
||||||
path: path, offset: startOffset + firstTextNode.delta.length)));
|
path: path, offset: startOffset + firstTextNode.delta.length)));
|
||||||
tb.commit();
|
editorState.commit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -100,7 +104,7 @@ void _pasteHTML(EditorState editorState, String html) {
|
|||||||
|
|
||||||
void _pasteMultipleLinesInText(
|
void _pasteMultipleLinesInText(
|
||||||
EditorState editorState, List<int> path, int offset, List<Node> nodes) {
|
EditorState editorState, List<int> path, int offset, List<Node> nodes) {
|
||||||
final tb = TransactionBuilder(editorState);
|
final tb = editorState.transaction;
|
||||||
|
|
||||||
final firstNode = nodes[0];
|
final firstNode = nodes[0];
|
||||||
final nodeAtPath = editorState.document.nodeAtPath(path)!;
|
final nodeAtPath = editorState.document.nodeAtPath(path)!;
|
||||||
@ -116,9 +120,8 @@ void _pasteMultipleLinesInText(
|
|||||||
final firstTextNode = firstNode as TextNode;
|
final firstTextNode = firstNode as TextNode;
|
||||||
final remain = textNodeAtPath.delta.slice(offset);
|
final remain = textNodeAtPath.delta.slice(offset);
|
||||||
|
|
||||||
tb.textEdit(
|
tb.updateText(
|
||||||
textNodeAtPath,
|
textNodeAtPath,
|
||||||
() =>
|
|
||||||
(Delta()
|
(Delta()
|
||||||
..retain(offset)
|
..retain(offset)
|
||||||
..delete(remain.length)) +
|
..delete(remain.length)) +
|
||||||
@ -136,15 +139,15 @@ void _pasteMultipleLinesInText(
|
|||||||
final tailTextNode = tailNodes.last as TextNode;
|
final tailTextNode = tailNodes.last as TextNode;
|
||||||
tailTextNode.delta = tailTextNode.delta + remain;
|
tailTextNode.delta = tailTextNode.delta + remain;
|
||||||
} else if (remain.isNotEmpty) {
|
} else if (remain.isNotEmpty) {
|
||||||
tailNodes.add(TextNode(type: "text", delta: remain));
|
tailNodes.add(TextNode(delta: remain));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tailNodes.add(TextNode(type: "text", delta: remain));
|
tailNodes.add(TextNode(delta: remain));
|
||||||
}
|
}
|
||||||
|
|
||||||
tb.setAfterSelection(afterSelection);
|
tb.afterSelection = afterSelection;
|
||||||
tb.insertNodes(path, tailNodes);
|
tb.insertNodes(path, tailNodes);
|
||||||
tb.commit();
|
editorState.commit();
|
||||||
|
|
||||||
if (startNumber != null) {
|
if (startNumber != null) {
|
||||||
makeFollowingNodesIncremental(editorState, originalPath, afterSelection,
|
makeFollowingNodesIncremental(editorState, originalPath, afterSelection,
|
||||||
@ -157,9 +160,9 @@ void _pasteMultipleLinesInText(
|
|||||||
_computeSelectionAfterPasteMultipleNodes(editorState, nodes);
|
_computeSelectionAfterPasteMultipleNodes(editorState, nodes);
|
||||||
|
|
||||||
path[path.length - 1]++;
|
path[path.length - 1]++;
|
||||||
tb.setAfterSelection(afterSelection);
|
tb.afterSelection = afterSelection;
|
||||||
tb.insertNodes(path, nodes);
|
tb.insertNodes(path, nodes);
|
||||||
tb.commit();
|
editorState.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handlePaste(EditorState editorState) async {
|
void _handlePaste(EditorState editorState) async {
|
||||||
@ -192,15 +195,15 @@ void _pasteSingleLine(
|
|||||||
EditorState editorState, Selection selection, String line) {
|
EditorState editorState, Selection selection, String line) {
|
||||||
final node = editorState.document.nodeAtPath(selection.end.path)! as TextNode;
|
final node = editorState.document.nodeAtPath(selection.end.path)! as TextNode;
|
||||||
final beginOffset = selection.end.offset;
|
final beginOffset = selection.end.offset;
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..textEdit(
|
..updateText(
|
||||||
node,
|
node,
|
||||||
() => Delta()
|
Delta()
|
||||||
..retain(beginOffset)
|
..retain(beginOffset)
|
||||||
..addAll(_lineContentToDelta(line)))
|
..addAll(_lineContentToDelta(line)))
|
||||||
..setAfterSelection(Selection.collapsed(
|
..afterSelection = (Selection.collapsed(
|
||||||
Position(path: selection.end.path, offset: beginOffset + line.length)))
|
Position(path: selection.end.path, offset: beginOffset + line.length)));
|
||||||
..commit();
|
editorState.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// parse url from the line text
|
/// parse url from the line text
|
||||||
@ -218,7 +221,7 @@ Delta _lineContentToDelta(String lineContent) {
|
|||||||
delta.insert(lineContent.substring(lastUrlEndOffset, match.start));
|
delta.insert(lineContent.substring(lastUrlEndOffset, match.start));
|
||||||
}
|
}
|
||||||
final linkContent = lineContent.substring(match.start, match.end);
|
final linkContent = lineContent.substring(match.start, match.end);
|
||||||
delta.insert(linkContent, {"href": linkContent});
|
delta.insert(linkContent, attributes: {"href": linkContent});
|
||||||
lastUrlEndOffset = match.end;
|
lastUrlEndOffset = match.end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,7 +233,7 @@ Delta _lineContentToDelta(String lineContent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handlePastePlainText(EditorState editorState, String plainText) {
|
void _handlePastePlainText(EditorState editorState, String plainText) {
|
||||||
final selection = editorState.cursorSelection?.normalize;
|
final selection = editorState.cursorSelection?.normalized;
|
||||||
if (selection == null) {
|
if (selection == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -260,10 +263,9 @@ void _handlePastePlainText(EditorState editorState, String plainText) {
|
|||||||
final insertedLineSuffix = node.delta.slice(beginOffset);
|
final insertedLineSuffix = node.delta.slice(beginOffset);
|
||||||
|
|
||||||
path[path.length - 1]++;
|
path[path.length - 1]++;
|
||||||
final tb = TransactionBuilder(editorState);
|
final tb = editorState.transaction;
|
||||||
final List<TextNode> nodes = remains
|
final List<TextNode> nodes =
|
||||||
.map((e) => TextNode(type: "text", delta: _lineContentToDelta(e)))
|
remains.map((e) => TextNode(delta: _lineContentToDelta(e))).toList();
|
||||||
.toList();
|
|
||||||
|
|
||||||
final afterSelection =
|
final afterSelection =
|
||||||
_computeSelectionAfterPasteMultipleNodes(editorState, nodes);
|
_computeSelectionAfterPasteMultipleNodes(editorState, nodes);
|
||||||
@ -272,20 +274,20 @@ void _handlePastePlainText(EditorState editorState, String plainText) {
|
|||||||
if (nodes.isNotEmpty) {
|
if (nodes.isNotEmpty) {
|
||||||
final last = nodes.last;
|
final last = nodes.last;
|
||||||
nodes[nodes.length - 1] =
|
nodes[nodes.length - 1] =
|
||||||
TextNode(type: "text", delta: last.delta..addAll(insertedLineSuffix));
|
TextNode(delta: last.delta..addAll(insertedLineSuffix));
|
||||||
}
|
}
|
||||||
|
|
||||||
// insert first line
|
// insert first line
|
||||||
tb.textEdit(
|
tb.updateText(
|
||||||
node,
|
node,
|
||||||
() => Delta()
|
Delta()
|
||||||
..retain(beginOffset)
|
..retain(beginOffset)
|
||||||
..insert(firstLine)
|
..insert(firstLine)
|
||||||
..delete(node.delta.length - beginOffset));
|
..delete(node.delta.length - beginOffset));
|
||||||
// insert remains
|
// insert remains
|
||||||
tb.insertNodes(path, nodes);
|
tb.insertNodes(path, nodes);
|
||||||
tb.setAfterSelection(afterSelection);
|
tb.afterSelection = afterSelection;
|
||||||
tb.commit();
|
editorState.commit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,35 +299,38 @@ void _handleCut(EditorState editorState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _deleteSelectedContent(EditorState editorState) {
|
void _deleteSelectedContent(EditorState editorState) {
|
||||||
final selection = editorState.cursorSelection?.normalize;
|
final selection = editorState.cursorSelection?.normalized;
|
||||||
if (selection == null || selection.isCollapsed) {
|
if (selection == null || selection.isCollapsed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final beginNode = editorState.document.nodeAtPath(selection.start.path)!;
|
final beginNode = editorState.document.nodeAtPath(selection.start.path)!;
|
||||||
final endNode = editorState.document.nodeAtPath(selection.end.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") {
|
beginNode.type == "text") {
|
||||||
final textItem = beginNode as TextNode;
|
final textItem = beginNode as TextNode;
|
||||||
final tb = TransactionBuilder(editorState);
|
final tb = editorState.transaction;
|
||||||
final len = selection.end.offset - selection.start.offset;
|
final len = selection.end.offset - selection.start.offset;
|
||||||
tb.textEdit(
|
tb.updateText(
|
||||||
textItem,
|
textItem,
|
||||||
() => Delta()
|
Delta()
|
||||||
..retain(selection.start.offset)
|
..retain(selection.start.offset)
|
||||||
..delete(len));
|
..delete(len));
|
||||||
tb.setAfterSelection(Selection.collapsed(selection.start));
|
tb.afterSelection = Selection.collapsed(selection.start);
|
||||||
tb.commit();
|
editorState.commit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final traverser = NodeIterator(editorState.document, beginNode, endNode);
|
final traverser = NodeIterator(
|
||||||
|
document: editorState.document,
|
||||||
final tb = TransactionBuilder(editorState);
|
startNode: beginNode,
|
||||||
|
endNode: endNode,
|
||||||
|
);
|
||||||
|
final tb = editorState.transaction;
|
||||||
while (traverser.moveNext()) {
|
while (traverser.moveNext()) {
|
||||||
final item = traverser.current;
|
final item = traverser.current;
|
||||||
if (item.type == "text" && beginNode == item) {
|
if (item.type == "text" && beginNode == item) {
|
||||||
final textItem = item as TextNode;
|
final textItem = item as TextNode;
|
||||||
final deleteLen = textItem.delta.length - selection.start.offset;
|
final deleteLen = textItem.delta.length - selection.start.offset;
|
||||||
tb.textEdit(textItem, () {
|
tb.updateText(textItem, () {
|
||||||
final delta = Delta()
|
final delta = Delta()
|
||||||
..retain(selection.start.offset)
|
..retain(selection.start.offset)
|
||||||
..delete(deleteLen);
|
..delete(deleteLen);
|
||||||
@ -336,13 +341,13 @@ void _deleteSelectedContent(EditorState editorState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return delta;
|
return delta;
|
||||||
});
|
}());
|
||||||
} else {
|
} else {
|
||||||
tb.deleteNode(item);
|
tb.deleteNode(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tb.setAfterSelection(Selection.collapsed(selection.start));
|
tb.afterSelection = Selection.collapsed(selection.start);
|
||||||
tb.commit();
|
editorState.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
ShortcutEventHandler copyEventHandler = (editorState, event) {
|
ShortcutEventHandler copyEventHandler = (editorState, event) {
|
||||||
|
@ -39,11 +39,11 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
|
|||||||
final afterSelection = Selection.collapsed(
|
final afterSelection = Selection.collapsed(
|
||||||
Position(path: textNodes.first.path.next, offset: 0),
|
Position(path: textNodes.first.path.next, offset: 0),
|
||||||
);
|
);
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..deleteText(
|
..deleteText(
|
||||||
textNodes.first,
|
textNodes.first,
|
||||||
selection.start.offset,
|
selection.start.offset,
|
||||||
textNodes.first.toRawString().length,
|
textNodes.first.toPlainText().length,
|
||||||
)
|
)
|
||||||
..deleteNodes(subTextNodes)
|
..deleteNodes(subTextNodes)
|
||||||
..deleteText(
|
..deleteText(
|
||||||
@ -51,8 +51,8 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
|
|||||||
0,
|
0,
|
||||||
selection.end.offset,
|
selection.end.offset,
|
||||||
)
|
)
|
||||||
..afterSelection = afterSelection
|
..afterSelection = afterSelection;
|
||||||
..commit();
|
editorState.commit();
|
||||||
|
|
||||||
if (startNode is TextNode &&
|
if (startNode is TextNode &&
|
||||||
startNode.subtype == BuiltInAttributeKey.numberList) {
|
startNode.subtype == BuiltInAttributeKey.numberList) {
|
||||||
@ -73,16 +73,16 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
|
|||||||
// If selection is collapsed and position.start.offset == 0,
|
// If selection is collapsed and position.start.offset == 0,
|
||||||
// insert a empty text node before.
|
// insert a empty text node before.
|
||||||
if (selection.isCollapsed && selection.start.offset == 0) {
|
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(
|
final afterSelection = Selection.collapsed(
|
||||||
Position(path: textNode.path, offset: 0),
|
Position(path: textNode.path, offset: 0),
|
||||||
);
|
);
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..updateNode(textNode, {
|
..updateNode(textNode, {
|
||||||
BuiltInAttributeKey.subtype: null,
|
BuiltInAttributeKey.subtype: null,
|
||||||
})
|
})
|
||||||
..afterSelection = afterSelection
|
..afterSelection = afterSelection;
|
||||||
..commit();
|
editorState.commit();
|
||||||
|
|
||||||
final nextNode = textNode.next;
|
final nextNode = textNode.next;
|
||||||
if (nextNode is TextNode &&
|
if (nextNode is TextNode &&
|
||||||
@ -105,13 +105,13 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
|
|||||||
BuiltInAttributeKey.numberList;
|
BuiltInAttributeKey.numberList;
|
||||||
newNode.attributes[BuiltInAttributeKey.number] = prevNumber;
|
newNode.attributes[BuiltInAttributeKey.number] = prevNumber;
|
||||||
final insertPath = textNode.path;
|
final insertPath = textNode.path;
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..insertNode(
|
..insertNode(
|
||||||
insertPath,
|
insertPath,
|
||||||
newNode,
|
newNode,
|
||||||
)
|
)
|
||||||
..afterSelection = afterSelection
|
..afterSelection = afterSelection;
|
||||||
..commit();
|
editorState.commit();
|
||||||
|
|
||||||
makeFollowingNodesIncremental(editorState, insertPath, afterSelection,
|
makeFollowingNodesIncremental(editorState, insertPath, afterSelection,
|
||||||
beginNum: prevNumber);
|
beginNum: prevNumber);
|
||||||
@ -120,7 +120,7 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
|
|||||||
BuiltInAttributeKey.heading,
|
BuiltInAttributeKey.heading,
|
||||||
BuiltInAttributeKey.quote,
|
BuiltInAttributeKey.quote,
|
||||||
].contains(subtype);
|
].contains(subtype);
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..insertNode(
|
..insertNode(
|
||||||
textNode.path,
|
textNode.path,
|
||||||
textNode.copyWith(
|
textNode.copyWith(
|
||||||
@ -129,8 +129,8 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
|
|||||||
attributes: needCopyAttributes ? null : {},
|
attributes: needCopyAttributes ? null : {},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
..afterSelection = afterSelection
|
..afterSelection = afterSelection;
|
||||||
..commit();
|
editorState.commit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
@ -145,25 +145,25 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
|
|||||||
Position(path: nextPath, offset: 0),
|
Position(path: nextPath, offset: 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
final transactionBuilder = TransactionBuilder(editorState);
|
final transaction = editorState.transaction;
|
||||||
transactionBuilder.insertNode(
|
transaction.insertNode(
|
||||||
textNode.path.next,
|
textNode.path.next,
|
||||||
textNode.copyWith(
|
textNode.copyWith(
|
||||||
attributes: attributes,
|
attributes: attributes,
|
||||||
delta: textNode.delta.slice(selection.end.offset),
|
delta: textNode.delta.slice(selection.end.offset),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
transactionBuilder.deleteText(
|
transaction.deleteText(
|
||||||
textNode,
|
textNode,
|
||||||
selection.start.offset,
|
selection.start.offset,
|
||||||
textNode.toRawString().length - selection.start.offset,
|
textNode.toPlainText().length - selection.start.offset,
|
||||||
);
|
);
|
||||||
if (textNode.children.isNotEmpty) {
|
if (textNode.children.isNotEmpty) {
|
||||||
final children = textNode.children.toList(growable: false);
|
final children = textNode.children.toList(growable: false);
|
||||||
transactionBuilder.deleteNodes(children);
|
transaction.deleteNodes(children);
|
||||||
}
|
}
|
||||||
transactionBuilder.afterSelection = afterSelection;
|
transaction.afterSelection = afterSelection;
|
||||||
transactionBuilder.commit();
|
editorState.commit();
|
||||||
|
|
||||||
// If the new type of a text node is number list,
|
// If the new type of a text node is number list,
|
||||||
// the numbers of the following nodes should be incremental.
|
// 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:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
|
||||||
import 'package:flutter/material.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) {
|
ShortcutEventHandler formatBoldEventHandler = (editorState, event) {
|
||||||
final selection = editorState.service.selectionService.currentSelection.value;
|
final selection = editorState.service.selectionService.currentSelection.value;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:appflowy_editor/src/extensions/text_node_extensions.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:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
bool _isCodeStyle(TextNode textNode, int index) {
|
bool _isCodeStyle(TextNode textNode, int index) {
|
||||||
@ -44,7 +45,7 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
|
|||||||
|
|
||||||
final textNode = textNodes.first;
|
final textNode = textNodes.first;
|
||||||
final selectionText = textNode
|
final selectionText = textNode
|
||||||
.toRawString()
|
.toPlainText()
|
||||||
.substring(selection.start.offset, selection.end.offset);
|
.substring(selection.start.offset, selection.end.offset);
|
||||||
|
|
||||||
// toggle code style when selected some text
|
// toggle code style when selected some text
|
||||||
@ -53,7 +54,7 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
|
|||||||
return KeyEventResult.handled;
|
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);
|
final backquoteIndexes = _findBackquoteIndexes(text, textNode);
|
||||||
if (backquoteIndexes.isEmpty) {
|
if (backquoteIndexes.isEmpty) {
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
@ -72,7 +73,7 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
|
|||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..deleteText(textNode, lastBackquoteIndex, 1)
|
..deleteText(textNode, lastBackquoteIndex, 1)
|
||||||
..deleteText(textNode, firstBackquoteIndex, 2)
|
..deleteText(textNode, firstBackquoteIndex, 2)
|
||||||
..formatText(
|
..formatText(
|
||||||
@ -88,8 +89,8 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
|
|||||||
path: textNode.path,
|
path: textNode.path,
|
||||||
offset: endIndex - 3,
|
offset: endIndex - 3,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
..commit();
|
editorState.commit();
|
||||||
|
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
@ -103,7 +104,7 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
|
|||||||
// delete the backquote.
|
// delete the backquote.
|
||||||
// update the style of the text surround by ` ` to code.
|
// update the style of the text surround by ` ` to code.
|
||||||
// and update the cursor position.
|
// and update the cursor position.
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..deleteText(textNode, startIndex, 1)
|
..deleteText(textNode, startIndex, 1)
|
||||||
..formatText(
|
..formatText(
|
||||||
textNode,
|
textNode,
|
||||||
@ -118,8 +119,8 @@ ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
|
|||||||
path: textNode.path,
|
path: textNode.path,
|
||||||
offset: endIndex - 1,
|
offset: endIndex - 1,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
..commit();
|
editorState.commit();
|
||||||
|
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
};
|
};
|
||||||
@ -134,7 +135,7 @@ ShortcutEventHandler doubleTildeToStrikethrough = (editorState, event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final textNode = textNodes.first;
|
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 ~~.
|
// make sure the last two characters are ~~.
|
||||||
if (text.length < 2 || text[selection.end.offset - 1] != '~') {
|
if (text.length < 2 || text[selection.end.offset - 1] != '~') {
|
||||||
@ -165,7 +166,7 @@ ShortcutEventHandler doubleTildeToStrikethrough = (editorState, event) {
|
|||||||
// delete the last three tildes.
|
// delete the last three tildes.
|
||||||
// update the style of the text surround by `~~ ~~` to strikethrough.
|
// update the style of the text surround by `~~ ~~` to strikethrough.
|
||||||
// and update the cursor position.
|
// and update the cursor position.
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..deleteText(textNode, lastTildeIndex, 1)
|
..deleteText(textNode, lastTildeIndex, 1)
|
||||||
..deleteText(textNode, thirdToLastTildeIndex, 2)
|
..deleteText(textNode, thirdToLastTildeIndex, 2)
|
||||||
..formatText(
|
..formatText(
|
||||||
@ -181,8 +182,8 @@ ShortcutEventHandler doubleTildeToStrikethrough = (editorState, event) {
|
|||||||
path: textNode.path,
|
path: textNode.path,
|
||||||
offset: selection.end.offset - 3,
|
offset: selection.end.offset - 3,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
..commit();
|
editorState.commit();
|
||||||
|
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
};
|
};
|
||||||
@ -199,7 +200,7 @@ ShortcutEventHandler markdownLinkToLinkHandler = (editorState, event) {
|
|||||||
|
|
||||||
// find all of the indexs for important characters
|
// find all of the indexs for important characters
|
||||||
final textNode = textNodes.first;
|
final textNode = textNodes.first;
|
||||||
final text = textNode.toRawString();
|
final text = textNode.toPlainText();
|
||||||
final firstOpeningBracket = text.indexOf('[');
|
final firstOpeningBracket = text.indexOf('[');
|
||||||
final firstClosingBracket = 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,
|
// update the href attribute of the text surrounded by [ ] to the url,
|
||||||
// delete everything after the text,
|
// delete everything after the text,
|
||||||
// and update the cursor position.
|
// and update the cursor position.
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..deleteText(textNode, firstOpeningBracket, 1)
|
..deleteText(textNode, firstOpeningBracket, 1)
|
||||||
..formatText(
|
..formatText(
|
||||||
textNode,
|
textNode,
|
||||||
@ -236,8 +237,8 @@ ShortcutEventHandler markdownLinkToLinkHandler = (editorState, event) {
|
|||||||
path: textNode.path,
|
path: textNode.path,
|
||||||
offset: firstOpeningBracket + linkText!.length,
|
offset: firstOpeningBracket + linkText!.length,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
..commit();
|
editorState.commit();
|
||||||
|
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
};
|
};
|
||||||
|
@ -11,7 +11,7 @@ ShortcutEventHandler doubleAsterisksToBold = (editorState, event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final textNode = textNodes.first;
|
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 **.
|
// make sure the last two characters are **.
|
||||||
if (text.length < 2 || text[selection.end.offset - 1] != '*') {
|
if (text.length < 2 || text[selection.end.offset - 1] != '*') {
|
||||||
@ -42,7 +42,7 @@ ShortcutEventHandler doubleAsterisksToBold = (editorState, event) {
|
|||||||
// delete the last three asterisks.
|
// delete the last three asterisks.
|
||||||
// update the style of the text surround by `** **` to bold.
|
// update the style of the text surround by `** **` to bold.
|
||||||
// and update the cursor position.
|
// and update the cursor position.
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..deleteText(textNode, lastAsterisIndex, 1)
|
..deleteText(textNode, lastAsterisIndex, 1)
|
||||||
..deleteText(textNode, thirdToLastAsteriskIndex, 2)
|
..deleteText(textNode, thirdToLastAsteriskIndex, 2)
|
||||||
..formatText(
|
..formatText(
|
||||||
@ -59,8 +59,8 @@ ShortcutEventHandler doubleAsterisksToBold = (editorState, event) {
|
|||||||
path: textNode.path,
|
path: textNode.path,
|
||||||
offset: selection.end.offset - 3,
|
offset: selection.end.offset - 3,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
..commit();
|
editorState.commit();
|
||||||
|
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
};
|
};
|
||||||
@ -75,7 +75,7 @@ ShortcutEventHandler doubleUnderscoresToBold = (editorState, event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final textNode = textNodes.first;
|
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 __.
|
// make sure the last two characters are __.
|
||||||
if (text.length < 2 || text[selection.end.offset - 1] != '_') {
|
if (text.length < 2 || text[selection.end.offset - 1] != '_') {
|
||||||
@ -108,7 +108,7 @@ ShortcutEventHandler doubleUnderscoresToBold = (editorState, event) {
|
|||||||
// delete the last three underscores.
|
// delete the last three underscores.
|
||||||
// update the style of the text surround by `__ __` to bold.
|
// update the style of the text surround by `__ __` to bold.
|
||||||
// and update the cursor position.
|
// and update the cursor position.
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..deleteText(textNode, lastAsterisIndex, 1)
|
..deleteText(textNode, lastAsterisIndex, 1)
|
||||||
..deleteText(textNode, thirdToLastUnderscoreIndex, 2)
|
..deleteText(textNode, thirdToLastUnderscoreIndex, 2)
|
||||||
..formatText(
|
..formatText(
|
||||||
@ -125,8 +125,8 @@ ShortcutEventHandler doubleUnderscoresToBold = (editorState, event) {
|
|||||||
path: textNode.path,
|
path: textNode.path,
|
||||||
offset: selection.end.offset - 3,
|
offset: selection.end.offset - 3,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
..commit();
|
editorState.commit();
|
||||||
|
|
||||||
return KeyEventResult.handled;
|
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/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
|
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
|
||||||
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
|
import 'package:appflowy_editor/src/core/document/attributes.dart';
|
||||||
import 'package:appflowy_editor/src/document/attributes.dart';
|
|
||||||
|
|
||||||
void makeFollowingNodesIncremental(
|
void makeFollowingNodesIncremental(
|
||||||
EditorState editorState, List<int> insertPath, Selection afterSelection,
|
EditorState editorState, List<int> insertPath, Selection afterSelection,
|
||||||
@ -16,7 +15,7 @@ void makeFollowingNodesIncremental(
|
|||||||
int numPtr = beginNum + 1;
|
int numPtr = beginNum + 1;
|
||||||
var ptr = insertNode.next;
|
var ptr = insertNode.next;
|
||||||
|
|
||||||
final builder = TransactionBuilder(editorState);
|
final builder = editorState.transaction;
|
||||||
|
|
||||||
while (ptr != null) {
|
while (ptr != null) {
|
||||||
if (ptr.subtype != BuiltInAttributeKey.numberList) {
|
if (ptr.subtype != BuiltInAttributeKey.numberList) {
|
||||||
@ -34,5 +33,5 @@ void makeFollowingNodesIncremental(
|
|||||||
}
|
}
|
||||||
|
|
||||||
builder.afterSelection = afterSelection;
|
builder.afterSelection = afterSelection;
|
||||||
builder.commit();
|
editorState.commit();
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import 'package:appflowy_editor/src/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
import 'package:appflowy_editor/src/document/position.dart';
|
import 'package:appflowy_editor/src/core/location/position.dart';
|
||||||
import 'package:appflowy_editor/src/document/selection.dart';
|
import 'package:appflowy_editor/src/core/location/selection.dart';
|
||||||
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
|
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import 'package:appflowy_editor/src/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
import 'package:appflowy_editor/src/operation/transaction_builder.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/render/selection_menu/selection_menu_service.dart';
|
||||||
import 'package:appflowy_editor/src/extensions/node_extensions.dart';
|
import 'package:appflowy_editor/src/extensions/node_extensions.dart';
|
||||||
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.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) {
|
if (selection == null || context == null || selectable == null) {
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction.replaceText(textNode, selection.start.offset,
|
||||||
..replaceText(textNode, selection.start.offset,
|
selection.end.offset - selection.start.offset, event.character ?? '');
|
||||||
selection.end.offset - selection.start.offset, event.character ?? '')
|
editorState.commit();
|
||||||
..commit();
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_selectionMenuService =
|
_selectionMenuService =
|
||||||
|
@ -15,9 +15,8 @@ ShortcutEventHandler tabHandler = (editorState, event) {
|
|||||||
final previous = textNode.previous;
|
final previous = textNode.previous;
|
||||||
|
|
||||||
if (textNode.subtype != BuiltInAttributeKey.bulletedList) {
|
if (textNode.subtype != BuiltInAttributeKey.bulletedList) {
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction.insertText(textNode, selection.end.offset, ' ' * 4);
|
||||||
..insertText(textNode, selection.end.offset, ' ' * 4)
|
editorState.commit();
|
||||||
..commit();
|
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,11 +30,11 @@ ShortcutEventHandler tabHandler = (editorState, event) {
|
|||||||
start: selection.start.copyWith(path: path),
|
start: selection.start.copyWith(path: path),
|
||||||
end: selection.end.copyWith(path: path),
|
end: selection.end.copyWith(path: path),
|
||||||
);
|
);
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..deleteNode(textNode)
|
..deleteNode(textNode)
|
||||||
..insertNode(path, textNode)
|
..insertNode(path, textNode)
|
||||||
..setAfterSelection(afterSelection)
|
..afterSelection = afterSelection;
|
||||||
..commit();
|
editorState.commit();
|
||||||
|
|
||||||
return KeyEventResult.handled;
|
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:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
|
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
|
||||||
import 'package:appflowy_editor/src/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
import 'package:appflowy_editor/src/document/position.dart';
|
import 'package:appflowy_editor/src/core/location/position.dart';
|
||||||
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/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
|
|
||||||
import './number_list_helper.dart';
|
import './number_list_helper.dart';
|
||||||
import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
|
import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
|
||||||
|
|
||||||
@ -44,7 +44,7 @@ ShortcutEventHandler whiteSpaceHandler = (editorState, event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final textNode = textNodes.first;
|
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);
|
final numberMatch = _numberRegex.firstMatch(text);
|
||||||
|
|
||||||
@ -99,15 +99,14 @@ KeyEventResult _toNumberList(EditorState editorState, TextNode textNode,
|
|||||||
));
|
));
|
||||||
|
|
||||||
final insertPath = textNode.path;
|
final insertPath = textNode.path;
|
||||||
|
editorState.transaction
|
||||||
TransactionBuilder(editorState)
|
|
||||||
..deleteText(textNode, 0, matchText.length)
|
..deleteText(textNode, 0, matchText.length)
|
||||||
..updateNode(textNode, {
|
..updateNode(textNode, {
|
||||||
BuiltInAttributeKey.subtype: BuiltInAttributeKey.numberList,
|
BuiltInAttributeKey.subtype: BuiltInAttributeKey.numberList,
|
||||||
BuiltInAttributeKey.number: numValue
|
BuiltInAttributeKey.number: numValue
|
||||||
})
|
})
|
||||||
..afterSelection = afterSelection
|
..afterSelection = afterSelection;
|
||||||
..commit();
|
editorState.commit();
|
||||||
|
|
||||||
makeFollowingNodesIncremental(editorState, insertPath, afterSelection);
|
makeFollowingNodesIncremental(editorState, insertPath, afterSelection);
|
||||||
|
|
||||||
@ -118,7 +117,7 @@ KeyEventResult _toBulletedList(EditorState editorState, TextNode textNode) {
|
|||||||
if (textNode.subtype == BuiltInAttributeKey.bulletedList) {
|
if (textNode.subtype == BuiltInAttributeKey.bulletedList) {
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..deleteText(textNode, 0, 1)
|
..deleteText(textNode, 0, 1)
|
||||||
..updateNode(textNode, {
|
..updateNode(textNode, {
|
||||||
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
|
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
|
||||||
@ -128,8 +127,8 @@ KeyEventResult _toBulletedList(EditorState editorState, TextNode textNode) {
|
|||||||
path: textNode.path,
|
path: textNode.path,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
..commit();
|
editorState.commit();
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,18 +139,18 @@ KeyEventResult _toCheckboxList(EditorState editorState, TextNode textNode) {
|
|||||||
final String symbol;
|
final String symbol;
|
||||||
bool check = false;
|
bool check = false;
|
||||||
final symbols = List<String>.from(_checkboxListSymbols)
|
final symbols = List<String>.from(_checkboxListSymbols)
|
||||||
..retainWhere(textNode.toRawString().startsWith);
|
..retainWhere(textNode.toPlainText().startsWith);
|
||||||
if (symbols.isNotEmpty) {
|
if (symbols.isNotEmpty) {
|
||||||
symbol = symbols.first;
|
symbol = symbols.first;
|
||||||
check = true;
|
check = true;
|
||||||
} else {
|
} else {
|
||||||
symbol = (List<String>.from(_unCheckboxListSymbols)
|
symbol = (List<String>.from(_unCheckboxListSymbols)
|
||||||
..retainWhere(textNode.toRawString().startsWith))
|
..retainWhere(textNode.toPlainText().startsWith))
|
||||||
.first;
|
.first;
|
||||||
check = false;
|
check = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..deleteText(textNode, 0, symbol.length)
|
..deleteText(textNode, 0, symbol.length)
|
||||||
..updateNode(textNode, {
|
..updateNode(textNode, {
|
||||||
BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
|
BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
|
||||||
@ -162,22 +161,22 @@ KeyEventResult _toCheckboxList(EditorState editorState, TextNode textNode) {
|
|||||||
path: textNode.path,
|
path: textNode.path,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
..commit();
|
editorState.commit();
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEventResult _toHeadingStyle(
|
KeyEventResult _toHeadingStyle(
|
||||||
EditorState editorState, TextNode textNode, Selection selection) {
|
EditorState editorState, TextNode textNode, Selection selection) {
|
||||||
final x = _countOfSign(
|
final x = _countOfSign(
|
||||||
textNode.toRawString(),
|
textNode.toPlainText(),
|
||||||
selection,
|
selection,
|
||||||
);
|
);
|
||||||
final hX = 'h$x';
|
final hX = 'h$x';
|
||||||
if (textNode.attributes.heading == hX) {
|
if (textNode.attributes.heading == hX) {
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
TransactionBuilder(editorState)
|
editorState.transaction
|
||||||
..deleteText(textNode, 0, x)
|
..deleteText(textNode, 0, x)
|
||||||
..updateNode(textNode, {
|
..updateNode(textNode, {
|
||||||
BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
|
BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
|
||||||
@ -188,8 +187,8 @@ KeyEventResult _toHeadingStyle(
|
|||||||
path: textNode.path,
|
path: textNode.path,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
..commit();
|
editorState.commit();
|
||||||
return KeyEventResult.handled;
|
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/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/infra/log.dart';
|
import 'package:appflowy_editor/src/infra/log.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import 'package:appflowy_editor/src/infra/log.dart';
|
import 'package:appflowy_editor/src/infra/log.dart';
|
||||||
import 'package:flutter/material.dart';
|
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/document/node_iterator.dart';
|
import 'package:appflowy_editor/src/core/document/node_iterator.dart';
|
||||||
import 'package:appflowy_editor/src/document/position.dart';
|
import 'package:appflowy_editor/src/core/document/path.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/editor_state.dart';
|
import 'package:appflowy_editor/src/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/extensions/node_extensions.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/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/cursor_widget.dart';
|
||||||
import 'package:appflowy_editor/src/render/selection/selectable.dart';
|
import 'package:appflowy_editor/src/render/selection/selectable.dart';
|
||||||
import 'package:appflowy_editor/src/render/selection/selection_widget.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 startNode = editorState.document.nodeAtPath(start);
|
||||||
final endNode = editorState.document.nodeAtPath(end);
|
final endNode = editorState.document.nodeAtPath(end);
|
||||||
if (startNode != null && endNode != null) {
|
if (startNode != null && endNode != null) {
|
||||||
final nodes =
|
final nodes = NodeIterator(
|
||||||
NodeIterator(editorState.document, startNode, endNode).toList();
|
document: editorState.document,
|
||||||
|
startNode: startNode,
|
||||||
|
endNode: endNode,
|
||||||
|
).toList();
|
||||||
if (selection.isBackward) {
|
if (selection.isBackward) {
|
||||||
return nodes;
|
return nodes;
|
||||||
} else {
|
} else {
|
||||||
@ -363,7 +366,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
|
|||||||
|
|
||||||
final backwardNodes =
|
final backwardNodes =
|
||||||
selection.isBackward ? nodes : nodes.reversed.toList(growable: false);
|
selection.isBackward ? nodes : nodes.reversed.toList(growable: false);
|
||||||
final normalizedSelection = selection.normalize;
|
final normalizedSelection = selection.normalized;
|
||||||
assert(normalizedSelection.isBackward);
|
assert(normalizedSelection.isBackward);
|
||||||
|
|
||||||
Log.selection.debug('update selection areas, $normalizedSelection');
|
Log.selection.debug('update selection areas, $normalizedSelection');
|
||||||
@ -375,7 +378,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var newSelection = normalizedSelection.copy();
|
var newSelection = normalizedSelection.copyWith();
|
||||||
|
|
||||||
/// In the case of multiple selections,
|
/// In the case of multiple selections,
|
||||||
/// we need to return a new selection for each selected node individually.
|
/// we need to return a new selection for each selected node individually.
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import 'dart:collection';
|
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/infra/log.dart';
|
||||||
import 'package:appflowy_editor/src/operation/operation.dart';
|
import 'package:appflowy_editor/src/core/transform/operation.dart';
|
||||||
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
|
import 'package:appflowy_editor/src/core/transform/transaction.dart';
|
||||||
import 'package:appflowy_editor/src/operation/transaction.dart';
|
|
||||||
import 'package:appflowy_editor/src/editor_state.dart';
|
import 'package:appflowy_editor/src/editor_state.dart';
|
||||||
|
|
||||||
/// A [HistoryItem] contains list of operations committed by users.
|
/// 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.
|
/// Create a new [Transaction] by inverting the operations.
|
||||||
Transaction toTransaction(EditorState state) {
|
Transaction toTransaction(EditorState state) {
|
||||||
final builder = TransactionBuilder(state);
|
final builder = Transaction(document: state.document);
|
||||||
for (var i = operations.length - 1; i >= 0; i--) {
|
for (var i = operations.length - 1; i >= 0; i--) {
|
||||||
final operation = operations[i];
|
final operation = operations[i];
|
||||||
final inverted = operation.invert();
|
final inverted = operation.invert();
|
||||||
@ -47,7 +46,7 @@ class HistoryItem extends LinkedListEntry<HistoryItem> {
|
|||||||
}
|
}
|
||||||
builder.afterSelection = beforeSelection;
|
builder.afterSelection = beforeSelection;
|
||||||
builder.beforeSelection = afterSelection;
|
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';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
setUpAll(() {
|
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
|
||||||
});
|
|
||||||
|
|
||||||
group('node.dart', () {
|
group('node.dart', () {
|
||||||
test('test node copyWith', () {
|
test('test node copyWith', () {
|
||||||
final node = Node(
|
final node = Node(
|
||||||
@ -57,7 +53,6 @@ void main() async {
|
|||||||
|
|
||||||
test('test textNode copyWith', () {
|
test('test textNode copyWith', () {
|
||||||
final textNode = TextNode(
|
final textNode = TextNode(
|
||||||
type: 'example',
|
|
||||||
children: LinkedList(),
|
children: LinkedList(),
|
||||||
attributes: {
|
attributes: {
|
||||||
'example': 'example',
|
'example': 'example',
|
||||||
@ -65,7 +60,7 @@ void main() async {
|
|||||||
delta: Delta()..insert('AppFlowy'),
|
delta: Delta()..insert('AppFlowy'),
|
||||||
);
|
);
|
||||||
expect(textNode.toJson(), {
|
expect(textNode.toJson(), {
|
||||||
'type': 'example',
|
'type': 'text',
|
||||||
'attributes': {
|
'attributes': {
|
||||||
'example': 'example',
|
'example': 'example',
|
||||||
},
|
},
|
||||||
@ -79,7 +74,6 @@ void main() async {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final textNodeWithChildren = TextNode(
|
final textNodeWithChildren = TextNode(
|
||||||
type: 'example',
|
|
||||||
children: LinkedList()..add(textNode),
|
children: LinkedList()..add(textNode),
|
||||||
attributes: {
|
attributes: {
|
||||||
'example': 'example',
|
'example': 'example',
|
||||||
@ -87,7 +81,7 @@ void main() async {
|
|||||||
delta: Delta()..insert('AppFlowy'),
|
delta: Delta()..insert('AppFlowy'),
|
||||||
);
|
);
|
||||||
expect(textNodeWithChildren.toJson(), {
|
expect(textNodeWithChildren.toJson(), {
|
||||||
'type': 'example',
|
'type': 'text',
|
||||||
'attributes': {
|
'attributes': {
|
||||||
'example': 'example',
|
'example': 'example',
|
||||||
},
|
},
|
||||||
@ -96,7 +90,7 @@ void main() async {
|
|||||||
],
|
],
|
||||||
'children': [
|
'children': [
|
||||||
{
|
{
|
||||||
'type': 'example',
|
'type': 'text',
|
||||||
'attributes': {
|
'attributes': {
|
||||||
'example': 'example',
|
'example': 'example',
|
||||||
},
|
},
|
||||||
@ -149,5 +143,90 @@ void main() async {
|
|||||||
expect(identical(node.children, base.children), false);
|
expect(identical(node.children, base.children), false);
|
||||||
expect(identical(node.children.first, base.children.first), 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(p1 > p2, false);
|
||||||
expect(p2 >= p1, true);
|
expect(p2 >= p1, true);
|
||||||
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 😁';
|
const text = 'Welcome to Appflowy 😁';
|
||||||
TextNode textNode() {
|
TextNode textNode() {
|
||||||
return TextNode(
|
return TextNode(
|
||||||
type: 'text',
|
|
||||||
delta: Delta()..insert(text),
|
delta: Delta()..insert(text),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -19,7 +19,7 @@ class EditorWidgetTester {
|
|||||||
EditorState get editorState => _editorState;
|
EditorState get editorState => _editorState;
|
||||||
Node get root => _editorState.document.root;
|
Node get root => _editorState.document.root;
|
||||||
|
|
||||||
StateTree get document => _editorState.document;
|
Document get document => _editorState.document;
|
||||||
int get documentLength => _editorState.document.root.children.length;
|
int get documentLength => _editorState.document.root.children.length;
|
||||||
Selection? get documentSelection =>
|
Selection? get documentSelection =>
|
||||||
_editorState.service.selectionService.currentSelection.value;
|
_editorState.service.selectionService.currentSelection.value;
|
||||||
@ -63,8 +63,7 @@ class EditorWidgetTester {
|
|||||||
void insertTextNode(String? text, {Attributes? attributes, Delta? delta}) {
|
void insertTextNode(String? text, {Attributes? attributes, Delta? delta}) {
|
||||||
insert(
|
insert(
|
||||||
TextNode(
|
TextNode(
|
||||||
type: 'text',
|
delta: delta ?? Delta(operations: [TextInsert(text ?? 'Test')]),
|
||||||
delta: delta ?? Delta([TextInsert(text ?? 'Test')]),
|
|
||||||
attributes: attributes,
|
attributes: attributes,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -103,7 +102,7 @@ class EditorWidgetTester {
|
|||||||
{Selection? selection}) async {
|
{Selection? selection}) async {
|
||||||
await apply([
|
await apply([
|
||||||
TextEditingDeltaInsertion(
|
TextEditingDeltaInsertion(
|
||||||
oldText: textNode.toRawString(),
|
oldText: textNode.toPlainText(),
|
||||||
textInserted: text,
|
textInserted: text,
|
||||||
insertionOffset: offset,
|
insertionOffset: offset,
|
||||||
selection: selection != null
|
selection: selection != null
|
||||||
@ -156,7 +155,7 @@ class EditorWidgetTester {
|
|||||||
|
|
||||||
EditorState _createEmptyDocument() {
|
EditorState _createEmptyDocument() {
|
||||||
return EditorState(
|
return EditorState(
|
||||||
document: StateTree(
|
document: Document(
|
||||||
root: _createEmptyEditorRoot(),
|
root: _createEmptyEditorRoot(),
|
||||||
),
|
),
|
||||||
)..disableSealTimer = true;
|
)..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/core/document/path.dart';
|
||||||
import 'package:appflowy_editor/src/document/position.dart';
|
import 'package:appflowy_editor/src/core/location/position.dart';
|
||||||
import 'package:appflowy_editor/src/document/selection.dart';
|
import 'package:appflowy_editor/src/core/location/selection.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@ -9,16 +9,16 @@ void main() {
|
|||||||
test('create state tree', () async {
|
test('create state tree', () async {
|
||||||
// final String response = await rootBundle.loadString('assets/document.json');
|
// final String response = await rootBundle.loadString('assets/document.json');
|
||||||
// final data = Map<String, Object>.from(json.decode(response));
|
// final data = Map<String, Object>.from(json.decode(response));
|
||||||
// final stateTree = StateTree.fromJson(data);
|
// final document = Document.fromJson(data);
|
||||||
// expect(stateTree.root.type, 'root');
|
// expect(document.root.type, 'root');
|
||||||
// expect(stateTree.root.toJson(), data['document']);
|
// expect(document.root.toJson(), data['document']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('search node by Path in state tree', () async {
|
test('search node by Path in state tree', () async {
|
||||||
// final String response = await rootBundle.loadString('assets/document.json');
|
// final String response = await rootBundle.loadString('assets/document.json');
|
||||||
// final data = Map<String, Object>.from(json.decode(response));
|
// final data = Map<String, Object>.from(json.decode(response));
|
||||||
// final stateTree = StateTree.fromJson(data);
|
// final document = Document.fromJson(data);
|
||||||
// final checkBoxNode = stateTree.root.childAtPath([1, 0]);
|
// final checkBoxNode = document.root.childAtPath([1, 0]);
|
||||||
// expect(checkBoxNode != null, true);
|
// expect(checkBoxNode != null, true);
|
||||||
// final textType = checkBoxNode!.attributes['text-type'];
|
// final textType = checkBoxNode!.attributes['text-type'];
|
||||||
// expect(textType != null, true);
|
// expect(textType != null, true);
|
||||||
@ -27,8 +27,8 @@ void main() {
|
|||||||
test('search node by Self in state tree', () async {
|
test('search node by Self in state tree', () async {
|
||||||
// final String response = await rootBundle.loadString('assets/document.json');
|
// final String response = await rootBundle.loadString('assets/document.json');
|
||||||
// final data = Map<String, Object>.from(json.decode(response));
|
// final data = Map<String, Object>.from(json.decode(response));
|
||||||
// final stateTree = StateTree.fromJson(data);
|
// final document = Document.fromJson(data);
|
||||||
// final checkBoxNode = stateTree.root.childAtPath([1, 0]);
|
// final checkBoxNode = document.root.childAtPath([1, 0]);
|
||||||
// expect(checkBoxNode != null, true);
|
// expect(checkBoxNode != null, true);
|
||||||
// final textType = checkBoxNode!.attributes['text-type'];
|
// final textType = checkBoxNode!.attributes['text-type'];
|
||||||
// expect(textType != null, true);
|
// expect(textType != null, true);
|
||||||
@ -39,21 +39,21 @@ void main() {
|
|||||||
test('insert node in state tree', () async {
|
test('insert node in state tree', () async {
|
||||||
// final String response = await rootBundle.loadString('assets/document.json');
|
// final String response = await rootBundle.loadString('assets/document.json');
|
||||||
// final data = Map<String, Object>.from(json.decode(response));
|
// final data = Map<String, Object>.from(json.decode(response));
|
||||||
// final stateTree = StateTree.fromJson(data);
|
// final document = Document.fromJson(data);
|
||||||
// final insertNode = Node.fromJson({
|
// final insertNode = Node.fromJson({
|
||||||
// 'type': 'text',
|
// 'type': 'text',
|
||||||
// });
|
// });
|
||||||
// bool result = stateTree.insert([1, 1], [insertNode]);
|
// bool result = document.insert([1, 1], [insertNode]);
|
||||||
// expect(result, true);
|
// 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 {
|
test('delete node in state tree', () async {
|
||||||
// final String response = await rootBundle.loadString('assets/document.json');
|
// final String response = await rootBundle.loadString('assets/document.json');
|
||||||
// final data = Map<String, Object>.from(json.decode(response));
|
// final data = Map<String, Object>.from(json.decode(response));
|
||||||
// final stateTree = StateTree.fromJson(data);
|
// final document = Document.fromJson(data);
|
||||||
// stateTree.delete([1, 1], 1);
|
// document.delete([1, 1], 1);
|
||||||
// final node = stateTree.nodeAtPath([1, 1]);
|
// final node = document.nodeAtPath([1, 1]);
|
||||||
// expect(node != null, true);
|
// expect(node != null, true);
|
||||||
// expect(node!.attributes['tag'], '**');
|
// expect(node!.attributes['tag'], '**');
|
||||||
});
|
});
|
||||||
@ -61,10 +61,10 @@ void main() {
|
|||||||
test('update node in state tree', () async {
|
test('update node in state tree', () async {
|
||||||
// final String response = await rootBundle.loadString('assets/document.json');
|
// final String response = await rootBundle.loadString('assets/document.json');
|
||||||
// final data = Map<String, Object>.from(json.decode(response));
|
// final data = Map<String, Object>.from(json.decode(response));
|
||||||
// final stateTree = StateTree.fromJson(data);
|
// final document = Document.fromJson(data);
|
||||||
// final test = stateTree.update([1, 1], {'text-type': 'heading1'});
|
// final test = document.update([1, 1], {'text-type': 'heading1'});
|
||||||
// expect(test, true);
|
// expect(test, true);
|
||||||
// final updatedNode = stateTree.nodeAtPath([1, 1]);
|
// final updatedNode = document.nodeAtPath([1, 1]);
|
||||||
// expect(updatedNode != null, true);
|
// expect(updatedNode != null, true);
|
||||||
// expect(updatedNode!.attributes['text-type'], 'heading1');
|
// expect(updatedNode!.attributes['text-type'], 'heading1');
|
||||||
});
|
});
|
||||||
@ -72,7 +72,7 @@ void main() {
|
|||||||
test('test path utils 1', () {
|
test('test path utils 1', () {
|
||||||
final path1 = <int>[1];
|
final path1 = <int>[1];
|
||||||
final path2 = <int>[1];
|
final path2 = <int>[1];
|
||||||
expect(pathEquals(path1, path2), true);
|
expect(path1.equals(path2), true);
|
||||||
|
|
||||||
expect(Object.hashAll(path1), Object.hashAll(path2));
|
expect(Object.hashAll(path1), Object.hashAll(path2));
|
||||||
});
|
});
|
||||||
@ -80,7 +80,7 @@ void main() {
|
|||||||
test('test path utils 2', () {
|
test('test path utils 2', () {
|
||||||
final path1 = <int>[1];
|
final path1 = <int>[1];
|
||||||
final path2 = <int>[2];
|
final path2 = <int>[2];
|
||||||
expect(pathEquals(path1, path2), false);
|
expect(path1.equals(path2), false);
|
||||||
|
|
||||||
expect(Object.hashAll(path1) != Object.hashAll(path2), true);
|
expect(Object.hashAll(path1) != Object.hashAll(path2), true);
|
||||||
});
|
});
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import 'dart:collection';
|
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:flutter_test/flutter_test.dart';
|
||||||
import 'package:appflowy_editor/src/operation/operation.dart';
|
import 'package:appflowy_editor/src/core/transform/operation.dart';
|
||||||
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
|
|
||||||
import 'package:appflowy_editor/src/editor_state.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() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
@ -55,18 +54,19 @@ void main() {
|
|||||||
item1,
|
item1,
|
||||||
item2,
|
item2,
|
||||||
item3,
|
item3,
|
||||||
]));
|
]),
|
||||||
final state = EditorState(document: StateTree(root: root));
|
);
|
||||||
|
final state = EditorState(document: Document(root: root));
|
||||||
|
|
||||||
expect(item1.path, [0]);
|
expect(item1.path, [0]);
|
||||||
expect(item2.path, [1]);
|
expect(item2.path, [1]);
|
||||||
expect(item3.path, [2]);
|
expect(item3.path, [2]);
|
||||||
|
|
||||||
final tb = TransactionBuilder(state);
|
final transaction = state.transaction;
|
||||||
tb.deleteNode(item1);
|
transaction.deleteNode(item1);
|
||||||
tb.deleteNode(item2);
|
transaction.deleteNode(item2);
|
||||||
tb.deleteNode(item3);
|
transaction.deleteNode(item3);
|
||||||
final transaction = tb.finish();
|
state.commit();
|
||||||
expect(transaction.operations[0].path, [0]);
|
expect(transaction.operations[0].path, [0]);
|
||||||
expect(transaction.operations[1].path, [0]);
|
expect(transaction.operations[1].path, [0]);
|
||||||
expect(transaction.operations[2].path, [0]);
|
expect(transaction.operations[2].path, [0]);
|
||||||
@ -74,13 +74,12 @@ void main() {
|
|||||||
group("toJson", () {
|
group("toJson", () {
|
||||||
test("insert", () {
|
test("insert", () {
|
||||||
final root = Node(type: "root", attributes: {}, children: LinkedList());
|
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 item1 = Node(type: "node", attributes: {}, children: LinkedList());
|
||||||
final tb = TransactionBuilder(state);
|
final transaction = state.transaction;
|
||||||
tb.insertNode([0], item1);
|
transaction.insertNode([0], item1);
|
||||||
|
state.commit();
|
||||||
final transaction = tb.finish();
|
|
||||||
expect(transaction.toJson(), {
|
expect(transaction.toJson(), {
|
||||||
"operations": [
|
"operations": [
|
||||||
{
|
{
|
||||||
@ -99,11 +98,12 @@ void main() {
|
|||||||
children: LinkedList()
|
children: LinkedList()
|
||||||
..addAll([
|
..addAll([
|
||||||
item1,
|
item1,
|
||||||
]));
|
]),
|
||||||
final state = EditorState(document: StateTree(root: root));
|
);
|
||||||
final tb = TransactionBuilder(state);
|
final state = EditorState(document: Document(root: root));
|
||||||
tb.deleteNode(item1);
|
final transaction = state.transaction;
|
||||||
final transaction = tb.finish();
|
transaction.deleteNode(item1);
|
||||||
|
state.commit();
|
||||||
expect(transaction.toJson(), {
|
expect(transaction.toJson(), {
|
||||||
"operations": [
|
"operations": [
|
||||||
{
|
{
|
||||||
|
@ -17,16 +17,16 @@ void main() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("HistoryItem #1", () {
|
test("HistoryItem #1", () {
|
||||||
final document = StateTree(root: _createEmptyEditorRoot());
|
final document = Document(root: _createEmptyEditorRoot());
|
||||||
final editorState = EditorState(document: document);
|
final editorState = EditorState(document: document);
|
||||||
|
|
||||||
final historyItem = HistoryItem();
|
final historyItem = HistoryItem();
|
||||||
historyItem.add(DeleteOperation(
|
historyItem
|
||||||
[0], [TextNode(type: 'text', delta: Delta()..insert('0'))]));
|
.add(DeleteOperation([0], [TextNode(delta: Delta()..insert('0'))]));
|
||||||
historyItem.add(DeleteOperation(
|
historyItem
|
||||||
[0], [TextNode(type: 'text', delta: Delta()..insert('1'))]));
|
.add(DeleteOperation([0], [TextNode(delta: Delta()..insert('1'))]));
|
||||||
historyItem.add(DeleteOperation(
|
historyItem
|
||||||
[0], [TextNode(type: 'text', delta: Delta()..insert('2'))]));
|
.add(DeleteOperation([0], [TextNode(delta: Delta()..insert('2'))]));
|
||||||
|
|
||||||
final transaction = historyItem.toTransaction(editorState);
|
final transaction = historyItem.toTransaction(editorState);
|
||||||
assert(isInsertAndPathEqual(transaction.operations[0], [0], '2'));
|
assert(isInsertAndPathEqual(transaction.operations[0], [0], '2'));
|
||||||
@ -35,12 +35,12 @@ void main() async {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("HistoryItem #2", () {
|
test("HistoryItem #2", () {
|
||||||
final document = StateTree(root: _createEmptyEditorRoot());
|
final document = Document(root: _createEmptyEditorRoot());
|
||||||
final editorState = EditorState(document: document);
|
final editorState = EditorState(document: document);
|
||||||
|
|
||||||
final historyItem = HistoryItem();
|
final historyItem = HistoryItem();
|
||||||
historyItem.add(DeleteOperation(
|
historyItem
|
||||||
[0], [TextNode(type: 'text', delta: Delta()..insert('0'))]));
|
.add(DeleteOperation([0], [TextNode(delta: Delta()..insert('0'))]));
|
||||||
historyItem
|
historyItem
|
||||||
.add(UpdateOperation([0], {"subType": "number"}, {"subType": null}));
|
.add(UpdateOperation([0], {"subType": "number"}, {"subType": null}));
|
||||||
historyItem.add(DeleteOperation([0], [TextNode.empty(), TextNode.empty()]));
|
historyItem.add(DeleteOperation([0], [TextNode.empty(), TextNode.empty()]));
|
||||||
@ -59,11 +59,11 @@ bool isInsertAndPathEqual(Operation operation, Path path, [String? content]) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pathEquals(operation.path, path)) {
|
if (!operation.path.equals(path)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final firstNode = operation.nodes[0];
|
final firstNode = operation.nodes.first;
|
||||||
if (firstNode is! TextNode) {
|
if (firstNode is! TextNode) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -72,5 +72,5 @@ bool isInsertAndPathEqual(Operation operation, Path path, [String? content]) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return firstNode.delta.toRawString() == content;
|
return firstNode.delta.toPlainText() == content;
|
||||||
}
|
}
|
||||||
|
@ -26,8 +26,8 @@ void main() async {
|
|||||||
BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
|
BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
|
||||||
BuiltInAttributeKey.checkbox: false,
|
BuiltInAttributeKey.checkbox: false,
|
||||||
},
|
},
|
||||||
delta: Delta([
|
delta: Delta(operations: [
|
||||||
TextInsert(text, {
|
TextInsert(text, attributes: {
|
||||||
BuiltInAttributeKey.bold: true,
|
BuiltInAttributeKey.bold: true,
|
||||||
BuiltInAttributeKey.italic: true,
|
BuiltInAttributeKey.italic: true,
|
||||||
BuiltInAttributeKey.underline: true,
|
BuiltInAttributeKey.underline: true,
|
||||||
|
@ -147,7 +147,7 @@ Future<void> _testDefaultSelectionMenuItems(
|
|||||||
int index, EditorWidgetTester editor) async {
|
int index, EditorWidgetTester editor) async {
|
||||||
expect(editor.documentLength, 4);
|
expect(editor.documentLength, 4);
|
||||||
expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0));
|
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 😁');
|
'Welcome to Appflowy 😁');
|
||||||
final node = editor.nodeAtPath([2]);
|
final node = editor.nodeAtPath([2]);
|
||||||
final item = defaultSelectionMenuItems[index];
|
final item = defaultSelectionMenuItems[index];
|
||||||
|
@ -117,7 +117,7 @@ void main() async {
|
|||||||
expect(editor.documentLength, 1);
|
expect(editor.documentLength, 1);
|
||||||
expect(editor.documentSelection,
|
expect(editor.documentSelection,
|
||||||
Selection.single(path: [0], startOffset: text.length));
|
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
|
// Before
|
||||||
@ -275,7 +275,6 @@ void main() async {
|
|||||||
// * Welcome to Appflowy 😁
|
// * Welcome to Appflowy 😁
|
||||||
const text = 'Welcome to Appflowy 😁';
|
const text = 'Welcome to Appflowy 😁';
|
||||||
final node = TextNode(
|
final node = TextNode(
|
||||||
type: 'text',
|
|
||||||
delta: Delta()..insert(text),
|
delta: Delta()..insert(text),
|
||||||
attributes: {
|
attributes: {
|
||||||
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
|
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
|
||||||
@ -320,7 +319,7 @@ void main() async {
|
|||||||
editor.documentSelection,
|
editor.documentSelection,
|
||||||
Selection.single(path: [0, 0], startOffset: text.length),
|
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 {
|
testWidgets('Delete the complicated nested bulleted list', (tester) async {
|
||||||
@ -331,7 +330,6 @@ void main() async {
|
|||||||
// * Welcome to Appflowy 😁
|
// * Welcome to Appflowy 😁
|
||||||
const text = 'Welcome to Appflowy 😁';
|
const text = 'Welcome to Appflowy 😁';
|
||||||
final node = TextNode(
|
final node = TextNode(
|
||||||
type: 'text',
|
|
||||||
delta: Delta()..insert(text),
|
delta: Delta()..insert(text),
|
||||||
attributes: {
|
attributes: {
|
||||||
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
|
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
|
||||||
@ -390,7 +388,7 @@ void main() async {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
(editor.nodeAtPath([0, 0]) as TextNode).toRawString() == text * 2,
|
(editor.nodeAtPath([0, 0]) as TextNode).toPlainText() == text * 2,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
@ -496,7 +494,7 @@ Future<void> _deleteStyledTextByBackspace(
|
|||||||
expect(editor.documentSelection,
|
expect(editor.documentSelection,
|
||||||
Selection.single(path: [1], startOffset: text.length));
|
Selection.single(path: [1], startOffset: text.length));
|
||||||
expect(editor.nodeAtPath([1])?.subtype, style);
|
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(
|
await editor.updateSelection(
|
||||||
Selection.single(path: [1], startOffset: 0),
|
Selection.single(path: [1], startOffset: 0),
|
||||||
@ -538,7 +536,7 @@ Future<void> _deleteStyledTextByDelete(
|
|||||||
expect(
|
expect(
|
||||||
editor.documentSelection, Selection.single(path: [1], startOffset: 0));
|
editor.documentSelection, Selection.single(path: [1], startOffset: 0));
|
||||||
expect(editor.nodeAtPath([1])?.subtype, style);
|
expect(editor.nodeAtPath([1])?.subtype, style);
|
||||||
expect((editor.nodeAtPath([1]) as TextNode).toRawString(),
|
expect((editor.nodeAtPath([1]) as TextNode).toPlainText(),
|
||||||
text.safeSubString(i));
|
text.safeSubString(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -548,7 +546,7 @@ Future<void> _deleteStyledTextByDelete(
|
|||||||
expect(editor.documentLength, 2);
|
expect(editor.documentLength, 2);
|
||||||
expect(editor.documentSelection, Selection.single(path: [1], startOffset: 0));
|
expect(editor.documentSelection, Selection.single(path: [1], startOffset: 0));
|
||||||
expect(editor.nodeAtPath([1])?.subtype, style);
|
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(
|
Future<void> _deleteTextByBackspace(
|
||||||
@ -568,7 +566,7 @@ Future<void> _deleteTextByBackspace(
|
|||||||
|
|
||||||
expect(editor.documentLength, 3);
|
expect(editor.documentLength, 3);
|
||||||
expect(editor.documentSelection, Selection.single(path: [1], startOffset: 9));
|
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 😁');
|
'Welcome t Appflowy 😁');
|
||||||
|
|
||||||
// delete 'to '
|
// delete 'to '
|
||||||
@ -578,7 +576,7 @@ Future<void> _deleteTextByBackspace(
|
|||||||
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
|
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
|
||||||
expect(editor.documentLength, 3);
|
expect(editor.documentLength, 3);
|
||||||
expect(editor.documentSelection, Selection.single(path: [2], startOffset: 8));
|
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 😁');
|
'Welcome Appflowy 😁');
|
||||||
|
|
||||||
// delete 'Appflowy 😁
|
// delete 'Appflowy 😁
|
||||||
@ -593,7 +591,7 @@ Future<void> _deleteTextByBackspace(
|
|||||||
expect(editor.documentLength, 1);
|
expect(editor.documentLength, 1);
|
||||||
expect(
|
expect(
|
||||||
editor.documentSelection, Selection.single(path: [0], startOffset: 11));
|
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 😁');
|
'Welcome to Appflowy 😁');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -614,7 +612,7 @@ Future<void> _deleteTextByDelete(
|
|||||||
|
|
||||||
expect(editor.documentLength, 3);
|
expect(editor.documentLength, 3);
|
||||||
expect(editor.documentSelection, Selection.single(path: [1], startOffset: 9));
|
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 😁');
|
'Welcome t Appflowy 😁');
|
||||||
|
|
||||||
// delete 'to '
|
// delete 'to '
|
||||||
@ -624,7 +622,7 @@ Future<void> _deleteTextByDelete(
|
|||||||
await editor.pressLogicKey(LogicalKeyboardKey.delete);
|
await editor.pressLogicKey(LogicalKeyboardKey.delete);
|
||||||
expect(editor.documentLength, 3);
|
expect(editor.documentLength, 3);
|
||||||
expect(editor.documentSelection, Selection.single(path: [2], startOffset: 8));
|
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 😁');
|
'Welcome Appflowy 😁');
|
||||||
|
|
||||||
// delete 'Appflowy 😁
|
// delete 'Appflowy 😁
|
||||||
@ -639,6 +637,6 @@ Future<void> _deleteTextByDelete(
|
|||||||
expect(editor.documentLength, 1);
|
expect(editor.documentLength, 1);
|
||||||
expect(
|
expect(
|
||||||
editor.documentSelection, Selection.single(path: [0], startOffset: 11));
|
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 😁');
|
'Welcome to Appflowy 😁');
|
||||||
}
|
}
|
||||||
|
@ -74,10 +74,10 @@ void main() async {
|
|||||||
expect(lastNode != null, true);
|
expect(lastNode != null, true);
|
||||||
expect(lastNode is TextNode, true);
|
expect(lastNode is TextNode, true);
|
||||||
lastNode = lastNode as TextNode;
|
lastNode = lastNode as TextNode;
|
||||||
expect(lastNode.delta.toRawString(), text);
|
expect(lastNode.delta.toPlainText(), text);
|
||||||
expect((lastNode.previous as TextNode).delta.toRawString(), '');
|
expect((lastNode.previous as TextNode).delta.toPlainText(), '');
|
||||||
expect(
|
expect(
|
||||||
(lastNode.previous!.previous as TextNode).delta.toRawString(), text);
|
(lastNode.previous!.previous as TextNode).delta.toPlainText(), text);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Before
|
// Before
|
||||||
@ -134,7 +134,7 @@ void main() async {
|
|||||||
);
|
);
|
||||||
await editor.pressLogicKey(LogicalKeyboardKey.enter);
|
await editor.pressLogicKey(LogicalKeyboardKey.enter);
|
||||||
expect(editor.documentLength, 2);
|
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.documentLength, 2);
|
||||||
expect((editor.nodeAtPath([0]) as TextNode).toRawString(), 'Welcome');
|
expect((editor.nodeAtPath([0]) as TextNode).toPlainText(), 'Welcome');
|
||||||
expect((editor.nodeAtPath([1]) as TextNode).toRawString(), 'to Appflowy 😁');
|
expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), 'to Appflowy 😁');
|
||||||
}
|
}
|
||||||
|
@ -39,11 +39,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 0,
|
startOffset: 0,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allBold, true);
|
expect(allBold, true);
|
||||||
expect(textNode.toRawString(), 'AppFlowy');
|
expect(textNode.toPlainText(), 'AppFlowy');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('App**Flowy** to bold AppFlowy', (tester) async {
|
testWidgets('App**Flowy** to bold AppFlowy', (tester) async {
|
||||||
@ -62,11 +62,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 3,
|
startOffset: 3,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allBold, true);
|
expect(allBold, true);
|
||||||
expect(textNode.toRawString(), 'AppFlowy');
|
expect(textNode.toPlainText(), 'AppFlowy');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('***AppFlowy** to bold *AppFlowy', (tester) async {
|
testWidgets('***AppFlowy** to bold *AppFlowy', (tester) async {
|
||||||
@ -85,11 +85,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 1,
|
startOffset: 1,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allBold, true);
|
expect(allBold, true);
|
||||||
expect(textNode.toRawString(), '*AppFlowy');
|
expect(textNode.toPlainText(), '*AppFlowy');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('**AppFlowy** application to bold AppFlowy only',
|
testWidgets('**AppFlowy** application to bold AppFlowy only',
|
||||||
@ -115,7 +115,7 @@ void main() async {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(appFlowyBold, true);
|
expect(appFlowyBold, true);
|
||||||
expect(textNode.toRawString(), 'AppFlowy');
|
expect(textNode.toPlainText(), 'AppFlowy');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('**** nothing changes', (tester) async {
|
testWidgets('**** nothing changes', (tester) async {
|
||||||
@ -134,11 +134,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 0,
|
startOffset: 0,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allBold, false);
|
expect(allBold, false);
|
||||||
expect(textNode.toRawString(), text);
|
expect(textNode.toPlainText(), text);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -171,11 +171,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 0,
|
startOffset: 0,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allBold, true);
|
expect(allBold, true);
|
||||||
expect(textNode.toRawString(), 'AppFlowy');
|
expect(textNode.toPlainText(), 'AppFlowy');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('App__Flowy__ to bold AppFlowy', (tester) async {
|
testWidgets('App__Flowy__ to bold AppFlowy', (tester) async {
|
||||||
@ -194,11 +194,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 3,
|
startOffset: 3,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allBold, true);
|
expect(allBold, true);
|
||||||
expect(textNode.toRawString(), 'AppFlowy');
|
expect(textNode.toPlainText(), 'AppFlowy');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('___AppFlowy__ to bold _AppFlowy', (tester) async {
|
testWidgets('___AppFlowy__ to bold _AppFlowy', (tester) async {
|
||||||
@ -217,11 +217,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 1,
|
startOffset: 1,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allBold, true);
|
expect(allBold, true);
|
||||||
expect(textNode.toRawString(), '_AppFlowy');
|
expect(textNode.toPlainText(), '_AppFlowy');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('__AppFlowy__ application to bold AppFlowy only',
|
testWidgets('__AppFlowy__ application to bold AppFlowy only',
|
||||||
@ -247,7 +247,7 @@ void main() async {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(appFlowyBold, true);
|
expect(appFlowyBold, true);
|
||||||
expect(textNode.toRawString(), 'AppFlowy');
|
expect(textNode.toPlainText(), 'AppFlowy');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('____ nothing changes', (tester) async {
|
testWidgets('____ nothing changes', (tester) async {
|
||||||
@ -266,11 +266,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 0,
|
startOffset: 0,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allBold, false);
|
expect(allBold, false);
|
||||||
expect(textNode.toRawString(), text);
|
expect(textNode.toPlainText(), text);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -38,11 +38,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 0,
|
startOffset: 0,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allCode, true);
|
expect(allCode, true);
|
||||||
expect(textNode.toRawString(), 'AppFlowy');
|
expect(textNode.toPlainText(), 'AppFlowy');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('App`Flowy` to code AppFlowy', (tester) async {
|
testWidgets('App`Flowy` to code AppFlowy', (tester) async {
|
||||||
@ -61,11 +61,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 3,
|
startOffset: 3,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allCode, true);
|
expect(allCode, true);
|
||||||
expect(textNode.toRawString(), 'AppFlowy');
|
expect(textNode.toPlainText(), 'AppFlowy');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('`` nothing changes', (tester) async {
|
testWidgets('`` nothing changes', (tester) async {
|
||||||
@ -84,11 +84,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 0,
|
startOffset: 0,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allCode, false);
|
expect(allCode, false);
|
||||||
expect(textNode.toRawString(), text);
|
expect(textNode.toPlainText(), text);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -120,11 +120,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 1,
|
startOffset: 1,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allCode, true);
|
expect(allCode, true);
|
||||||
expect(textNode.toRawString(), '`AppFlowy');
|
expect(textNode.toPlainText(), '`AppFlowy');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('```` nothing changes', (tester) async {
|
testWidgets('```` nothing changes', (tester) async {
|
||||||
@ -143,11 +143,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 0,
|
startOffset: 0,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allCode, false);
|
expect(allCode, false);
|
||||||
expect(textNode.toRawString(), text);
|
expect(textNode.toPlainText(), text);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -180,11 +180,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 0,
|
startOffset: 0,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allStrikethrough, true);
|
expect(allStrikethrough, true);
|
||||||
expect(textNode.toRawString(), 'AppFlowy');
|
expect(textNode.toPlainText(), 'AppFlowy');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('App~~Flowy~~ to strikethrough AppFlowy', (tester) async {
|
testWidgets('App~~Flowy~~ to strikethrough AppFlowy', (tester) async {
|
||||||
@ -203,11 +203,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 3,
|
startOffset: 3,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allStrikethrough, true);
|
expect(allStrikethrough, true);
|
||||||
expect(textNode.toRawString(), 'AppFlowy');
|
expect(textNode.toPlainText(), 'AppFlowy');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('~~~AppFlowy~~ to bold ~AppFlowy', (tester) async {
|
testWidgets('~~~AppFlowy~~ to bold ~AppFlowy', (tester) async {
|
||||||
@ -226,11 +226,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 1,
|
startOffset: 1,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allStrikethrough, true);
|
expect(allStrikethrough, true);
|
||||||
expect(textNode.toRawString(), '~AppFlowy');
|
expect(textNode.toPlainText(), '~AppFlowy');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('~~~~ nothing changes', (tester) async {
|
testWidgets('~~~~ nothing changes', (tester) async {
|
||||||
@ -249,11 +249,11 @@ void main() async {
|
|||||||
Selection.single(
|
Selection.single(
|
||||||
path: [0],
|
path: [0],
|
||||||
startOffset: 0,
|
startOffset: 0,
|
||||||
endOffset: textNode.toRawString().length,
|
endOffset: textNode.toPlainText().length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(allStrikethrough, false);
|
expect(allStrikethrough, false);
|
||||||
expect(textNode.toRawString(), text);
|
expect(textNode.toPlainText(), text);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -56,7 +56,7 @@ Future<void> _testBackspaceUndoRedo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(editor.documentLength, 3);
|
expect(editor.documentLength, 3);
|
||||||
expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text);
|
expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), text);
|
||||||
expect(editor.documentSelection, selection);
|
expect(editor.documentSelection, selection);
|
||||||
|
|
||||||
if (Platform.isWindows || Platform.isLinux) {
|
if (Platform.isWindows || Platform.isLinux) {
|
||||||
|
@ -26,7 +26,7 @@ void main() async {
|
|||||||
);
|
);
|
||||||
await editor.pressLogicKey(LogicalKeyboardKey.space);
|
await editor.pressLogicKey(LogicalKeyboardKey.space);
|
||||||
expect(
|
expect(
|
||||||
(editor.nodeAtPath([i]) as TextNode).toRawString(),
|
(editor.nodeAtPath([i]) as TextNode).toPlainText(),
|
||||||
'W elcome to Appflowy 😁',
|
'W elcome to Appflowy 😁',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -36,7 +36,7 @@ void main() async {
|
|||||||
);
|
);
|
||||||
await editor.pressLogicKey(LogicalKeyboardKey.space);
|
await editor.pressLogicKey(LogicalKeyboardKey.space);
|
||||||
expect(
|
expect(
|
||||||
(editor.nodeAtPath([i]) as TextNode).toRawString(),
|
(editor.nodeAtPath([i]) as TextNode).toPlainText(),
|
||||||
'W elcome to Appflowy 😁 ',
|
'W elcome to Appflowy 😁 ',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -87,7 +87,7 @@ void main() async {
|
|||||||
expect(textNode.subtype, BuiltInAttributeKey.heading);
|
expect(textNode.subtype, BuiltInAttributeKey.heading);
|
||||||
// BuiltInAttributeKey.h1 ~ BuiltInAttributeKey.h6
|
// BuiltInAttributeKey.h1 ~ BuiltInAttributeKey.h6
|
||||||
expect(textNode.attributes.heading, 'h$i');
|
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);
|
await editor.pressLogicKey(LogicalKeyboardKey.space);
|
||||||
expect(textNode.subtype, BuiltInAttributeKey.checkbox);
|
expect(textNode.subtype, BuiltInAttributeKey.checkbox);
|
||||||
expect(textNode.attributes.check, true);
|
expect(textNode.attributes.check, true);
|
||||||
expect(textNode.toRawString(), insertedText);
|
expect(textNode.toPlainText(), insertedText);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -63,9 +63,9 @@ void main() async {
|
|||||||
..insertTextNode(text)
|
..insertTextNode(text)
|
||||||
..insertTextNode(
|
..insertTextNode(
|
||||||
null,
|
null,
|
||||||
delta: Delta([
|
delta: Delta(operations: [
|
||||||
TextInsert(text),
|
TextInsert(text),
|
||||||
TextInsert(text, attributes),
|
TextInsert(text, attributes: attributes),
|
||||||
TextInsert(text),
|
TextInsert(text),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
@ -171,8 +171,8 @@ void main() async {
|
|||||||
BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
|
BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
|
||||||
BuiltInAttributeKey.heading: BuiltInAttributeKey.h1,
|
BuiltInAttributeKey.heading: BuiltInAttributeKey.h1,
|
||||||
},
|
},
|
||||||
delta: Delta([
|
delta: Delta(operations: [
|
||||||
TextInsert(text, {
|
TextInsert(text, attributes: {
|
||||||
BuiltInAttributeKey.bold: true,
|
BuiltInAttributeKey.bold: true,
|
||||||
})
|
})
|
||||||
]),
|
]),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user