From abe0658cd3a8a3e8679207d49ff9efbaad454f5e Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Tue, 19 Jul 2022 15:24:51 +0800 Subject: [PATCH 1/3] feat: insert text at cursor --- .../example/lib/plugin/text_node_widget.dart | 45 ++++++++++++++--- .../lib/operation/transaction_builder.dart | 49 +++++++++++++++---- 2 files changed, 77 insertions(+), 17 deletions(-) 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..517c0cda4b 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 @@ -95,11 +95,20 @@ 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 { TextNode get node => widget.node as TextNode; EditorState get editorState => widget.editorState; - TextEditingValue get textEditingValue => const TextEditingValue(); + TextSelection? _localSelection; TextInputConnection? _textInputConnection; @@ -112,20 +121,22 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> TextSpan( children: node.toTextSpans(), ), - onTap: () { + onSelectionChanged: ((selection, cause) { _textInputConnection?.close(); _textInputConnection = TextInput.attach( this, const TextInputConfiguration( - enableDeltaModel: false, + enableDeltaModel: true, inputType: TextInputType.multiline, textCapitalization: TextCapitalization.sentences, ), ); + debugPrint('selection: $selection'); _textInputConnection ?..show() - ..setEditingState(textEditingValue); - }, + ..setEditingState(TextEditingValue( + text: _textContentOfDelta(node.delta), selection: selection)); + }), ), if (node.children.isNotEmpty) ...node.children.map( @@ -152,7 +163,9 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> @override // TODO: implement currentTextEditingValue - TextEditingValue? get currentTextEditingValue => textEditingValue; + TextEditingValue? get currentTextEditingValue => TextEditingValue( + text: _textContentOfDelta(node.delta), + selection: _localSelection ?? const TextSelection.collapsed(offset: -1)); @override void insertTextPlaceholder(Size size) { @@ -186,7 +199,23 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> @override void updateEditingValue(TextEditingValue value) { - debugPrint(value.text); + debugPrint('offset: ${value.selection}'); + } + + @override + void updateEditingValueWithDeltas(List textEditingDeltas) { + 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/operation/transaction_builder.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart index 51b05f187b..9635c0ebc5 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 @@ -26,39 +26,70 @@ class TransactionBuilder { TransactionBuilder(this.state); + /// Commit the operations to the state commit() { final transaction = _finish(); state.apply(transaction); } - void insertNode(Path path, Node node) { + insertNode(Path path, Node node) { cursorSelection = state.cursorSelection; - operations.add(InsertOperation(path: path, value: node)); + add(InsertOperation(path: path, value: node)); } - void updateNode(Node node, Attributes attributes) { + updateNode(Node node, Attributes attributes) { cursorSelection = state.cursorSelection; - operations.add(UpdateOperation( + add(UpdateOperation( path: node.path, attributes: Attributes.from(node.attributes)..addAll(attributes), oldAttributes: node.attributes, )); } - void deleteNode(Node node) { + deleteNode(Node node) { cursorSelection = state.cursorSelection; - operations.add(DeleteOperation(path: node.path, removedValue: node)); + add(DeleteOperation(path: node.path, removedValue: node)); } - void textEdit(TextNode node, Delta Function() f) { + textEdit(TextNode node, Delta Function() f) { cursorSelection = 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)); + } + + 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)); + } + + 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() { From 8c6c9f7c0de275e6bb159baadb857534c9771304 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Tue, 19 Jul 2022 16:56:53 +0800 Subject: [PATCH 2/3] feat: transform betweens global/local cursor --- .../example/lib/plugin/text_node_widget.dart | 125 +++++++++++++++--- .../flowy_editor/lib/editor_state.dart | 1 + .../lib/operation/transaction.dart | 6 +- .../lib/operation/transaction_builder.dart | 20 ++- 4 files changed, 122 insertions(+), 30 deletions(-) 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 517c0cda4b..edacd6716e 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; @@ -106,37 +142,80 @@ String _textContentOfDelta(Delta delta) { class __TextNodeWidgetState extends State<_TextNodeWidget> implements DeltaTextInputClient { + final _focusNode = FocusNode(debugLabel: "input"); TextNode get node => widget.node as TextNode; EditorState get editorState => widget.editorState; - TextSelection? _localSelection; 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(); + } + + _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(), - ), - onSelectionChanged: ((selection, cause) { - _textInputConnection?.close(); - _textInputConnection = TextInput.attach( - this, - const TextInputConfiguration( - enableDeltaModel: true, - inputType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - ), - ); - debugPrint('selection: $selection'); - _textInputConnection - ?..show() - ..setEditingState(TextEditingValue( - text: _textContentOfDelta(node.delta), selection: selection)); + 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); + } + } }), + 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)); + }), + ), ), if (node.children.isNotEmpty) ...node.children.map( @@ -165,7 +244,8 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> // TODO: implement currentTextEditingValue TextEditingValue? get currentTextEditingValue => TextEditingValue( text: _textContentOfDelta(node.delta), - selection: _localSelection ?? const TextSelection.collapsed(offset: -1)); + selection: _globalSelectionToLocal(node, editorState.cursorSelection) ?? + const TextSelection.collapsed(offset: 0)); @override void insertTextPlaceholder(Size size) { @@ -174,7 +254,7 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> @override void performAction(TextInputAction action) { - // TODO: implement performAction + debugPrint('action:$action'); } @override @@ -204,6 +284,7 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> @override void updateEditingValueWithDeltas(List textEditingDeltas) { + debugPrint(textEditingDeltas.toString()); for (final textDelta in textEditingDeltas) { if (textDelta is TextEditingDeltaInsertion) { TransactionBuilder(editorState) 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 9635c0ebc5..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,7 +24,8 @@ import './transaction.dart'; class TransactionBuilder { final List operations = []; EditorState state; - Selection? cursorSelection; + Selection? beforeSelection; + Selection? afterSelection; TransactionBuilder(this.state); @@ -33,12 +36,12 @@ class TransactionBuilder { } insertNode(Path path, Node node) { - cursorSelection = state.cursorSelection; + beforeSelection = state.cursorSelection; add(InsertOperation(path: path, value: node)); } updateNode(Node node, Attributes attributes) { - cursorSelection = state.cursorSelection; + beforeSelection = state.cursorSelection; add(UpdateOperation( path: node.path, attributes: Attributes.from(node.attributes)..addAll(attributes), @@ -47,12 +50,12 @@ class TransactionBuilder { } deleteNode(Node node) { - cursorSelection = state.cursorSelection; + beforeSelection = state.cursorSelection; add(DeleteOperation(path: node.path, removedValue: node)); } textEdit(TextNode node, Delta Function() f) { - cursorSelection = state.cursorSelection; + beforeSelection = state.cursorSelection; final path = node.path; final delta = f(); @@ -64,6 +67,8 @@ class TransactionBuilder { 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) { @@ -72,6 +77,8 @@ class TransactionBuilder { 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) { @@ -95,7 +102,8 @@ class TransactionBuilder { Transaction _finish() { return Transaction( operations: UnmodifiableListView(operations), - cursorSelection: cursorSelection, + beforeSelection: beforeSelection, + afterSelection: afterSelection, ); } } From 7b513a71a952eb8aacba28695034a5aadfbd7026 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Tue, 19 Jul 2022 18:40:01 +0800 Subject: [PATCH 3/3] feat: handle Delete key --- .../example/lib/plugin/text_node_widget.dart | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 edacd6716e..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 @@ -166,6 +166,27 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> ..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) ?? @@ -185,6 +206,8 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> _globalSelectionToLocal(node, editorState.cursorSelection); if (value.logicalKey.keyLabel == "Backspace") { _backDeleteTextAtSelection(sel); + } else if (value.logicalKey.keyLabel == "Delete") { + _forwardDeleteTextAtSelection(sel); } } }),