fix: simple table issues (#6985)

* fix: list padding in table cell is too wide

* feat: improve tab in table cell

* feat: improve shift+tab in table cell

* fix: unable to edit cell after deleting an image

* fix: inline attribute issue

* fix: disable dragging a block into table

* feat: add distribute column evenly in column action menu

* fix: numbered list icon align in table cell

* feat: add setToPageWidth and distributeColumnEvenly in table menu

* feat: support highlight color

* chore: update editor version

* test: add setToPageWidth and distributeColumnEvenly in table menu

* test: inline attribute issues

* test: add distribute column evenly in column action menu

* test: select all in table

* test:  improve tab(+shift) shortcut in table cell

* test: improve enter shortcut in table cell

* feat: keep the same column width after using distribute column widths evenly

* test: keep the same column width after using distribute column widths evenly

* test: drag block to other block's child
This commit is contained in:
Lucas 2024-12-16 11:47:08 +08:00 committed by GitHub
parent d74e7b63ca
commit f307300b96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 407 additions and 40 deletions

View File

@ -138,13 +138,6 @@ void main() {
);
});
testWidgets('insert a bmp image from network', (tester) async {
await testEmbedImage(
tester,
'https://people.math.sc.edu/Burkardt/data/bmp/snail.bmp',
);
});
testWidgets('insert a jpg image from network', (tester) async {
await testEmbedImage(
tester,

View File

@ -113,5 +113,41 @@ void main() {
tester.expectToSeeText(formula);
});
testWidgets('insert a inline math equation and type something after it',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'math equation',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a inline page
const formula = 'E = MC ^ 2';
await tester.ime.insertText(formula);
await tester.editor.updateSelection(
Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
);
// tap the inline math equation button
final inlineMathEquationButton = find.findFlowyTooltip(
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
);
await tester.tapButton(inlineMathEquationButton);
// expect to see the math equation block
final inlineMathEquation = find.byType(InlineMathEquation);
expect(inlineMathEquation, findsOneWidget);
await tester.editor.tapLineOfEditorAt(0);
const text = 'Hello World';
await tester.ime.insertText(text);
expect(find.textContaining(text, findRichText: true), findsOneWidget);
});
});
}

View File

@ -94,6 +94,20 @@ void main() {
await tester.tapButton(finder);
expect(find.byType(GridPage), findsOneWidget);
});
testWidgets('insert a inline page and type something after the page',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await insertInlinePage(tester, ViewLayoutPB.Grid);
await tester.editor.tapLineOfEditorAt(0);
const text = 'Hello World';
await tester.ime.insertText(text);
expect(find.textContaining(text, findRichText: true), findsOneWidget);
});
});
}

View File

@ -406,6 +406,160 @@ void main() {
final afterWidth = tableNode.width;
expect(afterWidth, equals(beforeWidth));
final distributeColumnWidthsEvenly =
tableNode.attributes[SimpleTableBlockKeys.distributeColumnWidthsEvenly];
expect(distributeColumnWidthsEvenly, isTrue);
});
testWidgets('distribute columns evenly (2)', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
final tableNode = tester.editor.getNodeAtPath([0]);
final beforeWidth = tableNode.width;
// set the column width to page width
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.distributeColumnsEvenly,
);
await tester.pumpAndSettle();
final afterWidth = tableNode.width;
expect(afterWidth, equals(beforeWidth));
final distributeColumnWidthsEvenly =
tableNode.attributes[SimpleTableBlockKeys.distributeColumnWidthsEvenly];
expect(distributeColumnWidthsEvenly, isTrue);
});
testWidgets('using option menu to set column width', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
await tester.editor.hoverAndClickOptionMenuButton([0]);
final editorState = tester.editor.getCurrentEditorState();
final beforeWidth = editorState.document.nodeAtPath([0])!.width;
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_setToPageWidth.tr(),
),
);
await tester.pumpAndSettle();
final afterWidth = editorState.document.nodeAtPath([0])!.width;
expect(afterWidth, greaterThan(beforeWidth));
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButton(
find.text(
LocaleKeys
.document_plugins_simpleTable_moreActions_distributeColumnsWidth
.tr(),
),
);
await tester.pumpAndSettle();
final afterWidth2 = editorState.document.nodeAtPath([0])!.width;
expect(afterWidth2, equals(afterWidth));
});
testWidgets('insert a table and use select all the delete it',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
await tester.editor.tapLineOfEditorAt(1);
await tester.ime.insertText('Hello World');
// select all
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isMetaPressed: UniversalPlatform.isMacOS,
isControlPressed: !UniversalPlatform.isMacOS,
);
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
// only one paragraph left
expect(editorState.document.root.children.length, 1);
final paragraphNode = editorState.document.nodeAtPath([0])!;
expect(paragraphNode.delta, isNull);
});
testWidgets('use tab or shift+tab to navigate in table', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
await tester.simulateKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final selection = editorState.selection;
expect(selection, isNotNull);
expect(selection!.start.path, [0, 0, 1, 0]);
expect(selection.end.path, [0, 0, 1, 0]);
await tester.simulateKeyEvent(
LogicalKeyboardKey.tab,
isShiftPressed: true,
);
await tester.pumpAndSettle();
final selection2 = editorState.selection;
expect(selection2, isNotNull);
expect(selection2!.start.path, [0, 0, 0, 0]);
expect(selection2.end.path, [0, 0, 0, 0]);
});
testWidgets('shift+enter to insert a new line in table', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
await tester.simulateKeyEvent(
LogicalKeyboardKey.enter,
isShiftPressed: true,
);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final node = editorState.document.nodeAtPath([0, 0, 0])!;
expect(node.children.length, 1);
});
}

