Skip to content

Commit

Permalink
[SuperEditor][mobile] - Implement spellchecking support (Resolves #2353
Browse files Browse the repository at this point in the history
…) (#2378)
  • Loading branch information
angelosilvestre authored and web-flow committed Jan 15, 2025
1 parent f6a4fe8 commit d9b89d2
Show file tree
Hide file tree
Showing 75 changed files with 2,989 additions and 106 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,6 @@ class _DocumentMouseInteractorState extends State<DocumentMouseInteractor> with
widget.autoScroller
..addListener(_updateDragSelection)
..addListener(_updateMouseCursorAtLatestOffset);

if (widget.contentTapHandlers != null) {
for (final handler in widget.contentTapHandlers!) {
handler.addListener(_updateMouseCursorAtLatestOffset);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,29 @@ class SuperEditorAndroidControlsController {
}
}

/// {@template are_selection_handles_allowed}
/// Whether or not the selection handles are allowed to be displayed.
///
/// Typically, whenever the selection changes the drag handles are displayed. However,
/// there are some cases where we want to select some content, but don't show the
/// drag handles. For example, when the user taps a misspelled word, we might want to select
/// the misspelled word without showing any handles.
///
/// Defaults to `true`.
/// {@endtemplate}
ValueListenable<bool> get areSelectionHandlesAllowed => _areSelectionHandlesAllowed;
final _areSelectionHandlesAllowed = ValueNotifier<bool>(true);

/// Temporarily prevents any selection handles from being displayed.
///
/// Call this when you want to select some content, but don't want to show the drag handles.
/// [allowSelectionHandles] must be called to allow the drag handles to be displayed again.
void preventSelectionHandles() => _areSelectionHandlesAllowed.value = false;

/// Allows the selection handles to be displayed after they have been temporarily
/// prevented by [preventSelectionHandles].
void allowSelectionHandles() => _areSelectionHandlesAllowed.value = true;

/// (Optional) Builder to create the visual representation of the expanded drag handles.
///
/// If [expandedHandlesBuilder] is `null`, default Android handles are displayed.
Expand Down Expand Up @@ -1752,6 +1775,7 @@ class SuperEditorAndroidControlsOverlayManagerState extends State<SuperEditorAnd
link: _controlsController!.collapsedHandleFocalPoint,
leaderAnchor: Alignment.bottomCenter,
followerAnchor: Alignment.topCenter,
showWhenUnlinked: false,
// Use the offset to account for the invisible expanded touch region around the handle.
offset: -Offset(0, AndroidSelectionHandle.defaultTouchRegionExpansion.top) *
MediaQuery.devicePixelRatioOf(context),
Expand Down Expand Up @@ -1822,6 +1846,7 @@ class SuperEditorAndroidControlsOverlayManagerState extends State<SuperEditorAnd
link: _controlsController!.upstreamHandleFocalPoint,
leaderAnchor: Alignment.bottomLeft,
followerAnchor: Alignment.topRight,
showWhenUnlinked: false,
// Use the offset to account for the invisible expanded touch region around the handle.
offset:
-AndroidSelectionHandle.defaultTouchRegionExpansion.topRight * MediaQuery.devicePixelRatioOf(context),
Expand Down Expand Up @@ -1852,6 +1877,7 @@ class SuperEditorAndroidControlsOverlayManagerState extends State<SuperEditorAnd
link: _controlsController!.downstreamHandleFocalPoint,
leaderAnchor: Alignment.bottomRight,
followerAnchor: Alignment.topLeft,
showWhenUnlinked: false,
// Use the offset to account for the invisible expanded touch region around the handle.
offset:
-AndroidSelectionHandle.defaultTouchRegionExpansion.topLeft * MediaQuery.devicePixelRatioOf(context),
Expand Down
132 changes: 111 additions & 21 deletions super_editor/lib/src/default_editor/document_gestures_touch_ios.dart
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,25 @@ class SuperEditorIosControlsController {
/// Tells the caret to stop blinking by setting [shouldCaretBlink] to `false`.
void doNotBlinkCaret() => _shouldCaretBlink.value = false;

/// {@macro are_selection_handles_allowed}
ValueListenable<bool> get areSelectionHandlesAllowed => _areSelectionHandlesAllowed;
final _areSelectionHandlesAllowed = ValueNotifier<bool>(true);

/// Temporarily prevents any selection handles from being displayed.
///
/// Call this when you want to select some content, but don't want to show the drag handles.
/// [allowSelectionHandles] must be called to allow the drag handles to be displayed again.
void allowSelectionHandles() => _areSelectionHandlesAllowed.value = true;

/// Allows the selection handles to be displayed after they have been temporarily
/// prevented by [preventSelectionHandles].
void preventSelectionHandles() => _areSelectionHandlesAllowed.value = false;

/// Reports the [HandleType] of the handle being dragged by the user.
///
/// If no drag handle is being dragged, this value is `null`.
final ValueNotifier<HandleType?> handleBeingDragged = ValueNotifier<HandleType?>(null);

/// Controls the iOS floating cursor.
late final FloatingCursorController floatingCursorController;

Expand Down Expand Up @@ -580,14 +599,6 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
..hideMagnifier()
..blinkCaret();

final selection = widget.selection.value;
if (selection != null &&
!selection.isCollapsed &&
(_isOverBaseHandle(details.localPosition) || _isOverExtentHandle(details.localPosition))) {
_controlsController!.toggleToolbar();
return;
}

editorGesturesLog.info("Tap down on document");
final docOffset = _interactorOffsetToDocumentOffset(details.localPosition);
editorGesturesLog.fine(" - document offset: $docOffset");
Expand All @@ -609,6 +620,14 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
}
}

final selection = widget.selection.value;
if (selection != null &&
!selection.isCollapsed &&
(_isOverBaseHandle(details.localPosition) || _isOverExtentHandle(details.localPosition))) {
_controlsController!.toggleToolbar();
return;
}

final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset);
editorGesturesLog.fine(" - tapped document position: $docPosition");
if (docPosition != null &&
Expand Down Expand Up @@ -713,13 +732,6 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
}

void _onDoubleTapUp(TapUpDetails details) {
final selection = widget.selection.value;
if (selection != null &&
!selection.isCollapsed &&
(_isOverBaseHandle(details.localPosition) || _isOverExtentHandle(details.localPosition))) {
return;
}

editorGesturesLog.info("Double tap down on document");
final docOffset = _interactorOffsetToDocumentOffset(details.localPosition);
editorGesturesLog.fine(" - document offset: $docOffset");
Expand All @@ -741,6 +753,13 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
}
}

final selection = widget.selection.value;
if (selection != null &&
!selection.isCollapsed &&
(_isOverBaseHandle(details.localPosition) || _isOverExtentHandle(details.localPosition))) {
return;
}

final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset);
editorGesturesLog.fine(" - tapped document position: $docPosition");
if (docPosition != null) {
Expand Down Expand Up @@ -871,6 +890,24 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
_globalTapDownOffset = null;
_tapDownLongPressTimer?.cancel();

if (widget.contentTapHandlers != null) {
final docOffset = _interactorOffsetToDocumentOffset(details.localPosition);
for (final handler in widget.contentTapHandlers!) {
final result = handler.onPanStart(
DocumentTapDetails(
documentLayout: _docLayout,
layoutOffset: docOffset,
globalOffset: details.globalPosition,
),
);
if (result == TapHandlingInstruction.halt) {
// The custom tap handler doesn't want us to react at all
// to the tap.
return;
}
}
}

// TODO: to help the user drag handles instead of scrolling, try checking touch
// placement during onTapDown, and then pick that up here. I think the little
// bit of slop might be the problem.
Expand Down Expand Up @@ -957,6 +994,24 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
}

