mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge pull request #644 from AppFlowy-IO/feat/insert-text-at-cursor
Feat: insert text at cursor
This commit is contained in:
commit
ab687b9b7f
@ -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:flowy_editor/document/text_delta.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.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 {
|
class _TextNodeWidget extends StatefulWidget {
|
||||||
final Node node;
|
final Node node;
|
||||||
final EditorState editorState;
|
final EditorState editorState;
|
||||||
@ -95,37 +131,114 @@ class _TextNodeWidget extends StatefulWidget {
|
|||||||
State<_TextNodeWidget> createState() => __TextNodeWidgetState();
|
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>
|
class __TextNodeWidgetState extends State<_TextNodeWidget>
|
||||||
implements TextInputClient {
|
implements DeltaTextInputClient {
|
||||||
|
final _focusNode = FocusNode(debugLabel: "input");
|
||||||
TextNode get node => widget.node as TextNode;
|
TextNode get node => widget.node as TextNode;
|
||||||
EditorState get editorState => widget.editorState;
|
EditorState get editorState => widget.editorState;
|
||||||
TextEditingValue get textEditingValue => const TextEditingValue();
|
|
||||||
|
|
||||||
TextInputConnection? _textInputConnection;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SelectableText.rich(
|
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(
|
TextSpan(
|
||||||
children: node.toTextSpans(),
|
children: node.toTextSpans(),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
},
|
||||||
|
onSelectionChanged: ((selection, cause) {
|
||||||
_textInputConnection?.close();
|
_textInputConnection?.close();
|
||||||
_textInputConnection = TextInput.attach(
|
_textInputConnection = TextInput.attach(
|
||||||
this,
|
this,
|
||||||
const TextInputConfiguration(
|
const TextInputConfiguration(
|
||||||
enableDeltaModel: false,
|
enableDeltaModel: true,
|
||||||
inputType: TextInputType.multiline,
|
inputType: TextInputType.multiline,
|
||||||
textCapitalization: TextCapitalization.sentences,
|
textCapitalization: TextCapitalization.sentences,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
debugPrint('selection: $selection');
|
||||||
|
editorState.cursorSelection =
|
||||||
|
_localSelectionToGlobal(node, selection);
|
||||||
_textInputConnection
|
_textInputConnection
|
||||||
?..show()
|
?..show()
|
||||||
..setEditingState(textEditingValue);
|
..setEditingState(TextEditingValue(
|
||||||
},
|
text: _textContentOfDelta(node.delta),
|
||||||
|
selection: selection));
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (node.children.isNotEmpty)
|
if (node.children.isNotEmpty)
|
||||||
...node.children.map(
|
...node.children.map(
|
||||||
@ -152,7 +265,10 @@ class __TextNodeWidgetState extends State<_TextNodeWidget>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
// TODO: implement currentTextEditingValue
|
// 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
|
@override
|
||||||
void insertTextPlaceholder(Size size) {
|
void insertTextPlaceholder(Size size) {
|
||||||
@ -161,7 +277,7 @@ class __TextNodeWidgetState extends State<_TextNodeWidget>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void performAction(TextInputAction action) {
|
void performAction(TextInputAction action) {
|
||||||
// TODO: implement performAction
|
debugPrint('action:$action');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -186,7 +302,24 @@ class __TextNodeWidgetState extends State<_TextNodeWidget>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void updateEditingValue(TextEditingValue value) {
|
void updateEditingValue(TextEditingValue value) {
|
||||||
debugPrint(value.text);
|
debugPrint('offset: ${value.selection}');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateEditingValueWithDeltas(List<TextEditingDelta> 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
|
@override
|
||||||
|
@ -34,6 +34,7 @@ class EditorState {
|
|||||||
for (final op in transaction.operations) {
|
for (final op in transaction.operations) {
|
||||||
_applyOperation(op);
|
_applyOperation(op);
|
||||||
}
|
}
|
||||||
|
cursorSelection = transaction.afterSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyOperation(Operation op) {
|
void _applyOperation(Operation op) {
|
||||||
|
@ -19,10 +19,12 @@ import './operation.dart';
|
|||||||
@immutable
|
@immutable
|
||||||
class Transaction {
|
class Transaction {
|
||||||
final UnmodifiableListView<Operation> operations;
|
final UnmodifiableListView<Operation> operations;
|
||||||
final Selection? cursorSelection;
|
final Selection? beforeSelection;
|
||||||
|
final Selection? afterSelection;
|
||||||
|
|
||||||
const Transaction({
|
const Transaction({
|
||||||
required this.operations,
|
required this.operations,
|
||||||
this.cursorSelection,
|
this.beforeSelection,
|
||||||
|
this.afterSelection,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
import 'dart:math';
|
||||||
import 'package:flowy_editor/editor_state.dart';
|
import 'package:flowy_editor/editor_state.dart';
|
||||||
import 'package:flowy_editor/document/node.dart';
|
import 'package:flowy_editor/document/node.dart';
|
||||||
import 'package:flowy_editor/document/path.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/text_delta.dart';
|
||||||
import 'package:flowy_editor/document/attributes.dart';
|
import 'package:flowy_editor/document/attributes.dart';
|
||||||
import 'package:flowy_editor/document/selection.dart';
|
import 'package:flowy_editor/document/selection.dart';
|
||||||
@ -22,49 +24,86 @@ import './transaction.dart';
|
|||||||
class TransactionBuilder {
|
class TransactionBuilder {
|
||||||
final List<Operation> operations = [];
|
final List<Operation> operations = [];
|
||||||
EditorState state;
|
EditorState state;
|
||||||
Selection? cursorSelection;
|
Selection? beforeSelection;
|
||||||
|
Selection? afterSelection;
|
||||||
|
|
||||||
TransactionBuilder(this.state);
|
TransactionBuilder(this.state);
|
||||||
|
|
||||||
|
/// Commit the operations to the state
|
||||||
commit() {
|
commit() {
|
||||||
final transaction = _finish();
|
final transaction = _finish();
|
||||||
state.apply(transaction);
|
state.apply(transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
void insertNode(Path path, Node node) {
|
insertNode(Path path, Node node) {
|
||||||
cursorSelection = state.cursorSelection;
|
beforeSelection = 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;
|
beforeSelection = state.cursorSelection;
|
||||||
operations.add(UpdateOperation(
|
add(UpdateOperation(
|
||||||
path: node.path,
|
path: node.path,
|
||||||
attributes: Attributes.from(node.attributes)..addAll(attributes),
|
attributes: Attributes.from(node.attributes)..addAll(attributes),
|
||||||
oldAttributes: node.attributes,
|
oldAttributes: node.attributes,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteNode(Node node) {
|
deleteNode(Node node) {
|
||||||
cursorSelection = state.cursorSelection;
|
beforeSelection = 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;
|
beforeSelection = state.cursorSelection;
|
||||||
final path = node.path;
|
final path = node.path;
|
||||||
|
|
||||||
final delta = f();
|
final delta = f();
|
||||||
|
|
||||||
final inverted = delta.invert(node.delta);
|
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() {
|
Transaction _finish() {
|
||||||
return Transaction(
|
return Transaction(
|
||||||
operations: UnmodifiableListView(operations),
|
operations: UnmodifiableListView(operations),
|
||||||
cursorSelection: cursorSelection,
|
beforeSelection: beforeSelection,
|
||||||
|
afterSelection: afterSelection,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user