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.symmetric(horizontal: 20),
|
||||
cursorColor: Colors.green,
|
||||
dragHandleColor: Colors.green,
|
||||
selectionColor: Colors.green.withOpacity(0.5),
|
||||
textStyleConfiguration: TextStyleConfiguration(
|
||||
text: GoogleFonts.poppins(
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ class _MobileEditorState extends State<MobileEditor> {
|
|||
EditorStyle _buildMobileEditorStyle() {
|
||||
return EditorStyle.mobile(
|
||||
cursorColor: const Color.fromARGB(255, 134, 46, 247),
|
||||
dragHandleColor: const Color.fromARGB(255, 134, 46, 247),
|
||||
selectionColor: const Color.fromARGB(50, 134, 46, 247),
|
||||
textStyleConfiguration: TextStyleConfiguration(
|
||||
text: GoogleFonts.poppins(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
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/service/selection/selection_gesture.dart';
|
||||
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
|
||||
|
|
@ -191,6 +192,30 @@ class _DesktopSelectionServiceWidgetState
|
|||
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) {
|
||||
_clearContextMenu();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
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/flutter/overlay.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:appflowy_editor/src/service/selection/mobile_selection_gesture.dart';
|
||||
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// only used in mobile
|
||||
|
|
@ -62,8 +63,6 @@ class _MobileSelectionServiceWidgetState
|
|||
implements AppFlowySelectionService {
|
||||
@override
|
||||
final List<Rect> selectionRects = [];
|
||||
final List<OverlayEntry> _selectionAreas = [];
|
||||
final List<OverlayEntry> _cursorAreas = [];
|
||||
|
||||
@override
|
||||
ValueNotifier<Selection?> currentSelection = ValueNotifier(null);
|
||||
|
|
@ -81,6 +80,8 @@ class _MobileSelectionServiceWidgetState
|
|||
|
||||
MobileSelectionDragMode dragMode = MobileSelectionDragMode.none;
|
||||
|
||||
bool updateSelectionByTapUp = false;
|
||||
|
||||
late EditorState editorState = Provider.of<EditorState>(
|
||||
context,
|
||||
listen: false,
|
||||
|
|
@ -106,19 +107,23 @@ class _MobileSelectionServiceWidgetState
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MobileSelectionGestureDetector(
|
||||
onPanStart: _onPanStart,
|
||||
onPanUpdate: _onPanUpdate,
|
||||
onPanEnd: _onPanEnd,
|
||||
onTapUp: _onTapUp,
|
||||
onDoubleTapUp: _onDoubleTapUp,
|
||||
onTripleTapUp: _onTripleTapUp,
|
||||
onLongPressStart: _onLongPressStart,
|
||||
onLongPressMoveUpdate: _onLongPressMoveUpdate,
|
||||
onLongPressEnd: _onLongPressEnd,
|
||||
// onLongPressMoveUpdate: _onLongPressMoveUpdate,
|
||||
// onLongPressEnd: _onLongPressEnd,
|
||||
child: Stack(
|
||||
children: [
|
||||
widget.child,
|
||||
|
||||
// magnifier for zoom in the text.
|
||||
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
|
||||
void updateSelection(Selection? selection) {
|
||||
if (currentSelection.value == selection) {
|
||||
|
|
@ -175,23 +274,13 @@ class _MobileSelectionServiceWidgetState
|
|||
_clearSelection();
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
clearCursor();
|
||||
// clear selection areas
|
||||
_selectionAreas
|
||||
..forEach((overlay) => overlay.remove())
|
||||
..clear();
|
||||
// clear cursor areas
|
||||
_selectionAreas.clear();
|
||||
selectionRects.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void clearCursor() {
|
||||
// clear cursor areas
|
||||
_cursorAreas
|
||||
..forEach((overlay) => overlay.remove())
|
||||
..clear();
|
||||
_clearSelection();
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
selectionRects.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -290,6 +379,7 @@ class _MobileSelectionServiceWidgetState
|
|||
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(position),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
extraInfo: null,
|
||||
);
|
||||
}
|
||||
|
|
@ -311,10 +401,6 @@ class _MobileSelectionServiceWidgetState
|
|||
|
||||
void _onTripleTapUp(TapUpDetails details) {
|
||||
final offset = details.globalPosition;
|
||||
// if (_isClickOnSelectionArea(offset)) {
|
||||
// appFlowyEditorOnTapSelectionArea.add(0);
|
||||
// return;
|
||||
// }
|
||||
final node = getNodeInOffset(offset);
|
||||
final selectable = node?.selectable;
|
||||
if (selectable == null) {
|
||||
|
|
@ -328,45 +414,35 @@ class _MobileSelectionServiceWidgetState
|
|||
updateSelection(selection);
|
||||
}
|
||||
|
||||
void _onPanStart(DragStartDetails details) {
|
||||
@override
|
||||
Selection? onPanStart(
|
||||
DragStartDetails details,
|
||||
MobileSelectionDragMode mode,
|
||||
) {
|
||||
_panStartOffset = details.globalPosition.translate(-3.0, 0);
|
||||
_panStartScrollDy = editorState.service.scrollService?.dy;
|
||||
|
||||
final position = details.globalPosition;
|
||||
final selection = editorState.selection;
|
||||
_panStartSelection = selection;
|
||||
if (selection == null) {
|
||||
dragMode = MobileSelectionDragMode.none;
|
||||
} else if (selection.isCollapsed &&
|
||||
_isOverlayOnHandler(
|
||||
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;
|
||||
}
|
||||
|
||||
dragMode = mode;
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
void _onPanUpdate(DragUpdateDetails details) {
|
||||
@override
|
||||
Selection? onPanUpdate(
|
||||
DragUpdateDetails details,
|
||||
MobileSelectionDragMode mode,
|
||||
) {
|
||||
if (_panStartOffset == null || _panStartScrollDy == null) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// only support selection mode now.
|
||||
final selection = editorState.selection;
|
||||
if (selection == null || dragMode == MobileSelectionDragMode.none) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
final panEndOffset = details.globalPosition;
|
||||
|
|
@ -380,32 +456,34 @@ class _MobileSelectionServiceWidgetState
|
|||
?.getSelectionInRange(panStartOffset, panEndOffset)
|
||||
.end;
|
||||
|
||||
Selection? newSelection;
|
||||
|
||||
if (end != null) {
|
||||
if (dragMode == MobileSelectionDragMode.leftSelectionHandler) {
|
||||
updateSelection(
|
||||
Selection(
|
||||
newSelection = Selection(
|
||||
start: _panStartSelection!.normalized.end,
|
||||
end: end,
|
||||
).normalized,
|
||||
);
|
||||
).normalized;
|
||||
} else if (dragMode == MobileSelectionDragMode.rightSelectionHandler) {
|
||||
updateSelection(
|
||||
Selection(
|
||||
newSelection = Selection(
|
||||
start: _panStartSelection!.normalized.start,
|
||||
end: end,
|
||||
).normalized,
|
||||
);
|
||||
).normalized;
|
||||
} else if (dragMode == MobileSelectionDragMode.cursor) {
|
||||
updateSelection(
|
||||
Selection.collapsed(end),
|
||||
);
|
||||
newSelection = Selection.collapsed(end);
|
||||
}
|
||||
|
||||
_lastPanOffset.value = panEndOffset;
|
||||
}
|
||||
|
||||
if (newSelection != null) {
|
||||
updateSelection(newSelection);
|
||||
}
|
||||
|
||||
void _onPanEnd(DragEndDetails details) {
|
||||
return newSelection;
|
||||
}
|
||||
|
||||
@override
|
||||
void onPanEnd(DragEndDetails details, MobileSelectionDragMode mode) {
|
||||
// do nothing
|
||||
_lastPanOffset.value = null;
|
||||
|
||||
|
|
@ -413,55 +491,37 @@ class _MobileSelectionServiceWidgetState
|
|||
dragMode = MobileSelectionDragMode.none;
|
||||
editorState.updateSelectionWithReason(
|
||||
editorState.selection,
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
extraInfo: null,
|
||||
);
|
||||
}
|
||||
|
||||
void _onLongPressStart(LongPressStartDetails details) {
|
||||
if (!Platform.isAndroid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// on Android, long press to select a word.
|
||||
final offset = details.globalPosition;
|
||||
if (_isClickOnSelectionArea(offset)) {
|
||||
appFlowyEditorOnTapSelectionArea.add(0);
|
||||
return;
|
||||
}
|
||||
final node = getNodeInOffset(offset);
|
||||
final selection = node?.selectable?.getWordBoundaryInOffset(offset);
|
||||
if (selection == null) {
|
||||
clearSelection();
|
||||
|
||||
// clear old state.
|
||||
_panStartOffset = null;
|
||||
|
||||
final position = getPositionInOffset(details.globalPosition);
|
||||
if (position == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_lastPanOffset.value = details.globalPosition;
|
||||
|
||||
dragMode = MobileSelectionDragMode.cursor;
|
||||
updateSelection(
|
||||
Selection.collapsed(position),
|
||||
);
|
||||
if (editorState.editorStyle.enableHapticFeedbackOnAndroid) {
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
|
||||
void _onLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
|
||||
final panEndOffset = details.globalPosition;
|
||||
final position = getNodeInOffset(panEndOffset)
|
||||
?.selectable
|
||||
?.getPositionInOffset(panEndOffset);
|
||||
|
||||
if (position == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_lastPanOffset.value = panEndOffset;
|
||||
updateSelection(
|
||||
Selection.collapsed(position),
|
||||
);
|
||||
}
|
||||
|
||||
void _onLongPressEnd(LongPressEndDetails details) {
|
||||
_lastPanOffset.value = null;
|
||||
|
||||
dragMode = MobileSelectionDragMode.none;
|
||||
editorState.updateSelectionWithReason(
|
||||
editorState.selection,
|
||||
extraInfo: null,
|
||||
);
|
||||
updateSelection(selection);
|
||||
}
|
||||
|
||||
// delete this function in the future.
|
||||
void _updateSelectionAreas(Selection selection) {
|
||||
final nodes = editorState.getNodesInSelection(selection);
|
||||
|
||||
|
|
@ -509,39 +569,14 @@ class _MobileSelectionServiceWidgetState
|
|||
newSelection,
|
||||
shiftWithBaseOffset: true,
|
||||
);
|
||||
for (var (j, rect) in rects.indexed) {
|
||||
for (final rect in rects) {
|
||||
final selectionRect = selectable.transformRectToGlobal(
|
||||
rect,
|
||||
shiftWithBaseOffset: true,
|
||||
);
|
||||
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(
|
||||
|
|
@ -578,60 +613,6 @@ class _MobileSelectionServiceWidgetState
|
|||
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) {
|
||||
for (final rect in selectionRects) {
|
||||
if (rect.contains(point)) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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/selection.dart';
|
||||
import 'package:appflowy_editor/src/editor/editor_component/service/selection/mobile_selection_service.dart';
|
||||
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
|
||||
|
||||
/// [AppFlowySelectionService] is responsible for processing
|
||||
|
|
@ -66,6 +67,20 @@ abstract class AppFlowySelectionService {
|
|||
|
||||
void registerGestureInterceptor(SelectionGestureInterceptor interceptor);
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -93,4 +93,25 @@ class _SelectionServiceWidgetState extends State<SelectionServiceWidget>
|
|||
@override
|
||||
void updateSelection(Selection? 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({
|
||||
required this.padding,
|
||||
required this.cursorColor,
|
||||
required this.dragHandleColor,
|
||||
required this.selectionColor,
|
||||
required this.textStyleConfiguration,
|
||||
required this.textSpanDecorator,
|
||||
|
|
@ -17,44 +18,54 @@ class EditorStyle {
|
|||
this.mobileDragHandleBallSize = const Size(8, 8),
|
||||
this.mobileDragHandleWidth = 2.0,
|
||||
this.defaultTextDirection,
|
||||
this.enableHapticFeedbackOnAndroid = true,
|
||||
});
|
||||
|
||||
/// The padding of the editor.
|
||||
// The padding of the editor.
|
||||
final EdgeInsets padding;
|
||||
|
||||
/// The cursor color
|
||||
// The cursor color
|
||||
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;
|
||||
|
||||
/// Customize the text style of the editor.
|
||||
///
|
||||
/// All the text-based components will use this configuration to build their
|
||||
/// text style.
|
||||
///
|
||||
/// Notes, this configuration is only for the common config of text style and
|
||||
/// it maybe override if the text block has its own [BlockComponentConfiguration].
|
||||
// Customize the text style of the editor.
|
||||
//
|
||||
// All the text-based components will use this configuration to build their
|
||||
// text style.
|
||||
//
|
||||
// Notes, this configuration is only for the common config of text style and
|
||||
// it maybe override if the text block has its own [BlockComponentConfiguration].
|
||||
final TextStyleConfiguration textStyleConfiguration;
|
||||
|
||||
/// Customize the built-in or custom text span.
|
||||
///
|
||||
/// For example, you can add a custom text span for the mention text
|
||||
/// or override the built-in text span.
|
||||
// Customize the built-in or custom text span.
|
||||
//
|
||||
// For example, you can add a custom text span for the mention text
|
||||
// or override the built-in text span.
|
||||
final TextSpanDecoratorForAttribute? textSpanDecorator;
|
||||
|
||||
final String? defaultTextDirection;
|
||||
|
||||
/// The size of the magnifier.
|
||||
/// Only works on mobile.
|
||||
// The size of the magnifier.
|
||||
// Only works on mobile.
|
||||
final Size magnifierSize;
|
||||
|
||||
/// mobile drag handler size.
|
||||
/// /// Only works on mobile.
|
||||
// mobile drag handler size.
|
||||
// Only works on mobile.
|
||||
final Size mobileDragHandleBallSize;
|
||||
|
||||
final double mobileDragHandleWidth;
|
||||
|
||||
// only works on android
|
||||
// enable haptic feedback when updating selection by dragging the drag handler
|
||||
final bool enableHapticFeedbackOnAndroid;
|
||||
|
||||
const EditorStyle.desktop({
|
||||
EdgeInsets? padding,
|
||||
Color? cursorColor,
|
||||
|
|
@ -74,11 +85,14 @@ class EditorStyle {
|
|||
textSpanDecorator ?? defaultTextSpanDecoratorForAttribute,
|
||||
magnifierSize = Size.zero,
|
||||
mobileDragHandleBallSize = Size.zero,
|
||||
mobileDragHandleWidth = 0.0;
|
||||
mobileDragHandleWidth = 0.0,
|
||||
enableHapticFeedbackOnAndroid = false,
|
||||
dragHandleColor = Colors.transparent;
|
||||
|
||||
const EditorStyle.mobile({
|
||||
EdgeInsets? padding,
|
||||
Color? cursorColor,
|
||||
Color? dragHandleColor,
|
||||
Color? selectionColor,
|
||||
TextStyleConfiguration? textStyleConfiguration,
|
||||
TextSpanDecoratorForAttribute? textSpanDecorator,
|
||||
|
|
@ -86,8 +100,10 @@ class EditorStyle {
|
|||
this.magnifierSize = const Size(72, 48),
|
||||
this.mobileDragHandleBallSize = const Size(8, 8),
|
||||
this.mobileDragHandleWidth = 2.0,
|
||||
this.enableHapticFeedbackOnAndroid = true,
|
||||
}) : padding = padding ?? const EdgeInsets.symmetric(horizontal: 20),
|
||||
cursorColor = cursorColor ?? const Color(0xFF00BCF0),
|
||||
dragHandleColor = dragHandleColor ?? const Color(0xFF00BCF0),
|
||||
selectionColor =
|
||||
selectionColor ?? const Color.fromARGB(53, 111, 201, 231),
|
||||
textStyleConfiguration = textStyleConfiguration ??
|
||||
|
|
@ -100,19 +116,32 @@ class EditorStyle {
|
|||
EditorStyle copyWith({
|
||||
EdgeInsets? padding,
|
||||
Color? cursorColor,
|
||||
Color? dragHandleColor,
|
||||
Color? selectionColor,
|
||||
TextStyleConfiguration? textStyleConfiguration,
|
||||
TextSpanDecoratorForAttribute? textSpanDecorator,
|
||||
String? defaultTextDirection,
|
||||
Size? magnifierSize,
|
||||
Size? mobileDragHandleBallSize,
|
||||
double? mobileDragHandleWidth,
|
||||
bool? enableHapticFeedbackOnAndroid,
|
||||
}) {
|
||||
return EditorStyle(
|
||||
padding: padding ?? this.padding,
|
||||
cursorColor: cursorColor ?? this.cursorColor,
|
||||
dragHandleColor: dragHandleColor ?? this.dragHandleColor,
|
||||
selectionColor: selectionColor ?? this.selectionColor,
|
||||
textStyleConfiguration:
|
||||
textStyleConfiguration ?? this.textStyleConfiguration,
|
||||
textSpanDecorator: textSpanDecorator ?? this.textSpanDecorator,
|
||||
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;
|
||||
}
|
||||
selection = transaction.afterSelection;
|
||||
_selectionUpdateReason = SelectionUpdateReason.uiEvent;
|
||||
}
|
||||
|
||||
// TODO: execute this line after the UI has been updated.
|
||||
completer.complete();
|
||||
|
||||
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/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_test/flutter_test.dart';
|
||||
|
||||
|
|
@ -13,19 +13,16 @@ void main() {
|
|||
await tester.buildAndPump(
|
||||
Stack(
|
||||
children: [
|
||||
MobileSelectionWidget(
|
||||
showLeftHandler: true,
|
||||
showRightHandler: true,
|
||||
MobileSelectionArea(
|
||||
layerLink: node.layerLink,
|
||||
rect: const Rect.fromLTWH(0, 0, 100, 100),
|
||||
color: Colors.red,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MobileSelectionWidget), findsOneWidget);
|
||||
expect(find.byType(MobileSelectionArea), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue