feat: compute cursor and selection by [Selection] or [Offset]

This commit is contained in:
Lucas.Xu 2022-07-26 18:40:53 +08:00
parent 941671568e
commit 114ae2b45d
10 changed files with 270 additions and 140 deletions

View File

@ -1,3 +1,5 @@
import 'package:flowy_editor/document/position.dart';
import 'package:flowy_editor/document/selection.dart';
import 'package:flowy_editor/flowy_editor.dart'; import 'package:flowy_editor/flowy_editor.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -38,27 +40,27 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
String get src => widget.node.attributes['image_src'] as String; String get src => widget.node.attributes['image_src'] as String;
@override @override
List<Rect> getSelectionRectsInRange(Offset start, Offset end) { List<Rect> getRectsInSelection(Selection selection) {
final renderBox = context.findRenderObject() as RenderBox; // TODO: implement getRectsInSelection
return [Offset.zero & renderBox.size]; throw UnimplementedError();
} }
@override @override
Rect getCursorRect(Offset start) { Selection getSelectionInRange(Offset start, Offset end) {
final renderBox = context.findRenderObject() as RenderBox; // TODO: implement getSelectionInRange
final size = Size(2, renderBox.size.height); throw UnimplementedError();
final cursorOffset = Offset(renderBox.size.width, 0);
return cursorOffset & size;
} }
@override @override
TextSelection? getCurrentTextSelection() { Rect getCursorRectInPosition(Position position) {
return null; // TODO: implement getCursorRectInPosition
throw UnimplementedError();
} }
@override @override
Offset getOffsetByTextSelection(TextSelection textSelection) { Position getPositionInOffset(Offset start) {
return Offset.zero; // TODO: implement getPositionInOffset
throw UnimplementedError();
} }
@override @override

View File

@ -1,6 +1,8 @@
import 'dart:math'; import 'dart:math';
import 'package:example/plugin/debuggable_rich_text.dart'; import 'package:example/plugin/debuggable_rich_text.dart';
import 'package:flowy_editor/document/selection.dart';
import 'package:flowy_editor/document/position.dart';
import 'package:flowy_editor/flowy_editor.dart'; import 'package:flowy_editor/flowy_editor.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
@ -56,49 +58,43 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
_textKey.currentContext?.findRenderObject() as RenderParagraph; _textKey.currentContext?.findRenderObject() as RenderParagraph;
@override @override
List<Rect> getSelectionRectsInRange(Offset start, Offset end) { Selection getSelectionInRange(Offset start, Offset end) {
final localStart = _renderParagraph.globalToLocal(start); final localStart = _renderParagraph.globalToLocal(start);
final localEnd = _renderParagraph.globalToLocal(end); final localEnd = _renderParagraph.globalToLocal(end);
final baseOffset = _getTextPositionAtOffset(localStart).offset;
var textSelection = final extentOffset = _getTextPositionAtOffset(localEnd).offset;
TextSelection(baseOffset: 0, extentOffset: node.toRawString().length); return Selection.single(
// Returns select all if the start or end exceeds the size of the box path: node.path,
// TODO: don't need to compute everytime. startOffset: baseOffset,
var rects = _computeSelectionRects(textSelection); endOffset: extentOffset,
_textSelection = textSelection; );
}
if (localEnd.dy > localStart.dy) {
// downward @override
if (localEnd.dy >= rects.last.bottom) { List<Rect> getRectsInSelection(Selection selection) {
return rects; assert(pathEquals(selection.start.path, selection.end.path));
} assert(pathEquals(selection.start.path, node.path));
} else { final textSelection = TextSelection(
// upward baseOffset: selection.start.offset,
if (localEnd.dy <= rects.first.top) { extentOffset: selection.end.offset,
return rects;
}
}
final selectionBaseOffset = _getTextPositionAtOffset(localStart).offset;
final selectionExtentOffset = _getTextPositionAtOffset(localEnd).offset;
textSelection = TextSelection(
baseOffset: selectionBaseOffset,
extentOffset: selectionExtentOffset,
); );
_textSelection = textSelection;
return _computeSelectionRects(textSelection); return _computeSelectionRects(textSelection);
} }
@override @override
Rect getCursorRect(Offset start) { Rect getCursorRectInPosition(Position position) {
final localStart = _renderParagraph.globalToLocal(start); final textSelection = TextSelection.collapsed(offset: position.offset);
final selectionBaseOffset = _getTextPositionAtOffset(localStart).offset;
final textSelection = TextSelection.collapsed(offset: selectionBaseOffset);
_textSelection = textSelection; _textSelection = textSelection;
print('text selection = $textSelection');
return _computeCursorRect(textSelection.baseOffset); return _computeCursorRect(textSelection.baseOffset);
} }
@override
Position getPositionInOffset(Offset start) {
final localStart = _renderParagraph.globalToLocal(start);
final baseOffset = _getTextPositionAtOffset(localStart).offset;
return Position(path: node.path, offset: baseOffset);
}
@override @override
TextSelection? getCurrentTextSelection() { TextSelection? getCurrentTextSelection() {
return _textSelection; return _textSelection;
@ -175,8 +171,8 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
return _renderParagraph.getPositionForOffset(offset); return _renderParagraph.getPositionForOffset(offset);
} }
List<Rect> _computeSelectionRects(TextSelection selection) { List<Rect> _computeSelectionRects(TextSelection textSelection) {
final textBoxes = _renderParagraph.getBoxesForSelection(selection); final textBoxes = _renderParagraph.getBoxesForSelection(textSelection);
return textBoxes.map((box) => box.toRect()).toList(); return textBoxes.map((box) => box.toRect()).toList();
} }
@ -185,7 +181,6 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
final cursorOffset = final cursorOffset =
_renderParagraph.getOffsetForCaret(position, Rect.zero); _renderParagraph.getOffsetForCaret(position, Rect.zero);
final cursorHeight = _renderParagraph.getFullHeightForCaret(position); final cursorHeight = _renderParagraph.getFullHeightForCaret(position);
print('offset = $offset, cursorHeight = $cursorHeight');
if (cursorHeight != null) { if (cursorHeight != null) {
const cursorWidth = 2; const cursorWidth = 2;
return Rect.fromLTWH( return Rect.fromLTWH(

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
typedef Path = List<int>; typedef Path = List<int>;
@ -5,3 +7,27 @@ typedef Path = List<int>;
bool pathEquals(Path path1, Path path2) { bool pathEquals(Path path1, Path path2) {
return listEquals(path1, path2); return listEquals(path1, path2);
} }
/// Returns true if path1 >= path2, otherwise returns false.
/// TODO: Rename this function.
bool pathGreaterOrEquals(Path path1, Path path2) {
final length = min(path1.length, path2.length);
for (var i = 0; i < length; i++) {
if (path1[i] < path2[i]) {
return false;
}
}
return true;
}
/// Returns true if path1 <= path2, otherwise returns false.
/// TODO: Rename this function.
bool pathLessOrEquals(Path path1, Path path2) {
final length = min(path1.length, path2.length);
for (var i = 0; i < length; i++) {
if (path1[i] > path2[i]) {
return false;
}
}
return true;
}

View File

@ -24,4 +24,11 @@ class Position {
final pathHash = hashList(path); final pathHash = hashList(path);
return Object.hash(pathHash, offset); return Object.hash(pathHash, offset);
} }
Position copyWith({Path? path, int? offset}) {
return Position(
path: path ?? this.path,
offset: offset ?? this.offset,
);
}
} }

View File

@ -1,4 +1,5 @@
import './position.dart'; import 'package:flowy_editor/document/path.dart';
import 'package:flowy_editor/document/position.dart';
class Selection { class Selection {
final Position start; final Position start;
@ -9,9 +10,16 @@ class Selection {
required this.end, required this.end,
}); });
factory Selection.collapsed(Position pos) { Selection.single({
return Selection(start: pos, end: pos); required Path path,
} required int startOffset,
int? endOffset,
}) : start = Position(path: path, offset: startOffset),
end = Position(path: path, offset: endOffset ?? startOffset);
Selection.collapsed(Position position)
: start = position,
end = position;
Selection collapse({bool atStart = false}) { Selection collapse({bool atStart = false}) {
if (atStart) { if (atStart) {
@ -24,4 +32,11 @@ class Selection {
bool isCollapsed() { bool isCollapsed() {
return start == end; return start == end;
} }
Selection copyWith({Position? start, Position? end}) {
return Selection(
start: start ?? this.start,
end: end ?? this.end,
);
}
} }

View File

@ -1,3 +1,5 @@
import 'package:flowy_editor/document/position.dart';
import 'package:flowy_editor/document/selection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// ///
@ -9,14 +11,17 @@ mixin Selectable<T extends StatefulWidget> on State<T> {
/// ///
/// The return result must be a [List] of the [Rect] /// The return result must be a [List] of the [Rect]
/// under the local coordinate system. /// under the local coordinate system.
List<Rect> getSelectionRectsInRange(Offset start, Offset end); Selection getSelectionInRange(Offset start, Offset end);
List<Rect> getRectsInSelection(Selection selection);
/// Returns a [Rect] for the offset in current widget. /// Returns a [Rect] for the offset in current widget.
/// ///
/// [start] is the offset of the global coordination system. /// [start] is the offset of the global coordination system.
/// ///
/// The return result must be an offset of the local coordinate system. /// The return result must be an offset of the local coordinate system.
Rect getCursorRect(Offset start); Position getPositionInOffset(Offset start);
Rect getCursorRectInPosition(Position position);
/// Returns a backward offset of the current offset based on the cause. /// Returns a backward offset of the current offset based on the cause.
Offset getBackwardOffset(/* Cause */); Offset getBackwardOffset(/* Cause */);
@ -30,12 +35,12 @@ mixin Selectable<T extends StatefulWidget> on State<T> {
/// ///
/// Only the widget rendered by [TextNode] need to implement the detail, /// Only the widget rendered by [TextNode] need to implement the detail,
/// and the rest can return null. /// and the rest can return null.
TextSelection? getCurrentTextSelection(); TextSelection? getCurrentTextSelection() => null;
/// For [TextNode] only. /// For [TextNode] only.
/// ///
/// Retruns a [Offset]. /// Retruns a [Offset].
/// Only the widget rendered by [TextNode] need to implement the detail, /// Only the widget rendered by [TextNode] need to implement the detail,
/// and the rest can return [Offset.zero]. /// and the rest can return [Offset.zero].
Offset getOffsetByTextSelection(TextSelection textSelection); Offset getOffsetByTextSelection(TextSelection textSelection) => Offset.zero;
} }

View File

@ -30,7 +30,7 @@ FlowyKeyEventHandler arrowKeysHandler = (editorState, event) {
} }
final selectionService = editorState.service.selectionService; final selectionService = editorState.service.selectionService;
if (offset != null) { if (offset != null) {
selectionService.updateCursor(offset); // selectionService.updateCursor(offset);
return KeyEventResult.handled; return KeyEventResult.handled;
} }
return KeyEventResult.ignored; return KeyEventResult.ignored;

View File

@ -37,7 +37,7 @@ FlowyKeyEventHandler deleteSingleTextNodeHandler = (editorState, event) {
final newOfset = previousSelectable final newOfset = previousSelectable
?.getOffsetByTextSelection(newTextSelection); ?.getOffsetByTextSelection(newTextSelection);
if (newOfset != null) { if (newOfset != null) {
selectionService.updateCursor(newOfset); // selectionService.updateCursor(newOfset);
} }
// merge // merge
TransactionBuilder(editorState) TransactionBuilder(editorState)
@ -58,7 +58,7 @@ FlowyKeyEventHandler deleteSingleTextNodeHandler = (editorState, event) {
final selectionService = editorState.service.selectionService; final selectionService = editorState.service.selectionService;
final newOfset = final newOfset =
selectable.getOffsetByTextSelection(newTextSelection); selectable.getOffsetByTextSelection(newTextSelection);
selectionService.updateCursor(newOfset); // selectionService.updateCursor(newOfset);
return KeyEventResult.handled; return KeyEventResult.handled;
} }
} }

