feat: implement transform

This commit is contained in:
Lucas.Xu 2023-04-21 21:03:35 +08:00
commit 4f4c93e92f
29 changed files with 311 additions and 228 deletions

View file

@ -55,3 +55,4 @@ export 'src/service/internal_key_event_handlers/copy_paste_handler.dart';
export 'src/editor/block_component/block_component.dart'; export 'src/editor/block_component/block_component.dart';
export 'src/editor/editor_component/editor_component.dart'; export 'src/editor/editor_component/editor_component.dart';
export 'src/editor/transform/transform.dart';

View file

@ -95,18 +95,18 @@ extension TextCommands on EditorState {
); );
} }
Future<void> insertNewLine({ // Future<void> insertNewLine({
Path? path, // Path? path,
}) async { // }) async {
final p = path ?? getSelection(null).start.path.next; // final p = path ?? getSelection(null).start.path.next;
final transaction = this.transaction; // final transaction = this.transaction;
transaction.insertNode(p, TextNode.empty()); // transaction.insertNode(p, TextNode.empty());
transaction.afterSelection = Selection.single( // transaction.afterSelection = Selection.single(
path: p, // path: p,
startOffset: 0, // startOffset: 0,
); // );
return apply(transaction); // return apply(transaction);
} // }
Future<void> insertNewLineAtCurrentSelection() async { Future<void> insertNewLineAtCurrentSelection() async {
final selection = getSelection(null); final selection = getSelection(null);

View file

@ -34,6 +34,15 @@ class Document {
); );
} }
factory Document.blank() {
final root = Node(
type: 'editor',
);
return Document(
root: root,
);
}
final Node root; final Node root;
/// Returns the node at the given [path]. /// Returns the node at the given [path].

View file

