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

@ -130,8 +130,13 @@ abstract class RenderAbstractEditor implements TextLayoutMetrics {
/// {@macro flutter.rendering.editable.select}
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.
///
@ -148,7 +153,7 @@ abstract class RenderAbstractEditor implements TextLayoutMetrics {
/// If you have a [TextEditingController], it's generally easier to
/// programmatically manipulate its `value` or `selection` directly.
/// {@endtemplate}
void selectPosition(SelectionChangedCause cause);
void selectPosition({required SelectionChangedCause cause});
}
String _standardizeImageUrl(String url) {
@ -475,9 +480,8 @@ class _QuillEditorSelectionGestureDetectorBuilder
case TargetPlatform.iOS:
case TargetPlatform.macOS:
getRenderEditor()!.selectPositionAt(
details.globalPosition,
null,
SelectionChangedCause.longPress,
from: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
break;
case TargetPlatform.android:
@ -571,6 +575,13 @@ class _QuillEditorSelectionGestureDetectorBuilder
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
void onSingleTapUp(TapUpDetails details) {
if (_state.widget.onTapUp != null) {
@ -595,7 +606,17 @@ class _QuillEditorSelectionGestureDetectorBuilder
case PointerDeviceKind.mouse:
case PointerDeviceKind.stylus:
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;
case PointerDeviceKind.touch:
case PointerDeviceKind.unknown:
@ -611,7 +632,7 @@ class _QuillEditorSelectionGestureDetectorBuilder
case TargetPlatform.linux:
case TargetPlatform.windows:
try {
getRenderEditor()!.selectPosition(SelectionChangedCause.tap);
getRenderEditor()!.selectPosition(cause: SelectionChangedCause.tap);
} finally {
break;
}
@ -637,9 +658,8 @@ class _QuillEditorSelectionGestureDetectorBuilder
case TargetPlatform.iOS:
case TargetPlatform.macOS:
getRenderEditor()!.selectPositionAt(
details.globalPosition,
null,
SelectionChangedCause.longPress,
from: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
break;
case TargetPlatform.android:
@ -686,25 +706,35 @@ const EdgeInsets _kFloatingCursorAddedMargin = EdgeInsets.fromLTRB(4, 4, 4, 5);
const EdgeInsets _kFloatingCaretSizeIncrease =
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
with RelayoutWhenSystemFontsChangeMixin
implements RenderAbstractEditor {
RenderEditor(
ViewportOffset? offset,
List<RenderEditableBox>? children,
TextDirection textDirection,
double scrollBottomInset,
EdgeInsetsGeometry padding,
this.document,
this.selection,
this._hasFocus,
this.onSelectionChanged,
this._startHandleLayerLink,
this._endHandleLayerLink,
EdgeInsets floatingCursorAddedMargin,
this._cursorController,
this.floatingCursorDisabled)
: super(
RenderEditor({
required this.document,
required TextDirection textDirection,
required bool hasFocus,
required this.selection,
required LayerLink startHandleLayerLink,
required LayerLink endHandleLayerLink,
required EdgeInsetsGeometry padding,
required CursorCont cursorController,
required this.onSelectionChanged,
required double scrollBottomInset,
required this.floatingCursorDisabled,
ViewportOffset? offset,
List<RenderEditableBox>? children,
EdgeInsets floatingCursorAddedMargin =
const EdgeInsets.fromLTRB(4, 4, 4, 5),
}) : _hasFocus = hasFocus,
_extendSelectionOrigin = selection,
_startHandleLayerLink = startHandleLayerLink,
_endHandleLayerLink = endHandleLayerLink,
_cursorController = cursorController,
super(
children,
document.root,
textDirection,
@ -720,6 +750,8 @@ class RenderEditor extends RenderEditableContainerBox
bool _hasFocus = false;
LayerLink _startHandleLayerLink;
LayerLink _endHandleLayerLink;
/// Called when the selection changes.
TextSelectionChangedHandler onSelectionChanged;
final ValueNotifier<bool> _selectionStartInViewport =
ValueNotifier<bool>(true);
@ -800,8 +832,18 @@ class RenderEditor extends RenderEditableContainerBox
}
selection = t;
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) {
if (_startHandleLayerLink == value) {
return;
@ -885,11 +927,35 @@ class RenderEditor extends RenderEditableContainerBox
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
void handleTapDown(TapDownDetails details) {
_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
void selectWordsInRange(
Offset from,
@ -926,6 +992,34 @@ class RenderEditor extends RenderEditableContainerBox
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
void selectWordEdge(SelectionChangedCause cause) {
assert(_lastTapDownPosition != null);
@ -956,11 +1050,11 @@ class RenderEditor extends RenderEditableContainerBox
}
@override
void selectPositionAt(
Offset from,
TextSelection? selectPositionAt({
required Offset from,
required SelectionChangedCause cause,
Offset? to,
SelectionChangedCause cause,
) {
}) {
final fromPosition = getPositionForOffset(from);
final toPosition = to == null ? null : getPositionForOffset(to);
@ -976,7 +1070,10 @@ class RenderEditor extends RenderEditableContainerBox
extentOffset: extentOffset,
affinity: fromPosition.affinity,
);
// Call [onSelectionChanged] only when the selection actually changed.
_handleSelectionChange(newSelection, cause);
return newSelection;
}
@override
@ -985,8 +1082,8 @@ class RenderEditor extends RenderEditableContainerBox
}
@override
void selectPosition(SelectionChangedCause cause) {
selectPositionAt(_lastTapDownPosition!, null, cause);
void selectPosition({required SelectionChangedCause cause}) {
selectPositionAt(from: _lastTapDownPosition!, cause: cause);
}
@override

@ -140,18 +140,6 @@ class _RenderSingleChildViewport extends RenderBox
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
bool get isRepaintBoundary => true;

@ -228,13 +228,26 @@ class RawEditorState extends EditorState
void _handleSelectionChanged(
TextSelection selection, SelectionChangedCause cause) {
final oldSelection = widget.controller.selection;
widget.controller.updateSelection(selection, ChangeSource.LOCAL);
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles();
if (!_keyboardVisible) {
// This will show the keyboard for all selection changes on the
// editor, not just changes triggered by user gestures.
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
@ -838,20 +851,18 @@ class _Editor extends MultiChildRenderObjectWidget {
@override
RenderEditor createRenderObject(BuildContext context) {
return RenderEditor(
offset,
null,
textDirection,
scrollBottomInset,
padding,
document,
selection,
hasFocus,
onSelectionChanged,
startHandleLayerLink,
endHandleLayerLink,
const EdgeInsets.fromLTRB(4, 4, 4, 5),
cursorController,
floatingCursorDisabled);
offset: offset,
document: document,
textDirection: textDirection,
hasFocus: hasFocus,
selection: selection,
startHandleLayerLink: startHandleLayerLink,
endHandleLayerLink: endHandleLayerLink,
onSelectionChanged: onSelectionChanged,
cursorController: cursorController,
padding: padding,
scrollBottomInset: scrollBottomInset,
floatingCursorDisabled: floatingCursorDisabled);
}
@override

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

Loading…
Cancel
Save