Desktop selection improvements

Added `Shift` + `Click` support for extending selection on desktop
Added automatic scrolling while selecting text with mouse
pull/541/head
X Code 3 years ago
parent 048f4e5b7f
commit df20936737
  1. 27
      lib/src/widgets/delegate.dart
  2. 163
      lib/src/widgets/editor.dart
  3. 12
      lib/src/widgets/quill_single_child_scroll_view.dart
  4. 39
      lib/src/widgets/raw_editor.dart
  5. 27
      lib/src/widgets/simple_viewer.dart

@ -76,9 +76,8 @@ class EditorTextSelectionGestureDetectorBuilder {
void onSingleLongTapStart(LongPressStartDetails details) { void onSingleLongTapStart(LongPressStartDetails details) {
if (delegate.getSelectionEnabled()) { if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectPositionAt( getRenderEditor()!.selectPositionAt(
details.globalPosition, from: details.globalPosition,
null, cause: SelectionChangedCause.longPress,
SelectionChangedCause.longPress,
); );
} }
} }
@ -86,9 +85,8 @@ class EditorTextSelectionGestureDetectorBuilder {
void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) {
if (delegate.getSelectionEnabled()) { if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectPositionAt( getRenderEditor()!.selectPositionAt(
details.globalPosition, from: details.globalPosition,
null, cause: SelectionChangedCause.longPress,
SelectionChangedCause.longPress,
); );
} }
} }
@ -109,23 +107,18 @@ class EditorTextSelectionGestureDetectorBuilder {
} }
void onDragSelectionStart(DragStartDetails details) { void onDragSelectionStart(DragStartDetails details) {
getRenderEditor()!.selectPositionAt( getRenderEditor()!.handleDragStart(details);
details.globalPosition,
null,
SelectionChangedCause.drag,
);
} }
void onDragSelectionUpdate( void onDragSelectionUpdate(
DragStartDetails startDetails, DragUpdateDetails updateDetails) { DragStartDetails startDetails, DragUpdateDetails updateDetails) {
getRenderEditor()!.selectPositionAt( getRenderEditor()!.extendSelection(updateDetails.globalPosition,
startDetails.globalPosition, cause: SelectionChangedCause.drag);
updateDetails.globalPosition,
SelectionChangedCause.drag,
);
} }
void onDragSelectionEnd(DragEndDetails details) {} void onDragSelectionEnd(DragEndDetails details) {
getRenderEditor()!.handleDragEnd(details);
}
Widget build(HitTestBehavior behavior, Widget child) { Widget build(HitTestBehavior behavior, Widget child) {
return EditorTextSelectionGestureDetector( return EditorTextSelectionGestureDetector(

@ -130,8 +130,13 @@ abstract class RenderAbstractEditor implements TextLayoutMetrics {
/// {@macro flutter.rendering.editable.select} /// {@macro flutter.rendering.editable.select}
void selectWordEdge(SelectionChangedCause cause); void selectWordEdge(SelectionChangedCause cause);
/// Select text between the global positions [from] and [to]. ///
void selectPositionAt(Offset from, Offset to, SelectionChangedCause cause); /// Returns the new selection. Note that the returned value may not be
/// yet reflected in the latest widget state.
///
/// Returns null if no change occurred.
TextSelection? selectPositionAt(
{required Offset from, required SelectionChangedCause cause, Offset? to});
/// Select a word around the location of the last tap down. /// Select a word around the location of the last tap down.
/// ///
@ -148,7 +153,7 @@ abstract class RenderAbstractEditor implements TextLayoutMetrics {
/// If you have a [TextEditingController], it's generally easier to /// If you have a [TextEditingController], it's generally easier to
/// programmatically manipulate its `value` or `selection` directly. /// programmatically manipulate its `value` or `selection` directly.
/// {@endtemplate} /// {@endtemplate}
void selectPosition(SelectionChangedCause cause); void selectPosition({required SelectionChangedCause cause});
} }
String _standardizeImageUrl(String url) { String _standardizeImageUrl(String url) {
@ -475,9 +480,8 @@ class _QuillEditorSelectionGestureDetectorBuilder
case TargetPlatform.iOS: case TargetPlatform.iOS:
case TargetPlatform.macOS: case TargetPlatform.macOS:
getRenderEditor()!.selectPositionAt( getRenderEditor()!.selectPositionAt(
details.globalPosition, from: details.globalPosition,
null, cause: SelectionChangedCause.longPress,
SelectionChangedCause.longPress,
); );
break; break;
case TargetPlatform.android: case TargetPlatform.android:
@ -571,6 +575,13 @@ class _QuillEditorSelectionGestureDetectorBuilder
super.onTapDown(details); super.onTapDown(details);
} }
bool isShiftClick(PointerDeviceKind deviceKind) {
final pressed = RawKeyboard.instance.keysPressed;
return deviceKind == PointerDeviceKind.mouse &&
(pressed.contains(LogicalKeyboardKey.shiftLeft) ||
pressed.contains(LogicalKeyboardKey.shiftRight));
}
@override @override
void onSingleTapUp(TapUpDetails details) { void onSingleTapUp(TapUpDetails details) {
if (_state.widget.onTapUp != null) { if (_state.widget.onTapUp != null) {
@ -595,7 +606,17 @@ class _QuillEditorSelectionGestureDetectorBuilder
case PointerDeviceKind.mouse: case PointerDeviceKind.mouse:
case PointerDeviceKind.stylus: case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus: case PointerDeviceKind.invertedStylus:
getRenderEditor()!.selectPosition(SelectionChangedCause.tap); // Precise devices should place the cursor at a precise position.
// If `Shift` key is pressed then
// extend current selection instead.
if (isShiftClick(details.kind)) {
getRenderEditor()!.extendSelection(details.globalPosition,
cause: SelectionChangedCause.tap);
} else {
getRenderEditor()!
.selectPosition(cause: SelectionChangedCause.tap);
}
break; break;
case PointerDeviceKind.touch: case PointerDeviceKind.touch:
case PointerDeviceKind.unknown: case PointerDeviceKind.unknown:
@ -611,7 +632,7 @@ class _QuillEditorSelectionGestureDetectorBuilder
case TargetPlatform.linux: case TargetPlatform.linux:
case TargetPlatform.windows: case TargetPlatform.windows:
try { try {
getRenderEditor()!.selectPosition(SelectionChangedCause.tap); getRenderEditor()!.selectPosition(cause: SelectionChangedCause.tap);
} finally { } finally {
break; break;
} }
@ -637,9 +658,8 @@ class _QuillEditorSelectionGestureDetectorBuilder
case TargetPlatform.iOS: case TargetPlatform.iOS:
case TargetPlatform.macOS: case TargetPlatform.macOS:
getRenderEditor()!.selectPositionAt( getRenderEditor()!.selectPositionAt(
details.globalPosition, from: details.globalPosition,
null, cause: SelectionChangedCause.longPress,
SelectionChangedCause.longPress,
); );
break; break;
case TargetPlatform.android: case TargetPlatform.android:
@ -686,25 +706,35 @@ const EdgeInsets _kFloatingCursorAddedMargin = EdgeInsets.fromLTRB(4, 4, 4, 5);
const EdgeInsets _kFloatingCaretSizeIncrease = const EdgeInsets _kFloatingCaretSizeIncrease =
EdgeInsets.symmetric(horizontal: 0.5, vertical: 1); EdgeInsets.symmetric(horizontal: 0.5, vertical: 1);
/// Displays a document as a vertical list of document segments (lines
/// and blocks).
///
/// Children of [RenderEditor] must be instances of [RenderEditableBox].
class RenderEditor extends RenderEditableContainerBox class RenderEditor extends RenderEditableContainerBox
with RelayoutWhenSystemFontsChangeMixin with RelayoutWhenSystemFontsChangeMixin
implements RenderAbstractEditor { implements RenderAbstractEditor {
RenderEditor( RenderEditor({
ViewportOffset? offset, required this.document,
List<RenderEditableBox>? children, required TextDirection textDirection,
TextDirection textDirection, required bool hasFocus,
double scrollBottomInset, required this.selection,
EdgeInsetsGeometry padding, required LayerLink startHandleLayerLink,
this.document, required LayerLink endHandleLayerLink,
this.selection, required EdgeInsetsGeometry padding,
this._hasFocus, required CursorCont cursorController,
this.onSelectionChanged, required this.onSelectionChanged,
this._startHandleLayerLink, required double scrollBottomInset,
this._endHandleLayerLink, required this.floatingCursorDisabled,
EdgeInsets floatingCursorAddedMargin, ViewportOffset? offset,
this._cursorController, List<RenderEditableBox>? children,
this.floatingCursorDisabled) EdgeInsets floatingCursorAddedMargin =
: super( const EdgeInsets.fromLTRB(4, 4, 4, 5),
}) : _hasFocus = hasFocus,
_extendSelectionOrigin = selection,
_startHandleLayerLink = startHandleLayerLink,
_endHandleLayerLink = endHandleLayerLink,
_cursorController = cursorController,
super(
children, children,
document.root, document.root,
textDirection, textDirection,
@ -720,6 +750,8 @@ class RenderEditor extends RenderEditableContainerBox
bool _hasFocus = false; bool _hasFocus = false;
LayerLink _startHandleLayerLink; LayerLink _startHandleLayerLink;
LayerLink _endHandleLayerLink; LayerLink _endHandleLayerLink;
/// Called when the selection changes.
TextSelectionChangedHandler onSelectionChanged; TextSelectionChangedHandler onSelectionChanged;
final ValueNotifier<bool> _selectionStartInViewport = final ValueNotifier<bool> _selectionStartInViewport =
ValueNotifier<bool>(true); ValueNotifier<bool>(true);
@ -800,8 +832,18 @@ class RenderEditor extends RenderEditableContainerBox
} }
selection = t; selection = t;
markNeedsPaint(); markNeedsPaint();
if (!_shiftPressed && !_isDragging) {
// Only update extend selection origin if Shift key is not pressed and
// user is not dragging selection.
_extendSelectionOrigin = selection;
}
} }
bool get _shiftPressed =>
RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftLeft) ||
RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftLeft);
void setStartHandleLayerLink(LayerLink value) { void setStartHandleLayerLink(LayerLink value) {
if (_startHandleLayerLink == value) { if (_startHandleLayerLink == value) {
return; return;
@ -885,11 +927,35 @@ class RenderEditor extends RenderEditableContainerBox
Offset? _lastTapDownPosition; Offset? _lastTapDownPosition;
// Used on Desktop (mouse and keyboard enabled platforms) as base offset
// for extending selection, either with combination of `Shift` + Click or
// by dragging
TextSelection? _extendSelectionOrigin;
@override @override
void handleTapDown(TapDownDetails details) { void handleTapDown(TapDownDetails details) {
_lastTapDownPosition = details.globalPosition; _lastTapDownPosition = details.globalPosition;
} }
bool _isDragging = false;
void handleDragStart(DragStartDetails details) {
_isDragging = true;
final newSelection = selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.drag,
);
if (newSelection == null) return;
// Make sure to remember the origin for extend selection.
_extendSelectionOrigin = newSelection;
}
void handleDragEnd(DragEndDetails details) {
_isDragging = false;
}
@override @override
void selectWordsInRange( void selectWordsInRange(
Offset from, Offset from,
@ -926,6 +992,34 @@ class RenderEditor extends RenderEditableContainerBox
onSelectionChanged(nextSelection, cause); onSelectionChanged(nextSelection, cause);
} }
/// Extends current selection to the position closest to specified offset.
void extendSelection(Offset to, {required SelectionChangedCause cause}) {
/// The below logic does not exactly match the native version because
/// we do not allow swapping of base and extent positions.
assert(_extendSelectionOrigin != null);
final position = getPositionForOffset(to);
if (position.offset < _extendSelectionOrigin!.baseOffset) {
_handleSelectionChange(
TextSelection(
baseOffset: position.offset,
extentOffset: _extendSelectionOrigin!.extentOffset,
affinity: selection.affinity,
),
cause,
);
} else if (position.offset > _extendSelectionOrigin!.extentOffset) {
_handleSelectionChange(
TextSelection(
baseOffset: _extendSelectionOrigin!.baseOffset,
extentOffset: position.offset,
affinity: selection.affinity,
),
cause,
);
}
}
@override @override
void selectWordEdge(SelectionChangedCause cause) { void selectWordEdge(SelectionChangedCause cause) {
assert(_lastTapDownPosition != null); assert(_lastTapDownPosition != null);
@ -956,11 +1050,11 @@ class RenderEditor extends RenderEditableContainerBox
} }
@override @override
void selectPositionAt( TextSelection? selectPositionAt({
Offset from, required Offset from,
required SelectionChangedCause cause,
Offset? to, Offset? to,
SelectionChangedCause cause, }) {
) {
final fromPosition = getPositionForOffset(from); final fromPosition = getPositionForOffset(from);
final toPosition = to == null ? null : getPositionForOffset(to); final toPosition = to == null ? null : getPositionForOffset(to);
@ -976,7 +1070,10 @@ class RenderEditor extends RenderEditableContainerBox
extentOffset: extentOffset, extentOffset: extentOffset,
affinity: fromPosition.affinity, affinity: fromPosition.affinity,
); );
// Call [onSelectionChanged] only when the selection actually changed.
_handleSelectionChange(newSelection, cause); _handleSelectionChange(newSelection, cause);
return newSelection;
} }
@override @override
@ -985,8 +1082,8 @@ class RenderEditor extends RenderEditableContainerBox
} }
@override @override
void selectPosition(SelectionChangedCause cause) { void selectPosition({required SelectionChangedCause cause}) {
selectPositionAt(_lastTapDownPosition!, null, cause); selectPositionAt(from: _lastTapDownPosition!, cause: cause);
} }
@override @override

@ -140,18 +140,6 @@ class _RenderSingleChildViewport extends RenderBox
if (child.parentData is! ParentData) child.parentData = ParentData(); if (child.parentData is! ParentData) child.parentData = ParentData();
} }
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_offset.addListener(_hasScrolled);
}
@override
void detach() {
_offset.removeListener(_hasScrolled);
super.detach();
}
@override @override
bool get isRepaintBoundary => true; bool get isRepaintBoundary => true;

@ -228,13 +228,26 @@ class RawEditorState extends EditorState
void _handleSelectionChanged( void _handleSelectionChanged(
TextSelection selection, SelectionChangedCause cause) { TextSelection selection, SelectionChangedCause cause) {
final oldSelection = widget.controller.selection;
widget.controller.updateSelection(selection, ChangeSource.LOCAL); widget.controller.updateSelection(selection, ChangeSource.LOCAL);
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles();
if (!_keyboardVisible) { if (!_keyboardVisible) {
// This will show the keyboard for all selection changes on the
// editor, not just changes triggered by user gestures.
requestKeyboard(); requestKeyboard();
} }
if (cause == SelectionChangedCause.drag) {
// When user updates the selection while dragging make sure to
// bring the updated position (base or extent) into view.
if (oldSelection.baseOffset != selection.baseOffset) {
bringIntoView(selection.base);
} else if (oldSelection.extentOffset != selection.extentOffset) {
bringIntoView(selection.extent);
}
}
} }
/// Updates the checkbox positioned at [offset] in document /// Updates the checkbox positioned at [offset] in document
@ -838,20 +851,18 @@ class _Editor extends MultiChildRenderObjectWidget {
@override @override
RenderEditor createRenderObject(BuildContext context) { RenderEditor createRenderObject(BuildContext context) {
return RenderEditor( return RenderEditor(
offset, offset: offset,
null, document: document,
textDirection, textDirection: textDirection,
scrollBottomInset, hasFocus: hasFocus,
padding, selection: selection,
document, startHandleLayerLink: startHandleLayerLink,
selection, endHandleLayerLink: endHandleLayerLink,
hasFocus, onSelectionChanged: onSelectionChanged,
onSelectionChanged, cursorController: cursorController,
startHandleLayerLink, padding: padding,
endHandleLayerLink, scrollBottomInset: scrollBottomInset,
const EdgeInsets.fromLTRB(4, 4, 4, 5), floatingCursorDisabled: floatingCursorDisabled);
cursorController,
floatingCursorDisabled);
} }
@override @override

@ -337,21 +337,18 @@ class _SimpleViewer extends MultiChildRenderObjectWidget {
@override @override
RenderEditor createRenderObject(BuildContext context) { RenderEditor createRenderObject(BuildContext context) {
return RenderEditor( return RenderEditor(
offset, offset: offset,
null, document: document,
textDirection, textDirection: textDirection,
scrollBottomInset, hasFocus: false,
padding, selection: const TextSelection(baseOffset: 0, extentOffset: 0),
document, startHandleLayerLink: startHandleLayerLink,
const TextSelection(baseOffset: 0, extentOffset: 0), endHandleLayerLink: endHandleLayerLink,
false, onSelectionChanged: onSelectionChanged,
// hasFocus, cursorController: cursorController,
onSelectionChanged, padding: padding,
startHandleLayerLink, scrollBottomInset: scrollBottomInset,
endHandleLayerLink, floatingCursorDisabled: floatingCursorDisabled);
const EdgeInsets.fromLTRB(4, 4, 4, 5),
cursorController,
floatingCursorDisabled);
} }
@override @override

Loading…
Cancel
Save