View File

@ -18,13 +18,13 @@ FlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
final textNode = selectedNodes.first.unwrapOrNull<TextNode>(); final textNode = selectedNodes.first.unwrapOrNull<TextNode>();
final selectable = textNode?.key?.currentState?.unwrapOrNull<Selectable>(); final selectable = textNode?.key?.currentState?.unwrapOrNull<Selectable>();
final textSelection = selectable?.getCurrentTextSelection(); final textSelection = selectable?.getCurrentTextSelection();
if (textNode != null && selectable != null && textSelection != null) { // if (textNode != null && selectable != null && textSelection != null) {
final offset = selectable.getOffsetByTextSelection(textSelection); // final offset = selectable.getOffsetByTextSelection(textSelection);
final rect = selectable.getCursorRect(offset); // final rect = selectable.getCursorRect(offset);
editorState.service.floatingToolbarService // editorState.service.floatingToolbarService
.showInOffset(rect.topLeft, textNode.layerLink); // .showInOffset(rect.topLeft, textNode.layerLink);
return KeyEventResult.handled; // return KeyEventResult.handled;
} // }
return KeyEventResult.ignored; return KeyEventResult.ignored;
}; };

View File

@ -1,3 +1,6 @@
import 'package:flowy_editor/document/path.dart';
import 'package:flowy_editor/document/position.dart';
import 'package:flowy_editor/document/selection.dart';
import 'package:flowy_editor/render/selection/cursor_widget.dart'; import 'package:flowy_editor/render/selection/cursor_widget.dart';
import 'package:flowy_editor/render/selection/flowy_selection_widget.dart'; import 'package:flowy_editor/render/selection/flowy_selection_widget.dart';
import 'package:flowy_editor/extensions/object_extensions.dart'; import 'package:flowy_editor/extensions/object_extensions.dart';
@ -12,11 +15,8 @@ import '../render/selection/selectable.dart';
/// Process selection and cursor /// Process selection and cursor
mixin FlowySelectionService<T extends StatefulWidget> on State<T> { mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
/// [start] and [end] are the offsets under the global coordinate system. ///
void updateSelection(Offset start, Offset end); void updateSelection(Selection selection);
/// [start] is the offset under the global coordinate system.
void updateCursor(Offset start);
/// Returns selected [Node]s. Empty list would be returned /// Returns selected [Node]s. Empty list would be returned
/// if no nodes are being selected. /// if no nodes are being selected.
@ -26,18 +26,21 @@ mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
/// ///
/// If end is not null, it means multiple selection, /// If end is not null, it means multiple selection,
/// otherwise single selection. /// otherwise single selection.
List<Node> getSelectedNodes(Offset start, [Offset? end]); List<Node> getNodesInRange(Offset start, [Offset? end]);
///
List<Node> getNodesInSelection(Selection selection);
/// Return the [Node] or [Null] in single selection. /// Return the [Node] or [Null] in single selection.
/// ///
/// [start] is the offset under the global coordinate system. /// [start] is the offset under the global coordinate system.
Node? computeSelectedNodeInOffset(Node node, Offset offset); Node? computeNodeInOffset(Node node, Offset offset);
/// Return the [Node]s in multiple selection. Emtpy list would be returned /// Return the [Node]s in multiple selection. Emtpy list would be returned
/// if no nodes are in range. /// if no nodes are in range.
/// ///
/// [start] is the offset under the global coordinate system. /// [start] is the offset under the global coordinate system.
List<Node> computeSelectedNodesInRange( List<Node> computeNodesInRange(
Node node, Node node,
Offset start, Offset start,
Offset end, Offset end,
@ -93,6 +96,10 @@ class _FlowySelectionState extends State<FlowySelection>
EditorState get editorState => widget.editorState; EditorState get editorState => widget.editorState;
@override
List<Node> getNodesInSelection(Selection selection) =>
_selectedNodesInSelection(editorState.document.root, selection);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return RawGestureDetector( return RawGestureDetector(
@ -121,70 +128,23 @@ class _FlowySelectionState extends State<FlowySelection>
} }
@override @override
void updateSelection(Offset start, Offset end) { void updateSelection(Selection selection) {
_clearAllOverlayEntries(); _clearAllOverlayEntries();
final nodes = getSelectedNodes(start, end); // cursor
editorState.selectedNodes = nodes; if (selection.isCollapsed()) {
if (nodes.isEmpty) { _updateCursor(selection.start);
return;
}
for (final node in nodes) {
if (node.key?.currentState is! Selectable) {
continue;
}
final selectable = node.key?.currentState as Selectable;
final selectionRects = selectable.getSelectionRectsInRange(start, end);
for (final rect in selectionRects) {
final overlay = OverlayEntry(
builder: ((context) => SelectionWidget(
color: widget.selectionColor,
layerLink: node.layerLink,
rect: rect,
)),
);
_selectionOverlays.add(overlay);
}
}
Overlay.of(context)?.insertAll(_selectionOverlays);
}
@override
void updateCursor(Offset start) {
_clearAllOverlayEntries();
final nodes = getSelectedNodes(start);
editorState.selectedNodes = nodes;
if (nodes.isEmpty) {
return;
}
final selectedNode = nodes.first;
if (selectedNode.key?.currentState is! Selectable) {
return;
}
final selectable = selectedNode.key?.currentState as Selectable;
final rect = selectable.getCursorRect(start);
final cursor = OverlayEntry(
builder: ((context) => CursorWidget(
key: _cursorKey,
rect: rect,
color: widget.cursorColor,
layerLink: selectedNode.layerLink,
)),
);
_cursorOverlays.add(cursor);
Overlay.of(context)?.insertAll(_cursorOverlays);
}
@override
List<Node> getSelectedNodes(Offset start, [Offset? end]) {
if (end != null) {
return computeSelectedNodesInRange(editorState.document.root, start, end);
} else { } else {
final reuslt = _updateSelection(selection);
computeSelectedNodeInOffset(editorState.document.root, start); }
}
@override
List<Node> getNodesInRange(Offset start, [Offset? end]) {
if (end != null) {
return computeNodesInRange(editorState.document.root, start, end);
} else {
final reuslt = computeNodeInOffset(editorState.document.root, start);
if (reuslt != null) { if (reuslt != null) {
return [reuslt]; return [reuslt];
} }
@ -193,9 +153,9 @@ class _FlowySelectionState extends State<FlowySelection>
} }
@override @override
Node? computeSelectedNodeInOffset(Node node, Offset offset) { Node? computeNodeInOffset(Node node, Offset offset) {
for (final child in node.children) { for (final child in node.children) {
final result = computeSelectedNodeInOffset(child, offset); final result = computeNodeInOffset(child, offset);
if (result != null) { if (result != null) {
return result; return result;
} }
@ -209,7 +169,7 @@ class _FlowySelectionState extends State<FlowySelection>
} }
@override @override
List<Node> computeSelectedNodesInRange(Node node, Offset start, Offset end) { List<Node> computeNodesInRange(Node node, Offset start, Offset end) {
List<Node> result = []; List<Node> result = [];
if (node.parent != null && node.key != null) { if (node.parent != null && node.key != null) {
if (isNodeInSelection(node, start, end)) { if (isNodeInSelection(node, start, end)) {
@ -217,7 +177,7 @@ class _FlowySelectionState extends State<FlowySelection>
} }
} }
for (final child in node.children) { for (final child in node.children) {
result.addAll(computeSelectedNodesInRange(child, start, end)); result.addAll(computeNodesInRange(child, start, end));
} }
// TODO: sort the result // TODO: sort the result
return result; return result;
@ -254,7 +214,16 @@ class _FlowySelectionState extends State<FlowySelection>
panStartOffset = null; panStartOffset = null;
panEndOffset = null; panEndOffset = null;
updateCursor(tapOffset!); final nodes = getNodesInRange(tapOffset!);
if (nodes.isNotEmpty) {
assert(nodes.length == 1);
final selectable = nodes.first.selectable;
if (selectable != null) {
final position = selectable.getPositionInOffset(tapOffset!);
final selection = Selection.collapsed(position);
updateSelection(selection);
}
}
} }
void _onPanStart(DragStartDetails details) { void _onPanStart(DragStartDetails details) {
@ -271,7 +240,16 @@ class _FlowySelectionState extends State<FlowySelection>
panEndOffset = details.globalPosition; panEndOffset = details.globalPosition;
tapOffset = null; tapOffset = null;
updateSelection(panStartOffset!, panEndOffset!); final nodes = getNodesInRange(panStartOffset!, panEndOffset!);
final first = nodes.first.selectable;
final last = nodes.last.selectable;
if (first != null && last != null) {
final selection = Selection(
start: first.getSelectionInRange(panStartOffset!, panEndOffset!).start,
end: last.getSelectionInRange(panStartOffset!, panEndOffset!).end,
);
updateSelection(selection);
}
} }
void _onPanEnd(DragEndDetails details) { void _onPanEnd(DragEndDetails details) {
@ -302,4 +280,106 @@ class _FlowySelectionState extends State<FlowySelection>
?.unwrapOrNull<FlowyFloatingShortcutService>(); ?.unwrapOrNull<FlowyFloatingShortcutService>();
shortcutService?.hide(); shortcutService?.hide();
} }
void _updateSelection(Selection selection) {
final nodes =
_selectedNodesInSelection(editorState.document.root, selection);
var index = 0;
for (final node in nodes) {
final selectable = node.selectable;
if (selectable == null) {
continue;
}
Selection newSelection;
if (node is TextNode) {
if (pathEquals(selection.start.path, selection.end.path)) {
newSelection = selection.copyWith();
} else {
if (index == 0) {
newSelection = selection.copyWith(
/// FIXME: make it better.
end: selection.start.copyWith(offset: node.toRawString().length),
);
} else if (index == nodes.length - 1) {
newSelection = selection.copyWith(
/// FIXME: make it better.
start: selection.end.copyWith(offset: 0),
);
} else {
final position = Position(path: node.path);
newSelection = Selection(
start: position.copyWith(offset: 0),
end: position.copyWith(offset: node.toRawString().length),
);
}
}
} else {
newSelection = Selection.collapsed(
Position(path: node.path),
);
}
final rects = selectable.getRectsInSelection(newSelection);
for (final rect in rects) {
final overlay = OverlayEntry(
builder: ((context) => SelectionWidget(
color: widget.selectionColor,
layerLink: node.layerLink,
rect: rect,
)),
);
_selectionOverlays.add(overlay);
}
index += 1;
}
Overlay.of(context)?.insertAll(_selectionOverlays);
}
void _updateCursor(Position position) {
final node = _selectedNodeInPostion(editorState.document.root, position);
assert(node != null);
if (node == null) {
return;
}
final selectable = node.selectable;
final rect = selectable?.getCursorRectInPosition(position);
if (rect != null) {
final cursor = OverlayEntry(
builder: ((context) => CursorWidget(
key: _cursorKey,
rect: rect,
color: widget.cursorColor,
layerLink: node.layerLink,
)),
);
_cursorOverlays.add(cursor);
Overlay.of(context)?.insertAll(_cursorOverlays);
}
}
List<Node> _selectedNodesInSelection(Node node, Selection selection) {
List<Node> result = [];
if (node.parent != null) {
if (_isNodeInSelection(node, selection)) {
result.add(node);
}
}
for (final child in node.children) {
result.addAll(_selectedNodesInSelection(child, selection));
}
return result;
}
Node? _selectedNodeInPostion(Node node, Position position) =>
node.childAtPath(position.path);
bool _isNodeInSelection(Node node, Selection selection) {
return pathGreaterOrEquals(node.path, selection.start.path) &&
pathLessOrEquals(node.path, selection.end.path);
}
} }