@ -3,14 +3,20 @@ import 'package:appflowy_editor/src/core/document/document.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> {
/// Creates a NodeIterator.
NodeIterator({ NodeIterator({
required this.document, required this.document,
required this.startNode, required this.startNode,
this.endNode, this.endNode,
}); });
/// The document to iterate.
final Document document; final Document document;
/// The node to start the iteration with.
final Node startNode; final Node startNode;
/// The node to end the iteration with.
final Node? endNode; final Node? endNode;
Node? _currentNode; Node? _currentNode;
@ -58,7 +64,7 @@ class NodeIterator implements Iterator<Node> {
} }
List<Node> toList() { List<Node> toList() {
final result = <Node>[]; final List<Node> result = [];
while (moveNext()) { while (moveNext()) {
result.add(current); result.add(current);
} }

View file

@ -230,25 +230,37 @@ extension TextTransaction on Transaction {
} }
void mergeText( void mergeText(
TextNode first, Node left,
TextNode second, { Node right, {
int? firstOffset, int? leftOffset,
int secondOffset = 0, int rightOffset = 0,
}) { }) {
final firstLength = first.delta.length; final leftDelta = left.delta;
final secondLength = second.delta.length; final rightDelta = right.delta;
firstOffset ??= firstLength; if (leftDelta == null || rightDelta == null) {
updateText( return;
first, }
Delta() final leftLength = leftDelta.length;
..retain(firstOffset) final rightLength = rightDelta.length;
..delete(firstLength - firstOffset) leftOffset ??= leftLength;
..addAll(second.delta.slice(secondOffset, secondLength)),
); final composed = leftDelta
.compose(
Delta()
..retain(leftOffset)
..delete(leftLength - leftOffset)
..addAll(rightDelta.slice(rightOffset, rightLength)),
)
.toJson();
updateNode(left, {
'delta': composed,
});
afterSelection = Selection.collapsed( afterSelection = Selection.collapsed(
Position( Position(
path: first.path, path: left.path,
offset: firstOffset, offset: leftOffset,
), ),
); );
} }

View file

@ -1,29 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/extensions/extensions.dart';
extension DeleteSelection on EditorState {
Future<void> deleteSelection(Selection? selection) async {
final transaction = this.transaction;
if (selection == null || selection.isCollapsed) {
return;
}
final normalized = selection.normalized;
final nodes = this.selection.getNodesInSelection(normalized);
transaction.afterSelection = normalized.collapse(atStart: true);
// single line
if (nodes.length == 1) {
final node = nodes.first;
if (node.delta != null) {
transaction.deleteText2(node, normalized.startIndex, normalized.length);
} else {
transaction.deleteNode(node);
}
return apply(transaction);
} else {
throw UnimplementedError();
}
}
}

View file

@ -1,30 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
extension InsertNewLine on EditorState {
Future<void> insertNewLine2(Selection? selection) async {
if (selection == null || !selection.isCollapsed) {
return;
}
final transaction = this.transaction;
final path = selection.start.path.next;
transaction.insertNode(
path,
Node(
type: 'paragraph',
attributes: {
'delta': Delta().toJson(),
},
),
);
transaction.afterSelection = Selection.collapsed(
Position(
path: path,
offset: 0,
),
);
return apply(transaction);
}
}

View file

@ -1,7 +0,0 @@
// transaction
export 'transaction_delete_text.dart';
export 'transaction_insert_text.dart';
// editor state
export 'editor_state_delete_selection.dart';
export 'editor_state_insert_new_line.dart';

View file

@ -1,28 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
extension Extension on Transaction {
void deleteText2(
Node node,
int index,
int length,
) {
final delta = node.delta;
if (delta == null) {
return;
}
final now = delta.compose(
Delta()
..retain(index)
..delete(length),
);
updateNode(node, {
'delta': now.toJson(),
});
afterSelection = Selection.collapsed(
Position(path: node.path, offset: index),
);
}
}

View file

@ -1,5 +1,4 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/extensions/extensions.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
Future<void> onDelete( Future<void> onDelete(
@ -8,18 +7,18 @@ Future<void> onDelete(
) async { ) async {
Log.input.debug('onDelete: $deletion'); Log.input.debug('onDelete: $deletion');
final selection = editorState.selection.currentSelection.value; final selection = editorState.selection;
if (selection == null) { if (selection == null) {
return; return;
} }
// single line // single line
if (selection.isSingle) { if (selection.isSingle) {
final node = editorState.selection.currentSelectedNodes.first; final node = editorState.selectionService.currentSelectedNodes.first;
assert(node.delta != null); assert(node.delta != null);
final transaction = editorState.transaction final transaction = editorState.transaction
..deleteText2( ..deleteText(
node, node,
deletion.deletedRange.start, deletion.deletedRange.start,
deletion.textDeleted.length, deletion.textDeleted.length,

View file

@ -33,7 +33,7 @@ Future<void> onInsert(
} }
} }
final selection = editorState.selection.currentSelection.value; final selection = editorState.selection;
if (selection == null) { if (selection == null) {
return; return;
} }
@ -41,7 +41,7 @@ Future<void> onInsert(
// IME // IME
// single line // single line
if (selection.isCollapsed) { if (selection.isCollapsed) {
final node = editorState.selection.currentSelectedNodes.first; final node = editorState.selectionService.currentSelectedNodes.first;
assert(node.delta != null); assert(node.delta != null);
final transaction = editorState.transaction final transaction = editorState.transaction

View file

@ -26,6 +26,7 @@ class _KeyboardServiceWidgetState extends State<KeyboardServiceWidget> {
super.initState(); super.initState();
editorState = Provider.of<EditorState>(context, listen: false); editorState = Provider.of<EditorState>(context, listen: false);
editorState.selectionNotifier.addListener(_onSelectionChanged);
textInputService = DeltaTextInputService( textInputService = DeltaTextInputService(
onInsert: (insertion) => onInsert(insertion, editorState), onInsert: (insertion) => onInsert(insertion, editorState),
@ -34,15 +35,11 @@ class _KeyboardServiceWidgetState extends State<KeyboardServiceWidget> {
onNonTextUpdate: onNonTextUpdate, onNonTextUpdate: onNonTextUpdate,
onPerformAction: (action) => onPerformAction(action, editorState), onPerformAction: (action) => onPerformAction(action, editorState),
); );
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
editorState.selection.currentSelection.addListener(_onSelectionChanged);
});
} }
@override @override
void dispose() { void dispose() {
editorState.selection.currentSelection.removeListener(_onSelectionChanged); editorState.selectionNotifier.removeListener(_onSelectionChanged);
super.dispose(); super.dispose();
} }
@ -53,7 +50,7 @@ class _KeyboardServiceWidgetState extends State<KeyboardServiceWidget> {
void _onSelectionChanged() { void _onSelectionChanged() {
// attach the delta text input service if needed // attach the delta text input service if needed
final selection = editorState.selection.currentSelection.value; final selection = editorState.selection;
if (selection == null) { if (selection == null) {
textInputService.close(); textInputService.close();
} else { } else {
@ -73,10 +70,11 @@ class _KeyboardServiceWidgetState extends State<KeyboardServiceWidget> {
} }
TextEditingValue? _getCurrentTextEditingValue(Selection selection) { TextEditingValue? _getCurrentTextEditingValue(Selection selection) {
final editableNodes = editorState.selection.currentSelectedNodes.where( final editableNodes =
editorState.selectionService.currentSelectedNodes.where(
(element) => element.delta != null, (element) => element.delta != null,
); );
final selection = editorState.selection.currentSelection.value; final selection = editorState.selection;
final composingTextRange = textInputService.composingTextRange; final composingTextRange = textInputService.composingTextRange;
if (editableNodes.isNotEmpty && selection != null) { if (editableNodes.isNotEmpty && selection != null) {
var text = editableNodes.fold<String>( var text = editableNodes.fold<String>(

View file

@ -1,26 +1,34 @@
import 'package:appflowy_editor/src/editor/editor_component/service/extensions/extensions.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/block_component/block_component.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../../../util/util.dart'; import '../../../../util/util.dart';
CharacterShortcutEventHandler _insertNewLineHandler = (editorState) async { /// insert a new line block
if (PlatformExtension.isDesktop && RawKeyboard.instance.isShiftPressed) { ///
return false; /// - on desktop or web: enter
} /// - on mobile: enter
///
final selection = editorState.selection.currentSelection.value;
// delete the selection
await editorState.deleteSelection(selection);
// insert a new line
await editorState.insertNewLine2(selection);
return true;
};
CharacterShortcutEvent insertNewLine = CharacterShortcutEvent( CharacterShortcutEvent insertNewLine = CharacterShortcutEvent(
key: 'insert a new line', key: 'insert a new line',
character: '\n', character: '\n',
handler: _insertNewLineHandler, handler: _insertNewLineHandler,
); );
CharacterShortcutEventHandler _insertNewLineHandler = (editorState) async {
// on desktop or web, shift + enter to insert a '\n' character to the same line.
// so, we should return the false to let the system handle it.
if (PlatformExtension.isNotMobile && RawKeyboard.instance.isShiftPressed) {
return false;
}
final selection = editorState.selection?.normalized;
if (selection == null) {
return false;
}
// delete the selection
await editorState.deleteSelection(selection);
// insert a new line
await editorState.insertNewLine(selection.start);
return true;
};

View file

@ -1,7 +1,7 @@
import 'package:appflowy_editor/src/editor/block_component/block_component.dart'; import 'package:appflowy_editor/src/editor/block_component/block_component.dart';
CharacterShortcutEventHandler _markdownBlockHandler = (editorState) async { CharacterShortcutEventHandler _markdownBlockHandler = (editorState) async {
final selection = editorState.selection.currentSelection.value; final selection = editorState.selection;
return false; return false;
}; };

View file

@ -27,7 +27,7 @@ class MobileToolbar extends StatelessWidget {
children: [ children: [
IconButton( IconButton(
onPressed: () { onPressed: () {
editorState.selection.updateSelection(null); editorState.selectionService.updateSelection(null);
}, },
icon: const Icon(Icons.keyboard_hide), icon: const Icon(Icons.keyboard_hide),
), ),

View file

@ -1,43 +1,37 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
extension SelectionTransform on EditorState { extension SelectionTransform on EditorState {
Future<void> deleteSelection(Selection selection) async { Future<bool> deleteSelection(Selection selection) async {
if (selection.isCollapsed) { if (selection.isCollapsed) {
return; return false;
} }
selection = selection.normalized;
final transaction = this.transaction; final transaction = this.transaction;
final normalized = selection.normalized; final nodes = getNodesInSelection(selection);
final nodes = this.selection.getNodesInSelection(normalized);
transaction.afterSelection = normalized.collapse(atStart: true);
if (nodes.length == 1) { if (nodes.length == 1) {
final node = nodes.first; final node = nodes.first;
if (node.delta != null) { if (node.delta != null) {
transaction.deleteText( transaction.deleteText(
node, node,
normalized.startIndex, selection.startIndex,
normalized.length, selection.length,
); );
} else { } else {
transaction.deleteNode(node); transaction.deleteNode(node);
} }
} else { } else {
assert(nodes.first.path < nodes.last.path);
for (var i = 0; i < nodes.length; i++) { for (var i = 0; i < nodes.length; i++) {
final node = nodes[i]; final node = nodes[i];
if (node.delta != null) { if (node.delta != null) {
if (i == 0) { if (i == 0) {
transaction.deleteText( transaction.mergeText(
node, node,
normalized.startIndex, nodes.last,
node.delta!.length - normalized.startIndex, leftOffset: selection.startIndex,
); rightOffset: selection.endIndex,
} else if (i == nodes.length - 1) {
transaction.deleteText(
node,
0,
normalized.endIndex,
); );
} else { } else {
transaction.deleteNode(node); transaction.deleteNode(node);
@ -48,6 +42,9 @@ extension SelectionTransform on EditorState {
} }
} }
return apply(transaction); transaction.afterSelection = selection.collapse(atStart: true);
await apply(transaction);
return true;
} }
} }

View file

@ -8,15 +8,15 @@ extension TextTransforms on EditorState {
/// ///
/// Then it inserts a new paragraph node. After that, it sets the selection to be at the /// Then it inserts a new paragraph node. After that, it sets the selection to be at the
/// beginning of the new paragraph. /// beginning of the new paragraph.
Future<void> insertNewLine({ Future<void> insertNewLine(
Position? at, Position? position,
}) async { ) async {
// If the position is not passed in, use the current selection. // If the position is not passed in, use the current selection.
final position = at ?? selection.currentSelection.value?.start; position = position ?? selectionService.currentSelection.value?.start;
// If there is no position, or if the selection is not collapsed, do nothing. // If there is no position, or if the selection is not collapsed, do nothing.
if (position == null || if (position == null ||
!(selection.currentSelection.value?.isCollapsed ?? false)) { !(selectionService.currentSelection.value?.isCollapsed ?? false)) {
return; return;
} }

View file

@ -0,0 +1,2 @@
export 'selection_transform.dart';
export 'text_transform.dart';

View file

@ -4,4 +4,5 @@ extension PlatformExtension on Platform {
static bool get isDesktop => static bool get isDesktop =>
Platform.isWindows || Platform.isLinux || Platform.isMacOS; Platform.isWindows || Platform.isLinux || Platform.isMacOS;
static bool get isMobile => Platform.isAndroid || Platform.isIOS; static bool get isMobile => Platform.isAndroid || Platform.isIOS;
static bool get isNotMobile => !isMobile;
} }

View file

@ -43,10 +43,17 @@ enum CursorUpdateReason {
class EditorState { class EditorState {
final Document document; final Document document;
final ValueNotifier<Selection?> selectionNotifier =
ValueNotifier<Selection?>(null);
Selection? get selection => selectionNotifier.value;
set selection(Selection? value) {
selectionNotifier.value = value;
}
// Service reference. // Service reference.
final service = FlowyService(); final service = FlowyService();
AppFlowySelectionService get selection => service.selectionService; AppFlowySelectionService get selectionService => service.selectionService;
AppFlowyRenderPluginService get renderer => service.renderPluginService; AppFlowyRenderPluginService get renderer => service.renderPluginService;
List<CharacterShortcutEvent> characterShortcutEvents = [ List<CharacterShortcutEvent> characterShortcutEvents = [
@ -134,33 +141,72 @@ class EditorState {
/// ///
/// The options can be used to determine whether the editor /// The options can be used to determine whether the editor
/// should record the transaction in undo/redo stack. /// should record the transaction in undo/redo stack.
///
/// The maximumRuleApplyLoop is used to prevent infinite loop.
///
/// The withUpdateSelection is used to determine whether the editor
/// should update the selection after applying the transaction.
Future<void> apply( Future<void> apply(
Transaction transaction, { Transaction transaction, {
bool isRemote = false,
ApplyOptions options = const ApplyOptions(recordUndo: true), ApplyOptions options = const ApplyOptions(recordUndo: true),
ruleCount = 0, bool withUpdateSelection = true,
withUpdateCursor = true,
}) async { }) async {
if (!editable) {
return;
}
final completer = Completer<void>(); final completer = Completer<void>();
if (!editable) { for (final operation in transaction.operations) {
completer.complete(); _applyOperation(operation);
return completer.future;
}
// TODO: validate the transaction.
for (final op in transaction.operations) {
_applyOperation(op);
} }
// broadcast to other users here
_observer.add(transaction); _observer.add(transaction);
WidgetsBinding.instance.addPostFrameCallback((_) async { _recordRedoOrUndo(options, transaction);
_applyRules(ruleCount);
if (withUpdateCursor) {
await updateCursorSelection(transaction.afterSelection);
}
completer.complete();
});
if (withUpdateSelection) {
selection = transaction.afterSelection;
}
// TODO: execute this line after the UI has been updated.
{
completer.complete();
}
return completer.future;
}
/// get nodes in selection
///
/// if selection is backward, return nodes in order
/// if selection is forward, return nodes in reverse order
///
List<Node> getNodesInSelection(Selection selection) {
// Normalize the selection.
final normalized = selection.normalized;
// Get the start and end nodes.
final startNode = document.nodeAtPath(normalized.start.path);
final endNode = document.nodeAtPath(normalized.end.path);
// If we have both nodes, we can find the nodes in the selection.
if (startNode != null && endNode != null) {
final nodes = NodeIterator(
document: document,
startNode: startNode,
endNode: endNode,
).toList();
return nodes;
}
// If we don't have both nodes, we can't find the nodes in the selection.
return [];
}
void _recordRedoOrUndo(ApplyOptions options, Transaction transaction) {
if (options.recordUndo) { if (options.recordUndo) {
final undoItem = undoManager.getUndoHistoryItem(); final undoItem = undoManager.getUndoHistoryItem();
undoItem.addAll(transaction.operations); undoItem.addAll(transaction.operations);
@ -177,33 +223,6 @@ class EditorState {
redoItem.afterSelection = transaction.afterSelection; redoItem.afterSelection = transaction.afterSelection;
undoManager.redoStack.push(redoItem); undoManager.redoStack.push(redoItem);
} }
return completer.future;
}
/// get nodes in selection
///
List<Node> getNodesInSelection(Selection selection) {
final start =
selection.isBackward ? selection.start.path : selection.end.path;
final end =
selection.isBackward ? selection.end.path : selection.start.path;
assert(start <= end);
final startNode = editorState.document.nodeAtPath(start);
final endNode = editorState.document.nodeAtPath(end);
if (startNode != null && endNode != null) {
final nodes = NodeIterator(
document: editorState.document,
startNode: startNode,
endNode: endNode,
).toList();
if (selection.isBackward) {
return nodes;
} else {
return nodes.reversed.toList(growable: false);
}
}
return [];
} }
void _debouncedSealHistoryItem() { void _debouncedSealHistoryItem() {
@ -233,9 +252,9 @@ class EditorState {
} }
} }
void _applyRules(int ruleCount) { void _applyRules(int maximumRuleApplyLoop) {
// Set a maximum count to prevent a dead loop. // Set a maximum count to prevent a dead loop.
if (ruleCount >= 5 || disableRules) { if (maximumRuleApplyLoop >= 5 || disableRules) {
return; return;
} }
@ -243,7 +262,10 @@ class EditorState {
_insureLastNodeEditable(transaction); _insureLastNodeEditable(transaction);
if (transaction.operations.isNotEmpty) { if (transaction.operations.isNotEmpty) {
apply(transaction, ruleCount: ruleCount + 1, withUpdateCursor: false); apply(
transaction,
withUpdateSelection: false,
);
} }
} }

Some files were not shown because too many files have changed in this diff Show more