diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart index bc57241a51..81c0df2f9c 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart @@ -1,3 +1,5 @@ +import 'package:flowy_editor/document/position.dart'; +import 'package:flowy_editor/document/selection.dart'; import 'package:flowy_editor/document/text_delta.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -81,6 +83,40 @@ extension on TextNode { } } +TextSelection? _globalSelectionToLocal(Node node, Selection? globalSel) { + if (globalSel == null) { + return null; + } + final nodePath = node.path; + + if (!pathEquals(nodePath, globalSel.start.path)) { + return null; + } + if (globalSel.isCollapsed()) { + return TextSelection( + baseOffset: globalSel.start.offset, extentOffset: globalSel.end.offset); + } else { + if (pathEquals(globalSel.start.path, globalSel.end.path)) { + return TextSelection( + baseOffset: globalSel.start.offset, + extentOffset: globalSel.end.offset); + } + } + return null; +} + +Selection? _localSelectionToGlobal(Node node, TextSelection? sel) { + if (sel == null) { + return null; + } + final nodePath = node.path; + + return Selection( + start: Position(path: nodePath, offset: sel.baseOffset), + end: Position(path: nodePath, offset: sel.extentOffset), + ); +} + class _TextNodeWidget extends StatefulWidget { final Node node; final EditorState editorState; @@ -95,37 +131,114 @@ class _TextNodeWidget extends StatefulWidget { State<_TextNodeWidget> createState() => __TextNodeWidgetState(); } +String _textContentOfDelta(Delta delta) { + return delta.operations.fold("", (previousValue, element) { + if (element is TextInsert) { + return previousValue + element.content; + } + return previousValue; + }); +} + class __TextNodeWidgetState extends State<_TextNodeWidget> - implements TextInputClient { + implements DeltaTextInputClient { + final _focusNode = FocusNode(debugLabel: "input"); TextNode get node => widget.node as TextNode; EditorState get editorState => widget.editorState; - TextEditingValue get textEditingValue => const TextEditingValue(); TextInputConnection? _textInputConnection; + _backDeleteTextAtSelection(TextSelection? sel) { + if (sel == null) { + return; + } + if (sel.start == 0) { + return; + } + + if (sel.isCollapsed) { + TransactionBuilder(editorState) + ..deleteText(node, sel.start - 1, 1) + ..commit(); + } else { + TransactionBuilder(editorState) + ..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset) + ..commit(); + } + + _setEditingStateFromGlobal(); + } + + _forwardDeleteTextAtSelection(TextSelection? sel) { + if (sel == null) { + return; + } + + if (sel.isCollapsed) { + TransactionBuilder(editorState) + ..deleteText(node, sel.start, 1) + ..commit(); + } else { + TransactionBuilder(editorState) + ..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset) + ..commit(); + } + _setEditingStateFromGlobal(); + } + + _setEditingStateFromGlobal() { + _textInputConnection?.setEditingState(TextEditingValue( + text: _textContentOfDelta(node.delta), + selection: _globalSelectionToLocal(node, editorState.cursorSelection) ?? + const TextSelection.collapsed(offset: 0))); + } + @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SelectableText.rich( - TextSpan( - children: node.toTextSpans(), + KeyboardListener( + focusNode: _focusNode, + onKeyEvent: ((value) { + if (value is KeyDownEvent || value is KeyRepeatEvent) { + final sel = + _globalSelectionToLocal(node, editorState.cursorSelection); + if (value.logicalKey.keyLabel == "Backspace") { + _backDeleteTextAtSelection(sel); + } else if (value.logicalKey.keyLabel == "Delete") { + _forwardDeleteTextAtSelection(sel); + } + } + }), + child: SelectableText.rich( + showCursor: true, + TextSpan( + children: node.toTextSpans(), + ), + onTap: () { + _focusNode.requestFocus(); + }, + onSelectionChanged: ((selection, cause) { + _textInputConnection?.close(); + _textInputConnection = TextInput.attach( + this, + const TextInputConfiguration( + enableDeltaModel: true, + inputType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + ), + ); + debugPrint('selection: $selection'); + editorState.cursorSelection = + _localSelectionToGlobal(node, selection); + _textInputConnection + ?..show() + ..setEditingState(TextEditingValue( + text: _textContentOfDelta(node.delta), + selection: selection)); + }), ), - onTap: () { - _textInputConnection?.close(); - _textInputConnection = TextInput.attach( - this, - const TextInputConfiguration( - enableDeltaModel: false, - inputType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - ), - ); - _textInputConnection - ?..show() - ..setEditingState(textEditingValue); - }, ), if (node.children.isNotEmpty) ...node.children.map( @@ -152,7 +265,10 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> @override // TODO: implement currentTextEditingValue - TextEditingValue? get currentTextEditingValue => textEditingValue; + TextEditingValue? get currentTextEditingValue => TextEditingValue( + text: _textContentOfDelta(node.delta), + selection: _globalSelectionToLocal(node, editorState.cursorSelection) ?? + const TextSelection.collapsed(offset: 0)); @override void insertTextPlaceholder(Size size) { @@ -161,7 +277,7 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> @override void performAction(TextInputAction action) { - // TODO: implement performAction + debugPrint('action:$action'); } @override @@ -186,7 +302,24 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> @override void updateEditingValue(TextEditingValue value) { - debugPrint(value.text); + debugPrint('offset: ${value.selection}'); + } + + @override + void updateEditingValueWithDeltas(List textEditingDeltas) { + debugPrint(textEditingDeltas.toString()); + for (final textDelta in textEditingDeltas) { + if (textDelta is TextEditingDeltaInsertion) { + TransactionBuilder(editorState) + ..insertText(node, textDelta.insertionOffset, textDelta.textInserted) + ..commit(); + } else if (textDelta is TextEditingDeltaDeletion) { + TransactionBuilder(editorState) + ..deleteText(node, textDelta.deletedRange.start, + textDelta.deletedRange.end - textDelta.deletedRange.start) + ..commit(); + } + } } @override diff --git a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart index 74c8a8e58c..521231a495 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -34,6 +34,7 @@ class EditorState { for (final op in transaction.operations) { _applyOperation(op); } + cursorSelection = transaction.afterSelection; } void _applyOperation(Operation op) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart index fa56484ae3..3de528e868 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart @@ -19,10 +19,12 @@ import './operation.dart'; @immutable class Transaction { final UnmodifiableListView operations; - final Selection? cursorSelection; + final Selection? beforeSelection; + final Selection? afterSelection; const Transaction({ required this.operations, - this.cursorSelection, + this.beforeSelection, + this.afterSelection, }); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart index 51b05f187b..ec088dc25d 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart @@ -1,7 +1,9 @@ import 'dart:collection'; +import 'dart:math'; import 'package:flowy_editor/editor_state.dart'; import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/path.dart'; +import 'package:flowy_editor/document/position.dart'; import 'package:flowy_editor/document/text_delta.dart'; import 'package:flowy_editor/document/attributes.dart'; import 'package:flowy_editor/document/selection.dart'; @@ -22,49 +24,86 @@ import './transaction.dart'; class TransactionBuilder { final List operations = []; EditorState state; - Selection? cursorSelection; + Selection? beforeSelection; + Selection? afterSelection; TransactionBuilder(this.state); + /// Commit the operations to the state commit() { final transaction = _finish(); state.apply(transaction); } - void insertNode(Path path, Node node) { - cursorSelection = state.cursorSelection; - operations.add(InsertOperation(path: path, value: node)); + insertNode(Path path, Node node) { + beforeSelection = state.cursorSelection; + add(InsertOperation(path: path, value: node)); } - void updateNode(Node node, Attributes attributes) { - cursorSelection = state.cursorSelection; - operations.add(UpdateOperation( + updateNode(Node node, Attributes attributes) { + beforeSelection = state.cursorSelection; + add(UpdateOperation( path: node.path, attributes: Attributes.from(node.attributes)..addAll(attributes), oldAttributes: node.attributes, )); } - void deleteNode(Node node) { - cursorSelection = state.cursorSelection; - operations.add(DeleteOperation(path: node.path, removedValue: node)); + deleteNode(Node node) { + beforeSelection = state.cursorSelection; + add(DeleteOperation(path: node.path, removedValue: node)); } - void textEdit(TextNode node, Delta Function() f) { - cursorSelection = state.cursorSelection; + textEdit(TextNode node, Delta Function() f) { + beforeSelection = state.cursorSelection; final path = node.path; final delta = f(); final inverted = delta.invert(node.delta); - operations - .add(TextEditOperation(path: path, delta: delta, inverted: inverted)); + + add(TextEditOperation(path: path, delta: delta, inverted: inverted)); + } + + insertText(TextNode node, int index, String content) { + textEdit(node, () => Delta().retain(index).insert(content)); + afterSelection = Selection.collapsed( + Position(path: node.path, offset: index + content.length)); + } + + formatText(TextNode node, int index, int length, Attributes attributes) { + textEdit(node, () => Delta().retain(index).retain(length, attributes)); + } + + deleteText(TextNode node, int index, int length) { + textEdit(node, () => Delta().retain(index).delete(length)); + afterSelection = + Selection.collapsed(Position(path: node.path, offset: index)); + } + + add(Operation op) { + 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( + path: op.path, + delta: last.delta.compose(op.delta), + inverted: op.inverted.compose(last.inverted), + ); + operations[operations.length - 1] = newOp; + return; + } + } + operations.add(op); } Transaction _finish() { return Transaction( operations: UnmodifiableListView(operations), - cursorSelection: cursorSelection, + beforeSelection: beforeSelection, + afterSelection: afterSelection, ); } }