View File

@ -125,6 +125,14 @@ List<OptionAction> _buildOptionActions(BuildContext context, String type) {
standardActions.addAll([OptionAction.divider, OptionAction.depth]);
}
if (SimpleTableBlockKeys.type == type) {
standardActions.addAll([
OptionAction.divider,
OptionAction.setToPageWidth,
OptionAction.distributeColumnsEvenly,
]);
}
return standardActions;
}
@ -150,6 +158,7 @@ void _customBlockOptionActions(
}
return true;
};
builder.configuration = builder.configuration.copyWith(
blockSelectionAreaMargin: (_) => const EdgeInsets.symmetric(
vertical: 1,
@ -163,7 +172,7 @@ void _customBlockOptionActions(
if ((type == HeadingBlockKeys.type ||
type == ToggleListBlockKeys.type) &&
level > 0) {
final offset = [14.0, 11.0, 8.0, 6.0, 4.0, 2.0];
final offset = [13.0, 11.0, 8.0, 6.0, 4.0, 2.0];
top += offset[level - 1];
} else if (type == SimpleTableBlockKeys.type) {
top += 8.0;

View File

@ -144,6 +144,11 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
_initEditorL10n();
_initializeShortcuts();
AppFlowyRichTextKeys.partialSliced.addAll([
MentionBlockKeys.mention,
InlineMathEquationKeys.formula,
]);
indentableBlockTypes.add(ToggleListBlockKeys.type);
convertibleBlockTypes.add(ToggleListBlockKeys.type);
slashMenuItems = _customSlashMenuItems();

View File

@ -43,6 +43,12 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
case OptionAction.copyLinkToBlock:
await _copyLinkToBlock(node);
break;
case OptionAction.setToPageWidth:
await _setToPageWidth(node);
break;
case OptionAction.distributeColumnsEvenly:
await _distributeColumnsEvenly(node);
break;
case OptionAction.align:
case OptionAction.color:
case OptionAction.divider:
@ -657,4 +663,20 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
// then updating the selection with the beforeSelection that may contains multiple blocks
return beforeSelection;
}
Future<void> _setToPageWidth(Node node) async {
if (node.type != SimpleTableBlockKeys.type) {
return;
}
await editorState.setColumnWidthToPageWidth(tableNode: node);
}
Future<void> _distributeColumnsEvenly(Node node) async {
if (node.type != SimpleTableBlockKeys.type) {
return;
}
await editorState.distributeColumnWidthToPageWidth(tableNode: node);
}
}

View File

@ -86,6 +86,7 @@ class _DraggableOptionButtonState extends State<DraggableOptionButton> {
details.globalPosition,
builder: (context, data) {
return VisualDragArea(
editorState: widget.editorState,
data: data,
dragNode: widget.blockComponentContext.node,
);

View File

@ -1,3 +1,4 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
@ -37,13 +38,24 @@ Future<void> dragToMoveNode(
// Determine the new path based on drop position
// For VerticalPosition.top, we keep the target node's path
if (verticalPosition == VerticalPosition.bottom) {
newPath = horizontalPosition == HorizontalPosition.left
? newPath.next // Insert after target node
: newPath.child(0); // Insert as first child of target node
if (horizontalPosition == HorizontalPosition.left) {
newPath = newPath.next;
final node = editorState.document.nodeAtPath(newPath);
if (node == null) {
// if node is null, it means the node is the last one of the document.
newPath = targetNode.path;
}
} else {
newPath = newPath.child(0);
}
}
// Check if the drop should be ignored
if (shouldIgnoreDragTarget(node, newPath)) {
if (shouldIgnoreDragTarget(
editorState: editorState,
dragNode: node,
targetPath: newPath,
)) {
Log.info(
'Drop ignored: node($node, ${node.path}), path($acceptedPath)',
);
@ -110,7 +122,11 @@ Future<void> dragToMoveNode(
return (verticalPosition, horizontalPosition, globalBlockRect);
}
bool shouldIgnoreDragTarget(Node dragNode, Path? targetPath) {
bool shouldIgnoreDragTarget({
required EditorState editorState,
required Node dragNode,
required Path? targetPath,
}) {
if (targetPath == null) {
return true;
}
@ -123,5 +139,10 @@ bool shouldIgnoreDragTarget(Node dragNode, Path? targetPath) {
return true;
}
final targetNode = editorState.getNodeAtPath(targetPath);
if (targetNode != null && targetNode.isInTable) {
return true;
}
return false;
}

View File

@ -10,16 +10,21 @@ class VisualDragArea extends StatelessWidget {
super.key,
required this.data,
required this.dragNode,
required this.editorState,
});
final DragAreaBuilderData data;
final Node dragNode;
final EditorState editorState;
@override
Widget build(BuildContext context) {
final targetNode = data.targetNode;
final ignore = shouldIgnoreDragTarget(dragNode, targetNode.path);
final ignore = shouldIgnoreDragTarget(
editorState: editorState,
dragNode: dragNode,
targetPath: targetNode.path,
);
if (ignore) {
return const SizedBox.shrink();
}

View File

@ -67,7 +67,13 @@ enum OptionAction {
color,
divider,
align,
depth;
// Outline block
depth,
// Simple table
setToPageWidth,
distributeColumnsEvenly;
FlowySvgData get svg {
switch (this) {
@ -91,6 +97,10 @@ enum OptionAction {
return FlowySvgs.tag_s;
case OptionAction.copyLinkToBlock:
return FlowySvgs.share_tab_copy_s;
case OptionAction.setToPageWidth:
return FlowySvgs.table_set_to_page_width_s;
case OptionAction.distributeColumnsEvenly:
return FlowySvgs.table_distribute_columns_evenly_s;
}
}
@ -116,6 +126,14 @@ enum OptionAction {
return LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr();
case OptionAction.divider:
throw UnsupportedError('Divider does not have description');
case OptionAction.setToPageWidth:
return LocaleKeys
.document_plugins_simpleTable_moreActions_setToPageWidth
.tr();
case OptionAction.distributeColumnsEvenly:
return LocaleKeys
.document_plugins_simpleTable_moreActions_distributeColumnsWidth
.tr();
}
}
}

View File

@ -30,7 +30,7 @@ class NumberedListIcon extends StatelessWidget {
minWidth: size,
minHeight: size,
),
margin: const EdgeInsets.only(right: 8.0),
margin: const EdgeInsets.only(top: 0.5, right: 8.0),
alignment: Alignment.center,
child: Center(
child: Text(

View File

@ -47,6 +47,7 @@ List<CommandShortcutEvent> commandShortcutEvents = [
undoCommand,
redoCommand,
exitEditingCommand,
...tableCommands,
].contains(shortcut),
),

View File

@ -48,6 +48,13 @@ class SimpleTableBlockKeys {
// column widths
// it's a `SimpleTableColumnWidthMap` value, {column_index: width, ...}
static const String columnWidths = 'column_widths';
// distribute column widths evenly
// if the user distributed the column widths evenly before, the value should be true,
// and for the newly added column, using the width of the previous column.
// it's a bool value, default is false
static const String distributeColumnWidthsEvenly =
'distribute_column_widths_evenly';
}
Node simpleTableBlockNode({

View File

@ -187,8 +187,17 @@ class SimpleTableCellBlockWidgetState extends State<SimpleTableCellBlockWidget>
},
);
},
child: Column(
children: node.children.map(_buildCellContent).toList(),
child: Container(
padding: SimpleTableConstants.cellEdgePadding,
constraints: const BoxConstraints(
minWidth: SimpleTableConstants.minimumColumnWidth,
),
width: node.columnWidth,
child: node.children.isEmpty
? _buildEmptyCellContent()
: Column(
children: node.children.map(_buildCellContent).toList(),
),
),
),
);
@ -196,21 +205,33 @@ class SimpleTableCellBlockWidgetState extends State<SimpleTableCellBlockWidget>
Widget _buildCellContent(Node childNode) {
final alignment = _buildAlignment();
return Container(
padding: SimpleTableConstants.cellEdgePadding,
constraints: const BoxConstraints(
minWidth: SimpleTableConstants.minimumColumnWidth,
),
width: node.columnWidth,
return Align(
alignment: alignment,
child: IntrinsicWidth(
child: IntrinsicHeight(
child: editorState.renderer.build(context, childNode),
),
child: editorState.renderer.build(context, childNode),
),
);
}
Widget _buildEmptyCellContent() {
// if the table cell is empty, we should allow the user to tap on it to create a new paragraph.
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
final transaction = editorState.transaction;
final path = node.path.child(0);
transaction
..insertNode(
path,
paragraphNode(),
)
..afterSelection = Selection.collapsed(Position(path: path));
editorState.apply(transaction);
},
);
}
Widget _buildRowMoreActionButton() {
final rowIndex = node.rowIndex;
@ -252,7 +273,12 @@ class SimpleTableCellBlockWidgetState extends State<SimpleTableCellBlockWidget>
}
Color? _buildBackgroundColor() {
// Priority: column color > row color > header color > default color
// Priority: highlight color > column color > row color > header color > default color
final isSelectingTable =
simpleTableContext?.isSelectingTable.value ?? false;
if (isSelectingTable) {
return Theme.of(context).colorScheme.primary.withOpacity(0.1);
}
final columnColor = node.buildColumnColor(context);
if (columnColor != null && columnColor != Colors.transparent) {

View File

@ -47,6 +47,7 @@ enum SimpleTableMoreActionType {
SimpleTableMoreAction.align,
SimpleTableMoreAction.divider,
SimpleTableMoreAction.setToPageWidth,
SimpleTableMoreAction.distributeColumnsEvenly,
SimpleTableMoreAction.divider,
SimpleTableMoreAction.duplicate,
SimpleTableMoreAction.clearContents,

View File

@ -167,6 +167,16 @@ extension TableMapOperation on Node {
comparator: (iKey, index) => iKey >= index,
);
final bool distributeColumnWidthsEvenly =
attributes[SimpleTableBlockKeys.distributeColumnWidthsEvenly] ??
false;
if (distributeColumnWidthsEvenly) {
// if the distribute column widths evenly flag is true,
// we should distribute the column widths evenly
columnWidths[index.toString()] = columnWidths.values.firstOrNull;
}
return attributes
.mergeValues(
SimpleTableBlockKeys.columnColors,

View File

@ -210,6 +210,11 @@ extension TableNodeExtension on Node {
return tableCellNode;
}
/// Whether the current node is in a table.
bool get isInTable {
return parentTableNode != null;
}
double get columnWidth {
final parentTableNode = this.parentTableNode;

View File

@ -95,6 +95,8 @@ extension TableOptionOperation on EditorState {
double.infinity,
),
},
// reset the distribute column widths evenly flag
SimpleTableBlockKeys.distributeColumnWidthsEvenly: false,
});
await apply(transaction);
}
@ -276,6 +278,7 @@ extension TableOptionOperation on EditorState {
}
transaction.updateNode(tableNode, {
SimpleTableBlockKeys.columnWidths: columnWidths,
SimpleTableBlockKeys.distributeColumnWidthsEvenly: false,
});
await apply(transaction);
}
@ -315,6 +318,7 @@ extension TableOptionOperation on EditorState {
}
transaction.updateNode(tableNode, {
SimpleTableBlockKeys.columnWidths: columnWidths,
SimpleTableBlockKeys.distributeColumnWidthsEvenly: true,
});
await apply(transaction);
}

View File

@ -39,7 +39,7 @@ class SimpleTableRowBlockComponentBuilder extends BlockComponentBuilder {
}
@override
BlockComponentValidate get validate => (node) => node.children.isNotEmpty;
BlockComponentValidate get validate => (_) => true;
}
class SimpleTableRowBlockWidget extends BlockComponentStatefulWidget {
@ -72,6 +72,10 @@ class _SimpleTableRowBlockWidgetState extends State<SimpleTableRowBlockWidget>
@override
Widget build(BuildContext context) {
if (node.children.isEmpty) {
return const SizedBox.shrink();
}
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,

View File

@ -21,13 +21,13 @@ KeyEventResult _backspaceInTableCellHandler(EditorState editorState) {
}
final onlyContainsOneChild = tableCellNode.children.length == 1;
final isParagraphNode =
tableCellNode.children.first.type == ParagraphBlockKeys.type;
final isCodeBlock = tableCellNode.children.first.type == CodeBlockKeys.type;
final firstChild = tableCellNode.children.first;
final isParagraphNode = firstChild.type == ParagraphBlockKeys.type;
final isCodeBlock = firstChild.type == CodeBlockKeys.type;
if (onlyContainsOneChild &&
selection.isCollapsed &&
selection.end.offset == 0) {
if (isParagraphNode) {
if (isParagraphNode && firstChild.children.isEmpty) {
return KeyEventResult.skipRemainingHandlers;
} else if (isCodeBlock) {
// replace the codeblock with a paragraph

View File

@ -65,6 +65,10 @@ extension TableCommandExtension on EditorState {
return KeyEventResult.ignored;
}
if (isOutdentable(editorState)) {
return outdentCommand.execute(editorState);
}
Selection? newSelection;
final previousCell = tableCellNode.getPreviousCellInSameRow();
@ -126,6 +130,10 @@ extension TableCommandExtension on EditorState {
Selection? newSelection;
if (isIndentable(editorState)) {
return indentCommand.execute(editorState);
}
final nextCell = tableCellNode.getNextCellInSameRow();
if (nextCell != null && !nextCell.path.equals(tableCellNode.path)) {
// get the first children of the next cell

View File

@ -1,6 +1,8 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
final CommandShortcutEvent enterInTableCell = CommandShortcutEvent(
key: 'Press enter in table cell',
@ -15,10 +17,31 @@ KeyEventResult _enterInTableCellHandler(EditorState editorState) {
if (!isInTableCell ||
selection == null ||
tableCellNode == null ||
node == null) {
node == null ||
!selection.isCollapsed) {
return KeyEventResult.ignored;
}
// forward the enter command to the insertNewLine character command to support multi-line text in table cell
return KeyEventResult.skipRemainingHandlers;
// check if the shift key is pressed, if so, we should return false to let the system handle it.
final isShiftPressed = HardwareKeyboard.instance.isShiftPressed;
if (isShiftPressed) {
return KeyEventResult.ignored;
}
final delta = node.delta;
if (!indentableBlockTypes.contains(node.type) || delta == null) {
return KeyEventResult.ignored;
}
if (selection.startIndex == 0 && delta.isEmpty) {
// clear the style
if (node.parent?.type != SimpleTableCellBlockKeys.type) {
if (outdentCommand.execute(editorState) == KeyEventResult.handled) {
return KeyEventResult.handled;
}
}
return convertToParagraphCommand.execute(editorState);
}
return KeyEventResult.ignored;
}

View File

@ -61,8 +61,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "954ffa5"
resolved-ref: "954ffa538e0788a5d25dc16555ff80a3afae5ea3"
ref: ca04a67
resolved-ref: ca04a675c06071678ef5901d6000cc4d6ac745e8
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "4.0.0"

View File

@ -173,7 +173,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "954ffa5"
ref: "ca04a67"
appflowy_editor_plugins:
git: