Merge pull request #810 from LucasXu0/doc/flowy_selection_documentation

selection service documentation and implement auto wrap when selection changes.
This commit is contained in:
Lucas.Xu 2022-08-11 10:46:08 +08:00 committed by GitHub
commit eac54aefd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 525 additions and 548 deletions

View File

@ -1,15 +1,20 @@
library flowy_editor;
export 'src/document/state_tree.dart';
export 'src/document/node.dart';
export 'src/document/path.dart';
export 'src/document/position.dart';
export 'src/document/selection.dart';
export 'src/document/state_tree.dart';
export 'src/document/text_delta.dart';
export 'src/render/selection/selectable.dart';
export 'src/editor_state.dart';
export 'src/operation/operation.dart';
export 'src/operation/transaction.dart';
export 'src/operation/transaction_builder.dart';
export 'src/operation/operation.dart';
export 'src/editor_state.dart';
export 'src/render/selection/selectable.dart';
export 'src/service/editor_service.dart';
export 'src/document/selection.dart';
export 'src/document/position.dart';
export 'src/service/render_plugin_service.dart';
export 'src/service/service.dart';
export 'src/service/selection_service.dart';
export 'src/service/scroll_service.dart';
export 'src/service/keyboard_service.dart';
export 'src/service/input_service.dart';

View File

@ -2,15 +2,26 @@ import 'package:flowy_editor/src/document/path.dart';
import 'package:flowy_editor/src/document/position.dart';
import 'package:flowy_editor/src/extensions/path_extensions.dart';
/// Selection represents the selected area or the cursor area in the editor.
///
/// [Selection] is directional.
///
/// 1. forwardthe end position is before the start position.
/// 2. backward, the end position is after the start position.
/// 3. collapsed, the end position is equal to the start position.
class Selection {
final Position start;
final Position end;
/// Create a selection with [start], [end].
Selection({
required this.start,
required this.end,
});
/// Create a selection with [Path], [startOffset] and [endOffset].
///
/// The [endOffset] is optional.
///
/// This constructor will return a collapsed [Selection] if [endOffset] is null.
///
Selection.single({
required Path path,
required int startOffset,
@ -18,10 +29,23 @@ class Selection {
}) : start = Position(path: path, offset: startOffset),
end = Position(path: path, offset: endOffset ?? startOffset);
/// Create a collapsed selection with [position].
Selection.collapsed(Position position)
: start = position,
end = position;
final Position start;
final Position end;
bool get isCollapsed => start == end;
bool get isSingle => pathEquals(start.path, end.path);
bool get isForward =>
(start.path >= end.path && !pathEquals(start.path, end.path)) ||
(isSingle && start.offset > end.offset);
bool get isBackward =>
(start.path <= end.path && !pathEquals(start.path, end.path)) ||
(isSingle && start.offset < end.offset);
Selection collapse({bool atStart = false}) {
if (atStart) {
return Selection(start: start, end: start);
@ -30,13 +54,6 @@ class Selection {
}
}
bool get isCollapsed => start == end;
bool get isSingle => pathEquals(start.path, end.path);
bool get isUpward =>
start.path >= end.path && !pathEquals(start.path, end.path);
bool get isDownward =>
start.path <= end.path && !pathEquals(start.path, end.path);
Selection copyWith({Position? start, Position? end}) {
return Selection(
start: start ?? this.start,
@ -46,13 +63,10 @@ class Selection {
Selection copy() => Selection(start: start, end: end);
@override
String toString() => '[Selection] start = $start, end = $end';
Map<String, dynamic> toJson() {
return {
"start": start.toJson(),
"end": end.toJson(),
'start': start.toJson(),
'end': end.toJson(),
};
}
@ -69,4 +83,7 @@ class Selection {
@override
int get hashCode => Object.hash(start, end);
@override
String toString() => '[Selection] start = $start, end = $end';
}

View File

@ -59,7 +59,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
@override
Position end() => Position(
path: widget.textNode.path, offset: widget.textNode.toRawString().length);
path: widget.textNode.path, offset: widget.textNode.delta.length);
@override
Rect? getCursorRectInPosition(Position position) {

View File

@ -1,3 +1,4 @@
import 'package:flowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart';
import 'package:flutter/material.dart';
import 'package:flowy_editor/src/editor_state.dart';
@ -9,15 +10,6 @@ import 'package:flowy_editor/src/render/rich_text/number_list_text.dart';
import 'package:flowy_editor/src/render/rich_text/quoted_text.dart';
import 'package:flowy_editor/src/render/rich_text/rich_text.dart';
import 'package:flowy_editor/src/service/input_service.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/arrow_keys_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/delete_nodes_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/delete_text_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/slash_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart';
import 'package:flowy_editor/src/service/keyboard_service.dart';
import 'package:flowy_editor/src/service/render_plugin_service.dart';
import 'package:flowy_editor/src/service/scroll_service.dart';
@ -34,18 +26,6 @@ NodeWidgetBuilders defaultBuilders = {
'text/quote': QuotedTextNodeWidgetBuilder(),
};
List<FlowyKeyEventHandler> defaultKeyEventHandler = [
deleteTextHandler,
slashShortcutHandler,
flowyDeleteNodesHandler,
arrowKeysHandler,
copyPasteKeysHandler,
redoUndoKeysHandler,
enterWithoutShiftInTextNodesHandler,
updateTextStyleByCommandXHandler,
whiteSpaceHandler,
];
class FlowyEditor extends StatefulWidget {
const FlowyEditor({
Key? key,
@ -98,7 +78,7 @@ class _FlowyEditorState extends State<FlowyEditor> {
child: FlowyKeyboard(
key: editorState.service.keyboardServiceKey,
handlers: [
...defaultKeyEventHandler,
...defaultKeyEventHandlers,
...widget.keyEventHandlers,
],
editorState: editorState,

View File

@ -7,7 +7,7 @@ import 'package:flowy_editor/src/editor_state.dart';
import 'package:flowy_editor/src/extensions/node_extensions.dart';
import 'package:flowy_editor/src/operation/transaction_builder.dart';
mixin FlowyInputService {
abstract class FlowyInputService {
void attach(TextEditingValue textEditingValue);
void apply(List<TextEditingDelta> deltas);
void close();
@ -29,8 +29,7 @@ class FlowyInput extends StatefulWidget {
}
class _FlowyInputState extends State<FlowyInput>
with FlowyInputService
implements DeltaTextInputClient {
implements FlowyInputService, DeltaTextInputClient {
TextInputConnection? _textInputConnection;
TextRange? _composingTextRange;

View File

@ -1,5 +1,4 @@
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flowy_editor/src/service/keyboard_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -49,25 +48,25 @@ extension on Position {
}
Position? _goUp(EditorState editorState) {
final rects = editorState.service.selectionService.rects();
final rects = editorState.service.selectionService.selectionRects;
if (rects.isEmpty) {
return null;
}
final first = rects.first;
final firstOffset = Offset(first.left, first.top);
final hitOffset = firstOffset - Offset(0, first.height * 0.5);
return editorState.service.selectionService.hitTest(hitOffset);
return editorState.service.selectionService.getPositionInOffset(hitOffset);
}
Position? _goDown(EditorState editorState) {
final rects = editorState.service.selectionService.rects();
final rects = editorState.service.selectionService.selectionRects;
if (rects.isEmpty) {
return null;
}
final first = rects.last;
final firstOffset = Offset(first.right, first.bottom);
final hitOffset = firstOffset + Offset(0, first.height * 0.5);
return editorState.service.selectionService.hitTest(hitOffset);
return editorState.service.selectionService.getPositionInOffset(hitOffset);
}
KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) {

View File

@ -0,0 +1,22 @@
import 'package:flowy_editor/src/service/internal_key_event_handlers/arrow_keys_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/delete_nodes_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/delete_text_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/slash_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart';
import 'package:flowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart';
import 'package:flowy_editor/src/service/keyboard_service.dart';
List<FlowyKeyEventHandler> defaultKeyEventHandlers = [
deleteTextHandler,
slashShortcutHandler,
flowyDeleteNodesHandler,
arrowKeysHandler,
copyPasteKeysHandler,
redoUndoKeysHandler,
enterWithoutShiftInTextNodesHandler,
updateTextStyleByCommandXHandler,
whiteSpaceHandler,
];

View File

@ -67,7 +67,7 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
// If selection is collapsed and position.start.offset == 0,
// insert a empty text node before.
if (selection.isCollapsed && selection.start.offset == 0) {
if (textNode.toRawString().isEmpty) {
if (textNode.toRawString().isEmpty && textNode.subtype != null) {
final afterSelection = Selection.collapsed(
Position(path: textNode.path, offset: 0),
);

View File

@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
mixin FlowyKeyboardService<T extends StatefulWidget> on State<T> {
abstract class FlowyKeyboardService {
void enable();
void disable();
}
@ -31,7 +31,7 @@ class FlowyKeyboard extends StatefulWidget {
}
class _FlowyKeyboardState extends State<FlowyKeyboard>
with FlowyKeyboardService {
implements FlowyKeyboardService {
final FocusNode _focusNode = FocusNode(debugLabel: 'flowy_keyboard_service');
bool isFocus = true;

View File

@ -1,7 +1,7 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
mixin FlowyScrollService<T extends StatefulWidget> on State<T> {
abstract class FlowyScrollService {
double get dy;
void scrollTo(double dy);
@ -22,7 +22,8 @@ class FlowyScroll extends StatefulWidget {
State<FlowyScroll> createState() => _FlowyScrollState();
}
class _FlowyScrollState extends State<FlowyScroll> with FlowyScrollService {
class _FlowyScrollState extends State<FlowyScroll>
implements FlowyScrollService {
final _scrollController = ScrollController();
final _scrollViewKey = GlobalKey();

View File

@ -0,0 +1,113 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer]
/// for a while. So we need to implement our own GestureDetector.
@immutable
class SelectionGestureDetector extends StatefulWidget {
const SelectionGestureDetector({
Key? key,
this.child,
this.onTapDown,
this.onDoubleTapDown,
this.onTripleTapDown,
this.onPanStart,
this.onPanUpdate,
this.onPanEnd,
}) : super(key: key);
@override
State<SelectionGestureDetector> createState() =>
SelectionGestureDetectorState();
final Widget? child;
final GestureTapDownCallback? onTapDown;
final GestureTapDownCallback? onDoubleTapDown;
final GestureTapDownCallback? onTripleTapDown;
final GestureDragStartCallback? onPanStart;
final GestureDragUpdateCallback? onPanUpdate;
final GestureDragEndCallback? onPanEnd;
}
class SelectionGestureDetectorState extends State<SelectionGestureDetector> {
bool _isDoubleTap = false;
Timer? _doubleTapTimer;
int _tripleTabCount = 0;
Timer? _tripleTabTimer;
final kTripleTapTimeout = const Duration(milliseconds: 500);
@override
Widget build(BuildContext context) {
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: {
PanGestureRecognizer:
GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
() => PanGestureRecognizer(),
(recognizer) {
recognizer
..onStart = widget.onPanStart
..onUpdate = widget.onPanUpdate
..onEnd = widget.onPanEnd;
},
),
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(),
(recognizer) {
recognizer.onTapDown = _tapDownDelegate;
},
),
},
child: widget.child,
);
}
_tapDownDelegate(TapDownDetails tapDownDetails) {
if (_tripleTabCount == 2) {
_tripleTabCount = 0;
_tripleTabTimer?.cancel();
_tripleTabTimer = null;
if (widget.onTripleTapDown != null) {
widget.onTripleTapDown!(tapDownDetails);
}
} else if (_isDoubleTap) {
_isDoubleTap = false;
_doubleTapTimer?.cancel();
_doubleTapTimer = null;
if (widget.onDoubleTapDown != null) {
widget.onDoubleTapDown!(tapDownDetails);
}
_tripleTabCount++;
} else {
if (widget.onTapDown != null) {
widget.onTapDown!(tapDownDetails);
}
_isDoubleTap = true;
_doubleTapTimer?.cancel();
_doubleTapTimer = Timer(kDoubleTapTimeout, () {
_isDoubleTap = false;
_doubleTapTimer = null;
});
_tripleTabCount = 1;
_tripleTabTimer?.cancel();
_tripleTabTimer = Timer(kTripleTapTimeout, () {
_tripleTabCount = 0;
_tripleTabTimer = null;
});
}
}
@override
void dispose() {
_doubleTapTimer?.cancel();
_tripleTabTimer?.cancel();
super.dispose();
}
}

View File

@ -1,81 +1,79 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flowy_editor/src/document/node.dart';
import 'package:flowy_editor/src/document/node_iterator.dart';
import 'package:flowy_editor/src/document/position.dart';
import 'package:flowy_editor/src/document/selection.dart';
import 'package:flowy_editor/src/document/state_tree.dart';
import 'package:flowy_editor/src/editor_state.dart';
import 'package:flowy_editor/src/extensions/node_extensions.dart';
import 'package:flowy_editor/src/extensions/object_extensions.dart';
import 'package:flowy_editor/src/extensions/path_extensions.dart';
import 'package:flowy_editor/src/render/selection/cursor_widget.dart';
import 'package:flowy_editor/src/render/selection/selectable.dart';
import 'package:flowy_editor/src/render/selection/selection_widget.dart';
import 'package:flowy_editor/src/service/selection/selection_gesture.dart';
/// Process selection and cursor
mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
/// Returns the current [Selection]
/// [FlowySelectionService] is responsible for processing
/// the [Selection] changes and updates.
///
/// Usually, this service can be obtained by the following code.
/// ```dart
/// final selectionService = editorState.service.selectionService;
///
/// /** get current selection value*/
/// final selection = selectionService.currentSelection.value;
///
/// /** get current selected nodes*/
/// final nodes = selectionService.currentSelectedNodes;
/// ```
///
abstract class FlowySelectionService {
/// The current [Selection] in editor.
///
/// The value is null if there is no nodes are selected.
ValueNotifier<Selection?> get currentSelection;
/// Returns the current selected [Node]s.
/// The current selected [Node]s in editor.
///
/// The order of the return is determined according to the selected order.
/// The order of the result is determined according to the [currentSelection].
/// The result are ordered from back to front if the selection is forward.
/// The result are ordered from front to back if the selection is backward.
///
/// For example, Here is an array of selected nodes, [n1, n2, n3].
/// The result will be [n3, n2, n1] if the selection is forward,
/// and [n1, n2, n3] if the selection is backward.
///
/// Returns empty result if there is no nodes are selected.
List<Node> get currentSelectedNodes;
/// Update the selection or cursor.
/// Updates the selection.
///
/// If selection is collapsed, this method will
/// update the position of the cursor.
/// Otherwise, will update the selection.
/// The editor will update selection area and toolbar area
/// if the [selection] is not collapsed,
/// otherwise, will update the cursor area.
void updateSelection(Selection selection);
/// Clear the selection or cursor.
/// Clears the selection area, cursor area and the popup list area.
void clearSelection();
/// ------------------ Selection ------------------------
List<Rect> rects();
Position? hitTest(Offset? offset);
///
/// Returns the [Node]s in [Selection].
List<Node> getNodesInSelection(Selection selection);
/// ------------------ Selection ------------------------
/// ------------------ Offset ------------------------
/// Return the [Node] or [Null] in single selection.
/// Returns the [Node] containing to the [offset].
///
/// [offset] is under the global coordinate system.
/// [offset] must be under the global coordinate system.
Node? getNodeInOffset(Offset offset);
/// Returns selected [Node]s. Empty list would be returned
/// if no nodes are in range.
/// Returns the [Position] closest to the [offset].
///
/// Returns null if there is no nodes are selected.
///
/// [start] and [end] are under the global coordinate system.
///
List<Node> getNodeInRange(Offset start, Offset end);
/// [offset] must be under the global coordinate system.
Position? getPositionInOffset(Offset offset);
/// Return [bool] to identify the [Node] is in Range or not.
///
/// [start] and [end] are under the global coordinate system.
bool isNodeInRange(
Node node,
Offset start,
Offset end,
);
/// Return [bool] to identify the [Node] contains [Offset] or not.
///
/// [offset] is under the global coordinate system.
bool isNodeInOffset(Node node, Offset offset);
/// ------------------ Offset ------------------------
/// The current selection areas's rect in editor.
List<Rect> get selectionRects;
}
class FlowySelection extends StatefulWidget {
@ -97,41 +95,29 @@ class FlowySelection extends StatefulWidget {
}
class _FlowySelectionState extends State<FlowySelection>
with FlowySelectionService, WidgetsBindingObserver {
with WidgetsBindingObserver
implements FlowySelectionService {
final _cursorKey = GlobalKey(debugLabel: 'cursor');
final List<OverlayEntry> _selectionOverlays = [];
final List<OverlayEntry> _cursorOverlays = [];
@override
final List<Rect> selectionRects = [];
final List<OverlayEntry> _selectionAreas = [];
final List<OverlayEntry> _cursorAreas = [];
OverlayEntry? _debugOverlay;
/// [Pan] and [Tap] must be mutually exclusive.
/// Pan
Offset? panStartOffset;
double? panStartScrollDy;
Offset? panEndOffset;
/// Tap
Offset? tapOffset;
final List<Rect> _rects = [];
Offset? _panStartOffset;
double? _panStartScrollDy;
EditorState get editorState => widget.editorState;
@override
ValueNotifier<Selection?> currentSelection = ValueNotifier(null);
@override
List<Node> currentSelectedNodes = [];
@override
List<Node> getNodesInSelection(Selection selection) =>
_selectedNodesInSelection(editorState.document, selection);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
currentSelection.addListener(_onSelectionChange);
}
@override
@ -148,13 +134,14 @@ class _FlowySelectionState extends State<FlowySelection>
void dispose() {
clearSelection();
WidgetsBinding.instance.removeObserver(this);
currentSelection.removeListener(_onSelectionChange);
super.dispose();
}
@override
Widget build(BuildContext context) {
return _SelectionGestureDetector(
return SelectionGestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
@ -166,23 +153,48 @@ class _FlowySelectionState extends State<FlowySelection>
}
@override
List<Rect> rects() {
return _rects;
ValueNotifier<Selection?> currentSelection = ValueNotifier(null);
@override
List<Node> currentSelectedNodes = [];
@override
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(editorState.document, startNode, endNode).toList();
if (selection.isBackward) {
return nodes;
} else {
return nodes.reversed.toList(growable: false);
}
}
return [];
}
@override
void updateSelection(Selection selection) {
_rects.clear();
selectionRects.clear();
clearSelection();
// cursor
if (selection.isCollapsed) {
debugPrint('Update cursor');
_updateCursor(selection.start);
/// updates cursor area.
debugPrint('updating cursor');
_updateCursorAreas(selection.start);
} else {
debugPrint('Update selection');
_updateSelection(selection);
// updates selection area.
debugPrint('updating selection');
_updateSelectionAreas(selection);
}
currentSelection.value = selection;
}
@override
@ -190,224 +202,172 @@ class _FlowySelectionState extends State<FlowySelection>
currentSelectedNodes = [];
currentSelection.value = null;
// clear selection
_selectionOverlays
// clear selection areas
_selectionAreas
..forEach((overlay) => overlay.remove())
..clear();
// clear cursors
_cursorOverlays
// clear cursor areas
_cursorAreas
..forEach((overlay) => overlay.remove())
..clear();
// clear toolbar
// hide toolbar
editorState.service.toolbarService?.hide();
}
@override
Node? getNodeInOffset(Offset offset) {
return _lowerBoundInDocument(offset);
final sortedNodes =
editorState.document.root.children.toList(growable: false);
return _getNodeInOffset(
sortedNodes,
offset,
0,
sortedNodes.length - 1,
);
}
@override
List<Node> getNodeInRange(Offset start, Offset end) {
final startNode = _lowerBoundInDocument(start);
final endNode = _upperBoundInDocument(end);
return NodeIterator(editorState.document, startNode, endNode).toList();
}
@override
bool isNodeInOffset(Node node, Offset offset) {
final renderBox = node.renderBox;
if (renderBox != null) {
final boxOffset = renderBox.localToGlobal(Offset.zero);
final boxRect = boxOffset & renderBox.size;
return boxRect.contains(offset);
}
return false;
}
@override
bool isNodeInRange(Node node, Offset start, Offset end) {
final renderBox = node.renderBox;
if (renderBox != null) {
final rect = Rect.fromPoints(start, end);
final boxOffset = renderBox.localToGlobal(Offset.zero);
final boxRect = boxOffset & renderBox.size;
return rect.overlaps(boxRect);
}
return false;
}
void _onDoubleTapDown(TapDownDetails details) {
final offset = details.globalPosition;
Position? getPositionInOffset(Offset offset) {
final node = getNodeInOffset(offset);
if (node == null) {
editorState.updateCursorSelection(null);
return;
}
final selectable = node.selectable;
final selectable = node?.selectable;
if (selectable == null) {
editorState.updateCursorSelection(null);
return;
}
editorState
.updateCursorSelection(selectable.getWorldBoundaryInOffset(offset));
}
void _onTripleTapDown(TapDownDetails details) {
final offset = details.globalPosition;
final node = getNodeInOffset(offset);
if (node == null) {
editorState.updateCursorSelection(null);
return;
}
Selection selection;
if (node is TextNode) {
final textLen = node.delta.length;
selection = Selection(
start: Position(path: node.path, offset: 0),
end: Position(path: node.path, offset: textLen));
} else {
selection = Selection.collapsed(Position(path: node.path, offset: 0));
}
editorState.updateCursorSelection(selection);
}
void _onTapDown(TapDownDetails details) {
// clear old state.
panStartOffset = null;
panEndOffset = null;
tapOffset = details.globalPosition;
final position = hitTest(tapOffset);
if (position == null) {
return;
}
final selection = Selection.collapsed(position);
editorState.updateCursorSelection(selection);
editorState.service.keyboardService?.enable();
editorState.service.scrollService?.enable();
}
@override
Position? hitTest(Offset? offset) {
if (offset == null) {
editorState.updateCursorSelection(null);
return null;
}
final node = getNodeInOffset(offset);
if (node == null) {
editorState.updateCursorSelection(null);
return null;
}
final selectable = node.selectable;
if (selectable == null) {
editorState.updateCursorSelection(null);
clearSelection();
return null;
}
return selectable.getPositionInOffset(offset);
}
void _onPanStart(DragStartDetails details) {
void _onTapDown(TapDownDetails details) {
// clear old state.
panEndOffset = null;
tapOffset = null;
_panStartOffset = null;
final position = getPositionInOffset(details.globalPosition);
if (position == null) {
return;
}
final selection = Selection.collapsed(position);
updateSelection(selection);
_enableInteraction();
_showDebugLayerIfNeeded(offset: details.globalPosition);
}
void _onDoubleTapDown(TapDownDetails details) {
final offset = details.globalPosition;
final node = getNodeInOffset(offset);
final selection = node?.selectable?.getWorldBoundaryInOffset(offset);
if (selection == null) {
clearSelection();
return;
}
updateSelection(selection);
_enableInteraction();
}
void _onTripleTapDown(TapDownDetails details) {
final offset = details.globalPosition;
final node = getNodeInOffset(offset);
final selectable = node?.selectable;
if (selectable == null) {
clearSelection();
return;
}
Selection selection = Selection(
start: selectable.start(),
end: selectable.end(),
);
updateSelection(selection);
_enableInteraction();
}
void _onPanStart(DragStartDetails details) {
clearSelection();
panStartOffset = details.globalPosition;
panStartScrollDy = editorState.service.scrollService?.dy;
_panStartOffset = details.globalPosition;
_panStartScrollDy = editorState.service.scrollService?.dy;
debugPrint('[_onPanStart] panStartOffset = $panStartOffset');
_enableInteraction();
}
void _onPanUpdate(DragUpdateDetails details) {
if (panStartOffset == null || panStartScrollDy == null) {
if (_panStartOffset == null || _panStartScrollDy == null) {
return;
}
editorState.service.keyboardService?.enable();
editorState.service.scrollService?.enable();
_enableInteraction();
panEndOffset = details.globalPosition;
final panEndOffset = details.globalPosition;
final dy = editorState.service.scrollService?.dy;
var panStartOffsetWithScrollDyGap = panStartOffset!;
if (dy != null) {
panStartOffsetWithScrollDyGap =
panStartOffsetWithScrollDyGap.translate(0, panStartScrollDy! - dy);
}
final panStartOffset = dy == null
? _panStartOffset!
: _panStartOffset!.translate(0, _panStartScrollDy! - dy);
final first =
_lowerBoundInDocument(panStartOffsetWithScrollDyGap).selectable;
final last = _upperBoundInDocument(panEndOffset!).selectable;
final first = getNodeInOffset(panStartOffset)?.selectable;
final last = getNodeInOffset(panEndOffset)?.selectable;
// compute the selection in range.
if (first != null && last != null) {
bool isDownward;
if (first == last) {
isDownward = panStartOffsetWithScrollDyGap.dx < panEndOffset!.dx;
} else {
isDownward = panStartOffsetWithScrollDyGap.dy < panEndOffset!.dy;
}
final start = first
.getSelectionInRange(panStartOffsetWithScrollDyGap, panEndOffset!)
.start;
final end = last
.getSelectionInRange(panStartOffsetWithScrollDyGap, panEndOffset!)
.end;
final selection = Selection(
start: isDownward ? start : end, end: isDownward ? end : start);
bool isDownward = (identical(first, last))
? panStartOffset.dx < panEndOffset.dx
: panStartOffset.dy < panEndOffset.dy;
final start =
first.getSelectionInRange(panStartOffset, panEndOffset).start;
final end = last.getSelectionInRange(panStartOffset, panEndOffset).end;
final selection = Selection(start: start, end: end);
debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection');
editorState.updateCursorSelection(selection);
_scrollUpOrDownIfNeeded(panEndOffset!, isDownward);
updateSelection(selection);
}
_showDebugLayerIfNeeded();
_showDebugLayerIfNeeded(offset: panEndOffset);
}
void _onPanEnd(DragEndDetails details) {
// do nothing
}
void _updateSelection(Selection selection) {
final nodes = _selectedNodesInSelection(editorState.document, selection);
void _updateSelectionAreas(Selection selection) {
final nodes = getNodesInSelection(selection);
currentSelectedNodes = nodes;
currentSelection.value = selection;
// TODO: need to be refactored.
Rect? topmostRect;
LayerLink? layerLink;
var index = 0;
for (final node in nodes) {
final backwardNodes =
selection.isBackward ? nodes : nodes.reversed.toList(growable: false);
final backwardSelection = selection.isBackward
? selection
: selection.copyWith(start: selection.end, end: selection.start);
assert(backwardSelection.isBackward);
for (var i = 0; i < backwardNodes.length; i++) {
final node = backwardNodes[i];
final selectable = node.selectable;
if (selectable == null) {
continue;
}
var newSelection = selection.copy();
// In the case of multiple selections,
// we need to return a new selection for each selected node individually.
if (!selection.isSingle) {
// <> means selected.
// text: abcd<ef
// text: ghijkl
// text: mn>opqr
if (index == 0) {
if (selection.isDownward) {
newSelection = selection.copyWith(end: selectable.end());
} else {
newSelection = selection.copyWith(start: selectable.start());
}
} else if (index == nodes.length - 1) {
if (selection.isDownward) {
newSelection = selection.copyWith(start: selectable.start());
} else {
newSelection = selection.copyWith(end: selectable.end());
}
var newSelection = backwardSelection.copy();
/// In the case of multiple selections,
/// we need to return a new selection for each selected node individually.
///
/// < > means selected.
/// text: abcd<ef
/// text: ghijkl
/// text: mn>opqr
///
if (!backwardSelection.isSingle) {
if (i == 0) {
newSelection = newSelection.copyWith(end: selectable.end());
} else if (i == nodes.length - 1) {
newSelection = newSelection.copyWith(start: selectable.start());
} else {
newSelection = selection.copyWith(
newSelection = Selection(
start: selectable.start(),
end: selectable.end(),
);
@ -415,13 +375,13 @@ class _FlowySelectionState extends State<FlowySelection>
}
final rects = selectable.getRectsInSelection(newSelection);
for (final rect in rects) {
// FIXME: Need to compute more precise location.
// TODO: Need to compute more precise location.
topmostRect ??= rect;
layerLink ??= node.layerLink;
_rects.add(_transformRectToGlobal(selectable, rect));
selectionRects.add(_transformRectToGlobal(selectable, rect));
final overlay = OverlayEntry(
builder: (context) => SelectionWidget(
color: widget.selectionColor,
@ -429,11 +389,11 @@ class _FlowySelectionState extends State<FlowySelection>
rect: rect,
),
);
_selectionOverlays.add(overlay);
_selectionAreas.add(overlay);
}
index += 1;
}
Overlay.of(context)?.insertAll(_selectionOverlays);
Overlay.of(context)?.insertAll(_selectionAreas);
if (topmostRect != null && layerLink != null) {
editorState.service.toolbarService
@ -441,89 +401,141 @@ class _FlowySelectionState extends State<FlowySelection>
}
}
void _updateCursorAreas(Position position) {
final node = editorState.document.root.childAtPath(position.path);
if (node == null) {
assert(false);
return;
}
currentSelectedNodes = [node];
_showCursor(node, position);
}
void _showCursor(Node node, Position position) {
final selectable = node.selectable;
final cursorRect = selectable?.getCursorRectInPosition(position);
if (selectable != null && cursorRect != null) {
final cursorArea = OverlayEntry(
builder: (context) => CursorWidget(
key: _cursorKey,
rect: cursorRect,
color: widget.cursorColor,
layerLink: node.layerLink,
),
);
_cursorAreas.add(cursorArea);
selectionRects.add(_transformRectToGlobal(selectable, cursorRect));
Overlay.of(context)?.insertAll(_cursorAreas);
_forceShowCursor();
}
}
void _forceShowCursor() {
_cursorKey.currentState?.unwrapOrNull<CursorWidgetState>()?.show();
}
void _scrollUpOrDownIfNeeded() {
final dy = editorState.service.scrollService?.dy;
final selectNodes = currentSelectedNodes;
final selection = currentSelection.value;
if (dy == null || selection == null || selectNodes.isEmpty) {
return;
}
final rect = selectNodes.last.rect;
final size = MediaQuery.of(context).size.height;
final topLimit = size * 0.3;
final bottomLimit = size * 0.8;
/// TODO: It is necessary to calculate the relative speed
/// according to the gap and move forward more gently.
if (rect.top >= bottomLimit) {
if (selection.isSingle) {
editorState.service.scrollService?.scrollTo(dy + size * 0.2);
} else if (selection.isBackward) {
editorState.service.scrollService?.scrollTo(dy + 10.0);
}
} else if (rect.bottom <= topLimit) {
if (selection.isForward) {
editorState.service.scrollService?.scrollTo(dy - 10.0);
}
}
}
Node? _getNodeInOffset(
List<Node> sortedNodes, Offset offset, int start, int end) {
if (start < 0 && end >= sortedNodes.length) {
return null;
}
var min = start;
var max = end;
while (min <= max) {
final mid = min + ((max - min) >> 1);
final rect = sortedNodes[mid].rect;
if (rect.bottom <= offset.dy) {
min = mid + 1;
} else {
max = mid - 1;
}
}
final node = sortedNodes[min];
if (node.children.isNotEmpty && node.children.first.rect.top <= offset.dy) {
final children = node.children.toList(growable: false);
return _getNodeInOffset(
children,
offset,
0,
children.length - 1,
);
}
return node;
}
void _enableInteraction() {
editorState.service.keyboardService?.enable();
editorState.service.scrollService?.enable();
}
Rect _transformRectToGlobal(Selectable selectable, Rect r) {
final Offset topLeft = selectable.localToGlobal(Offset(r.left, r.top));
return Rect.fromLTWH(topLeft.dx, topLeft.dy, r.width, r.height);
}
void _updateCursor(Position position) {
final node = editorState.document.root.childAtPath(position.path);
assert(node != null);
if (node == null) {
return;
}
currentSelectedNodes = [node];
currentSelection.value = Selection.collapsed(position);
final selectable = node.selectable;
final rect = selectable?.getCursorRectInPosition(position);
if (rect != null) {
_rects.add(_transformRectToGlobal(selectable!, rect));
final cursor = OverlayEntry(
builder: (context) => CursorWidget(
key: _cursorKey,
rect: rect,
color: widget.cursorColor,
layerLink: node.layerLink,
),
);
_cursorOverlays.add(cursor);
Overlay.of(context)?.insertAll(_cursorOverlays);
_forceShowCursor();
}
void _onSelectionChange() {
_scrollUpOrDownIfNeeded();
}
_forceShowCursor() {
final currentState = _cursorKey.currentState as CursorWidgetState?;
currentState?.show();
}
List<Node> _selectedNodesInSelection(
StateTree stateTree, Selection selection) {
final startNode = stateTree.nodeAtPath(selection.start.path)!;
final endNode = stateTree.nodeAtPath(selection.end.path)!;
return NodeIterator(stateTree, startNode, endNode).toList();
}
void _scrollUpOrDownIfNeeded(Offset offset, bool isDownward) {
final dy = editorState.service.scrollService?.dy;
if (dy == null) {
assert(false, 'Dy could not be null');
return;
}
final topLimit = MediaQuery.of(context).size.height * 0.2;
final bottomLimit = MediaQuery.of(context).size.height * 0.8;
/// TODO: It is necessary to calculate the relative speed
/// according to the gap and move forward more gently.
final distance = 10.0;
if (offset.dy <= topLimit && !isDownward) {
// up
editorState.service.scrollService?.scrollTo(dy - distance);
} else if (offset.dy >= bottomLimit && isDownward) {
//down
editorState.service.scrollService?.scrollTo(dy + distance);
}
}
void _showDebugLayerIfNeeded() {
void _showDebugLayerIfNeeded({Offset? offset}) {
// remove false to show debug overlay.
if (kDebugMode && false) {
_debugOverlay?.remove();
if (panStartOffset != null) {
if (offset != null) {
_debugOverlay = OverlayEntry(
builder: (context) => Positioned.fromRect(
rect: Rect.fromPoints(offset, offset.translate(20, 20)),
child: Container(
color: Colors.red.withOpacity(0.2),
),
),
);
Overlay.of(context)?.insert(_debugOverlay!);
} else if (_panStartOffset != null) {
_debugOverlay = OverlayEntry(
builder: (context) => Positioned.fromRect(
rect: Rect.fromPoints(
panStartOffset?.translate(
0,
-(editorState.service.scrollService!.dy -
panStartScrollDy!),
) ??
Offset.zero,
panEndOffset ?? Offset.zero)
.translate(0, 0),
_panStartOffset?.translate(
0,
-(editorState.service.scrollService!.dy -
_panStartScrollDy!),
) ??
Offset.zero,
offset ?? Offset.zero),
child: Container(
color: Colors.red.withOpacity(0.2),
),
@ -535,175 +547,4 @@ class _FlowySelectionState extends State<FlowySelection>
}
}
}
Node _lowerBoundInDocument(Offset offset) {
final sortedNodes =
editorState.document.root.children.toList(growable: false);
return _lowerBound(sortedNodes, offset, 0, sortedNodes.length - 1);
}
Node _upperBoundInDocument(Offset offset) {
final sortedNodes =
editorState.document.root.children.toList(growable: false);
return _upperBound(sortedNodes, offset, 0, sortedNodes.length - 1);
}
/// TODO: Supports multi-level nesting,
/// currently only single-level nesting is supported
// find the first node's rect.bottom <= offset.dy
Node _lowerBound(List<Node> sortedNodes, Offset offset, int start, int end) {
assert(start >= 0 && end < sortedNodes.length);
var min = start;
var max = end;
while (min <= max) {
final mid = min + ((max - min) >> 1);
if (sortedNodes[mid].rect.bottom <= offset.dy) {
min = mid + 1;
} else {
max = mid - 1;
}
}
final node = sortedNodes[min];
if (node.children.isNotEmpty && node.children.first.rect.top <= offset.dy) {
final children = node.children.toList(growable: false);
return _lowerBound(children, offset, 0, children.length - 1);
}
return node;
}
/// TODO: Supports multi-level nesting,
/// currently only single-level nesting is supported
// find the first node's rect.top < offset.dy
Node _upperBound(
List<Node> sortedNodes,
Offset offset,
int start,
int end,
) {
assert(start >= 0 && end < sortedNodes.length);
var min = start;
var max = end;
while (min <= max) {
final mid = min + ((max - min) >> 1);
if (sortedNodes[mid].rect.top < offset.dy) {
min = mid + 1;
} else {
max = mid - 1;
}
}
final node = sortedNodes[max];
if (node.children.isNotEmpty && node.children.first.rect.top <= offset.dy) {
final children = node.children.toList(growable: false);
return _lowerBound(children, offset, 0, children.length - 1);
}
return node;
}
}
/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer]
/// for a while. So we need to implement our own GestureDetector.
@immutable
class _SelectionGestureDetector extends StatefulWidget {
const _SelectionGestureDetector(
{Key? key,
this.child,
this.onTapDown,
this.onDoubleTapDown,
this.onTripleTapDown,
this.onPanStart,
this.onPanUpdate,
this.onPanEnd})
: super(key: key);
@override
State<_SelectionGestureDetector> createState() =>
_SelectionGestureDetectorState();
final Widget? child;
final GestureTapDownCallback? onTapDown;
final GestureTapDownCallback? onDoubleTapDown;
final GestureTapDownCallback? onTripleTapDown;
final GestureDragStartCallback? onPanStart;
final GestureDragUpdateCallback? onPanUpdate;
final GestureDragEndCallback? onPanEnd;
}
const Duration kTripleTapTimeout = Duration(milliseconds: 500);
class _SelectionGestureDetectorState extends State<_SelectionGestureDetector> {
bool _isDoubleTap = false;
Timer? _doubleTapTimer;
int _tripleTabCount = 0;
Timer? _tripleTabTimer;
@override
Widget build(BuildContext context) {
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: {
PanGestureRecognizer:
GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
() => PanGestureRecognizer(),
(recognizer) {
recognizer
..onStart = widget.onPanStart
..onUpdate = widget.onPanUpdate
..onEnd = widget.onPanEnd;
},
),
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(),
(recognizer) {
recognizer.onTapDown = _tapDownDelegate;
},
),
},
child: widget.child,
);
}
_tapDownDelegate(TapDownDetails tapDownDetails) {
if (_tripleTabCount == 2) {
_tripleTabCount = 0;
_tripleTabTimer?.cancel();
_tripleTabTimer = null;
if (widget.onTripleTapDown != null) {
widget.onTripleTapDown!(tapDownDetails);
}
} else if (_isDoubleTap) {
_isDoubleTap = false;
_doubleTapTimer?.cancel();
_doubleTapTimer = null;
if (widget.onDoubleTapDown != null) {
widget.onDoubleTapDown!(tapDownDetails);
}
_tripleTabCount++;
} else {
if (widget.onTapDown != null) {
widget.onTapDown!(tapDownDetails);
}
_isDoubleTap = true;
_doubleTapTimer?.cancel();
_doubleTapTimer = Timer(kDoubleTapTimeout, () {
_isDoubleTap = false;
_doubleTapTimer = null;
});
_tripleTabCount = 1;
_tripleTabTimer?.cancel();
_tripleTabTimer = Timer(kTripleTapTimeout, () {
_tripleTabCount = 0;
_tripleTabTimer = null;
});
}
}
@override
void dispose() {
_doubleTapTimer?.cancel();
_tripleTabTimer?.cancel();
super.dispose();
}
}

View File

@ -1,10 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flowy_editor/src/service/keyboard_service.dart';
import 'package:flowy_editor/src/service/render_plugin_service.dart';
import 'package:flowy_editor/src/service/scroll_service.dart';
import 'package:flowy_editor/src/service/selection_service.dart';
import 'package:flowy_editor/src/service/toolbar_service.dart';
import 'package:flutter/material.dart';
class FlowyService {
// selection service

View File

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flowy_editor/src/render/selection/toolbar_widget.dart';
mixin FlowyToolbarService {
abstract class FlowyToolbarService {
/// Show the toolbar widget beside the offset.
void showInOffset(Offset offset, LayerLink layerLink);
@ -25,7 +25,8 @@ class FlowyToolbar extends StatefulWidget {
State<FlowyToolbar> createState() => _FlowyToolbarState();
}
class _FlowyToolbarState extends State<FlowyToolbar> with FlowyToolbarService {
class _FlowyToolbarState extends State<FlowyToolbar>
implements FlowyToolbarService {
OverlayEntry? _toolbarOverlay;
@override