fix: simple table issues on mobile (#7115)

* fix: header row/column tap areas are too small on mobile

* test: header row/column tap areas are too small on mobile

* feat: enable auto scroll after inserting column or row

* fix: enter after emoji will create a softbreak on mobile

* fix: header row/column tap areas are too small on mobile

* fix: simple table alignment not work for item that wraps

* test: simple table alignment not work for item that wraps
This commit is contained in:
Lucas 2024-12-31 16:01:45 +08:00 committed by GitHub
parent 7dedb84504
commit 8f7cb50dd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 239 additions and 40 deletions

View File

@ -2,9 +2,10 @@ import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
@ -244,6 +245,53 @@ void main() {
expect(table.isHeaderColumnEnabled, isTrue); expect(table.isHeaderColumnEnabled, isTrue);
expect(table.isHeaderRowEnabled, isTrue); expect(table.isHeaderRowEnabled, isTrue);
// disable header column
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the row menu button
await tester.clickColumnMenuButton(0);
final toggleButton = find.descendant(
of: find.byType(SimpleTableHeaderActionButton),
matching: find.byType(CupertinoSwitch),
);
await tester.tapButton(toggleButton);
}
// enable header row
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the row menu button
await tester.clickRowMenuButton(0);
// enable header column
final toggleButton = find.descendant(
of: find.byType(SimpleTableHeaderActionButton),
matching: find.byType(CupertinoSwitch),
);
await tester.tapButton(toggleButton);
}
// check the table is updated
expect(table.isHeaderColumnEnabled, isFalse);
expect(table.isHeaderRowEnabled, isFalse);
// set to page width // set to page width
{ {
final table = editorState.getNodeAtPath([0])!; final table = editorState.getNodeAtPath([0])!;

View File

@ -86,6 +86,9 @@ class SimpleTableContext {
/// This value is available on mobile only /// This value is available on mobile only
final ValueNotifier<int?> isReorderingHitIndex = ValueNotifier(null); final ValueNotifier<int?> isReorderingHitIndex = ValueNotifier(null);
/// Scroll controller for the table
ScrollController? horizontalScrollController;
void _onHoveringOnColumnsAndRowsChanged() { void _onHoveringOnColumnsAndRowsChanged() {
if (!_enableTableDebugLog) { if (!_enableTableDebugLog) {
return; return;

View File

@ -117,6 +117,8 @@ extension TableOptionOperation on EditorState {
required Node tableCellNode, required Node tableCellNode,
required TableAlign align, required TableAlign align,
}) async { }) async {
await clearColumnTextAlign(tableCellNode: tableCellNode);
final columnIndex = tableCellNode.columnIndex; final columnIndex = tableCellNode.columnIndex;
await _updateTableAttributes( await _updateTableAttributes(
tableCellNode: tableCellNode, tableCellNode: tableCellNode,
@ -144,6 +146,8 @@ extension TableOptionOperation on EditorState {
required Node tableCellNode, required Node tableCellNode,
required TableAlign align, required TableAlign align,
}) async { }) async {
await clearRowTextAlign(tableCellNode: tableCellNode);
final rowIndex = tableCellNode.rowIndex; final rowIndex = tableCellNode.rowIndex;
await _updateTableAttributes( await _updateTableAttributes(
tableCellNode: tableCellNode, tableCellNode: tableCellNode,
@ -385,4 +389,67 @@ extension TableOptionOperation on EditorState {
transaction.updateNode(parentTableNode, attributes); transaction.updateNode(parentTableNode, attributes);
await apply(transaction); await apply(transaction);
} }
/// Clear the text align of the column at the index where the table cell node is located.
Future<void> clearColumnTextAlign({
required Node tableCellNode,
}) async {
final parentTableNode = tableCellNode.parentTableNode;
if (parentTableNode == null) {
Log.warn('parent table node is null');
return;
}
final columnIndex = tableCellNode.columnIndex;
final transaction = this.transaction;
for (var i = 0; i < parentTableNode.rowLength; i++) {
final cell = parentTableNode.getTableCellNode(
rowIndex: i,
columnIndex: columnIndex,
);
if (cell == null) {
continue;
}
for (final child in cell.children) {
transaction.updateNode(child, {
blockComponentAlign: null,
});
}
}
if (transaction.operations.isNotEmpty) {
await apply(transaction);
}
}
/// Clear the text align of the row at the index where the table cell node is located.
Future<void> clearRowTextAlign({
required Node tableCellNode,
}) async {
final parentTableNode = tableCellNode.parentTableNode;
if (parentTableNode == null) {
Log.warn('parent table node is null');
return;
}
final rowIndex = tableCellNode.rowIndex;
final transaction = this.transaction;
for (var i = 0; i < parentTableNode.columnLength; i++) {
final cell = parentTableNode.getTableCellNode(
rowIndex: rowIndex,
columnIndex: i,
);
if (cell == null) {
continue;
}
for (final child in cell.children) {
transaction.updateNode(
child,
{
blockComponentAlign: null,
},
);
}
}
if (transaction.operations.isNotEmpty) {
await apply(transaction);
}
}
} }

View File

@ -47,8 +47,16 @@ class _DesktopSimpleTableWidgetState extends State<DesktopSimpleTableWidget> {
final scrollController = ScrollController(); final scrollController = ScrollController();
late final editorState = context.read<EditorState>(); late final editorState = context.read<EditorState>();
@override
void initState() {
super.initState();
simpleTableContext.horizontalScrollController = scrollController;
}
@override @override
void dispose() { void dispose() {
simpleTableContext.horizontalScrollController = null;
scrollController.dispose(); scrollController.dispose();
super.dispose(); super.dispose();

View File

@ -47,8 +47,16 @@ class _MobileSimpleTableWidgetState extends State<MobileSimpleTableWidget> {
final scrollController = ScrollController(); final scrollController = ScrollController();
late final editorState = context.read<EditorState>(); late final editorState = context.read<EditorState>();
@override
void initState() {
super.initState();
simpleTableContext.horizontalScrollController = scrollController;
}
@override @override
void dispose() { void dispose() {
simpleTableContext.horizontalScrollController = null;
scrollController.dispose(); scrollController.dispose();
super.dispose(); super.dispose();

View File

@ -239,18 +239,20 @@ class SimpleTableInsertActions extends ISimpleTableBottomSheetActions {
SimpleTableInsertAction( SimpleTableInsertAction(
type: SimpleTableMoreAction.insertAbove, type: SimpleTableMoreAction.insertAbove,
enableLeftBorder: true, enableLeftBorder: true,
onTap: () => _onActionTap( onTap: (increaseCounter) async => _onActionTap(
context, context,
SimpleTableMoreAction.insertAbove, type: SimpleTableMoreAction.insertAbove,
increaseCounter: increaseCounter,
), ),
), ),
const HSpace(2), const HSpace(2),
SimpleTableInsertAction( SimpleTableInsertAction(
type: SimpleTableMoreAction.insertBelow, type: SimpleTableMoreAction.insertBelow,
enableRightBorder: true, enableRightBorder: true,
onTap: () => _onActionTap( onTap: (increaseCounter) async => _onActionTap(
context, context,
SimpleTableMoreAction.insertBelow, type: SimpleTableMoreAction.insertBelow,
increaseCounter: increaseCounter,
), ),
), ),
], ],
@ -260,18 +262,20 @@ class SimpleTableInsertActions extends ISimpleTableBottomSheetActions {
SimpleTableInsertAction( SimpleTableInsertAction(
type: SimpleTableMoreAction.insertLeft, type: SimpleTableMoreAction.insertLeft,
enableLeftBorder: true, enableLeftBorder: true,
onTap: () => _onActionTap( onTap: (increaseCounter) async => _onActionTap(
context, context,
SimpleTableMoreAction.insertLeft, type: SimpleTableMoreAction.insertLeft,
increaseCounter: increaseCounter,
), ),
), ),
const HSpace(2), const HSpace(2),
SimpleTableInsertAction( SimpleTableInsertAction(
type: SimpleTableMoreAction.insertRight, type: SimpleTableMoreAction.insertRight,
enableRightBorder: true, enableRightBorder: true,
onTap: () => _onActionTap( onTap: (increaseCounter) async => _onActionTap(
context, context,
SimpleTableMoreAction.insertRight, type: SimpleTableMoreAction.insertRight,
increaseCounter: increaseCounter,
), ),
), ),
], ],
@ -279,7 +283,11 @@ class SimpleTableInsertActions extends ISimpleTableBottomSheetActions {
}; };
} }
void _onActionTap(BuildContext context, SimpleTableMoreAction type) { Future<void> _onActionTap(
BuildContext context, {
required SimpleTableMoreAction type,
required int increaseCounter,
}) async {
final simpleTableContext = context.read<SimpleTableContext>(); final simpleTableContext = context.read<SimpleTableContext>();
final tableNode = cellNode.parentTableNode; final tableNode = cellNode.parentTableNode;
if (tableNode == null) { if (tableNode == null) {
@ -291,34 +299,48 @@ class SimpleTableInsertActions extends ISimpleTableBottomSheetActions {
case SimpleTableMoreAction.insertAbove: case SimpleTableMoreAction.insertAbove:
// update the highlight status for the selecting row // update the highlight status for the selecting row
simpleTableContext.selectingRow.value = cellNode.rowIndex + 1; simpleTableContext.selectingRow.value = cellNode.rowIndex + 1;
editorState.insertRowInTable( await editorState.insertRowInTable(
tableNode, tableNode,
cellNode.rowIndex, cellNode.rowIndex,
); );
case SimpleTableMoreAction.insertBelow: case SimpleTableMoreAction.insertBelow:
editorState.insertRowInTable( await editorState.insertRowInTable(
tableNode, tableNode,
cellNode.rowIndex + 1, cellNode.rowIndex + 1,
); );
// scroll to the next cell position
editorState.scrollService?.scrollTo(
SimpleTableConstants.defaultRowHeight,
duration: Durations.short3,
);
case SimpleTableMoreAction.insertLeft: case SimpleTableMoreAction.insertLeft:
// update the highlight status for the selecting column // update the highlight status for the selecting column
simpleTableContext.selectingColumn.value = cellNode.columnIndex + 1; simpleTableContext.selectingColumn.value = cellNode.columnIndex + 1;
editorState.insertColumnInTable( await editorState.insertColumnInTable(
tableNode, tableNode,
cellNode.columnIndex, cellNode.columnIndex,
); );
case SimpleTableMoreAction.insertRight: case SimpleTableMoreAction.insertRight:
editorState.insertColumnInTable( await editorState.insertColumnInTable(
tableNode, tableNode,
cellNode.columnIndex + 1, cellNode.columnIndex + 1,
); );
final horizontalScrollController =
simpleTableContext.horizontalScrollController;
if (horizontalScrollController != null) {
final previousWidth = horizontalScrollController.offset;
horizontalScrollController.jumpTo(
previousWidth + SimpleTableConstants.defaultColumnWidth,
);
}
default: default:
assert(false, 'Unsupported action: $type'); assert(false, 'Unsupported action: $type');
} }
} }
} }
class SimpleTableInsertAction extends StatelessWidget { class SimpleTableInsertAction extends StatefulWidget {
const SimpleTableInsertAction({ const SimpleTableInsertAction({
super.key, super.key,
required this.type, required this.type,
@ -330,7 +352,16 @@ class SimpleTableInsertAction extends StatelessWidget {
final SimpleTableMoreAction type; final SimpleTableMoreAction type;
final bool enableLeftBorder; final bool enableLeftBorder;
final bool enableRightBorder; final bool enableRightBorder;
final void Function() onTap; final ValueChanged<int> onTap;
@override
State<SimpleTableInsertAction> createState() =>
_SimpleTableInsertActionState();
}
class _SimpleTableInsertActionState extends State<SimpleTableInsertAction> {
// used to count how many times the action is tapped
int increaseCounter = 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -341,19 +372,19 @@ class SimpleTableInsertAction extends StatelessWidget {
shape: _buildBorder(), shape: _buildBorder(),
), ),
child: AnimatedGestureDetector( child: AnimatedGestureDetector(
onTapUp: onTap, onTapUp: () => widget.onTap(increaseCounter++),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(1), padding: const EdgeInsets.all(1),
child: FlowySvg( child: FlowySvg(
type.leftIconSvg, widget.type.leftIconSvg,
size: const Size.square(22), size: const Size.square(22),
), ),
), ),
FlowyText( FlowyText(
type.name, widget.type.name,
fontSize: 12, fontSize: 12,
figmaLineHeight: 16, figmaLineHeight: 16,
), ),
@ -370,10 +401,10 @@ class SimpleTableInsertAction extends StatelessWidget {
); );
return RoundedRectangleBorder( return RoundedRectangleBorder(
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: enableLeftBorder ? radius : Radius.zero, topLeft: widget.enableLeftBorder ? radius : Radius.zero,
bottomLeft: enableLeftBorder ? radius : Radius.zero, bottomLeft: widget.enableLeftBorder ? radius : Radius.zero,
topRight: enableRightBorder ? radius : Radius.zero, topRight: widget.enableRightBorder ? radius : Radius.zero,
bottomRight: enableRightBorder ? radius : Radius.zero, bottomRight: widget.enableRightBorder ? radius : Radius.zero,
), ),
); );
} }
@ -592,7 +623,7 @@ class _SimpleTableHeaderActionButtonState
child: CupertinoSwitch( child: CupertinoSwitch(
value: value, value: value,
activeColor: Theme.of(context).colorScheme.primary, activeColor: Theme.of(context).colorScheme.primary,
onChanged: (_) {}, onChanged: (_) => _toggle(),
), ),
), ),
); );
@ -1198,19 +1229,12 @@ class SimpleTableQuickActions extends StatelessWidget {
SimpleTableMoreAction.copy, SimpleTableMoreAction.copy,
), ),
), ),
FutureBuilder( SimpleTableQuickAction(
future: getIt<ClipboardService>().getData(), type: SimpleTableMoreAction.paste,
builder: (context, snapshot) { onTap: () => _onActionTap(
final hasContent = snapshot.data?.tableJson != null; context,
return SimpleTableQuickAction( SimpleTableMoreAction.paste,
type: SimpleTableMoreAction.paste, ),
isEnabled: hasContent,
onTap: () => _onActionTap(
context,
SimpleTableMoreAction.paste,
),
);
},
), ),
SimpleTableQuickAction( SimpleTableQuickAction(
type: SimpleTableMoreAction.delete, type: SimpleTableMoreAction.delete,

View File

@ -61,8 +61,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: cfb8b1b ref: "9f6a299"
resolved-ref: cfb8b1b6eb06f73a4fb297b6fd1d54b0ccec2922 resolved-ref: "9f6a29968ecbb61678b8e0e8c9d90bcba44a24e3"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git" url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git source: git
version: "4.0.0" version: "4.0.0"

View File

@ -174,7 +174,7 @@ dependency_overrides:
appflowy_editor: appflowy_editor:
git: git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "cfb8b1b" ref: "9f6a299"
appflowy_editor_plugins: appflowy_editor_plugins:
git: git:

View File

@ -1,5 +1,6 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'simple_table_test_helper.dart'; import 'simple_table_test_helper.dart';
@ -193,5 +194,45 @@ void main() {
expect(tableNode.tableAlign, align); expect(tableNode.tableAlign, align);
} }
}); });
test('clear the existing align of the column before updating', () async {
final (editorState, tableNode) = createEditorStateAndTable(
rowCount: 2,
columnCount: 3,
);
final firstCellNode = tableNode.getTableCellNode(
rowIndex: 0,
columnIndex: 0,
);
Node firstParagraphNode = firstCellNode!.children.first;
// format the first paragraph to center align
final transaction = editorState.transaction;
transaction.updateNode(
firstParagraphNode,
{
blockComponentAlign: TableAlign.right.key,
},
);
await editorState.apply(transaction);
firstParagraphNode = editorState.getNodeAtPath([0, 0, 0, 0])!;
expect(
firstParagraphNode.attributes[blockComponentAlign],
TableAlign.right.key,
);
await editorState.updateColumnAlign(
tableCellNode: firstCellNode,
align: TableAlign.center,
);
expect(
firstParagraphNode.attributes[blockComponentAlign],
null,
);
});
}); });
} }