void _onPanUpdate(DragUpdateDetails details) {
if (widget.contentTapHandlers != null) {
final docOffset = _interactorOffsetToDocumentOffset(details.localPosition);
for (final handler in widget.contentTapHandlers!) {
final result = handler.onPanUpdate(
DocumentTapDetails(
documentLayout: _docLayout,
layoutOffset: docOffset,
globalOffset: details.globalPosition,
),
);
if (result == TapHandlingInstruction.halt) {
// The custom tap handler doesn't want us to react at all
// to the tap.
return;
}
}
}

_globalDragOffset = details.globalPosition;

_dragEndInInteractor = interactorBox.globalToLocal(details.globalPosition);
Expand Down Expand Up @@ -1003,6 +1058,7 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
const ClearComposingRegionRequest(),
]);
} else if (_dragHandleType == HandleType.upstream) {
_controlsController!.handleBeingDragged.value = HandleType.upstream;
widget.editor.execute([
ChangeSelectionRequest(
widget.selection.value!.copyWith(
Expand All @@ -1014,6 +1070,7 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
const ClearComposingRegionRequest(),
]);
} else if (_dragHandleType == HandleType.downstream) {
_controlsController!.handleBeingDragged.value = HandleType.downstream;
widget.editor.execute([
ChangeSelectionRequest(
widget.selection.value!.copyWith(
Expand All @@ -1028,9 +1085,28 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
}

void _onPanEnd(DragEndDetails details) {
if (widget.contentTapHandlers != null) {
final docOffset = _interactorOffsetToDocumentOffset(details.localPosition);
for (final handler in widget.contentTapHandlers!) {
final result = handler.onPanEnd(
DocumentTapDetails(
documentLayout: _docLayout,
layoutOffset: docOffset,
globalOffset: details.globalPosition,
),
);
if (result == TapHandlingInstruction.halt) {
// The custom tap handler doesn't want us to react at all
// to the tap.
return;
}
}
}

_controlsController!
..hideMagnifier()
..blinkCaret();
..blinkCaret()
..handleBeingDragged.value = null;

if (_dragMode != null) {
// The user was dragging a selection change in some way, either with handles
Expand All @@ -1040,9 +1116,21 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
}

void _onPanCancel() {
if (widget.contentTapHandlers != null) {
for (final handler in widget.contentTapHandlers!) {
final result = handler.onPanCancel();
if (result == TapHandlingInstruction.halt) {
// The custom tap handler doesn't want us to react at all
// to the tap.
return;
}
}
}

if (_dragMode != null) {
_onDragSelectionEnd();
}
_controlsController!.handleBeingDragged.value = null;
}

void _onDragSelectionEnd() {
Expand Down Expand Up @@ -1888,6 +1976,8 @@ class SuperEditorIosHandlesDocumentLayerBuilder implements SuperEditorLayerBuild
return const ContentLayerProxyWidget(child: SizedBox());
}

final controlsController = SuperEditorIosControlsScope.rootOf(context);

return IosHandlesDocumentLayer(
document: editContext.document,
documentLayout: editContext.documentLayout,
Expand All @@ -1898,13 +1988,13 @@ class SuperEditorIosHandlesDocumentLayerBuilder implements SuperEditorLayerBuild
const ClearComposingRegionRequest(),
]);
},
handleColor: handleColor ??
SuperEditorIosControlsScope.maybeRootOf(context)?.handleColor ??
Theme.of(context).primaryColor,
areSelectionHandlesAllowed: controlsController.areSelectionHandlesAllowed,
handleBeingDragged: controlsController.handleBeingDragged,
handleColor: handleColor ?? controlsController.handleColor ?? Theme.of(context).primaryColor,
caretWidth: caretWidth ?? 2,
handleBallDiameter: handleBallDiameter ?? defaultIosHandleBallDiameter,
shouldCaretBlink: SuperEditorIosControlsScope.rootOf(context).shouldCaretBlink,
floatingCursorController: SuperEditorIosControlsScope.rootOf(context).floatingCursorController,
shouldCaretBlink: controlsController.shouldCaretBlink,
floatingCursorController: controlsController.floatingCursorController,
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class SpellingAndGrammarStyler extends SingleColumnLayoutStylePhase {
SpellingAndGrammarStyler({
UnderlineStyle? spellingErrorUnderlineStyle,
UnderlineStyle? grammarErrorUnderlineStyle,
this.selectionHighlightColor,
}) : _spellingErrorUnderlineStyle = spellingErrorUnderlineStyle,
_grammarErrorUnderlineStyle = grammarErrorUnderlineStyle;

Expand All @@ -34,6 +35,35 @@ class SpellingAndGrammarStyler extends SingleColumnLayoutStylePhase {
markDirty();
}

/// Whether or not we should override the default selection color with [selectionHighlightColor].
///
/// On mobile platforms, when the suggestions popover is opened, the selected text uses a different
/// highlight color.
bool _overrideSelectionColor = false;

/// The color to use for the selection highlight [overrideSelectionColor] is called.
final Color? selectionHighlightColor;

/// Configure this styler to override the default selection color with [selectionHighlightColor].
///
/// The default editor selection styler phase configures a selection color for all selections.
/// Call this method to use [selectionHighlightColor] instead. This is useful to highlight a
/// selected misspelled word with a color that is different from the default selection color.
///
/// Call [useDefaultSelectionColor] to stop overriding the default selection color.
void overrideSelectionColor() {
_overrideSelectionColor = true;
markDirty();
}

/// Stop overriding the default selection color.
///
/// After calling this method, all selections will use the default selection color.
void useDefaultSelectionColor() {
_overrideSelectionColor = false;
markDirty();
}

final _errorsByNode = <String, Set<TextError>>{};
final _dirtyNodes = <String>{};

Expand Down Expand Up @@ -65,7 +95,7 @@ class SpellingAndGrammarStyler extends SingleColumnLayoutStylePhase {
padding: viewModel.padding,
componentViewModels: [
for (final previousViewModel in viewModel.componentViewModels) //
_applyErrors(previousViewModel),
_applyErrors(previousViewModel.copy()),
],
);

Expand Down Expand Up @@ -96,6 +126,10 @@ class SpellingAndGrammarStyler extends SingleColumnLayoutStylePhase {
for (final spellingError in spellingErrors) spellingError.range,
]);

if (_overrideSelectionColor && selectionHighlightColor != null) {
viewModel.selectionColor = selectionHighlightColor!;
}

final grammarErrors = _errorsByNode[viewModel.nodeId]!.where((error) => error.type == TextErrorType.grammar);
if (_grammarErrorUnderlineStyle != null) {
// The user explicitly requested this style be used for grammar errors.
Expand Down
Loading

0 comments on commit d9b89d2

Please sign in to comment.