diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart index e7b5d0d79bad6..ee47a9516fc07 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; @@ -284,6 +285,24 @@ abstract class GridCellState extends State { void initState() { super.initState(); widget.requestFocus.addListener(onRequestFocus); + widget.shortcutHandlers[CellKeyboardKey.onCopy] = () { + final s = onCopy(); + if (s != null) { + Clipboard.setData(ClipboardData(text: s)); + } + }; + widget.shortcutHandlers[CellKeyboardKey.onCut] = () { + final s = onCut(); + if (s != null) { + Clipboard.setData(ClipboardData(text: s)); + } + }; + widget.shortcutHandlers[CellKeyboardKey.onPaste] = () async { + await onPaste(); + }; + widget.shortcutHandlers[CellKeyboardKey.onDelete] = () { + onDelete(); + }; } @override @@ -306,6 +325,9 @@ abstract class GridCellState extends State { void onRequestFocus(); String? onCopy() => null; + String? onCut() => null; + Future onPaste() async {} + void onDelete() {} } abstract class GridEditableTextCell diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/number.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/number.dart index 4d2bfdf627f01..771ae25c53081 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/number.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/number.dart @@ -7,6 +7,7 @@ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../desktop_grid/desktop_grid_number_cell.dart'; @@ -112,6 +113,26 @@ class _NumberCellState extends GridEditableTextCell { @override String? onCopy() => cellBloc.state.content; + @override + String? onCut() { + final text = cellBloc.state.content; + cellBloc.add(const NumberCellEvent.updateCell('')); + return text; + } + + @override + Future onPaste() async { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + if (clipboardData?.text != null) { + cellBloc.add(NumberCellEvent.updateCell(clipboardData!.text!)); + } + } + + @override + void onDelete() { + cellBloc.add(const NumberCellEvent.updateCell('')); + } + @override Future focusChanged() async { if (mounted && diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart index 67ca6275a6aae..4f45bb2e1bc36 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart @@ -93,4 +93,12 @@ class _RelationCellState extends GridCellState { @override String? onCopy() => ""; + + @override + void onDelete() { + final rows = cellBloc.state.rows; + for (final row in rows) { + cellBloc.add(RelationCellEvent.selectRow(row.rowId)); + } + } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart index 3ea622374e292..ea78b556fd620 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart @@ -7,6 +7,7 @@ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../desktop_grid/desktop_grid_text_cell.dart'; @@ -115,6 +116,26 @@ class _TextCellState extends GridEditableTextCell { @override String? onCopy() => cellBloc.state.content; + @override + String? onCut() { + final text = cellBloc.state.content; + cellBloc.add(const TextCellEvent.updateText('')); + return text; + } + + @override + Future onPaste() async { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + if (clipboardData?.text != null) { + cellBloc.add(TextCellEvent.updateText(clipboardData!.text!)); + } + } + + @override + void onDelete() { + cellBloc.add(const TextCellEvent.updateText('')); + } + @override Future focusChanged() { if (mounted && diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_shortcuts.dart index 3fc2131f24c4d..b8a84f9282443 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_shortcuts.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_shortcuts.dart @@ -8,6 +8,9 @@ typedef CellKeyboardAction = dynamic Function(); enum CellKeyboardKey { onEnter, onCopy, + onCut, + onPaste, + onDelete, onInsert, } @@ -43,6 +46,24 @@ class GridCellShortcuts extends StatelessWidget { : LogicalKeyboardKey.control, LogicalKeyboardKey.keyC, ): const GridCellCopyIntent(), + if (shouldAddKeyboardKey(CellKeyboardKey.onCut)) + LogicalKeySet( + Platform.isMacOS + ? LogicalKeyboardKey.meta + : LogicalKeyboardKey.control, + LogicalKeyboardKey.keyX, + ): const GridCellCutIntent(), + if (shouldAddKeyboardKey(CellKeyboardKey.onPaste)) + LogicalKeySet( + Platform.isMacOS + ? LogicalKeyboardKey.meta + : LogicalKeyboardKey.control, + LogicalKeyboardKey.keyV, + ): const GridCellPasteIntent(), + if (shouldAddKeyboardKey(CellKeyboardKey.onDelete)) + LogicalKeySet(LogicalKeyboardKey.delete): const GridCellDeleteIntent(), + if (shouldAddKeyboardKey(CellKeyboardKey.onDelete)) + LogicalKeySet(LogicalKeyboardKey.backspace): const GridCellDeleteIntent(), }; Map> get actions => { @@ -50,6 +71,12 @@ class GridCellShortcuts extends StatelessWidget { GridCellEnterIdent: GridCellEnterAction(child: child), if (shouldAddKeyboardKey(CellKeyboardKey.onCopy)) GridCellCopyIntent: GridCellCopyAction(child: child), + if (shouldAddKeyboardKey(CellKeyboardKey.onCut)) + GridCellCutIntent: GridCellCutAction(child: child), + if (shouldAddKeyboardKey(CellKeyboardKey.onPaste)) + GridCellPasteIntent: GridCellPasteAction(child: child), + if (shouldAddKeyboardKey(CellKeyboardKey.onDelete)) + GridCellDeleteIntent: GridCellDeleteAction(child: child), }; bool shouldAddKeyboardKey(CellKeyboardKey key) => @@ -86,13 +113,54 @@ class GridCellCopyAction extends Action { @override void invoke(covariant GridCellCopyIntent intent) { final callback = child.shortcutHandlers[CellKeyboardKey.onCopy]; - if (callback == null) { - return; - } + if (callback != null) callback(); + } +} - final s = callback(); - if (s is String) { - Clipboard.setData(ClipboardData(text: s)); - } +class GridCellCutIntent extends Intent { + const GridCellCutIntent(); +} + +class GridCellCutAction extends Action { + GridCellCutAction({required this.child}); + + final CellShortcuts child; + + @override + void invoke(covariant GridCellCutIntent intent) { + final callback = child.shortcutHandlers[CellKeyboardKey.onCut]; + if (callback != null) callback(); + } +} + +class GridCellPasteIntent extends Intent { + const GridCellPasteIntent(); +} + +class GridCellPasteAction extends Action { + GridCellPasteAction({required this.child}); + + final CellShortcuts child; + + @override + Future invoke(covariant GridCellPasteIntent intent) async { + final callback = child.shortcutHandlers[CellKeyboardKey.onPaste]; + if (callback != null) await callback(); + } +} + +class GridCellDeleteIntent extends Intent { + const GridCellDeleteIntent(); +} + +class GridCellDeleteAction extends Action { + GridCellDeleteAction({required this.child}); + + final CellShortcuts child; + + @override + void invoke(covariant GridCellDeleteIntent intent) { + final callback = child.shortcutHandlers[CellKeyboardKey.onDelete]; + if (callback != null) callback(); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart index 333ff0fe9652f..6f6d817854c8a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart @@ -1,5 +1,6 @@ import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -9,7 +10,7 @@ import '../../cell/editable_cell_builder.dart'; import '../accessory/cell_accessory.dart'; import '../accessory/cell_shortcuts.dart'; -class CellContainer extends StatelessWidget { +class CellContainer extends StatefulWidget { const CellContainer({ super.key, required this.child, @@ -23,43 +24,94 @@ class CellContainer extends StatelessWidget { final double width; final bool isPrimary; + @override + State createState() => _CellContainerState(); +} + +class _CellContainerState extends State { + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + void _onFocusChange() { + if (mounted) { + setState(() {}); + } + } + @override Widget build(BuildContext context) { return ChangeNotifierProvider.value( - value: child.cellContainerNotifier, + value: widget.child.cellContainerNotifier, child: Selector( selector: (context, notifier) => notifier.isFocus, - builder: (providerContext, isFocus, _) { - Widget container = Center(child: GridCellShortcuts(child: child)); + builder: (providerContext, isChildFocus, _) { + Widget container = Center(child: GridCellShortcuts(child: widget.child)); - if (accessoryBuilder != null) { - final accessories = accessoryBuilder!.call( + if (widget.accessoryBuilder != null) { + final accessories = widget.accessoryBuilder!.call( GridCellAccessoryBuildContext( anchorContext: context, - isCellEditing: isFocus, + isCellEditing: isChildFocus, ), ); if (accessories.isNotEmpty) { container = _GridCellEnterRegion( accessories: accessories, - isPrimary: isPrimary, + isPrimary: widget.isPrimary, child: container, ); } } - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - if (!isFocus) { - child.requestFocus.notify(); + final isSelected = _focusNode.hasFocus; + final isEditing = isChildFocus; + + return Focus( + focusNode: _focusNode, + onKeyEvent: (node, event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.enter) { + widget.child.requestFocus.notify(); + return KeyEventResult.handled; + } } + return KeyEventResult.ignored; }, - child: Container( - constraints: BoxConstraints(maxWidth: width, minHeight: 32), - decoration: _makeBoxDecoration(context, isFocus), - child: container, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (!isEditing) { + _focusNode.requestFocus(); + } + }, + onDoubleTap: () { + if (!isEditing) { + widget.child.requestFocus.notify(); + } + }, + child: Container( + constraints: BoxConstraints(maxWidth: widget.width, minHeight: 32), + decoration: _makeBoxDecoration( + context, + isSelected: isSelected, + isEditing: isEditing, + ), + child: container, + ), ), ); }, @@ -67,12 +119,24 @@ class CellContainer extends StatelessWidget { ); } - BoxDecoration _makeBoxDecoration(BuildContext context, bool isFocus) { - if (isFocus) { + BoxDecoration _makeBoxDecoration( + BuildContext context, { + required bool isSelected, + required bool isEditing, + }) { + if (isEditing) { final borderSide = BorderSide( color: Theme.of(context).colorScheme.primary, + width: 1.5, ); + return BoxDecoration(border: Border.fromBorderSide(borderSide)); + } + if (isSelected) { + final borderSide = BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 1.5, + ); return BoxDecoration(border: Border.fromBorderSide(borderSide)); } diff --git a/frontend/appflowy_flutter/test/widget_test/database/cell_container_test.dart b/frontend/appflowy_flutter/test/widget_test/database/cell_container_test.dart new file mode 100644 index 0000000000000..cebe833ab1dd0 --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/database/cell_container_test.dart @@ -0,0 +1,117 @@ +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:provider/provider.dart'; + +// Mock dependencies +class MockEditableCellWidget extends Mock implements EditableCellWidget { + @override + final CellContainerNotifier cellContainerNotifier = CellContainerNotifier(); + + @override + final SingleListenerChangeNotifier requestFocus = SingleListenerChangeNotifier(); +} + +void main() { + group('CellContainer Widget Tests', () { + late MockEditableCellWidget mockChild; + + setUp(() { + mockChild = MockEditableCellWidget(); + }); + + testWidgets('CellContainer creates Focus widget and responds to single tap', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CellContainer( + child: mockChild, + width: 100, + isPrimary: false, + ), + ), + ), + ); + + final containerFinder = find.byType(CellContainer); + expect(containerFinder, findsOneWidget); + + final focusFinder = find.descendant( + of: containerFinder, + matching: find.byType(Focus), + ); + expect(focusFinder, findsOneWidget); + + // Verify that tap requests focus + final focusWidget = tester.widget(focusFinder); + expect(focusWidget.focusNode?.hasFocus, isFalse); + + await tester.tap(containerFinder); + await tester.pumpAndSettle(); + + // Child should not be focused immediately on single tap (only the container node gets focus first) + expect(mockChild.cellContainerNotifier.isFocus, isFalse); + }); + + testWidgets('CellContainer passes double tap to child requestFocus', (WidgetTester tester) async { + int requestFocusCalls = 0; + mockChild.requestFocus.addListener(() { + requestFocusCalls++; + }); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CellContainer( + child: mockChild, + width: 100, + isPrimary: false, + ), + ), + ), + ); + + final containerFinder = find.byType(CellContainer); + + // Perform a double tap + await tester.tap(containerFinder); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(containerFinder); + await tester.pumpAndSettle(); + + // Double tap should trigger child's requestFocus + expect(requestFocusCalls, greaterThanOrEqualTo(1)); + }); + + testWidgets('CellContainer passes enter key to child requestFocus', (WidgetTester tester) async { + int requestFocusCalls = 0; + mockChild.requestFocus.addListener(() { + requestFocusCalls++; + }); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CellContainer( + child: mockChild, + width: 100, + isPrimary: false, + ), + ), + ), + ); + + final containerFinder = find.byType(CellContainer); + + await tester.tap(containerFinder); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(requestFocusCalls, greaterThanOrEqualTo(1)); + }); + }); +}