Skip to content

Commit

Permalink
[SuperEditor] Add selection reason to selection changes (Resolves #847)…
Browse files Browse the repository at this point in the history
… (#849)
  • Loading branch information
angelosilvestre committed Nov 20, 2022
1 parent 3fd4352 commit 6c95044
Show file tree
Hide file tree
Showing 5 changed files with 222 additions and 32 deletions.
94 changes: 92 additions & 2 deletions super_editor/lib/src/core/document_composer.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:attributed_text/attributed_text.dart';
import 'package:flutter/foundation.dart';
import 'package:super_editor/src/core/document.dart';
Expand All @@ -18,8 +20,9 @@ class DocumentComposer with ChangeNotifier {
ImeConfiguration? imeConfiguration,
}) : imeConfiguration = ValueNotifier(imeConfiguration ?? const ImeConfiguration()),
_preferences = ComposerPreferences() {
_streamController = StreamController<DocumentSelectionChange>.broadcast();
selectionNotifier.addListener(_onSelectionChangedBySelectionNotifier);
selectionNotifier.value = initialSelection;

_preferences.addListener(() {
editorLog.fine("Composer preferences changed");
notifyListeners();
Expand All @@ -29,27 +32,80 @@ class DocumentComposer with ChangeNotifier {
@override
void dispose() {
_preferences.dispose();
selectionNotifier.removeListener(_onSelectionChangedBySelectionNotifier);
super.dispose();
}

/// Returns the current [DocumentSelection] for a [Document].
DocumentSelection? get selection => selectionNotifier.value;

/// Sets the current [selection] for a [Document].
/// Sets the current [selection] for a [Document] using [SelectionReason.userInteraction] as the reason.
set selection(DocumentSelection? newSelection) {
if (newSelection != selectionNotifier.value) {
selectionNotifier.value = newSelection;
notifyListeners();
}
}

/// Sets the current [selection] for a [Document].
///
/// [reason] represents what caused the selection change to happen.
void setSelectionWithReason(DocumentSelection? newSelection, [Object reason = SelectionReason.userInteraction]) {
_latestSelectionChange = DocumentSelectionChange(
selection: newSelection,
reason: reason,
);
_streamController.sink.add(_latestSelectionChange);

// Remove the listener, so we don't emit another DocumentSelectionChange.
selectionNotifier.removeListener(_onSelectionChangedBySelectionNotifier);

// Updates the selection, so both _latestSelectionChange and selectionNotifier are in sync.
selectionNotifier.value = newSelection;

selectionNotifier.addListener(_onSelectionChangedBySelectionNotifier);
}

/// Returns the reason for the most recent selection change in the composer.
///
/// For example, a selection might change as a result of user interaction, or as
/// a result of another user editing content, or some other reason.
Object? get latestSelectionChangeReason => _latestSelectionChange.reason;

/// Returns the most recent selection change in the composer.
///
/// The [DocumentSelectionChange] includes the most recent document selection,
/// along with the reason that the selection changed.
DocumentSelectionChange get latestSelectionChange => _latestSelectionChange;
late DocumentSelectionChange _latestSelectionChange;

/// A stream of document selection changes.
///
/// Each new [DocumentSelectionChange] includes the most recent document selection,
/// along with the reason that the selection changed.
///
/// Listen to this [Stream] when the selection reason is needed. Otherwise, use [selectionNotifier].
Stream<DocumentSelectionChange> get selectionChanges => _streamController.stream;
late StreamController<DocumentSelectionChange> _streamController;

/// Notifies whenever the current [DocumentSelection] changes.
///
/// If the selection change reason is needed, use [selectionChanges] instead.
final selectionNotifier = ValueNotifier<DocumentSelection?>(null);

/// Clears the current [selection].
void clearSelection() {
selection = null;
}

void _onSelectionChangedBySelectionNotifier() {
_latestSelectionChange = DocumentSelectionChange(
selection: selectionNotifier.value,
reason: SelectionReason.userInteraction,
);
_streamController.sink.add(_latestSelectionChange);
}

final ValueNotifier<ImeConfiguration> imeConfiguration;

final ComposerPreferences _preferences;
Expand Down Expand Up @@ -123,3 +179,37 @@ class ComposerPreferences with ChangeNotifier {
notifyListeners();
}
}

/// Represents a change of a [DocumentSelection].
///
/// The [reason] represents what cause the selection to change.
/// For example, [SelectionReason.userInteraction] represents
/// a selection change caused by the user interacting with the editor.
class DocumentSelectionChange {
DocumentSelectionChange({
this.selection,
required this.reason,
});

final DocumentSelection? selection;
final Object reason;

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DocumentSelectionChange && selection == other.selection && reason == other.reason;

@override
int get hashCode => (selection?.hashCode ?? 0) ^ reason.hashCode;
}

/// Holds common reasons for selection changes.
/// Developers aren't limited to these selection change reasons. Any object can be passed as
/// a reason for a selection change. However, some Super Editor behavior is based on [userInteraction].
class SelectionReason {
/// Represents a change caused by an user interaction.
static const userInteraction = "userInteraction";

/// Represents a changed caused by an event which was not initiated by the user.
static const contentChange = "contentChange";
}
76 changes: 47 additions & 29 deletions super_editor/lib/src/default_editor/document_gestures_mouse.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:math';

import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:super_editor/src/core/document.dart';
import 'package:super_editor/src/core/document_composer.dart';
import 'package:super_editor/src/core/document_layout.dart';
import 'package:super_editor/src/core/document_selection.dart';
import 'package:super_editor/src/default_editor/document_scrollable.dart';
Expand All @@ -30,13 +32,17 @@ import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart';
/// components
/// - automatically scrolls up or down when the user drags near
/// a boundary
///
/// Whenever a selection change caused by a [SelectionReason.userInteraction] happens,
/// [DocumentMouseInteractor] auto-scrolls the editor to make the selection region visible.
class DocumentMouseInteractor extends StatefulWidget {
const DocumentMouseInteractor({
Key? key,
this.focusNode,
required this.document,
required this.getDocumentLayout,
required this.selection,
required this.selectionNotifier,
required this.selectionChanges,
required this.autoScroller,
this.showDebugPaint = false,
required this.child,
Expand All @@ -46,7 +52,8 @@ class DocumentMouseInteractor extends StatefulWidget {

final Document document;
final DocumentLayoutResolver getDocumentLayout;
final ValueNotifier<DocumentSelection?> selection;
final Stream<DocumentSelectionChange> selectionChanges;
final ValueNotifier<DocumentSelection?> selectionNotifier;

/// Auto-scrolling delegate.
final AutoScrollController autoScroller;
Expand Down Expand Up @@ -77,17 +84,21 @@ class _DocumentMouseInteractorState extends State<DocumentMouseInteractor>
/// Holds which kind of device started a pan gesture, e.g., a mouse or a trackpad.
PointerDeviceKind? _panGestureDevice;

late StreamSubscription<DocumentSelectionChange> _selectionSubscription;

DocumentSelection? get _currentSelection => widget.selectionNotifier.value;

@override
void initState() {
super.initState();
_focusNode = widget.focusNode ?? FocusNode();
widget.selection.addListener(_onSelectionChange);
_selectionSubscription = widget.selectionChanges.listen(_onSelectionChange);
widget.autoScroller.addListener(_updateDragSelection);

startSyncingSelectionWithFocus(
focusNode: _focusNode,
getDocumentLayout: widget.getDocumentLayout,
selection: widget.selection,
selection: widget.selectionNotifier,
);
}

Expand All @@ -98,10 +109,12 @@ class _DocumentMouseInteractorState extends State<DocumentMouseInteractor>
_focusNode = widget.focusNode ?? FocusNode();
onFocusNodeReplaced(_focusNode);
}
if (widget.selection != oldWidget.selection) {
oldWidget.selection.removeListener(_onSelectionChange);
widget.selection.addListener(_onSelectionChange);
onDocumentSelectionNotifierReplaced(widget.selection);
if (widget.selectionChanges != oldWidget.selectionChanges) {
_selectionSubscription.cancel();
_selectionSubscription = widget.selectionChanges.listen(_onSelectionChange);
}
if (widget.selectionNotifier != oldWidget.selectionNotifier) {
onDocumentSelectionNotifierReplaced(widget.selectionNotifier);
}
if (widget.autoScroller != oldWidget.autoScroller) {
oldWidget.autoScroller.removeListener(_updateDragSelection);
Expand All @@ -115,7 +128,7 @@ class _DocumentMouseInteractorState extends State<DocumentMouseInteractor>
if (widget.focusNode == null) {
_focusNode.dispose();
}
widget.selection.removeListener(_onSelectionChange);
_selectionSubscription.cancel();
widget.autoScroller.removeListener(_updateDragSelection);
stopSyncingSelectionWithFocus();
super.dispose();
Expand All @@ -134,9 +147,14 @@ class _DocumentMouseInteractorState extends State<DocumentMouseInteractor>
RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftRight) ||
RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shift)) &&
// TODO: this condition doesn't belong here. Move it to where it applies
widget.selection.value != null;
_currentSelection != null;

void _onSelectionChange() {
void _onSelectionChange(DocumentSelectionChange selectionChange) {
if (selectionChange.reason != SelectionReason.userInteraction) {
// The selection changed, but it isn't caused by an user interaction.
// We don't want auto-scroll.
return;
}
if (mounted) {
// Use a post-frame callback to "ensure selection extent is visible"
// so that any pending visual document changes can happen before
Expand All @@ -153,7 +171,7 @@ class _DocumentMouseInteractorState extends State<DocumentMouseInteractor>
}

Rect? _getSelectionExtentAsGlobalRect() {
final selection = widget.selection.value;
final selection = _currentSelection;
if (selection == null) {
return null;
}
Expand Down Expand Up @@ -192,7 +210,7 @@ class _DocumentMouseInteractorState extends State<DocumentMouseInteractor>
}

final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!;
final expandSelection = _isShiftPressed && widget.selection.value != null;
final expandSelection = _isShiftPressed && _currentSelection != null;

if (!tappedComponent.isVisualSelectionSupported()) {
_moveToNearestSelectableComponent(
Expand All @@ -206,7 +224,7 @@ class _DocumentMouseInteractorState extends State<DocumentMouseInteractor>
if (expandSelection) {
// The user tapped while pressing shift and there's an existing
// selection. Move the extent of the selection to where the user tapped.
widget.selection.value = widget.selection.value!.copyWith(
widget.selectionNotifier.value = _currentSelection!.copyWith(
extent: docPosition,
);
} else {
Expand Down Expand Up @@ -260,7 +278,7 @@ class _DocumentMouseInteractorState extends State<DocumentMouseInteractor>
}) {
final newSelection = getWordSelection(docPosition: docPosition, docLayout: docLayout);
if (newSelection != null) {
widget.selection.value = newSelection;
widget.selectionNotifier.value = newSelection;
return true;
} else {
return false;
Expand All @@ -272,7 +290,7 @@ class _DocumentMouseInteractorState extends State<DocumentMouseInteractor>
return false;
}

widget.selection.value = DocumentSelection(
widget.selectionNotifier.value = DocumentSelection(
base: DocumentPosition(
nodeId: position.nodeId,
nodePosition: const UpstreamDownstreamNodePosition.upstream(),
Expand Down Expand Up @@ -329,7 +347,7 @@ class _DocumentMouseInteractorState extends State<DocumentMouseInteractor>
}) {
final newSelection = getParagraphSelection(docPosition: docPosition, docLayout: docLayout);
if (newSelection != null) {
widget.selection.value = newSelection;
widget.selectionNotifier.value = newSelection;
return true;
} else {
return false;
Expand All @@ -343,7 +361,7 @@ class _DocumentMouseInteractorState extends State<DocumentMouseInteractor>

void _selectPosition(DocumentPosition position) {
editorGesturesLog.fine("Setting document selection to $position");
widget.selection.value = DocumentSelection.collapsed(
widget.selectionNotifier.value = DocumentSelection.collapsed(
position: position,
);
}
Expand Down Expand Up @@ -500,7 +518,7 @@ Updating drag selection:
editorGesturesLog.fine(" - base: $basePosition, extent: $extentPosition");

if (basePosition == null || extentPosition == null) {
widget.selection.value = null;
_clearSelection();
return;
}

Expand All @@ -510,7 +528,7 @@ Updating drag selection:
docLayout: documentLayout,
);
if (baseParagraphSelection == null) {
widget.selection.value = null;
_clearSelection();
return;
}
basePosition = baseOffsetInDocument.dy < extentOffsetInDocument.dy
Expand All @@ -522,7 +540,7 @@ Updating drag selection:
docLayout: documentLayout,
);
if (extentParagraphSelection == null) {
widget.selection.value = null;
_clearSelection();
return;
}
extentPosition = baseOffsetInDocument.dy < extentOffsetInDocument.dy
Expand All @@ -534,7 +552,7 @@ Updating drag selection:
docLayout: documentLayout,
);
if (baseWordSelection == null) {
widget.selection.value = null;
_clearSelection();
return;
}
basePosition = baseWordSelection.base;
Expand All @@ -544,23 +562,23 @@ Updating drag selection:
docLayout: documentLayout,
);
if (extentWordSelection == null) {
widget.selection.value = null;
_clearSelection();
return;
}
extentPosition = extentWordSelection.extent;
}

widget.selection.value = (DocumentSelection(
widget.selectionNotifier.value = DocumentSelection(
// If desired, expand the selection instead of replacing it.
base: expandSelection ? widget.selection.value?.base ?? basePosition : basePosition,
base: expandSelection ? _currentSelection?.base ?? basePosition : basePosition,
extent: extentPosition,
));
editorGesturesLog.fine("Selected region: ${widget.selection.value}");
);
editorGesturesLog.fine("Selected region: $_currentSelection");
}

void _clearSelection() {
editorGesturesLog.fine("Clearing document selection");
widget.selection.value = null;
widget.selectionNotifier.value = null;
}

void _moveToNearestSelectableComponent(
Expand All @@ -571,7 +589,7 @@ Updating drag selection:
moveSelectionToNearestSelectableNode(
document: widget.document,
documentLayoutResolver: widget.getDocumentLayout,
selection: widget.selection,
selection: widget.selectionNotifier,
startingNode: widget.document.getNodeById(nodeId)!,
expand: expandSelection,
);
Expand Down
3 changes: 2 additions & 1 deletion super_editor/lib/src/default_editor/super_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,8 @@ class SuperEditorState extends State<SuperEditor> {
focusNode: _focusNode,
document: editContext.editor.document,
getDocumentLayout: () => editContext.documentLayout,
selection: editContext.composer.selectionNotifier,
selectionChanges: editContext.composer.selectionChanges,
selectionNotifier: editContext.composer.selectionNotifier,
autoScroller: _autoScrollController,
showDebugPaint: widget.debugPaint.gestures,
child: const SizedBox(),
Expand Down
Loading

0 comments on commit 6c95044

Please sign in to comment.