feat: improve android selection gesture (#647)
* feat: improve android touch gesture * feat: long press to select a word on Android
This commit is contained in:
parent
7ec43c9eee
commit
07afd7a839
11 changed files with 756 additions and 402 deletions
|
|
@ -156,6 +156,7 @@ class _CustomizeThemeForEditorState extends State<CustomizeThemeForEditor> {
|
||||||
? const EdgeInsets.only(left: 200, right: 200)
|
? const EdgeInsets.only(left: 200, right: 200)
|
||||||
: const EdgeInsets.symmetric(horizontal: 20),
|
: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
cursorColor: Colors.green,
|
cursorColor: Colors.green,
|
||||||
|
dragHandleColor: Colors.green,
|
||||||
selectionColor: Colors.green.withOpacity(0.5),
|
selectionColor: Colors.green.withOpacity(0.5),
|
||||||
textStyleConfiguration: TextStyleConfiguration(
|
textStyleConfiguration: TextStyleConfiguration(
|
||||||
text: GoogleFonts.poppins(
|
text: GoogleFonts.poppins(
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ class _MobileEditorState extends State<MobileEditor> {
|
||||||
EditorStyle _buildMobileEditorStyle() {
|
EditorStyle _buildMobileEditorStyle() {
|
||||||
return EditorStyle.mobile(
|
return EditorStyle.mobile(
|
||||||
cursorColor: const Color.fromARGB(255, 134, 46, 247),
|
cursorColor: const Color.fromARGB(255, 134, 46, 247),
|
||||||
|
dragHandleColor: const Color.fromARGB(255, 134, 46, 247),
|
||||||
selectionColor: const Color.fromARGB(50, 134, 46, 247),
|
selectionColor: const Color.fromARGB(50, 134, 46, 247),
|
||||||
textStyleConfiguration: TextStyleConfiguration(
|
textStyleConfiguration: TextStyleConfiguration(
|
||||||
text: GoogleFonts.poppins(
|
text: GoogleFonts.poppins(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_selection_service.dart';
|
||||||
import 'package:appflowy_editor/src/flutter/overlay.dart';
|
import 'package:appflowy_editor/src/flutter/overlay.dart';
|
||||||
import 'package:appflowy_editor/src/service/selection/selection_gesture.dart';
|
import 'package:appflowy_editor/src/service/selection/selection_gesture.dart';
|
||||||
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
|
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
|
||||||
|
|
@ -191,6 +192,30 @@ class _DesktopSelectionServiceWidgetState
|
||||||
return selectable.getPositionInOffset(offset);
|
return selectable.getPositionInOffset(offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Selection? onPanStart(
|
||||||
|
DragStartDetails details,
|
||||||
|
MobileSelectionDragMode mode,
|
||||||
|
) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Selection? onPanUpdate(
|
||||||
|
DragUpdateDetails details,
|
||||||
|
MobileSelectionDragMode mode,
|
||||||
|
) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onPanEnd(
|
||||||
|
DragEndDetails details,
|
||||||
|
MobileSelectionDragMode mode,
|
||||||
|
) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
void _onTapDown(TapDownDetails details) {
|
void _onTapDown(TapDownDetails details) {
|
||||||
_clearContextMenu();
|
_clearContextMenu();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_magnifier.dart';
|
import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_magnifier.dart';
|
||||||
import 'package:appflowy_editor/src/flutter/overlay.dart';
|
import 'package:appflowy_editor/src/render/selection/mobile_selection_handle.dart';
|
||||||
import 'package:appflowy_editor/src/render/selection/mobile_selection_widget.dart';
|
|
||||||
import 'package:appflowy_editor/src/service/selection/mobile_selection_gesture.dart';
|
import 'package:appflowy_editor/src/service/selection/mobile_selection_gesture.dart';
|
||||||
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
|
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
/// only used in mobile
|
/// only used in mobile
|
||||||
|
|
@ -62,8 +63,6 @@ class _MobileSelectionServiceWidgetState
|
||||||
implements AppFlowySelectionService {
|
implements AppFlowySelectionService {
|
||||||
@override
|
@override
|
||||||
final List<Rect> selectionRects = [];
|
final List<Rect> selectionRects = [];
|
||||||
final List<OverlayEntry> _selectionAreas = [];
|
|
||||||
final List<OverlayEntry> _cursorAreas = [];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ValueNotifier<Selection?> currentSelection = ValueNotifier(null);
|
ValueNotifier<Selection?> currentSelection = ValueNotifier(null);
|
||||||
|
|
@ -81,6 +80,8 @@ class _MobileSelectionServiceWidgetState
|
||||||
|
|
||||||
MobileSelectionDragMode dragMode = MobileSelectionDragMode.none;
|
MobileSelectionDragMode dragMode = MobileSelectionDragMode.none;
|
||||||
|
|
||||||
|
bool updateSelectionByTapUp = false;
|
||||||
|
|
||||||
late EditorState editorState = Provider.of<EditorState>(
|
late EditorState editorState = Provider.of<EditorState>(
|
||||||
context,
|
context,
|
||||||
listen: false,
|
listen: false,
|
||||||
|
|
@ -106,19 +107,23 @@ class _MobileSelectionServiceWidgetState
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MobileSelectionGestureDetector(
|
return MobileSelectionGestureDetector(
|
||||||
onPanStart: _onPanStart,
|
|
||||||
onPanUpdate: _onPanUpdate,
|
|
||||||
onPanEnd: _onPanEnd,
|
|
||||||
onTapUp: _onTapUp,
|
onTapUp: _onTapUp,
|
||||||
onDoubleTapUp: _onDoubleTapUp,
|
onDoubleTapUp: _onDoubleTapUp,
|
||||||
onTripleTapUp: _onTripleTapUp,
|
onTripleTapUp: _onTripleTapUp,
|
||||||
onLongPressStart: _onLongPressStart,
|
onLongPressStart: _onLongPressStart,
|
||||||
onLongPressMoveUpdate: _onLongPressMoveUpdate,
|
// onLongPressMoveUpdate: _onLongPressMoveUpdate,
|
||||||
onLongPressEnd: _onLongPressEnd,
|
// onLongPressEnd: _onLongPressEnd,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
widget.child,
|
widget.child,
|
||||||
|
|
||||||
|
// magnifier for zoom in the text.
|
||||||
if (widget.showMagnifier) _buildMagnifier(),
|
if (widget.showMagnifier) _buildMagnifier(),
|
||||||
|
|
||||||
|
// the handles for expanding the selection area.
|
||||||
|
_buildLeftHandle(),
|
||||||
|
_buildRightHandle(),
|
||||||
|
_buildCollapsedHandle(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -141,6 +146,100 @@ class _MobileSelectionServiceWidgetState
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCollapsedHandle() {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueListenableBuilder(
|
||||||
|
valueListenable: editorState.selectionNotifier,
|
||||||
|
builder: (context, selection, _) {
|
||||||
|
if (selection == null ||
|
||||||
|
!selection.isCollapsed ||
|
||||||
|
editorState.selectionUpdateReason !=
|
||||||
|
SelectionUpdateReason.uiEvent) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
selection = selection.normalized;
|
||||||
|
|
||||||
|
final node = editorState.getNodeAtPath(selection.start.path);
|
||||||
|
final selectable = node?.selectable;
|
||||||
|
var rect = selectable?.getCursorRectInPosition(
|
||||||
|
selection.start,
|
||||||
|
shiftWithBaseOffset: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (node == null || rect == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final editorStyle = editorState.editorStyle;
|
||||||
|
return AndroidCollapsedHandle(
|
||||||
|
layerLink: node.layerLink,
|
||||||
|
rect: rect,
|
||||||
|
handleColor: editorStyle.dragHandleColor,
|
||||||
|
handleWidth: editorStyle.mobileDragHandleWidth,
|
||||||
|
handleBallWidth: editorStyle.mobileDragHandleBallSize.width,
|
||||||
|
enableHapticFeedbackOnAndroid:
|
||||||
|
editorStyle.enableHapticFeedbackOnAndroid,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLeftHandle() {
|
||||||
|
return _buildHandle(HandleType.left);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRightHandle() {
|
||||||
|
return _buildHandle(HandleType.right);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHandle(HandleType handleType) {
|
||||||
|
if (![HandleType.left, HandleType.right].contains(handleType)) {
|
||||||
|
throw ArgumentError('showLeftHandle and showRightHandle cannot be same.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueListenableBuilder(
|
||||||
|
valueListenable: editorState.selectionNotifier,
|
||||||
|
builder: (context, selection, _) {
|
||||||
|
if (selection == null || selection.isCollapsed) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
selection = selection.normalized;
|
||||||
|
|
||||||
|
final node = editorState.getNodeAtPath(
|
||||||
|
handleType == HandleType.left
|
||||||
|
? selection.start.path
|
||||||
|
: selection.end.path,
|
||||||
|
);
|
||||||
|
final selectable = node?.selectable;
|
||||||
|
final rects = selectable?.getRectsInSelection(
|
||||||
|
selection,
|
||||||
|
shiftWithBaseOffset: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (node == null || rects == null || rects.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final editorStyle = editorState.editorStyle;
|
||||||
|
return MobileSelectionArea(
|
||||||
|
layerLink: node.layerLink,
|
||||||
|
rect: handleType == HandleType.left ? rects.first : rects.last,
|
||||||
|
handleType: handleType,
|
||||||
|
handleColor: editorStyle.dragHandleColor,
|
||||||
|
handleWidth: editorStyle.mobileDragHandleWidth,
|
||||||
|
handleBallWidth: editorStyle.mobileDragHandleBallSize.width,
|
||||||
|
enableHapticFeedbackOnAndroid:
|
||||||
|
editorStyle.enableHapticFeedbackOnAndroid,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void updateSelection(Selection? selection) {
|
void updateSelection(Selection? selection) {
|
||||||
if (currentSelection.value == selection) {
|
if (currentSelection.value == selection) {
|
||||||
|
|
@ -175,23 +274,13 @@ class _MobileSelectionServiceWidgetState
|
||||||
_clearSelection();
|
_clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearSelection() {
|
|
||||||
clearCursor();
|
|
||||||
// clear selection areas
|
|
||||||
_selectionAreas
|
|
||||||
..forEach((overlay) => overlay.remove())
|
|
||||||
..clear();
|
|
||||||
// clear cursor areas
|
|
||||||
_selectionAreas.clear();
|
|
||||||
selectionRects.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void clearCursor() {
|
void clearCursor() {
|
||||||
// clear cursor areas
|
_clearSelection();
|
||||||
_cursorAreas
|
}
|
||||||
..forEach((overlay) => overlay.remove())
|
|
||||||
..clear();
|
void _clearSelection() {
|
||||||
|
selectionRects.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -290,6 +379,7 @@ class _MobileSelectionServiceWidgetState
|
||||||
|
|
||||||
editorState.updateSelectionWithReason(
|
editorState.updateSelectionWithReason(
|
||||||
Selection.collapsed(position),
|
Selection.collapsed(position),
|
||||||
|
reason: SelectionUpdateReason.uiEvent,
|
||||||
extraInfo: null,
|
extraInfo: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -311,10 +401,6 @@ class _MobileSelectionServiceWidgetState
|
||||||
|
|
||||||
void _onTripleTapUp(TapUpDetails details) {
|
void _onTripleTapUp(TapUpDetails details) {
|
||||||
final offset = details.globalPosition;
|
final offset = details.globalPosition;
|
||||||
// if (_isClickOnSelectionArea(offset)) {
|
|
||||||
// appFlowyEditorOnTapSelectionArea.add(0);
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
final node = getNodeInOffset(offset);
|
final node = getNodeInOffset(offset);
|
||||||
final selectable = node?.selectable;
|
final selectable = node?.selectable;
|
||||||
if (selectable == null) {
|
if (selectable == null) {
|
||||||
|
|
@ -328,45 +414,35 @@ class _MobileSelectionServiceWidgetState
|
||||||
updateSelection(selection);
|
updateSelection(selection);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPanStart(DragStartDetails details) {
|
@override
|
||||||
|
Selection? onPanStart(
|
||||||
|
DragStartDetails details,
|
||||||
|
MobileSelectionDragMode mode,
|
||||||
|
) {
|
||||||
_panStartOffset = details.globalPosition.translate(-3.0, 0);
|
_panStartOffset = details.globalPosition.translate(-3.0, 0);
|
||||||
_panStartScrollDy = editorState.service.scrollService?.dy;
|
_panStartScrollDy = editorState.service.scrollService?.dy;
|
||||||
|
|
||||||
final position = details.globalPosition;
|
|
||||||
final selection = editorState.selection;
|
final selection = editorState.selection;
|
||||||
_panStartSelection = selection;
|
_panStartSelection = selection;
|
||||||
if (selection == null) {
|
|
||||||
dragMode = MobileSelectionDragMode.none;
|
dragMode = mode;
|
||||||
} else if (selection.isCollapsed &&
|
|
||||||
_isOverlayOnHandler(
|
return selection;
|
||||||
position,
|
|
||||||
MobileSelectionHandlerType.cursorHandler,
|
|
||||||
)) {
|
|
||||||
dragMode = MobileSelectionDragMode.cursor;
|
|
||||||
} else if (_isOverlayOnHandler(
|
|
||||||
position,
|
|
||||||
MobileSelectionHandlerType.leftHandler,
|
|
||||||
)) {
|
|
||||||
dragMode = MobileSelectionDragMode.leftSelectionHandler;
|
|
||||||
} else if (_isOverlayOnHandler(
|
|
||||||
position,
|
|
||||||
MobileSelectionHandlerType.rightHandler,
|
|
||||||
)) {
|
|
||||||
dragMode = MobileSelectionDragMode.rightSelectionHandler;
|
|
||||||
} else {
|
|
||||||
dragMode = MobileSelectionDragMode.none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPanUpdate(DragUpdateDetails details) {
|
@override
|
||||||
|
Selection? onPanUpdate(
|
||||||
|
DragUpdateDetails details,
|
||||||
|
MobileSelectionDragMode mode,
|
||||||
|
) {
|
||||||
if (_panStartOffset == null || _panStartScrollDy == null) {
|
if (_panStartOffset == null || _panStartScrollDy == null) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// only support selection mode now.
|
// only support selection mode now.
|
||||||
final selection = editorState.selection;
|
final selection = editorState.selection;
|
||||||
if (selection == null || dragMode == MobileSelectionDragMode.none) {
|
if (selection == null || dragMode == MobileSelectionDragMode.none) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final panEndOffset = details.globalPosition;
|
final panEndOffset = details.globalPosition;
|
||||||
|
|
@ -380,32 +456,34 @@ class _MobileSelectionServiceWidgetState
|
||||||
?.getSelectionInRange(panStartOffset, panEndOffset)
|
?.getSelectionInRange(panStartOffset, panEndOffset)
|
||||||
.end;
|
.end;
|
||||||
|
|
||||||
|
Selection? newSelection;
|
||||||
|
|
||||||
if (end != null) {
|
if (end != null) {
|
||||||
if (dragMode == MobileSelectionDragMode.leftSelectionHandler) {
|
if (dragMode == MobileSelectionDragMode.leftSelectionHandler) {
|
||||||
updateSelection(
|
newSelection = Selection(
|
||||||
Selection(
|
start: _panStartSelection!.normalized.end,
|
||||||
start: _panStartSelection!.normalized.end,
|
end: end,
|
||||||
end: end,
|
).normalized;
|
||||||
).normalized,
|
|
||||||
);
|
|
||||||
} else if (dragMode == MobileSelectionDragMode.rightSelectionHandler) {
|
} else if (dragMode == MobileSelectionDragMode.rightSelectionHandler) {
|
||||||
updateSelection(
|
newSelection = Selection(
|
||||||
Selection(
|
start: _panStartSelection!.normalized.start,
|
||||||
start: _panStartSelection!.normalized.start,
|
end: end,
|
||||||
end: end,
|
).normalized;
|
||||||
).normalized,
|
|
||||||
);
|
|
||||||
} else if (dragMode == MobileSelectionDragMode.cursor) {
|
} else if (dragMode == MobileSelectionDragMode.cursor) {
|
||||||
updateSelection(
|
newSelection = Selection.collapsed(end);
|
||||||
Selection.collapsed(end),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastPanOffset.value = panEndOffset;
|
_lastPanOffset.value = panEndOffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newSelection != null) {
|
||||||
|
updateSelection(newSelection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPanEnd(DragEndDetails details) {
|
@override
|
||||||
|
void onPanEnd(DragEndDetails details, MobileSelectionDragMode mode) {
|
||||||
// do nothing
|
// do nothing
|
||||||
_lastPanOffset.value = null;
|
_lastPanOffset.value = null;
|
||||||
|
|
||||||
|
|
@ -413,55 +491,37 @@ class _MobileSelectionServiceWidgetState
|
||||||
dragMode = MobileSelectionDragMode.none;
|
dragMode = MobileSelectionDragMode.none;
|
||||||
editorState.updateSelectionWithReason(
|
editorState.updateSelectionWithReason(
|
||||||
editorState.selection,
|
editorState.selection,
|
||||||
|
reason: SelectionUpdateReason.uiEvent,
|
||||||
extraInfo: null,
|
extraInfo: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onLongPressStart(LongPressStartDetails details) {
|
void _onLongPressStart(LongPressStartDetails details) {
|
||||||
clearSelection();
|
if (!Platform.isAndroid) {
|
||||||
|
|
||||||
// clear old state.
|
|
||||||
_panStartOffset = null;
|
|
||||||
|
|
||||||
final position = getPositionInOffset(details.globalPosition);
|
|
||||||
if (position == null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastPanOffset.value = details.globalPosition;
|
// on Android, long press to select a word.
|
||||||
|
final offset = details.globalPosition;
|
||||||
dragMode = MobileSelectionDragMode.cursor;
|
if (_isClickOnSelectionArea(offset)) {
|
||||||
updateSelection(
|
appFlowyEditorOnTapSelectionArea.add(0);
|
||||||
Selection.collapsed(position),
|
return;
|
||||||
);
|
}
|
||||||
}
|
final node = getNodeInOffset(offset);
|
||||||
|
final selection = node?.selectable?.getWordBoundaryInOffset(offset);
|
||||||
void _onLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
|
if (selection == null) {
|
||||||
final panEndOffset = details.globalPosition;
|
clearSelection();
|
||||||
final position = getNodeInOffset(panEndOffset)
|
|
||||||
?.selectable
|
|
||||||
?.getPositionInOffset(panEndOffset);
|
|
||||||
|
|
||||||
if (position == null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastPanOffset.value = panEndOffset;
|
if (editorState.editorStyle.enableHapticFeedbackOnAndroid) {
|
||||||
updateSelection(
|
HapticFeedback.mediumImpact();
|
||||||
Selection.collapsed(position),
|
}
|
||||||
);
|
|
||||||
}
|
updateSelection(selection);
|
||||||
|
|
||||||
void _onLongPressEnd(LongPressEndDetails details) {
|
|
||||||
_lastPanOffset.value = null;
|
|
||||||
|
|
||||||
dragMode = MobileSelectionDragMode.none;
|
|
||||||
editorState.updateSelectionWithReason(
|
|
||||||
editorState.selection,
|
|
||||||
extraInfo: null,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delete this function in the future.
|
||||||
void _updateSelectionAreas(Selection selection) {
|
void _updateSelectionAreas(Selection selection) {
|
||||||
final nodes = editorState.getNodesInSelection(selection);
|
final nodes = editorState.getNodesInSelection(selection);
|
||||||
|
|
||||||
|
|
@ -509,39 +569,14 @@ class _MobileSelectionServiceWidgetState
|
||||||
newSelection,
|
newSelection,
|
||||||
shiftWithBaseOffset: true,
|
shiftWithBaseOffset: true,
|
||||||
);
|
);
|
||||||
for (var (j, rect) in rects.indexed) {
|
for (final rect in rects) {
|
||||||
final selectionRect = selectable.transformRectToGlobal(
|
final selectionRect = selectable.transformRectToGlobal(
|
||||||
rect,
|
rect,
|
||||||
shiftWithBaseOffset: true,
|
shiftWithBaseOffset: true,
|
||||||
);
|
);
|
||||||
selectionRects.add(selectionRect);
|
selectionRects.add(selectionRect);
|
||||||
final showLeftHandler = i == 0 && j == 0;
|
|
||||||
final showRightHandler =
|
|
||||||
i == backwardNodes.length - 1 && j == rects.length - 1;
|
|
||||||
if (rect.width <= 0) {
|
|
||||||
rect = Rect.fromLTWH(rect.left, rect.top, 8.0, rect.height);
|
|
||||||
}
|
|
||||||
final overlay = OverlayEntry(
|
|
||||||
builder: (context) => MobileSelectionWidget(
|
|
||||||
color: Colors.transparent,
|
|
||||||
layerLink: node.layerLink,
|
|
||||||
rect: rect,
|
|
||||||
showLeftHandler: showLeftHandler,
|
|
||||||
showRightHandler: showRightHandler,
|
|
||||||
handlerColor: editorState.editorStyle.cursorColor,
|
|
||||||
handlerWidth: editorState.editorStyle.mobileDragHandleWidth,
|
|
||||||
handlerBallWidth:
|
|
||||||
editorState.editorStyle.mobileDragHandleBallSize.width,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_selectionAreas.add(overlay);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final overlay = Overlay.of(context);
|
|
||||||
overlay?.insertAll(
|
|
||||||
_selectionAreas,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Node? _getNodeInOffset(
|
Node? _getNodeInOffset(
|
||||||
|
|
@ -578,60 +613,6 @@ class _MobileSelectionServiceWidgetState
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isOverlayOnHandler(Offset point, MobileSelectionHandlerType type) {
|
|
||||||
final selection = editorState.selection;
|
|
||||||
if (selection == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
SelectableMixin? selectable;
|
|
||||||
Rect? rect;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case MobileSelectionHandlerType.leftHandler:
|
|
||||||
case MobileSelectionHandlerType.cursorHandler:
|
|
||||||
selectable =
|
|
||||||
editorState.getNodeAtPath(selection.start.path)?.selectable;
|
|
||||||
if (selectable == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
rect = selectable.getCursorRectInPosition(
|
|
||||||
selection.start,
|
|
||||||
shiftWithBaseOffset: true,
|
|
||||||
);
|
|
||||||
if (rect == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case MobileSelectionHandlerType.rightHandler:
|
|
||||||
selectable = editorState.getNodeAtPath(selection.end.path)?.selectable;
|
|
||||||
if (selectable == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
rect = selectable.getCursorRectInPosition(
|
|
||||||
selection.end,
|
|
||||||
shiftWithBaseOffset: true,
|
|
||||||
);
|
|
||||||
if (rect == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const extend = 20.0;
|
|
||||||
final handlerRect = selectable.transformRectToGlobal(
|
|
||||||
Rect.fromLTWH(
|
|
||||||
rect.left - extend,
|
|
||||||
rect.top - extend,
|
|
||||||
extend * 2,
|
|
||||||
rect.height + 2 * extend,
|
|
||||||
),
|
|
||||||
shiftWithBaseOffset: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
return handlerRect.contains(point);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isClickOnSelectionArea(Offset point) {
|
bool _isClickOnSelectionArea(Offset point) {
|
||||||
for (final rect in selectionRects) {
|
for (final rect in selectionRects) {
|
||||||
if (rect.contains(point)) {
|
if (rect.contains(point)) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:appflowy_editor/src/core/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
import 'package:appflowy_editor/src/core/location/position.dart';
|
import 'package:appflowy_editor/src/core/location/position.dart';
|
||||||
import 'package:appflowy_editor/src/core/location/selection.dart';
|
import 'package:appflowy_editor/src/core/location/selection.dart';
|
||||||
|
import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_selection_service.dart';
|
||||||
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
|
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
|
||||||
|
|
||||||
/// [AppFlowySelectionService] is responsible for processing
|
/// [AppFlowySelectionService] is responsible for processing
|
||||||
|
|
@ -66,6 +67,20 @@ abstract class AppFlowySelectionService {
|
||||||
|
|
||||||
void registerGestureInterceptor(SelectionGestureInterceptor interceptor);
|
void registerGestureInterceptor(SelectionGestureInterceptor interceptor);
|
||||||
void unregisterGestureInterceptor(String key);
|
void unregisterGestureInterceptor(String key);
|
||||||
|
|
||||||
|
/// The functions below are only for mobile.
|
||||||
|
Selection? onPanStart(
|
||||||
|
DragStartDetails details,
|
||||||
|
MobileSelectionDragMode mode,
|
||||||
|
);
|
||||||
|
Selection? onPanUpdate(
|
||||||
|
DragUpdateDetails details,
|
||||||
|
MobileSelectionDragMode mode,
|
||||||
|
);
|
||||||
|
void onPanEnd(
|
||||||
|
DragEndDetails details,
|
||||||
|
MobileSelectionDragMode mode,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SelectionGestureInterceptor {
|
class SelectionGestureInterceptor {
|
||||||
|
|
|
||||||
|
|
@ -93,4 +93,25 @@ class _SelectionServiceWidgetState extends State<SelectionServiceWidget>
|
||||||
@override
|
@override
|
||||||
void updateSelection(Selection? selection) =>
|
void updateSelection(Selection? selection) =>
|
||||||
forward.updateSelection(selection);
|
forward.updateSelection(selection);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Selection? onPanStart(
|
||||||
|
DragStartDetails details,
|
||||||
|
MobileSelectionDragMode mode,
|
||||||
|
) =>
|
||||||
|
forward.onPanStart(details, mode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Selection? onPanUpdate(
|
||||||
|
DragUpdateDetails details,
|
||||||
|
MobileSelectionDragMode mode,
|
||||||
|
) =>
|
||||||
|
forward.onPanUpdate(details, mode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onPanEnd(
|
||||||
|
DragEndDetails details,
|
||||||
|
MobileSelectionDragMode mode,
|
||||||
|
) =>
|
||||||
|
forward.onPanEnd(details, mode);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ class EditorStyle {
|
||||||
const EditorStyle({
|
const EditorStyle({
|
||||||
required this.padding,
|
required this.padding,
|
||||||
required this.cursorColor,
|
required this.cursorColor,
|
||||||
|
required this.dragHandleColor,
|
||||||
required this.selectionColor,
|
required this.selectionColor,
|
||||||
required this.textStyleConfiguration,
|
required this.textStyleConfiguration,
|
||||||
required this.textSpanDecorator,
|
required this.textSpanDecorator,
|
||||||
|
|
@ -17,44 +18,54 @@ class EditorStyle {
|
||||||
this.mobileDragHandleBallSize = const Size(8, 8),
|
this.mobileDragHandleBallSize = const Size(8, 8),
|
||||||
this.mobileDragHandleWidth = 2.0,
|
this.mobileDragHandleWidth = 2.0,
|
||||||
this.defaultTextDirection,
|
this.defaultTextDirection,
|
||||||
|
this.enableHapticFeedbackOnAndroid = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The padding of the editor.
|
// The padding of the editor.
|
||||||
final EdgeInsets padding;
|
final EdgeInsets padding;
|
||||||
|
|
||||||
/// The cursor color
|
// The cursor color
|
||||||
final Color cursorColor;
|
final Color cursorColor;
|
||||||
|
|
||||||
/// The selection color
|
// The drag handle color
|
||||||
|
// only works on mobile
|
||||||
|
// the drag handle color will be ignored on Android.
|
||||||
|
final Color dragHandleColor;
|
||||||
|
|
||||||
|
// The selection color
|
||||||
final Color selectionColor;
|
final Color selectionColor;
|
||||||
|
|
||||||
/// Customize the text style of the editor.
|
// Customize the text style of the editor.
|
||||||
///
|
//
|
||||||
/// All the text-based components will use this configuration to build their
|
// All the text-based components will use this configuration to build their
|
||||||
/// text style.
|
// text style.
|
||||||
///
|
//
|
||||||
/// Notes, this configuration is only for the common config of text style and
|
// Notes, this configuration is only for the common config of text style and
|
||||||
/// it maybe override if the text block has its own [BlockComponentConfiguration].
|
// it maybe override if the text block has its own [BlockComponentConfiguration].
|
||||||
final TextStyleConfiguration textStyleConfiguration;
|
final TextStyleConfiguration textStyleConfiguration;
|
||||||
|
|
||||||
/// Customize the built-in or custom text span.
|
// Customize the built-in or custom text span.
|
||||||
///
|
//
|
||||||
/// For example, you can add a custom text span for the mention text
|
// For example, you can add a custom text span for the mention text
|
||||||
/// or override the built-in text span.
|
// or override the built-in text span.
|
||||||
final TextSpanDecoratorForAttribute? textSpanDecorator;
|
final TextSpanDecoratorForAttribute? textSpanDecorator;
|
||||||
|
|
||||||
final String? defaultTextDirection;
|
final String? defaultTextDirection;
|
||||||
|
|
||||||
/// The size of the magnifier.
|
// The size of the magnifier.
|
||||||
/// Only works on mobile.
|
// Only works on mobile.
|
||||||
final Size magnifierSize;
|
final Size magnifierSize;
|
||||||
|
|
||||||
/// mobile drag handler size.
|
// mobile drag handler size.
|
||||||
/// /// Only works on mobile.
|
// Only works on mobile.
|
||||||
final Size mobileDragHandleBallSize;
|
final Size mobileDragHandleBallSize;
|
||||||
|
|
||||||
final double mobileDragHandleWidth;
|
final double mobileDragHandleWidth;
|
||||||
|
|
||||||
|
// only works on android
|
||||||
|
// enable haptic feedback when updating selection by dragging the drag handler
|
||||||
|
final bool enableHapticFeedbackOnAndroid;
|
||||||
|
|
||||||
const EditorStyle.desktop({
|
const EditorStyle.desktop({
|
||||||
EdgeInsets? padding,
|
EdgeInsets? padding,
|
||||||
Color? cursorColor,
|
Color? cursorColor,
|
||||||
|
|
@ -74,11 +85,14 @@ class EditorStyle {
|
||||||
textSpanDecorator ?? defaultTextSpanDecoratorForAttribute,
|
textSpanDecorator ?? defaultTextSpanDecoratorForAttribute,
|
||||||
magnifierSize = Size.zero,
|
magnifierSize = Size.zero,
|
||||||
mobileDragHandleBallSize = Size.zero,
|
mobileDragHandleBallSize = Size.zero,
|
||||||
mobileDragHandleWidth = 0.0;
|
mobileDragHandleWidth = 0.0,
|
||||||
|
enableHapticFeedbackOnAndroid = false,
|
||||||
|
dragHandleColor = Colors.transparent;
|
||||||
|
|
||||||
const EditorStyle.mobile({
|
const EditorStyle.mobile({
|
||||||
EdgeInsets? padding,
|
EdgeInsets? padding,
|
||||||
Color? cursorColor,
|
Color? cursorColor,
|
||||||
|
Color? dragHandleColor,
|
||||||
Color? selectionColor,
|
Color? selectionColor,
|
||||||
TextStyleConfiguration? textStyleConfiguration,
|
TextStyleConfiguration? textStyleConfiguration,
|
||||||
TextSpanDecoratorForAttribute? textSpanDecorator,
|
TextSpanDecoratorForAttribute? textSpanDecorator,
|
||||||
|
|
@ -86,8 +100,10 @@ class EditorStyle {
|
||||||
this.magnifierSize = const Size(72, 48),
|
this.magnifierSize = const Size(72, 48),
|
||||||
this.mobileDragHandleBallSize = const Size(8, 8),
|
this.mobileDragHandleBallSize = const Size(8, 8),
|
||||||
this.mobileDragHandleWidth = 2.0,
|
this.mobileDragHandleWidth = 2.0,
|
||||||
|
this.enableHapticFeedbackOnAndroid = true,
|
||||||
}) : padding = padding ?? const EdgeInsets.symmetric(horizontal: 20),
|
}) : padding = padding ?? const EdgeInsets.symmetric(horizontal: 20),
|
||||||
cursorColor = cursorColor ?? const Color(0xFF00BCF0),
|
cursorColor = cursorColor ?? const Color(0xFF00BCF0),
|
||||||
|
dragHandleColor = dragHandleColor ?? const Color(0xFF00BCF0),
|
||||||
selectionColor =
|
selectionColor =
|
||||||
selectionColor ?? const Color.fromARGB(53, 111, 201, 231),
|
selectionColor ?? const Color.fromARGB(53, 111, 201, 231),
|
||||||
textStyleConfiguration = textStyleConfiguration ??
|
textStyleConfiguration = textStyleConfiguration ??
|
||||||
|
|
@ -100,19 +116,32 @@ class EditorStyle {
|
||||||
EditorStyle copyWith({
|
EditorStyle copyWith({
|
||||||
EdgeInsets? padding,
|
EdgeInsets? padding,
|
||||||
Color? cursorColor,
|
Color? cursorColor,
|
||||||
|
Color? dragHandleColor,
|
||||||
Color? selectionColor,
|
Color? selectionColor,
|
||||||
TextStyleConfiguration? textStyleConfiguration,
|
TextStyleConfiguration? textStyleConfiguration,
|
||||||
TextSpanDecoratorForAttribute? textSpanDecorator,
|
TextSpanDecoratorForAttribute? textSpanDecorator,
|
||||||
String? defaultTextDirection,
|
String? defaultTextDirection,
|
||||||
|
Size? magnifierSize,
|
||||||
|
Size? mobileDragHandleBallSize,
|
||||||
|
double? mobileDragHandleWidth,
|
||||||
|
bool? enableHapticFeedbackOnAndroid,
|
||||||
}) {
|
}) {
|
||||||
return EditorStyle(
|
return EditorStyle(
|
||||||
padding: padding ?? this.padding,
|
padding: padding ?? this.padding,
|
||||||
cursorColor: cursorColor ?? this.cursorColor,
|
cursorColor: cursorColor ?? this.cursorColor,
|
||||||
|
dragHandleColor: dragHandleColor ?? this.dragHandleColor,
|
||||||
selectionColor: selectionColor ?? this.selectionColor,
|
selectionColor: selectionColor ?? this.selectionColor,
|
||||||
textStyleConfiguration:
|
textStyleConfiguration:
|
||||||
textStyleConfiguration ?? this.textStyleConfiguration,
|
textStyleConfiguration ?? this.textStyleConfiguration,
|
||||||
textSpanDecorator: textSpanDecorator ?? this.textSpanDecorator,
|
textSpanDecorator: textSpanDecorator ?? this.textSpanDecorator,
|
||||||
defaultTextDirection: defaultTextDirection,
|
defaultTextDirection: defaultTextDirection,
|
||||||
|
magnifierSize: magnifierSize ?? this.magnifierSize,
|
||||||
|
mobileDragHandleBallSize:
|
||||||
|
mobileDragHandleBallSize ?? this.mobileDragHandleBallSize,
|
||||||
|
mobileDragHandleWidth:
|
||||||
|
mobileDragHandleWidth ?? this.mobileDragHandleWidth,
|
||||||
|
enableHapticFeedbackOnAndroid:
|
||||||
|
enableHapticFeedbackOnAndroid ?? this.enableHapticFeedbackOnAndroid,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -300,10 +300,8 @@ class EditorState {
|
||||||
selectionExtraInfo = transaction.selectionExtraInfo;
|
selectionExtraInfo = transaction.selectionExtraInfo;
|
||||||
}
|
}
|
||||||
selection = transaction.afterSelection;
|
selection = transaction.afterSelection;
|
||||||
_selectionUpdateReason = SelectionUpdateReason.uiEvent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: execute this line after the UI has been updated.
|
|
||||||
completer.complete();
|
completer.complete();
|
||||||
|
|
||||||
return completer.future;
|
return completer.future;
|
||||||
|
|
|
||||||
475
lib/src/render/selection/mobile_selection_handle.dart
Normal file
475
lib/src/render/selection/mobile_selection_handle.dart
Normal file
|
|
@ -0,0 +1,475 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_selection_service.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
GlobalKey _mobileLeftHandleKey = GlobalKey();
|
||||||
|
GlobalKey _mobileRightHandleKey = GlobalKey();
|
||||||
|
GlobalKey _mobileCollapsedHandleKey = GlobalKey();
|
||||||
|
|
||||||
|
enum HandleType {
|
||||||
|
none,
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
collapsed;
|
||||||
|
|
||||||
|
GlobalKey get key {
|
||||||
|
switch (this) {
|
||||||
|
case HandleType.none:
|
||||||
|
throw UnsupportedError('Unsupported handle type');
|
||||||
|
case HandleType.left:
|
||||||
|
return _mobileLeftHandleKey;
|
||||||
|
case HandleType.right:
|
||||||
|
return _mobileRightHandleKey;
|
||||||
|
case HandleType.collapsed:
|
||||||
|
return _mobileCollapsedHandleKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MobileSelectionDragMode get dragMode {
|
||||||
|
switch (this) {
|
||||||
|
case HandleType.none:
|
||||||
|
throw UnsupportedError('Unsupported handle type');
|
||||||
|
case HandleType.left:
|
||||||
|
return MobileSelectionDragMode.leftSelectionHandler;
|
||||||
|
case HandleType.right:
|
||||||
|
return MobileSelectionDragMode.rightSelectionHandler;
|
||||||
|
case HandleType.collapsed:
|
||||||
|
return MobileSelectionDragMode.cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CrossAxisAlignment get crossAxisAlignment {
|
||||||
|
switch (this) {
|
||||||
|
case HandleType.none:
|
||||||
|
throw UnsupportedError('Unsupported handle type');
|
||||||
|
case HandleType.left:
|
||||||
|
return CrossAxisAlignment.end;
|
||||||
|
case HandleType.right:
|
||||||
|
return CrossAxisAlignment.start;
|
||||||
|
case HandleType.collapsed:
|
||||||
|
return CrossAxisAlignment.center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// only used on Android
|
||||||
|
class AndroidCollapsedHandle extends StatelessWidget {
|
||||||
|
const AndroidCollapsedHandle({
|
||||||
|
super.key,
|
||||||
|
required this.layerLink,
|
||||||
|
required this.rect,
|
||||||
|
this.handleColor = Colors.black,
|
||||||
|
this.handleBallWidth = 6.0,
|
||||||
|
this.handleWidth = 2.0,
|
||||||
|
this.enableHapticFeedbackOnAndroid = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Rect rect;
|
||||||
|
final LayerLink layerLink;
|
||||||
|
final Color handleColor;
|
||||||
|
final double handleWidth;
|
||||||
|
final double handleBallWidth;
|
||||||
|
final bool enableHapticFeedbackOnAndroid;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Extend the click area to make it easier to click.
|
||||||
|
final adjustedRect = Rect.fromLTWH(
|
||||||
|
rect.left - 2 * (handleBallWidth),
|
||||||
|
rect.top,
|
||||||
|
rect.width + 4 * (handleBallWidth),
|
||||||
|
// Enable clicking in the handle area outside the stack.
|
||||||
|
// https://github.com/flutter/flutter/issues/75747
|
||||||
|
rect.height + 2 * handleBallWidth,
|
||||||
|
);
|
||||||
|
return Positioned.fromRect(
|
||||||
|
rect: adjustedRect,
|
||||||
|
child: CompositedTransformFollower(
|
||||||
|
link: layerLink,
|
||||||
|
offset: adjustedRect.topLeft,
|
||||||
|
showWhenUnlinked: false,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
top: 4.0,
|
||||||
|
child: _AndroidDragHandle(
|
||||||
|
handleHeight: adjustedRect.height,
|
||||||
|
handleType: HandleType.collapsed,
|
||||||
|
handleColor: handleColor,
|
||||||
|
handleWidth: adjustedRect.width,
|
||||||
|
handleBallWidth: handleBallWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MobileSelectionArea extends StatelessWidget {
|
||||||
|
const MobileSelectionArea({
|
||||||
|
super.key,
|
||||||
|
required this.layerLink,
|
||||||
|
required this.rect,
|
||||||
|
this.handleType = HandleType.none,
|
||||||
|
this.handleColor = Colors.black,
|
||||||
|
this.handleBallWidth = 6.0,
|
||||||
|
this.handleWidth = 2.0,
|
||||||
|
this.enableHapticFeedbackOnAndroid = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Rect rect;
|
||||||
|
final LayerLink layerLink;
|
||||||
|
final HandleType handleType;
|
||||||
|
final Color handleColor;
|
||||||
|
final double handleWidth;
|
||||||
|
final double handleBallWidth;
|
||||||
|
final bool enableHapticFeedbackOnAndroid;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
assert(handleType != HandleType.collapsed);
|
||||||
|
|
||||||
|
var adjustedRect = rect;
|
||||||
|
if (handleType != HandleType.none) {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
// on iOS, the cursor will still be visible if the selection is not collapsed.
|
||||||
|
// So, adding a threshold padding to avoid row overflow.
|
||||||
|
const threshold = 0.25;
|
||||||
|
adjustedRect = Rect.fromLTWH(
|
||||||
|
rect.left - 2 * (handleWidth + threshold),
|
||||||
|
rect.top - handleBallWidth,
|
||||||
|
rect.width + 4 * (handleWidth + threshold),
|
||||||
|
rect.height + 2 * handleBallWidth,
|
||||||
|
);
|
||||||
|
} else if (Platform.isAndroid) {
|
||||||
|
// on Android, normally the cursor will be hidden if the selection is not collapsed.
|
||||||
|
// Extend the click area to make it easier to click.
|
||||||
|
adjustedRect = Rect.fromLTWH(
|
||||||
|
rect.left - 2 * (handleBallWidth),
|
||||||
|
rect.top,
|
||||||
|
rect.width + 4 * (handleBallWidth),
|
||||||
|
// Enable clicking in the handle area outside the stack.
|
||||||
|
// https://github.com/flutter/flutter/issues/75747
|
||||||
|
rect.height + 2 * handleBallWidth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Positioned.fromRect(
|
||||||
|
rect: adjustedRect,
|
||||||
|
child: CompositedTransformFollower(
|
||||||
|
link: layerLink,
|
||||||
|
offset: adjustedRect.topLeft,
|
||||||
|
showWhenUnlinked: false,
|
||||||
|
child: _MobileSelectionWithHandles(
|
||||||
|
handleType: handleType,
|
||||||
|
handleColor: handleColor,
|
||||||
|
handleHeight: adjustedRect.height,
|
||||||
|
handleWidth: handleWidth,
|
||||||
|
handleBallWidth: handleBallWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MobileSelectionWithHandles extends StatelessWidget {
|
||||||
|
const _MobileSelectionWithHandles({
|
||||||
|
required this.handleType,
|
||||||
|
this.handleColor = Colors.black,
|
||||||
|
this.handleWidth = 2.0,
|
||||||
|
required this.handleHeight,
|
||||||
|
required this.handleBallWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
final HandleType handleType;
|
||||||
|
final Color handleColor;
|
||||||
|
final double handleWidth;
|
||||||
|
final double handleHeight;
|
||||||
|
final double handleBallWidth;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
assert(handleType != HandleType.collapsed);
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
if (handleType != HandleType.none) {
|
||||||
|
final offset = Platform.isIOS ? -handleWidth : 0.0;
|
||||||
|
child = Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
if (handleType == HandleType.left)
|
||||||
|
Positioned(
|
||||||
|
left: offset,
|
||||||
|
child: _DragHandle(
|
||||||
|
handleColor: handleColor,
|
||||||
|
handleWidth: handleWidth,
|
||||||
|
handleBallWidth: handleBallWidth,
|
||||||
|
handleHeight: handleHeight,
|
||||||
|
handleType: HandleType.left,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (handleType == HandleType.right)
|
||||||
|
Positioned(
|
||||||
|
right: offset,
|
||||||
|
child: _DragHandle(
|
||||||
|
handleColor: handleColor,
|
||||||
|
handleWidth: handleWidth,
|
||||||
|
handleBallWidth: handleBallWidth,
|
||||||
|
handleHeight: handleHeight,
|
||||||
|
handleType: HandleType.right,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
child = const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _IDragHandle extends StatelessWidget {
|
||||||
|
const _IDragHandle({
|
||||||
|
required this.handleHeight,
|
||||||
|
this.handleColor = Colors.black,
|
||||||
|
this.handleWidth = 2.0,
|
||||||
|
this.handleBallWidth = 6.0,
|
||||||
|
required this.handleType,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Color handleColor;
|
||||||
|
final double handleWidth;
|
||||||
|
final double handleHeight;
|
||||||
|
final double handleBallWidth;
|
||||||
|
final HandleType handleType;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DragHandle extends _IDragHandle {
|
||||||
|
const _DragHandle({
|
||||||
|
required super.handleHeight,
|
||||||
|
super.handleColor,
|
||||||
|
super.handleWidth,
|
||||||
|
super.handleBallWidth,
|
||||||
|
required super.handleType,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return _IOSDragHandle(
|
||||||
|
handleHeight: handleHeight,
|
||||||
|
handleColor: handleColor,
|
||||||
|
handleWidth: handleWidth,
|
||||||
|
handleBallWidth: handleBallWidth,
|
||||||
|
handleType: handleType,
|
||||||
|
);
|
||||||
|
} else if (Platform.isAndroid) {
|
||||||
|
return _AndroidDragHandle(
|
||||||
|
handleHeight: handleHeight,
|
||||||
|
handleColor: handleColor,
|
||||||
|
handleWidth: handleWidth,
|
||||||
|
handleBallWidth: handleBallWidth,
|
||||||
|
handleType: handleType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw UnsupportedError('Unsupported platform');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IOSDragHandle extends _IDragHandle {
|
||||||
|
const _IOSDragHandle({
|
||||||
|
required super.handleHeight,
|
||||||
|
super.handleColor,
|
||||||
|
super.handleWidth,
|
||||||
|
super.handleBallWidth,
|
||||||
|
required super.handleType,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.max,
|
||||||
|
children: [
|
||||||
|
if (handleType == HandleType.left)
|
||||||
|
Container(
|
||||||
|
width: handleBallWidth,
|
||||||
|
height: handleBallWidth,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: handleColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (handleType == HandleType.right)
|
||||||
|
SizedBox(
|
||||||
|
width: handleBallWidth,
|
||||||
|
height: handleBallWidth,
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: handleWidth,
|
||||||
|
color: handleColor,
|
||||||
|
height: handleHeight - 2.0 * handleBallWidth,
|
||||||
|
),
|
||||||
|
if (handleType == HandleType.right)
|
||||||
|
Container(
|
||||||
|
width: handleBallWidth,
|
||||||
|
height: handleBallWidth,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: handleColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (handleType == HandleType.left)
|
||||||
|
SizedBox(
|
||||||
|
width: handleBallWidth,
|
||||||
|
height: handleBallWidth,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: must_be_immutable
|
||||||
|
class _AndroidDragHandle extends _IDragHandle {
|
||||||
|
_AndroidDragHandle({
|
||||||
|
required super.handleHeight,
|
||||||
|
super.handleColor,
|
||||||
|
super.handleWidth,
|
||||||
|
super.handleBallWidth,
|
||||||
|
required super.handleType,
|
||||||
|
});
|
||||||
|
|
||||||
|
Selection? selection;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final editorState = context.read<EditorState>();
|
||||||
|
Widget child = SizedBox(
|
||||||
|
width: handleWidth,
|
||||||
|
height: handleHeight - 2.0 * handleBallWidth,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (handleType == HandleType.none) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ballWidth = handleBallWidth * 2.0;
|
||||||
|
|
||||||
|
child = GestureDetector(
|
||||||
|
key: handleType.key,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
dragStartBehavior: DragStartBehavior.down,
|
||||||
|
onPanStart: (details) {
|
||||||
|
selection = editorState.service.selectionService.onPanStart(
|
||||||
|
details.translate(0, -ballWidth),
|
||||||
|
handleType.dragMode,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onPanUpdate: (details) {
|
||||||
|
final selection = editorState.service.selectionService.onPanUpdate(
|
||||||
|
details.translate(0, -ballWidth),
|
||||||
|
handleType.dragMode,
|
||||||
|
);
|
||||||
|
if (this.selection != selection) {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
}
|
||||||
|
this.selection = selection;
|
||||||
|
},
|
||||||
|
onPanEnd: (details) {
|
||||||
|
editorState.service.selectionService.onPanEnd(
|
||||||
|
details,
|
||||||
|
handleType.dragMode,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: handleType.crossAxisAlignment,
|
||||||
|
children: [
|
||||||
|
child,
|
||||||
|
if (handleType == HandleType.collapsed)
|
||||||
|
Transform.rotate(
|
||||||
|
angle: pi / 4.0,
|
||||||
|
child: Container(
|
||||||
|
width: ballWidth,
|
||||||
|
height: ballWidth,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: handleColor,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topRight: Radius.circular(handleBallWidth),
|
||||||
|
bottomLeft: Radius.circular(handleBallWidth),
|
||||||
|
bottomRight: Radius.circular(handleBallWidth),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (handleType == HandleType.left)
|
||||||
|
Container(
|
||||||
|
width: ballWidth,
|
||||||
|
height: ballWidth,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: handleColor,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(handleBallWidth),
|
||||||
|
bottomLeft: Radius.circular(handleBallWidth),
|
||||||
|
bottomRight: Radius.circular(handleBallWidth),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (handleType == HandleType.right)
|
||||||
|
Container(
|
||||||
|
width: ballWidth,
|
||||||
|
height: ballWidth,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: handleColor,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topRight: Radius.circular(handleBallWidth),
|
||||||
|
bottomLeft: Radius.circular(handleBallWidth),
|
||||||
|
bottomRight: Radius.circular(handleBallWidth),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// use it to debug the handle area.
|
||||||
|
// if (kDebugMode) {
|
||||||
|
// child = ColoredBox(
|
||||||
|
// color: Colors.red.withOpacity(0.5),
|
||||||
|
// child: child,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on DragStartDetails {
|
||||||
|
DragStartDetails translate(double dx, double dy) {
|
||||||
|
return DragStartDetails(
|
||||||
|
sourceTimeStamp: sourceTimeStamp,
|
||||||
|
globalPosition: Offset(globalPosition.dx + dx, globalPosition.dy + dy),
|
||||||
|
localPosition: Offset(localPosition.dx + dx, localPosition.dy + dy),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on DragUpdateDetails {
|
||||||
|
DragUpdateDetails translate(double dx, double dy) {
|
||||||
|
return DragUpdateDetails(
|
||||||
|
sourceTimeStamp: sourceTimeStamp,
|
||||||
|
globalPosition: Offset(globalPosition.dx + dx, globalPosition.dy + dy),
|
||||||
|
localPosition: Offset(localPosition.dx + dx, localPosition.dy + dy),
|
||||||
|
delta: Offset(delta.dx + dx, delta.dy + dy),
|
||||||
|
primaryDelta: primaryDelta,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class MobileSelectionWidget extends StatelessWidget {
|
|
||||||
const MobileSelectionWidget({
|
|
||||||
super.key,
|
|
||||||
required this.layerLink,
|
|
||||||
required this.rect,
|
|
||||||
required this.color,
|
|
||||||
this.decoration,
|
|
||||||
this.showLeftHandler = false,
|
|
||||||
this.showRightHandler = false,
|
|
||||||
this.handlerColor = Colors.black,
|
|
||||||
this.handlerBallWidth = 6.0,
|
|
||||||
this.handlerWidth = 2.0,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Color color;
|
|
||||||
final Rect rect;
|
|
||||||
final LayerLink layerLink;
|
|
||||||
final BoxDecoration? decoration;
|
|
||||||
final bool showLeftHandler;
|
|
||||||
final bool showRightHandler;
|
|
||||||
final Color handlerColor;
|
|
||||||
final double handlerWidth;
|
|
||||||
final double handlerBallWidth;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// to avoid row overflow
|
|
||||||
const threshold = 0.25;
|
|
||||||
// left and right add 2px to avoid the selection area from being too narrow
|
|
||||||
var adjustedRect = rect;
|
|
||||||
if (showLeftHandler || showRightHandler) {
|
|
||||||
adjustedRect = Rect.fromLTWH(
|
|
||||||
rect.left - 2 * (handlerWidth + threshold),
|
|
||||||
rect.top - handlerBallWidth,
|
|
||||||
rect.width + 4 * (handlerWidth + threshold),
|
|
||||||
rect.height + 2 * handlerBallWidth,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Positioned.fromRect(
|
|
||||||
rect: adjustedRect,
|
|
||||||
child: CompositedTransformFollower(
|
|
||||||
link: layerLink,
|
|
||||||
offset: adjustedRect.topLeft,
|
|
||||||
showWhenUnlinked: false,
|
|
||||||
// Ignore the gestures in selection overlays
|
|
||||||
// to solve the problem that selection areas cannot overlap.
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: MobileSelectionWithHandler(
|
|
||||||
color: color,
|
|
||||||
decoration: decoration,
|
|
||||||
showLeftHandler: showLeftHandler,
|
|
||||||
showRightHandler: showRightHandler,
|
|
||||||
handlerColor: handlerColor,
|
|
||||||
handlerHeight: adjustedRect.height,
|
|
||||||
handlerWidth: handlerWidth,
|
|
||||||
handlerBallWidth: handlerBallWidth,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MobileSelectionWithHandler extends StatelessWidget {
|
|
||||||
const MobileSelectionWithHandler({
|
|
||||||
super.key,
|
|
||||||
required this.color,
|
|
||||||
this.showLeftHandler = false,
|
|
||||||
this.showRightHandler = false,
|
|
||||||
this.handlerColor = Colors.black,
|
|
||||||
this.decoration,
|
|
||||||
this.handlerWidth = 2.0,
|
|
||||||
required this.handlerHeight,
|
|
||||||
required this.handlerBallWidth,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Color color;
|
|
||||||
final BoxDecoration? decoration;
|
|
||||||
|
|
||||||
final bool showLeftHandler;
|
|
||||||
final bool showRightHandler;
|
|
||||||
final Color handlerColor;
|
|
||||||
final double handlerWidth;
|
|
||||||
final double handlerHeight;
|
|
||||||
final double handlerBallWidth;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
Widget child = Container(
|
|
||||||
color: decoration == null ? color : null,
|
|
||||||
decoration: decoration,
|
|
||||||
);
|
|
||||||
if (showLeftHandler || showRightHandler) {
|
|
||||||
child = Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
if (showLeftHandler)
|
|
||||||
Positioned(
|
|
||||||
left: -handlerWidth,
|
|
||||||
child: _DragHandler(
|
|
||||||
handlerColor: handlerColor,
|
|
||||||
handlerWidth: handlerWidth,
|
|
||||||
handlerBallWidth: handlerBallWidth,
|
|
||||||
handlerHeight: handlerHeight,
|
|
||||||
showLeftHandler: true,
|
|
||||||
showRightHandler: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child,
|
|
||||||
if (showRightHandler)
|
|
||||||
Positioned(
|
|
||||||
right: -handlerWidth,
|
|
||||||
child: _DragHandler(
|
|
||||||
handlerColor: handlerColor,
|
|
||||||
handlerWidth: handlerWidth,
|
|
||||||
handlerBallWidth: handlerBallWidth,
|
|
||||||
handlerHeight: handlerHeight,
|
|
||||||
showRightHandler: true,
|
|
||||||
showLeftHandler: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DragHandler extends StatelessWidget {
|
|
||||||
const _DragHandler({
|
|
||||||
required this.handlerHeight,
|
|
||||||
this.handlerColor = Colors.black,
|
|
||||||
this.handlerWidth = 2.0,
|
|
||||||
this.handlerBallWidth = 6.0,
|
|
||||||
this.showLeftHandler = false,
|
|
||||||
this.showRightHandler = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Color handlerColor;
|
|
||||||
final double handlerWidth;
|
|
||||||
final double handlerHeight;
|
|
||||||
final double handlerBallWidth;
|
|
||||||
final bool showLeftHandler;
|
|
||||||
final bool showRightHandler;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.max,
|
|
||||||
children: [
|
|
||||||
if (showLeftHandler)
|
|
||||||
Container(
|
|
||||||
width: handlerBallWidth,
|
|
||||||
height: handlerBallWidth,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: handlerColor,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (showRightHandler)
|
|
||||||
SizedBox(
|
|
||||||
width: handlerBallWidth,
|
|
||||||
height: handlerBallWidth,
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
width: handlerWidth,
|
|
||||||
color: handlerColor,
|
|
||||||
height: handlerHeight - 2.0 * handlerBallWidth,
|
|
||||||
),
|
|
||||||
if (showRightHandler)
|
|
||||||
Container(
|
|
||||||
width: handlerBallWidth,
|
|
||||||
height: handlerBallWidth,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: handlerColor,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (showLeftHandler)
|
|
||||||
SizedBox(
|
|
||||||
width: handlerBallWidth,
|
|
||||||
height: handlerBallWidth,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:appflowy_editor/src/core/document/node.dart';
|
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||||
import 'package:appflowy_editor/src/render/selection/mobile_selection_widget.dart';
|
import 'package:appflowy_editor/src/render/selection/mobile_selection_handle.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
|
@ -13,19 +13,16 @@ void main() {
|
||||||
await tester.buildAndPump(
|
await tester.buildAndPump(
|
||||||
Stack(
|
Stack(
|
||||||
children: [
|
children: [
|
||||||
MobileSelectionWidget(
|
MobileSelectionArea(
|
||||||
showLeftHandler: true,
|
|
||||||
showRightHandler: true,
|
|
||||||
layerLink: node.layerLink,
|
layerLink: node.layerLink,
|
||||||
rect: const Rect.fromLTWH(0, 0, 100, 100),
|
rect: const Rect.fromLTWH(0, 0, 100, 100),
|
||||||
color: Colors.red,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(MobileSelectionWidget), findsOneWidget);
|
expect(find.byType(MobileSelectionArea), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue