feat: support column and row reordering in table (#6912)

* chore: update changelog

* feat: add draggable in table reorder button

* feat: support displaying text color, background color and font item in table cell

* feat: separate gestures for popup menu and drag operations

* feat: support feedback mode for table

* feat: build dummy node to render table feedback

* feat: disable column resize handle when dragging column

* feat: higtlight the cell border when dragging

* fix: unable to reorder in row

* fix: do not rebuild the reorder button when reordering

* feat: add reorder logic and tests

* feat: reorder column

* feat: reorder row

* test: reorder row

* fix: table attributes are broken after reordering

* chore: remove unused listerner

* chore: code refactor

* fix: remove unused code

* feat: support rendering table feedback

* fix: unit test
This commit is contained in:
Lucas 2024-12-06 09:22:32 +08:00 committed by GitHub
parent dddf5aa195
commit 67fe0d6bfd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1887 additions and 494 deletions

View File

@ -109,18 +109,12 @@ class TextRobot {
Position(path: next),
);
await editorState.apply(transaction);
debugPrint(
'AI insertNewParagraph: path: ${editorState.selection!.end.path}, index: ${editorState.selection!.endIndex}',
);
await Future.delayed(const Duration(milliseconds: 10));
}
Future<void> insertText(String text, Duration delay) async {
final selection = editorState.selection;
debugPrint(
'AI insertText: get selection, path: ${selection!.end.path}, index: ${selection.endIndex}',
);
if (!selection.isCollapsed) {
if (selection == null || !selection.isCollapsed) {
return;
}
final node = editorState.getNodeAtPath(selection.end.path);

View File

@ -1,5 +1,3 @@
import 'dart:ui';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
@ -14,47 +12,6 @@ import 'package:provider/provider.dart';
/// when hovering on the last row / last column / last cell.
bool _enableHoveringLogicV2 = true;
class SimpleTableReorderButton extends StatelessWidget {
const SimpleTableReorderButton({
super.key,
required this.isShowingMenu,
required this.type,
});
final ValueNotifier<bool> isShowingMenu;
final SimpleTableMoreActionType type;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: isShowingMenu,
builder: (context, isShowingMenu, child) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
decoration: BoxDecoration(
color: isShowingMenu
? context.simpleTableMoreActionHoverColor
: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8.0),
border: Border.all(
color: context.simpleTableMoreActionBorderColor,
),
),
height: 16.0,
width: 16.0,
child: FlowySvg(
type.reorderIconSvg,
color: isShowingMenu ? Colors.white : null,
size: const Size.square(16.0),
),
),
);
},
);
}
}
class SimpleTableAddRowHoverButton extends StatefulWidget {
const SimpleTableAddRowHoverButton({
super.key,
@ -549,96 +506,6 @@ class SimpleTableBasicButton extends StatelessWidget {
}
}
class SimpleTableColumnResizeHandle extends StatefulWidget {
const SimpleTableColumnResizeHandle({
super.key,
required this.node,
});
final Node node;
@override
State<SimpleTableColumnResizeHandle> createState() =>
_SimpleTableColumnResizeHandleState();
}
class _SimpleTableColumnResizeHandleState
extends State<SimpleTableColumnResizeHandle> {
bool isStartDragging = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
onEnter: (event) => context
.read<SimpleTableContext>()
.hoveringOnResizeHandle
.value = widget.node,
onExit: (event) {
Future.delayed(const Duration(milliseconds: 100), () {
// the onExit event will be triggered before dragging started.
// delay the hiding of the resize handle to avoid flickering.
if (!isStartDragging) {
context.read<SimpleTableContext>().hoveringOnResizeHandle.value =
null;
}
});
},
child: GestureDetector(
onHorizontalDragStart: (details) {
// disable the two-finger drag on trackpad
if (details.kind == PointerDeviceKind.trackpad) {
return;
}
isStartDragging = true;
},
onHorizontalDragUpdate: (details) {
if (!isStartDragging) {
return;
}
context.read<EditorState>().updateColumnWidthInMemory(
tableCellNode: widget.node,
deltaX: details.delta.dx,
);
},
onHorizontalDragEnd: (details) {
if (!isStartDragging) {
return;
}
context.read<SimpleTableContext>().hoveringOnResizeHandle.value =
null;
isStartDragging = false;
context.read<EditorState>().updateColumnWidth(
tableCellNode: widget.node,
width: widget.node.columnWidth,
);
},
child: ValueListenableBuilder<Node?>(
valueListenable: context.read<SimpleTableContext>().hoveringTableCell,
builder: (context, hoveringCell, child) {
return ValueListenableBuilder(
valueListenable:
context.read<SimpleTableContext>().hoveringOnResizeHandle,
builder: (context, hoveringOnResizeHandle, child) {
final isSameRowIndex = hoveringOnResizeHandle?.columnIndex ==
widget.node.columnIndex;
return Opacity(
opacity: isSameRowIndex ? 1.0 : 0.0,
child: Container(
height: double.infinity,
width: SimpleTableConstants.resizeHandleWidth,
color: Theme.of(context).colorScheme.primary,
),
);
},
);
},
),
),
);
}
}
class SimpleTableBackgroundColorMenu extends StatefulWidget {
const SimpleTableBackgroundColorMenu({
super.key,

View File

@ -1,7 +1,7 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
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_constants.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -187,16 +187,21 @@ class _SimpleTableBlockWidgetState extends State<SimpleTableBlockWidget>
@override
Widget build(BuildContext context) {
Widget child = Transform.translate(
offset: Offset(
UniversalPlatform.isDesktop
? -SimpleTableConstants.tableLeftPadding
: 0,
0,
),
child: _buildTable(),
Widget child = SimpleTableWidget(
node: node,
simpleTableContext: simpleTableContext,
);
if (UniversalPlatform.isDesktop) {
child = Transform.translate(
offset: const Offset(
-SimpleTableConstants.tableLeftPadding,
0,
),
child: child,
);
}
child = Container(
alignment: Alignment.topLeft,
padding: padding,
@ -228,111 +233,6 @@ class _SimpleTableBlockWidgetState extends State<SimpleTableBlockWidget>
return child;
}
Widget _buildTable() {
if (UniversalPlatform.isDesktop) {
return _buildDesktopTable();
} else {
return _buildMobileTable();
}
}
Widget _buildDesktopTable() {
// IntrinsicWidth and IntrinsicHeight are used to make the table size fit the content.
return MouseRegion(
onEnter: (event) => simpleTableContext.isHoveringOnTableArea.value = true,
onExit: (event) {
simpleTableContext.isHoveringOnTableArea.value = false;
},
child: Provider.value(
value: simpleTableContext,
child: Stack(
children: [
MouseRegion(
hitTestBehavior: HitTestBehavior.opaque,
onEnter: (event) =>
simpleTableContext.isHoveringOnColumnsAndRows.value = true,
onExit: (event) {
simpleTableContext.isHoveringOnColumnsAndRows.value = false;
simpleTableContext.hoveringTableCell.value = null;
},
child: Scrollbar(
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
child: Padding(
padding: SimpleTableConstants.tablePadding,
child: IntrinsicWidth(
child: IntrinsicHeight(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildRows(),
),
),
),
),
),
),
),
if (UniversalPlatform.isDesktop) ...[
SimpleTableAddColumnHoverButton(
editorState: editorState,
node: node,
),
SimpleTableAddRowHoverButton(
editorState: editorState,
tableNode: node,
),
SimpleTableAddColumnAndRowHoverButton(
editorState: editorState,
node: node,
),
],
],
),
),
);
}
Widget _buildMobileTable() {
return Provider.value(
value: simpleTableContext,
child: SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
child: IntrinsicWidth(
child: IntrinsicHeight(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildRows(),
),
),
),
),
);
}
List<Widget> _buildRows() {
final List<Widget> rows = [];
if (SimpleTableConstants.borderType == SimpleTableBorderRenderType.table) {
rows.add(const SimpleTableColumnDivider());
}
for (final child in node.children) {
rows.add(editorState.renderer.build(context, child));
if (SimpleTableConstants.borderType ==
SimpleTableBorderRenderType.table) {
rows.add(const SimpleTableColumnDivider());
}
}
return rows;
}
void _onSelectionChanged() {
final selection = editorState.selectionNotifier.value;
final selectionType = editorState.selectionType;

View File

@ -1,5 +1,6 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.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_border_builder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_column_resize_handle.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -79,6 +80,11 @@ class SimpleTableCellBlockWidgetState extends State<SimpleTableCellBlockWidget>
late SimpleTableContext? simpleTableContext =
context.read<SimpleTableContext?>();
late final borderBuilder = SimpleTableBorderBuilder(
context: context,
simpleTableContext: simpleTableContext!,
node: node,
);
ValueNotifier<bool> isEditingCellNotifier = ValueNotifier(false);
@ -120,17 +126,19 @@ class SimpleTableCellBlockWidgetState extends State<SimpleTableCellBlockWidget>
clipBehavior: Clip.none,
children: [
_buildCell(),
Positioned(
top: 0,
bottom: 0,
left: -SimpleTableConstants.tableLeftPadding,
child: _buildRowMoreActionButton(),
),
Positioned(
left: 0,
right: 0,
child: _buildColumnMoreActionButton(),
),
if (node.columnIndex == 0)
Positioned(
top: 0,
bottom: 0,
left: -SimpleTableConstants.tableLeftPadding,
child: _buildRowMoreActionButton(),
),
if (node.rowIndex == 0)
Positioned(
left: 0,
right: 0,
child: _buildColumnMoreActionButton(),
),
Positioned(
right: 0,
top: node.rowIndex == 0 ? SimpleTableConstants.tableTopPadding : 0,
@ -156,27 +164,34 @@ class SimpleTableCellBlockWidgetState extends State<SimpleTableCellBlockWidget>
padding: EdgeInsets.only(
top: node.rowIndex == 0 ? SimpleTableConstants.tableTopPadding : 0,
),
// TODO(Lucas): find a better way to handle the multiple value listenable builder
// There's flutter pub can do that.
child: ValueListenableBuilder<bool>(
valueListenable: isEditingCellNotifier,
builder: (context, isEditingCell, child) {
return ValueListenableBuilder(
valueListenable: simpleTableContext!.selectingColumn,
builder: (context, selectingColumn, child) {
builder: (context, selectingColumn, _) {
return ValueListenableBuilder(
valueListenable: simpleTableContext!.selectingRow,
builder: (context, selectingRow, _) {
return DecoratedBox(
decoration: _buildDecoration(),
child: child!,
return ValueListenableBuilder(
valueListenable: simpleTableContext!.hoveringTableCell,
builder: (context, hoveringTableCell, _) {
return DecoratedBox(
decoration: _buildDecoration(),
child: child!,
);
},
);
},
);
},
child: Column(
children: node.children.map(_buildCellContent).toList(),
),
);
},
child: Column(
children: node.children.map(_buildCellContent).toList(),
),
),
);
}
@ -199,13 +214,8 @@ class SimpleTableCellBlockWidgetState extends State<SimpleTableCellBlockWidget>
}
Widget _buildRowMoreActionButton() {
final columnIndex = node.columnIndex;
final rowIndex = node.rowIndex;
if (columnIndex != 0) {
return const SizedBox.shrink();
}
return SimpleTableMoreActionMenu(
index: rowIndex,
type: SimpleTableMoreActionType.row,
@ -214,11 +224,6 @@ class SimpleTableCellBlockWidgetState extends State<SimpleTableCellBlockWidget>
Widget _buildColumnMoreActionButton() {
final columnIndex = node.columnIndex;
final rowIndex = node.rowIndex;
if (rowIndex != 0) {
return const SizedBox.shrink();
}
return SimpleTableMoreActionMenu(
index: columnIndex,
@ -238,7 +243,9 @@ class SimpleTableCellBlockWidgetState extends State<SimpleTableCellBlockWidget>
Decoration _buildDecoration() {
final backgroundColor = _buildBackgroundColor();
final border = _buildBorder();
final border = borderBuilder.buildBorder(
isEditingCell: isEditingCellNotifier.value,
);
return BoxDecoration(
border: border,
@ -269,29 +276,6 @@ class SimpleTableCellBlockWidgetState extends State<SimpleTableCellBlockWidget>
return Theme.of(context).colorScheme.surface;
}
Border? _buildBorder() {
if (SimpleTableConstants.borderType != SimpleTableBorderRenderType.cell) {
return null;
}
final tableContext = context.watch<SimpleTableContext>();
final isCellInSelectedColumn =
node.columnIndex == tableContext.selectingColumn.value;
final isCellInSelectedRow =
node.rowIndex == tableContext.selectingRow.value;
if (tableContext.isSelectingTable.value) {
return _buildSelectingTableBorder();
} else if (isCellInSelectedColumn) {
return _buildColumnBorder();
} else if (isCellInSelectedRow) {
return _buildRowBorder();
} else if (isEditingCellNotifier.value) {
return _buildEditingBorder();
} else {
return _buildCellBorder();
}
}
bool _isInHeader() {
final isHeaderColumnEnabled = node.isHeaderColumnEnabled;
final isHeaderRowEnabled = node.isHeaderRowEnabled;
@ -303,111 +287,6 @@ class SimpleTableCellBlockWidgetState extends State<SimpleTableCellBlockWidget>
isHeaderRowEnabled && isFirstColumn;
}
/// the column border means the `VERTICAL` border of the cell
///
/// ____
/// | 1 | 2 |
/// | 3 | 4 |
/// |___|
///
/// the border wrapping the cell 2 and cell 4 is the column border
Border _buildColumnBorder() {
return Border(
left: _buildHighlightBorderSide(),
right: _buildHighlightBorderSide(),
top: node.rowIndex == 0
? _buildHighlightBorderSide()
: _buildDefaultBorderSide(),
bottom: node.rowIndex + 1 == node.parentTableNode?.rowLength
? _buildHighlightBorderSide()
: _buildDefaultBorderSide(),
);
}
/// the row border means the `HORIZONTAL` border of the cell
///
/// ________
/// | 1 | 2 |
/// |_______|
/// | 3 | 4 |
///
/// the border wrapping the cell 1 and cell 2 is the row border
Border _buildRowBorder() {
return Border(
top: _buildHighlightBorderSide(),
bottom: _buildHighlightBorderSide(),
left: node.columnIndex == 0
? _buildHighlightBorderSide()
: _buildDefaultBorderSide(),
right: node.columnIndex + 1 == node.parentTableNode?.columnLength
? _buildHighlightBorderSide()
: _buildDefaultBorderSide(),
);
}
Border _buildCellBorder() {
return Border(
top: node.rowIndex == 0
? _buildDefaultBorderSide()
: _buildLightBorderSide(),
bottom: node.rowIndex + 1 == node.parentTableNode?.rowLength
? _buildDefaultBorderSide()
: _buildLightBorderSide(),
left: node.columnIndex == 0
? _buildDefaultBorderSide()
: _buildLightBorderSide(),
right: node.columnIndex + 1 == node.parentTableNode?.columnLength
? _buildDefaultBorderSide()
: _buildLightBorderSide(),
);
}
Border _buildEditingBorder() {
return Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
);
}
Border _buildSelectingTableBorder() {
final rowIndex = node.rowIndex;
final columnIndex = node.columnIndex;
return Border(
top:
rowIndex == 0 ? _buildHighlightBorderSide() : _buildLightBorderSide(),
bottom: rowIndex + 1 == node.parentTableNode?.rowLength
? _buildHighlightBorderSide()
: _buildLightBorderSide(),
left: columnIndex == 0
? _buildHighlightBorderSide()
: _buildLightBorderSide(),
right: columnIndex + 1 == node.parentTableNode?.columnLength
? _buildHighlightBorderSide()
: _buildLightBorderSide(),
);
}
BorderSide _buildHighlightBorderSide() {
return BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
);
}
BorderSide _buildLightBorderSide() {
return BorderSide(
color: context.simpleTableBorderColor,
width: 0.5,
);
}
BorderSide _buildDefaultBorderSide() {
return BorderSide(
color: context.simpleTableBorderColor,
);
}
void _onSelectingTableChanged() {
if (mounted) {
setState(() {});

View File

@ -5,11 +5,11 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
const enableTableDebugLog = false;
const _enableTableDebugLog = false;
class SimpleTableContext {
SimpleTableContext() {
if (enableTableDebugLog) {
if (_enableTableDebugLog) {
isHoveringOnColumnsAndRows.addListener(
_onHoveringOnColumnsAndRowsChanged,
);
@ -21,39 +21,56 @@ class SimpleTableContext {
selectingRow.addListener(_onSelectingRowChanged);
isSelectingTable.addListener(_onSelectingTableChanged);
isHoveringOnTableBlock.addListener(_onHoveringOnTableBlockChanged);
isReorderingColumn.addListener(_onDraggingColumnChanged);
isReorderingRow.addListener(_onDraggingRowChanged);
}
}
// the area only contains the columns and rows,
// the add row button, add column button, and add column and row button are not part of the table area
/// the area only contains the columns and rows,
/// the add row button, add column button, and add column and row button are not part of the table area
final ValueNotifier<bool> isHoveringOnColumnsAndRows = ValueNotifier(false);
// the table area contains the columns and rows,
// the add row button, add column button, and add column and row button are not part of the table area,
// not including the selection area and padding
/// the table area contains the columns and rows,
/// the add row button, add column button, and add column and row button are not part of the table area,
/// not including the selection area and padding
final ValueNotifier<bool> isHoveringOnTableArea = ValueNotifier(false);
// the table block area contains the table area and the add row button, add column button, and add column and row button
// also, the table block area contains the selection area and padding
/// the table block area contains the table area and the add row button, add column button, and add column and row button
/// also, the table block area contains the selection area and padding
final ValueNotifier<bool> isHoveringOnTableBlock = ValueNotifier(false);
// the hovering table cell is the cell that the mouse is hovering on
/// the hovering table cell is the cell that the mouse is hovering on
final ValueNotifier<Node?> hoveringTableCell = ValueNotifier(null);
// the hovering on resize handle is the resize handle that the mouse is hovering on
/// the hovering on resize handle is the resize handle that the mouse is hovering on
final ValueNotifier<Node?> hoveringOnResizeHandle = ValueNotifier(null);
// the selecting column is the column that the user is selecting
/// the selecting column is the column that the user is selecting
final ValueNotifier<int?> selectingColumn = ValueNotifier(null);
// the selecting row is the row that the user is selecting
/// the selecting row is the row that the user is selecting
final ValueNotifier<int?> selectingRow = ValueNotifier(null);
// the is selecting table is the table that the user is selecting
/// the is selecting table is the table that the user is selecting
final ValueNotifier<bool> isSelectingTable = ValueNotifier(false);
/// isReorderingColumn is a tuple of (isReordering, columnIndex)
final ValueNotifier<(bool, int)> isReorderingColumn =
ValueNotifier((false, -1));
/// isReorderingRow is a tuple of (isReordering, rowIndex)
final ValueNotifier<(bool, int)> isReorderingRow = ValueNotifier((false, -1));
/// reorderingOffset is the offset of the reordering
//
/// This value is only available when isReordering is true
final ValueNotifier<Offset> reorderingOffset = ValueNotifier(Offset.zero);
bool get isReordering =>
isReorderingColumn.value.$1 || isReorderingRow.value.$1;
void _onHoveringOnColumnsAndRowsChanged() {
if (!enableTableDebugLog) {
if (!_enableTableDebugLog) {
return;
}
@ -61,7 +78,7 @@ class SimpleTableContext {
}
void _onHoveringTableNodeChanged() {
if (!enableTableDebugLog) {
if (!_enableTableDebugLog) {
return;
}
@ -74,7 +91,7 @@ class SimpleTableContext {
}
void _onSelectingColumnChanged() {
if (!enableTableDebugLog) {
if (!_enableTableDebugLog) {
return;
}
@ -82,7 +99,7 @@ class SimpleTableContext {
}
void _onSelectingRowChanged() {
if (!enableTableDebugLog) {
if (!_enableTableDebugLog) {
return;
}
@ -90,7 +107,7 @@ class SimpleTableContext {
}
void _onSelectingTableChanged() {
if (!enableTableDebugLog) {
if (!_enableTableDebugLog) {
return;
}
@ -98,7 +115,7 @@ class SimpleTableContext {
}
void _onHoveringOnTableBlockChanged() {
if (!enableTableDebugLog) {
if (!_enableTableDebugLog) {
return;
}
@ -106,13 +123,29 @@ class SimpleTableContext {
}
void _onHoveringOnTableAreaChanged() {
if (!enableTableDebugLog) {
if (!_enableTableDebugLog) {
return;
}
Log.debug('isHoveringOnTableArea: ${isHoveringOnTableArea.value}');
}
void _onDraggingColumnChanged() {
if (!_enableTableDebugLog) {
return;
}
Log.debug('isDraggingColumn: ${isReorderingColumn.value}');
}
void _onDraggingRowChanged() {
if (!_enableTableDebugLog) {
return;
}
Log.debug('isDraggingRow: ${isReorderingRow.value}');
}
void dispose() {
isHoveringOnColumnsAndRows.dispose();
isHoveringOnTableBlock.dispose();
@ -122,11 +155,14 @@ class SimpleTableContext {
selectingColumn.dispose();
selectingRow.dispose();
isSelectingTable.dispose();
isReorderingColumn.dispose();
isReorderingRow.dispose();
reorderingOffset.dispose();
}
}
class SimpleTableConstants {
// Table
/// Table
static const defaultColumnWidth = 120.0;
static const minimumColumnWidth = 36.0;

View File

@ -4,6 +4,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_tab
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_reorder_button.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
@ -50,6 +51,14 @@ enum SimpleTableMoreActionType {
return FlowySvgs.table_reorder_row_s;
}
}
@override
String toString() {
return switch (this) {
SimpleTableMoreActionType.column => 'column',
SimpleTableMoreActionType.row => 'row',
};
}
}
enum SimpleTableMoreAction {
@ -143,6 +152,7 @@ class _SimpleTableMoreActionMenuState extends State<SimpleTableMoreActionMenu> {
@override
Widget build(BuildContext context) {
final simpleTableContext = context.read<SimpleTableContext>();
return Align(
alignment: widget.type == SimpleTableMoreActionType.row
? Alignment.centerLeft
@ -151,9 +161,24 @@ class _SimpleTableMoreActionMenuState extends State<SimpleTableMoreActionMenu> {
valueListenable: isShowingMenu,
builder: (context, isShowingMenu, child) {
return ValueListenableBuilder(
valueListenable:
context.read<SimpleTableContext>().hoveringTableCell,
valueListenable: simpleTableContext.hoveringTableCell,
builder: (context, hoveringTableNode, child) {
final reorderingIndex = switch (widget.type) {
SimpleTableMoreActionType.column =>
simpleTableContext.isReorderingColumn.value.$2,
SimpleTableMoreActionType.row =>
simpleTableContext.isReorderingRow.value.$2,
};
final isReordering = simpleTableContext.isReordering;
if (isReordering) {
// when reordering, hide the menu for another column or row that is not the current dragging one.
if (reorderingIndex != widget.index) {
return const SizedBox.shrink();
} else {
return child!;
}
}
final hoveringIndex =
widget.type == SimpleTableMoreActionType.column
? hoveringTableNode?.columnIndex
@ -166,7 +191,6 @@ class _SimpleTableMoreActionMenuState extends State<SimpleTableMoreActionMenu> {
return child!;
},
child: SimpleTableMoreActionPopup(
key: ValueKey(widget.type.name + widget.index.toString()),
index: widget.index,
isShowingMenu: this.isShowingMenu,
type: widget.type,
@ -198,6 +222,7 @@ class SimpleTableMoreActionPopup extends StatefulWidget {
class _SimpleTableMoreActionPopupState
extends State<SimpleTableMoreActionPopup> {
late final editorState = context.read<EditorState>();
SelectionGestureInterceptor? gestureInterceptor;
RenderBox? get renderBox => context.findRenderObject() as RenderBox?;
@ -230,67 +255,85 @@ class _SimpleTableMoreActionPopupState
@override
Widget build(BuildContext context) {
final tableCellNode =
context.read<SimpleTableContext>().hoveringTableCell.value;
final simpleTableContext = context.read<SimpleTableContext>();
final tableCellNode = simpleTableContext.hoveringTableCell.value;
final tableNode = tableCellNode?.parentTableNode;
if (tableNode == null) {
return const SizedBox.shrink();
}
return AppFlowyPopover(
onOpen: () {
widget.isShowingMenu.value = true;
switch (widget.type) {
case SimpleTableMoreActionType.column:
context.read<SimpleTableContext>().selectingColumn.value =
tableCellNode?.columnIndex;
case SimpleTableMoreActionType.row:
context.read<SimpleTableContext>().selectingRow.value =
tableCellNode?.rowIndex;
}
// Workaround to clear the selection after the menu is opened.
Future.delayed(Durations.short3, () {
if (!editorState.isDisposed) {
editorState.selection = null;
}
});
},
onClose: () {
widget.isShowingMenu.value = false;
// clear the selecting index
context.read<SimpleTableContext>().selectingColumn.value = null;
context.read<SimpleTableContext>().selectingRow.value = null;
},
onOpen: () => _onOpen(tableCellNode: tableCellNode),
onClose: () => _onClose(),
direction: widget.type == SimpleTableMoreActionType.row
? PopoverDirection.bottomWithCenterAligned
: PopoverDirection.bottomWithLeftAligned,
offset: widget.type == SimpleTableMoreActionType.row
? const Offset(24, 14)
: const Offset(-14, 8),
popupBuilder: (_) {
if (tableCellNode == null) {
return const SizedBox.shrink();
}
return MultiProvider(
providers: [
Provider.value(
value: context.read<SimpleTableContext>(),
),
Provider.value(
value: context.read<EditorState>(),
),
],
child: SimpleTableMoreActionList(
type: widget.type,
index: widget.index,
tableCellNode: tableCellNode,
),
);
},
child: SimpleTableReorderButton(
clickHandler: PopoverClickHandler.gestureDetector,
popupBuilder: (_) => _buildPopup(tableCellNode: tableCellNode),
child: SimpleTableDraggableReorderButton(
editorState: editorState,
simpleTableContext: simpleTableContext,
node: tableNode,
index: widget.index,
isShowingMenu: widget.isShowingMenu,
type: widget.type,
),
);
}
Widget _buildPopup({Node? tableCellNode}) {
if (tableCellNode == null) {
return const SizedBox.shrink();
}
return MultiProvider(
providers: [
Provider.value(
value: context.read<SimpleTableContext>(),
),
Provider.value(
value: context.read<EditorState>(),
),
],
child: SimpleTableMoreActionList(
type: widget.type,
index: widget.index,
tableCellNode: tableCellNode,
),
);
}
void _onOpen({Node? tableCellNode}) {
widget.isShowingMenu.value = true;
switch (widget.type) {
case SimpleTableMoreActionType.column:
context.read<SimpleTableContext>().selectingColumn.value =
tableCellNode?.columnIndex;
case SimpleTableMoreActionType.row:
context.read<SimpleTableContext>().selectingRow.value =
tableCellNode?.rowIndex;
}
// Workaround to clear the selection after the menu is opened.
Future.delayed(Durations.short3, () {
if (!editorState.isDisposed) {
editorState.selection = null;
}
});
}
void _onClose() {
widget.isShowingMenu.value = false;
// clear the selecting index
context.read<SimpleTableContext>().selectingColumn.value = null;
context.read<SimpleTableContext>().selectingRow.value = null;
}
bool _isTapInBounds(Offset offset) {
if (renderBox == null) {
return false;
@ -506,22 +549,16 @@ class _SimpleTableMoreActionItemState extends State<SimpleTableMoreActionItem> {
_deleteRow();
break;
}
break;
case SimpleTableMoreAction.insertLeft:
_insertColumnLeft();
break;
case SimpleTableMoreAction.insertRight:
_insertColumnRight();
break;
case SimpleTableMoreAction.insertAbove:
_insertRowAbove();
break;
case SimpleTableMoreAction.insertBelow:
_insertRowBelow();
break;
case SimpleTableMoreAction.clearContents:
_clearContent();
break;
case SimpleTableMoreAction.duplicate:
switch (widget.type) {
case SimpleTableMoreActionType.column:
@ -531,7 +568,6 @@ class _SimpleTableMoreActionItemState extends State<SimpleTableMoreActionItem> {
_duplicateRow();
break;
}
break;
default:
break;
}
@ -581,6 +617,8 @@ class _SimpleTableMoreActionItemState extends State<SimpleTableMoreActionItem> {
isEnableHeader.value,
);
}
PopoverContainer.of(context).close();
}
void _clearContent() {

View File

@ -10,6 +10,8 @@ enum TableMapOperationType {
deleteColumn,
duplicateRow,
duplicateColumn,
reorderColumn,
reorderRow,
}
extension TableMapOperation on Node {
@ -17,6 +19,8 @@ extension TableMapOperation on Node {
Node node, {
required TableMapOperationType type,
required int index,
// Only used for reorder column operation
int? toIndex,
}) {
assert(this.type == SimpleTableBlockKeys.type);
@ -39,10 +43,20 @@ extension TableMapOperation on Node {
attributes = _mapRowDeletionAttributes(index);
case TableMapOperationType.deleteColumn:
attributes = _mapColumnDeletionAttributes(index);
case TableMapOperationType.reorderColumn:
if (toIndex != null) {
attributes = _mapColumnReorderingAttributes(index, toIndex);
}
case TableMapOperationType.reorderRow:
if (toIndex != null) {
attributes = _mapRowReorderingAttributes(index, toIndex);
}
}
// clear the attributes that are null
attributes?.removeWhere((key, value) => value == null);
attributes?.removeWhere(
(key, value) => value == null,
);
return attributes;
}
@ -410,6 +424,7 @@ extension TableMapOperation on Node {
comparator: (iKey, index) => iKey > index,
filterIndex: index,
);
final rowAligns = _remapSource(
this.rowAligns,
index,
@ -432,6 +447,319 @@ extension TableMapOperation on Node {
return attributes;
}
}
/// Map the attributes of a column reordering operation.
///
///
/// Examples:
/// Case 1:
///
/// When reordering a column, if the from index is greater than the to index,
/// the attributes of the table before the from index should be updated.
///
/// Before:
/// reorder this column from index 1 to index 0
/// | 0 | 1 | 2 |
/// | 3 | 4 | 5 |
///
/// The original attributes of the table:
/// {
/// "rowColors": {
/// 0: "#FF0000",
/// 1: "#00FF00",
/// 2: "#0000FF",
/// }
/// }
///
/// After reordering:
/// | 1 | 0 | 2 |
/// | 4 | 3 | 5 |
///
/// The new attributes of the table:
/// {
/// "rowColors": {
/// 0: "#00FF00", The attributes of the original second column
/// 1: "#FF0000", The attributes of the original first column
/// 2: "#0000FF",
/// }
/// }
///
/// Case 2:
///
/// When reordering a column, if the from index is less than the to index,
/// the attributes of the table after the from index should be updated.
///
/// Before:
/// reorder this column from index 1 to index 2
/// | 0 | 1 | 2 |
/// | 3 | 4 | 5 |
///
/// The original attributes of the table:
/// {
/// "columnColors": {
/// 0: "#FF0000",
/// 1: "#00FF00",
/// 2: "#0000FF",
/// }
/// }
///
/// After reordering:
/// | 0 | 2 | 1 |
/// | 3 | 5 | 4 |
///
/// The new attributes of the table:
/// {
/// "columnColors": {
/// 0: "#FF0000",
/// 1: "#0000FF", The attributes of the original third column
/// 2: "#00FF00", The attributes of the original second column
/// }
/// }
Attributes? _mapColumnReorderingAttributes(int fromIndex, int toIndex) {
final attributes = this.attributes;
try {
final duplicatedColumnColor = this.columnColors[fromIndex.toString()];
final duplicatedColumnAlign = this.columnAligns[fromIndex.toString()];
final duplicatedColumnWidth = this.columnWidths[fromIndex.toString()];
/// Case 1: fromIndex > toIndex
/// Before:
/// Row 0: | 0 | 1 | 2 |
/// Row 1: | 3 | 4 | 5 |
/// Row 2: | 6 | 7 | 8 |
///
/// columnColors = {
/// "0": "#FF0000",
/// "1": "#00FF00",
/// "2": "#0000FF" Move this column (index 2)
/// }
///
/// Move column 2 to index 0:
/// Row 0: | 2 | 0 | 1 |
/// Row 1: | 5 | 3 | 4 |
/// Row 2: | 8 | 6 | 7 |
///
/// columnColors = {
/// "0": "#0000FF", Moved here
/// "1": "#FF0000",
/// "2": "#00FF00"
/// }
///
/// Case 2: fromIndex < toIndex
/// Before:
/// Row 0: | 0 | 1 | 2 |
/// Row 1: | 3 | 4 | 5 |
/// Row 2: | 6 | 7 | 8 |
///
/// columnColors = {
/// "0": "#FF0000" Move this column (index 0)
/// "1": "#00FF00",
/// "2": "#0000FF"
/// }
///
/// Move column 0 to index 2:
/// Row 0: | 1 | 2 | 0 |
/// Row 1: | 4 | 5 | 3 |
/// Row 2: | 7 | 8 | 6 |
///
/// columnColors = {
/// "0": "#00FF00",
/// "1": "#0000FF",
/// "2": "#FF0000" Moved here
/// }
final columnColors = _remapSource(
this.columnColors,
fromIndex,
increment: fromIndex > toIndex,
comparator: (iKey, index) {
if (fromIndex > toIndex) {
return iKey < fromIndex && iKey >= toIndex;
} else {
return iKey > fromIndex && iKey <= toIndex;
}
},
filterIndex: fromIndex,
);
final columnAligns = _remapSource(
this.columnAligns,
fromIndex,
increment: fromIndex > toIndex,
comparator: (iKey, index) {
if (fromIndex > toIndex) {
return iKey < fromIndex && iKey >= toIndex;
} else {
return iKey > fromIndex && iKey <= toIndex;
}
},
filterIndex: fromIndex,
);
final columnWidths = _remapSource(
this.columnWidths,
fromIndex,
increment: fromIndex > toIndex,
comparator: (iKey, index) {
if (fromIndex > toIndex) {
return iKey < fromIndex && iKey >= toIndex;
} else {
return iKey > fromIndex && iKey <= toIndex;
}
},
filterIndex: fromIndex,
);
return attributes
.mergeValues(
SimpleTableBlockKeys.columnColors,
columnColors,
duplicatedEntry: duplicatedColumnColor != null
? MapEntry(
toIndex.toString(),
duplicatedColumnColor,
)
: null,
removeNullValue: true,
)
.mergeValues(
SimpleTableBlockKeys.columnAligns,
columnAligns,
duplicatedEntry: duplicatedColumnAlign != null
? MapEntry(
toIndex.toString(),
duplicatedColumnAlign,
)
: null,
removeNullValue: true,
)
.mergeValues(
SimpleTableBlockKeys.columnWidths,
columnWidths,
duplicatedEntry: duplicatedColumnWidth != null
? MapEntry(
toIndex.toString(),
duplicatedColumnWidth,
)
: null,
removeNullValue: true,
);
} catch (e) {
Log.warn('Failed to map column deletion attributes: $e');
return attributes;
}
}
/// Map the attributes of a row reordering operation.
///
/// See [_mapColumnReorderingAttributes] for more details.
Attributes? _mapRowReorderingAttributes(int fromIndex, int toIndex) {
final attributes = this.attributes;
try {
final duplicatedRowColor = this.rowColors[fromIndex.toString()];
final duplicatedRowAlign = this.rowAligns[fromIndex.toString()];
/// Example:
/// Case 1: fromIndex > toIndex
/// Before:
/// Row 0: | 0 | 1 | 2 |
/// Row 1: | 3 | 4 | 5 | Move this row (index 1)
/// Row 2: | 6 | 7 | 8 |
///
/// rowColors = {
/// "0": "#FF0000",
/// "1": "#00FF00", This will be moved
/// "2": "#0000FF"
/// }
///
/// Move row 1 to index 0:
/// Row 0: | 3 | 4 | 5 | Moved here
/// Row 1: | 0 | 1 | 2 |
/// Row 2: | 6 | 7 | 8 |
///
/// rowColors = {
/// "0": "#00FF00", Moved here
/// "1": "#FF0000",
/// "2": "#0000FF"
/// }
///
/// Case 2: fromIndex < toIndex
/// Before:
/// Row 0: | 0 | 1 | 2 |
/// Row 1: | 3 | 4 | 5 | Move this row (index 1)
/// Row 2: | 6 | 7 | 8 |
///
/// rowColors = {
/// "0": "#FF0000",
/// "1": "#00FF00", This will be moved
/// "2": "#0000FF"
/// }
///
/// Move row 1 to index 2:
/// Row 0: | 0 | 1 | 2 |
/// Row 1: | 3 | 4 | 5 |
/// Row 2: | 6 | 7 | 8 | Moved here
///
/// rowColors = {
/// "0": "#FF0000",
/// "1": "#0000FF",
/// "2": "#00FF00" Moved here
/// }
final rowColors = _remapSource(
this.rowColors,
fromIndex,
increment: fromIndex > toIndex,
comparator: (iKey, index) {
if (fromIndex > toIndex) {
return iKey < fromIndex && iKey >= toIndex;
} else {
return iKey > fromIndex && iKey <= toIndex;
}
},
filterIndex: fromIndex,
);
final rowAligns = _remapSource(
this.rowAligns,
fromIndex,
increment: fromIndex > toIndex,
comparator: (iKey, index) {
if (fromIndex > toIndex) {
return iKey < fromIndex && iKey >= toIndex;
} else {
return iKey > fromIndex && iKey <= toIndex;
}
},
filterIndex: fromIndex,
);
return attributes
.mergeValues(
SimpleTableBlockKeys.rowColors,
rowColors,
duplicatedEntry: duplicatedRowColor != null
? MapEntry(
toIndex.toString(),
duplicatedRowColor,
)
: null,
removeNullValue: true,
)
.mergeValues(
SimpleTableBlockKeys.rowAligns,
rowAligns,
duplicatedEntry: duplicatedRowAlign != null
? MapEntry(
toIndex.toString(),
duplicatedRowAlign,
)
: null,
removeNullValue: true,
);
} catch (e) {
Log.warn('Failed to map row reordering attributes: $e');
return attributes;
}
}
}
/// Find the duplicated entry and remap the source.
@ -486,6 +814,7 @@ extension TableMapOperationAttributes on Attributes {
String key,
Map<String, dynamic> newSource, {
MapEntry? duplicatedEntry,
bool removeNullValue = false,
}) {
final result = {...this};
@ -493,6 +822,11 @@ extension TableMapOperationAttributes on Attributes {
newSource[duplicatedEntry.key] = duplicatedEntry.value;
}
if (removeNullValue) {
// remove the null value
newSource.removeWhere((key, value) => value == null);
}
result[key] = newSource;
return result;

View File

@ -514,4 +514,19 @@ extension TableNodeExtension on Node {
return children[rowIndex].children[columnIndex];
}
String? getTableCellContent({
required int rowIndex,
required int columnIndex,
}) {
final cell = getTableCellNode(rowIndex: rowIndex, columnIndex: columnIndex);
if (cell == null) {
return null;
}
final content = cell.children
.map((e) => e.delta?.toPlainText())
.where((e) => e != null)
.join('\n');
return content;
}
}

View File

@ -5,4 +5,5 @@ export 'simple_table_header_operation.dart';
export 'simple_table_insert_operation.dart';
export 'simple_table_map_operation.dart';
export 'simple_table_node_extension.dart';
export 'simple_table_reorder_operation.dart';
export 'simple_table_style_operation.dart';

View File

@ -0,0 +1,120 @@
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';
extension SimpleTableReorderOperation on EditorState {
/// Reorder the column of the table.
///
/// If the from index is equal to the to index, do nothing.
/// The node's type can be [SimpleTableCellBlockKeys.type] or [SimpleTableRowBlockKeys.type] or [SimpleTableBlockKeys.type].
Future<void> reorderColumn(
Node node, {
required int fromIndex,
required int toIndex,
}) async {
if (fromIndex == toIndex) {
return;
}
final tableNode = node.parentTableNode;
if (tableNode == null) {
assert(tableNode == null);
return;
}
final columnLength = tableNode.columnLength;
final rowLength = tableNode.rowLength;
if (fromIndex < 0 ||
fromIndex >= columnLength ||
toIndex < 0 ||
toIndex >= columnLength) {
Log.warn(
'reorder column: index out of range: fromIndex: $fromIndex, toIndex: $toIndex, column length: $columnLength',
);
return;
}
Log.info(
'reorder column in table ${node.id} at fromIndex: $fromIndex, toIndex: $toIndex, column length: $columnLength, row length: $rowLength',
);
final attributes = tableNode.mapTableAttributes(
tableNode,
type: TableMapOperationType.reorderColumn,
index: fromIndex,
toIndex: toIndex,
);
final transaction = this.transaction;
for (var i = 0; i < rowLength; i++) {
final row = tableNode.children[i];
final from = row.children[fromIndex];
final to = row.children[toIndex];
final path = fromIndex < toIndex ? to.path.next : to.path;
transaction.insertNode(path, from.copyWith());
transaction.deleteNode(from);
}
if (attributes != null) {
transaction.updateNode(tableNode, attributes);
}
await apply(transaction);
}
/// Reorder the row of the table.
///
/// If the from index is equal to the to index, do nothing.
/// The node's type can be [SimpleTableCellBlockKeys.type] or [SimpleTableRowBlockKeys.type] or [SimpleTableBlockKeys.type].
Future<void> reorderRow(
Node node, {
required int fromIndex,
required int toIndex,
}) async {
if (fromIndex == toIndex) {
return;
}
final tableNode = node.parentTableNode;
if (tableNode == null) {
assert(tableNode == null);
return;
}
final columnLength = tableNode.columnLength;
final rowLength = tableNode.rowLength;
if (fromIndex < 0 ||
fromIndex >= rowLength ||
toIndex < 0 ||
toIndex >= rowLength) {
Log.warn(
'reorder row: index out of range: fromIndex: $fromIndex, toIndex: $toIndex, row length: $rowLength',
);
return;
}
Log.info(
'reorder row in table ${node.id} at fromIndex: $fromIndex, toIndex: $toIndex, column length: $columnLength, row length: $rowLength',
);
final attributes = tableNode.mapTableAttributes(
tableNode,
type: TableMapOperationType.reorderRow,
index: fromIndex,
toIndex: toIndex,
);
final transaction = this.transaction;
final from = tableNode.children[fromIndex];
final to = tableNode.children[toIndex];
final path = fromIndex < toIndex ? to.path.next : to.path;
transaction.insertNode(path, from.copyWith());
transaction.deleteNode(from);
if (attributes != null) {
transaction.updateNode(tableNode, attributes);
}
await apply(transaction);
}
}

View File

@ -0,0 +1,247 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
class SimpleTableBorderBuilder {
SimpleTableBorderBuilder({
required this.context,
required this.simpleTableContext,
required this.node,
});
final BuildContext context;
final SimpleTableContext simpleTableContext;
final Node node;
/// Build the border for the cell.
Border? buildBorder({bool isEditingCell = false}) {
if (SimpleTableConstants.borderType != SimpleTableBorderRenderType.cell) {
return null;
}
// check if the cell is in the selected column
final isCellInSelectedColumn =
node.columnIndex == simpleTableContext.selectingColumn.value;
// check if the cell is in the selected row
final isCellInSelectedRow =
node.rowIndex == simpleTableContext.selectingRow.value;
// check if the cell is in the hovering column
final isCellInHoveringColumn =
simpleTableContext.hoveringTableCell.value?.columnIndex ==
node.columnIndex;
final isCellInHoveringRow =
simpleTableContext.hoveringTableCell.value?.rowIndex == node.rowIndex;
// check if the cell is in the reordering column
final isReordering = simpleTableContext.isReordering;
if (isReordering && (isCellInHoveringColumn || isCellInHoveringRow)) {
return buildReorderingBorder();
} else if (simpleTableContext.isSelectingTable.value) {
return buildSelectingTableBorder();
} else if (isCellInSelectedColumn) {
return buildColumnHighlightBorder();
} else if (isCellInSelectedRow) {
return buildRowHighlightBorder();
} else if (isEditingCell) {
return buildEditingBorder();
} else {
return buildCellBorder();
}
}
/// the column border means the `VERTICAL` border of the cell
///
/// ____
/// | 1 | 2 |
/// | 3 | 4 |
/// |___|
///
/// the border wrapping the cell 2 and cell 4 is the column border
Border buildColumnHighlightBorder() {
return Border(
left: _buildHighlightBorderSide(),
right: _buildHighlightBorderSide(),
top: node.rowIndex == 0
? _buildHighlightBorderSide()
: _buildDefaultBorderSide(),
bottom: node.rowIndex + 1 == node.parentTableNode?.rowLength
? _buildHighlightBorderSide()
: _buildDefaultBorderSide(),
);
}
/// the row border means the `HORIZONTAL` border of the cell
///
/// ________
/// | 1 | 2 |
/// |_______|
/// | 3 | 4 |
///
/// the border wrapping the cell 1 and cell 2 is the row border
Border buildRowHighlightBorder() {
return Border(
top: _buildHighlightBorderSide(),
bottom: _buildHighlightBorderSide(),
left: node.columnIndex == 0
? _buildHighlightBorderSide()
: _buildDefaultBorderSide(),
right: node.columnIndex + 1 == node.parentTableNode?.columnLength
? _buildHighlightBorderSide()
: _buildDefaultBorderSide(),
);
}
/// Build the border for the reordering state.
///
/// For example, when reordering a column, we should highlight the border of the
/// current column we're hovering.
Border buildReorderingBorder() {
final isReorderingColumn = simpleTableContext.isReorderingColumn.value.$1;
final isReorderingRow = simpleTableContext.isReorderingRow.value.$1;
if (isReorderingColumn) {
return _buildColumnReorderingBorder();
} else if (isReorderingRow) {
return _buildRowReorderingBorder();
}
return buildCellBorder();
}
/// Build the border for the cell without any state.
Border buildCellBorder() {
return Border(
top: node.rowIndex == 0
? _buildDefaultBorderSide()
: _buildLightBorderSide(),
bottom: node.rowIndex + 1 == node.parentTableNode?.rowLength
? _buildDefaultBorderSide()
: _buildLightBorderSide(),
left: node.columnIndex == 0
? _buildDefaultBorderSide()
: _buildLightBorderSide(),
right: node.columnIndex + 1 == node.parentTableNode?.columnLength
? _buildDefaultBorderSide()
: _buildLightBorderSide(),
);
}
/// Build the border for the editing state.
Border buildEditingBorder() {
return Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
);
}
/// Build the border for the selecting table state.
Border buildSelectingTableBorder() {
final rowIndex = node.rowIndex;
final columnIndex = node.columnIndex;
return Border(
top:
rowIndex == 0 ? _buildHighlightBorderSide() : _buildLightBorderSide(),
bottom: rowIndex + 1 == node.parentTableNode?.rowLength
? _buildHighlightBorderSide()
: _buildLightBorderSide(),
left: columnIndex == 0
? _buildHighlightBorderSide()
: _buildLightBorderSide(),
right: columnIndex + 1 == node.parentTableNode?.columnLength
? _buildHighlightBorderSide()
: _buildLightBorderSide(),
);
}
Border _buildColumnReorderingBorder() {
assert(simpleTableContext.isReordering);
final isDraggingInCurrentColumn =
simpleTableContext.isReorderingColumn.value.$2 == node.columnIndex;
// if the dragging column is the current column, don't show the highlight border
if (isDraggingInCurrentColumn) {
return buildCellBorder();
}
final hoveringTableCell = simpleTableContext.hoveringTableCell.value;
// if the hovering column is not the current column, don't show the highlight border
final isHitCurrentCell = hoveringTableCell?.columnIndex == node.columnIndex;
if (!isHitCurrentCell) {
return buildCellBorder();
}
// if the dragging column index is less than the current column index, show the
// highlight border on the left side
final isLeftSide =
simpleTableContext.isReorderingColumn.value.$2 > node.columnIndex;
// if the dragging column index is greater than the current column index, show
// the highlight border on the right side
final isRightSide =
simpleTableContext.isReorderingColumn.value.$2 < node.columnIndex;
return Border(
top: _buildDefaultBorderSide(),
bottom: _buildDefaultBorderSide(),
left:
isLeftSide ? _buildHighlightBorderSide() : _buildDefaultBorderSide(),
right:
isRightSide ? _buildHighlightBorderSide() : _buildDefaultBorderSide(),
);
}
Border _buildRowReorderingBorder() {
assert(simpleTableContext.isReordering);
final isDraggingInCurrentRow =
simpleTableContext.isReorderingRow.value.$2 == node.rowIndex;
// if the dragging row is the current row, don't show the highlight border
if (isDraggingInCurrentRow) {
return buildCellBorder();
}
final hoveringTableCell = simpleTableContext.hoveringTableCell.value;
final isHitCurrentCell = hoveringTableCell?.rowIndex == node.rowIndex;
if (!isHitCurrentCell) {
return buildCellBorder();
}
final isTopSide =
simpleTableContext.isReorderingRow.value.$2 > node.rowIndex;
final isBottomSide =
simpleTableContext.isReorderingRow.value.$2 < node.rowIndex;
return Border(
top: isTopSide ? _buildHighlightBorderSide() : _buildDefaultBorderSide(),
bottom: isBottomSide
? _buildHighlightBorderSide()
: _buildDefaultBorderSide(),
left: _buildDefaultBorderSide(),
right: _buildDefaultBorderSide(),
);
}
BorderSide _buildHighlightBorderSide() {
return BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
);
}
BorderSide _buildLightBorderSide() {
return BorderSide(
color: context.simpleTableBorderColor,
width: 0.5,
);
}
BorderSide _buildDefaultBorderSide() {
return BorderSide(
color: context.simpleTableBorderColor,
);
}
}

View File

@ -0,0 +1,110 @@
import 'dart:ui';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SimpleTableColumnResizeHandle extends StatefulWidget {
const SimpleTableColumnResizeHandle({
super.key,
required this.node,
});
final Node node;
@override
State<SimpleTableColumnResizeHandle> createState() =>
_SimpleTableColumnResizeHandleState();
}
class _SimpleTableColumnResizeHandleState
extends State<SimpleTableColumnResizeHandle> {
bool isStartDragging = false;
@override
Widget build(BuildContext context) {
final simpleTableContext = context.read<SimpleTableContext>();
return MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
onEnter: (_) => _onEnterHoverArea(),
onExit: (event) => _onExitHoverArea(),
child: GestureDetector(
onHorizontalDragStart: _onHorizontalDragStart,
onHorizontalDragUpdate: _onHorizontalDragUpdate,
onHorizontalDragEnd: _onHorizontalDragEnd,
child: ValueListenableBuilder(
valueListenable: simpleTableContext.hoveringOnResizeHandle,
builder: (context, hoveringOnResizeHandle, child) {
// when reordering a column, the resize handle should not be shown
final isSameRowIndex = hoveringOnResizeHandle?.columnIndex ==
widget.node.columnIndex &&
!simpleTableContext.isReordering;
return Opacity(
opacity: isSameRowIndex ? 1.0 : 0.0,
child: child,
);
},
child: Container(
height: double.infinity,
width: SimpleTableConstants.resizeHandleWidth,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
}
void _onEnterHoverArea() {
context.read<SimpleTableContext>().hoveringOnResizeHandle.value =
widget.node;
}
void _onExitHoverArea() {
Future.delayed(const Duration(milliseconds: 100), () {
// the onExit event will be triggered before dragging started.
// delay the hiding of the resize handle to avoid flickering.
if (!isStartDragging) {
context.read<SimpleTableContext>().hoveringOnResizeHandle.value = null;
}
});
}
void _onHorizontalDragStart(DragStartDetails details) {
// disable the two-finger drag on trackpad
if (details.kind == PointerDeviceKind.trackpad) {
return;
}
isStartDragging = true;
}
void _onHorizontalDragUpdate(DragUpdateDetails details) {
if (!isStartDragging) {
return;
}
// only update the column width in memory,
// the actual update will be applied in _onHorizontalDragEnd
context.read<EditorState>().updateColumnWidthInMemory(
tableCellNode: widget.node,
deltaX: details.delta.dx,
);
}
void _onHorizontalDragEnd(DragEndDetails details) {
if (!isStartDragging) {
return;
}
isStartDragging = false;
context.read<SimpleTableContext>().hoveringOnResizeHandle.value = null;
// apply the updated column width
context.read<EditorState>().updateColumnWidth(
tableCellNode: widget.node,
width: widget.node.columnWidth,
);
}
}

View File

@ -0,0 +1,258 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SimpleTableDraggableReorderButton extends StatelessWidget {
const SimpleTableDraggableReorderButton({
super.key,
required this.node,
required this.index,
required this.isShowingMenu,
required this.type,
required this.editorState,
required this.simpleTableContext,
});
final Node node;
final int index;
final ValueNotifier<bool> isShowingMenu;
final SimpleTableMoreActionType type;
final EditorState editorState;
final SimpleTableContext simpleTableContext;
@override
Widget build(BuildContext context) {
return Draggable<int>(
data: index,
onDragStarted: () => _startDragging(),
onDragUpdate: (details) => _onDragUpdate(details),
onDragEnd: (_) => _stopDragging(),
feedback: SimpleTableFeedback(
editorState: editorState,
node: node,
type: type,
index: index,
),
child: SimpleTableReorderButton(
isShowingMenu: isShowingMenu,
type: type,
),
);
}
void _startDragging() {
switch (type) {
case SimpleTableMoreActionType.column:
simpleTableContext.isReorderingColumn.value = (true, index);
break;
case SimpleTableMoreActionType.row:
simpleTableContext.isReorderingRow.value = (true, index);
break;
}
}
void _onDragUpdate(DragUpdateDetails details) {
simpleTableContext.reorderingOffset.value = details.globalPosition;
}
void _stopDragging() {
switch (type) {
case SimpleTableMoreActionType.column:
_reorderColumn();
case SimpleTableMoreActionType.row:
_reorderRow();
}
simpleTableContext.reorderingOffset.value = Offset.zero;
switch (type) {
case SimpleTableMoreActionType.column:
simpleTableContext.isReorderingColumn.value = (false, -1);
break;
case SimpleTableMoreActionType.row:
simpleTableContext.isReorderingRow.value = (false, -1);
break;
}
}
void _reorderColumn() {
final fromIndex = simpleTableContext.isReorderingColumn.value.$2;
final toIndex = simpleTableContext.hoveringTableCell.value?.columnIndex;
if (toIndex == null) {
return;
}
editorState.reorderColumn(
node,
fromIndex: fromIndex,
toIndex: toIndex,
);
}
void _reorderRow() {
final fromIndex = simpleTableContext.isReorderingRow.value.$2;
final toIndex = simpleTableContext.hoveringTableCell.value?.rowIndex;
if (toIndex == null) {
return;
}
editorState.reorderRow(
node,
fromIndex: fromIndex,
toIndex: toIndex,
);
}
}
class SimpleTableReorderButton extends StatelessWidget {
const SimpleTableReorderButton({
super.key,
required this.isShowingMenu,
required this.type,
});
final ValueNotifier<bool> isShowingMenu;
final SimpleTableMoreActionType type;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: isShowingMenu,
builder: (context, isShowingMenu, child) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
decoration: BoxDecoration(
color: isShowingMenu
? context.simpleTableMoreActionHoverColor
: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8.0),
border: Border.all(
color: context.simpleTableMoreActionBorderColor,
),
),
height: 16.0,
width: 16.0,
child: FlowySvg(
type.reorderIconSvg,
color: isShowingMenu ? Colors.white : null,
size: const Size.square(16.0),
),
),
);
},
);
}
}
class SimpleTableFeedback extends StatefulWidget {
const SimpleTableFeedback({
super.key,
required this.editorState,
required this.node,
required this.type,
required this.index,
});
/// The node of the table.
/// Its type must be [SimpleTableBlockKeys.type].
final Node node;
/// The type of the more action.
///
/// If the type is [SimpleTableMoreActionType.column], the feedback will use index as column index.
/// If the type is [SimpleTableMoreActionType.row], the feedback will use index as row index.
final SimpleTableMoreActionType type;
/// The index of the column or row.
final int index;
final EditorState editorState;
@override
State<SimpleTableFeedback> createState() => _SimpleTableFeedbackState();
}
class _SimpleTableFeedbackState extends State<SimpleTableFeedback> {
final simpleTableContext = SimpleTableContext();
late final Node dummyNode;
@override
void initState() {
super.initState();
simpleTableContext.isSelectingTable.value = true;
dummyNode = _buildDummyNode();
}
@override
void dispose() {
simpleTableContext.dispose();
dummyNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Provider.value(
value: widget.editorState,
child: SimpleTableWidget(
node: dummyNode,
simpleTableContext: simpleTableContext,
enableAddColumnButton: false,
enableAddRowButton: false,
enableAddColumnAndRowButton: false,
enableHoverEffect: false,
isFeedback: true,
),
);
}
/// Build the dummy node for the feedback.
///
/// For example,
///
/// If the type is [SimpleTableMoreActionType.row], we should build the dummy table node using the data from the first row of the table node.
/// If the type is [SimpleTableMoreActionType.column], we should build the dummy table node using the data from the first column of the table node.
Node _buildDummyNode() {
// deep copy the table node to avoid mutating the original node
final tableNode = widget.node.copyWith();
switch (widget.type) {
case SimpleTableMoreActionType.row:
if (widget.index >= tableNode.rowLength || widget.index < 0) {
return simpleTableBlockNode(children: []);
}
final row = tableNode.children[widget.index];
return tableNode.copyWith(
children: [row],
attributes: {
...tableNode.attributes,
if (widget.index != 0) SimpleTableBlockKeys.enableHeaderRow: false,
},
);
case SimpleTableMoreActionType.column:
if (widget.index >= tableNode.columnLength || widget.index < 0) {
return simpleTableBlockNode(children: []);
}
final rows = tableNode.children.map((row) {
final cell = row.children[widget.index];
return simpleTableRowBlockNode(children: [cell]);
}).toList();
return tableNode.copyWith(
children: rows,
attributes: {
...tableNode.attributes,
if (widget.index != 0)
SimpleTableBlockKeys.enableHeaderColumn: false,
},
);
}
}
}

View File

@ -0,0 +1,195 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
class SimpleTableWidget extends StatefulWidget {
const SimpleTableWidget({
super.key,
required this.simpleTableContext,
required this.node,
this.enableAddColumnButton = true,
this.enableAddRowButton = true,
this.enableAddColumnAndRowButton = true,
this.enableHoverEffect = true,
this.isFeedback = false,
});
/// The node of the table.
///
/// Its type must be [SimpleTableBlockKeys.type].
final Node node;
/// The context of the simple table.
final SimpleTableContext simpleTableContext;
/// Whether to show the add column button.
///
/// For the feedback widget builder, it should be false.
final bool enableAddColumnButton;
/// Whether to show the add row button.
///
/// For the feedback widget builder, it should be false.
final bool enableAddRowButton;
/// Whether to show the add column and row button.
///
/// For the feedback widget builder, it should be false.
final bool enableAddColumnAndRowButton;
/// Whether to enable the hover effect.
///
/// For the feedback widget builder, it should be false.
final bool enableHoverEffect;
/// Whether the widget is a feedback widget.
final bool isFeedback;
@override
State<SimpleTableWidget> createState() => _SimpleTableWidgetState();
}
class _SimpleTableWidgetState extends State<SimpleTableWidget> {
SimpleTableContext get simpleTableContext => widget.simpleTableContext;
final scrollController = ScrollController();
late final editorState = context.read<EditorState>();
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return UniversalPlatform.isDesktop
? _buildDesktopTable()
: _buildMobileTable();
}
Widget _buildDesktopTable() {
if (widget.isFeedback) {
return Provider.value(
value: simpleTableContext,
child: IntrinsicWidth(
child: IntrinsicHeight(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildRows(),
),
),
),
);
}
// table content
Widget child = Scrollbar(
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
child: Padding(
padding: SimpleTableConstants.tablePadding,
// IntrinsicWidth and IntrinsicHeight are used to make the table size fit the content.
child: IntrinsicWidth(
child: IntrinsicHeight(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildRows(),
),
),
),
),
),
);
if (widget.enableHoverEffect) {
child = MouseRegion(
onEnter: (event) =>
simpleTableContext.isHoveringOnTableArea.value = true,
onExit: (event) {
simpleTableContext.isHoveringOnTableArea.value = false;
},
child: Provider.value(
value: simpleTableContext,
child: Stack(
children: [
MouseRegion(
hitTestBehavior: HitTestBehavior.opaque,
onEnter: (event) =>
simpleTableContext.isHoveringOnColumnsAndRows.value = true,
onExit: (event) {
simpleTableContext.isHoveringOnColumnsAndRows.value = false;
simpleTableContext.hoveringTableCell.value = null;
},
child: child,
),
if (widget.enableAddColumnButton)
SimpleTableAddColumnHoverButton(
editorState: editorState,
node: widget.node,
),
if (widget.enableAddRowButton)
SimpleTableAddRowHoverButton(
editorState: editorState,
tableNode: widget.node,
),
if (widget.enableAddColumnAndRowButton)
SimpleTableAddColumnAndRowHoverButton(
editorState: editorState,
node: widget.node,
),
],
),
),
);
}
return child;
}
Widget _buildMobileTable() {
return Provider.value(
value: simpleTableContext,
child: SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
child: IntrinsicWidth(
child: IntrinsicHeight(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildRows(),
),
),
),
),
);
}
List<Widget> _buildRows() {
final List<Widget> rows = [];
if (SimpleTableConstants.borderType == SimpleTableBorderRenderType.table) {
rows.add(const SimpleTableColumnDivider());
}
for (final child in widget.node.children) {
rows.add(editorState.renderer.build(context, child));
if (SimpleTableConstants.borderType ==
SimpleTableBorderRenderType.table) {
rows.add(const SimpleTableColumnDivider());
}
}
return rows;
}
}

View File

@ -0,0 +1,335 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy_backend/log.dart';
import 'package:flutter_test/flutter_test.dart';
import 'simple_table_test_helper.dart';
void main() {
group('Simple table reorder operation:', () {
setUpAll(() {
Log.shared.disableLog = true;
});
tearDownAll(() {
Log.shared.disableLog = false;
});
group('reorder column', () {
test('reorder column from index 1 to index 2', () async {
final (editorState, tableNode) = createEditorStateAndTable(
rowCount: 4,
columnCount: 3,
contentBuilder: (rowIndex, columnIndex) =>
'cell $rowIndex-$columnIndex',
);
await editorState.reorderColumn(tableNode, fromIndex: 1, toIndex: 2);
expect(tableNode.columnLength, 3);
expect(tableNode.rowLength, 4);
expect(
tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0),
'cell 0-0',
);
expect(
tableNode.getTableCellContent(rowIndex: 0, columnIndex: 1),
'cell 0-2',
);
expect(
tableNode.getTableCellContent(rowIndex: 0, columnIndex: 2),
'cell 0-1',
);
});
test('reorder column from index 2 to index 0', () async {
final (editorState, tableNode) = createEditorStateAndTable(
rowCount: 4,
columnCount: 3,
contentBuilder: (rowIndex, columnIndex) =>
'cell $rowIndex-$columnIndex',
);
await editorState.reorderColumn(tableNode, fromIndex: 2, toIndex: 0);
expect(tableNode.columnLength, 3);
expect(tableNode.rowLength, 4);
expect(
tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0),
'cell 0-2',
);
expect(
tableNode.getTableCellContent(rowIndex: 0, columnIndex: 1),
'cell 0-0',
);
expect(
tableNode.getTableCellContent(rowIndex: 0, columnIndex: 2),
'cell 0-1',
);
});
test('reorder column with same index', () async {
final (editorState, tableNode) = createEditorStateAndTable(
rowCount: 4,
columnCount: 3,
contentBuilder: (rowIndex, columnIndex) =>
'cell $rowIndex-$columnIndex',
);
await editorState.reorderColumn(tableNode, fromIndex: 1, toIndex: 1);
expect(tableNode.columnLength, 3);
expect(tableNode.rowLength, 4);
expect(
tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0),
'cell 0-0',
);
expect(
tableNode.getTableCellContent(rowIndex: 0, columnIndex: 1),
'cell 0-1',
);
expect(
tableNode.getTableCellContent(rowIndex: 0, columnIndex: 2),
'cell 0-2',
);
});
test(
'reorder column from index 0 to index 2 with align/color/width attributes (1)',
() async {
final (editorState, tableNode) = createEditorStateAndTable(
rowCount: 4,
columnCount: 3,
contentBuilder: (rowIndex, columnIndex) =>
'cell $rowIndex-$columnIndex',
);
// before reorder
// Column 0: align: right, color: 0xFF0000, width: 100
// Column 1: align: center, color: 0x00FF00, width: 150
// Column 2: align: left, color: 0x0000FF, width: 200
await updateTableColumnAttributes(
editorState,
tableNode,
columnIndex: 0,
align: TableAlign.right,
color: '#FF0000',
width: 100,
);
await updateTableColumnAttributes(
editorState,
tableNode,
columnIndex: 1,
align: TableAlign.center,
color: '#00FF00',
width: 150,
);
await updateTableColumnAttributes(
editorState,
tableNode,
columnIndex: 2,
align: TableAlign.left,
color: '#0000FF',
width: 200,
);
// after reorder
// Column 0: align: center, color: 0x00FF00, width: 150
// Column 1: align: left, color: 0x0000FF, width: 200
// Column 2: align: right, color: 0xFF0000, width: 100
await editorState.reorderColumn(tableNode, fromIndex: 0, toIndex: 2);
expect(tableNode.columnLength, 3);
expect(tableNode.rowLength, 4);
expect(tableNode.columnAligns, {
"0": TableAlign.center.key,
"1": TableAlign.left.key,
"2": TableAlign.right.key,
});
expect(tableNode.columnColors, {
"0": '#00FF00',
"1": '#0000FF',
"2": '#FF0000',
});
expect(tableNode.columnWidths, {
"0": 150,
"1": 200,
"2": 100,
});
});
test(
'reorder column from index 0 to index 2 and reorder it back to index 0',
() async {
final (editorState, tableNode) = createEditorStateAndTable(
rowCount: 2,
columnCount: 3,
contentBuilder: (rowIndex, columnIndex) =>
'cell $rowIndex-$columnIndex',
);
// before reorder
// Column 0: null
// Column 1: align: center, color: 0x0000FF, width: 200
// Column 2: align: right, color: 0x0000FF, width: 250
await updateTableColumnAttributes(
editorState,
tableNode,
columnIndex: 1,
align: TableAlign.center,
color: '#FF0000',
width: 200,
);
await updateTableColumnAttributes(
editorState,
tableNode,
columnIndex: 2,
align: TableAlign.right,
color: '#0000FF',
width: 250,
);
// move column from index 0 to index 2
await editorState.reorderColumn(tableNode, fromIndex: 0, toIndex: 2);
// move column from index 2 to index 0
await editorState.reorderColumn(tableNode, fromIndex: 2, toIndex: 0);
expect(tableNode.columnLength, 3);
expect(tableNode.rowLength, 2);
expect(tableNode.columnAligns, {
"1": TableAlign.center.key,
"2": TableAlign.right.key,
});
expect(tableNode.columnColors, {
"1": '#FF0000',
"2": '#0000FF',
});
expect(tableNode.columnWidths, {
"1": 200,
"2": 250,
});
});
});
group('reorder row', () {
test('reorder row from index 1 to index 2', () async {
final (editorState, tableNode) = createEditorStateAndTable(
rowCount: 3,
columnCount: 2,
contentBuilder: (rowIndex, columnIndex) =>
'cell $rowIndex-$columnIndex',
);
await editorState.reorderRow(tableNode, fromIndex: 1, toIndex: 2);
expect(tableNode.columnLength, 2);
expect(tableNode.rowLength, 3);
expect(
tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0),
'cell 0-0',
);
expect(
tableNode.getTableCellContent(rowIndex: 1, columnIndex: 0),
'cell 2-0',
);
expect(
tableNode.getTableCellContent(rowIndex: 2, columnIndex: 0),
'cell 1-0',
);
});
test('reorder row from index 2 to index 0', () async {
final (editorState, tableNode) = createEditorStateAndTable(
rowCount: 3,
columnCount: 2,
contentBuilder: (rowIndex, columnIndex) =>
'cell $rowIndex-$columnIndex',
);
await editorState.reorderRow(tableNode, fromIndex: 2, toIndex: 0);
expect(tableNode.columnLength, 2);
expect(tableNode.rowLength, 3);
expect(
tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0),
'cell 2-0',
);
expect(
tableNode.getTableCellContent(rowIndex: 1, columnIndex: 0),
'cell 0-0',
);
expect(
tableNode.getTableCellContent(rowIndex: 2, columnIndex: 0),
'cell 1-0',
);
});
test('reorder row with same', () async {
final (editorState, tableNode) = createEditorStateAndTable(
rowCount: 3,
columnCount: 2,
contentBuilder: (rowIndex, columnIndex) =>
'cell $rowIndex-$columnIndex',
);
await editorState.reorderRow(tableNode, fromIndex: 1, toIndex: 1);
expect(tableNode.columnLength, 2);
expect(tableNode.rowLength, 3);
expect(
tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0),
'cell 0-0',
);
expect(
tableNode.getTableCellContent(rowIndex: 1, columnIndex: 0),
'cell 1-0',
);
expect(
tableNode.getTableCellContent(rowIndex: 2, columnIndex: 0),
'cell 2-0',
);
});
test('reorder row from index 0 to index 2 with align/color attributes',
() async {
final (editorState, tableNode) = createEditorStateAndTable(
rowCount: 3,
columnCount: 2,
contentBuilder: (rowIndex, columnIndex) =>
'cell $rowIndex-$columnIndex',
);
// before reorder
// Row 0: align: right, color: 0xFF0000
// Row 1: align: center, color: 0x00FF00
// Row 2: align: left, color: 0x0000FF
await updateTableRowAttributes(
editorState,
tableNode,
rowIndex: 0,
align: TableAlign.right,
color: '#FF0000',
);
await updateTableRowAttributes(
editorState,
tableNode,
rowIndex: 1,
align: TableAlign.center,
color: '#00FF00',
);
await updateTableRowAttributes(
editorState,
tableNode,
rowIndex: 2,
align: TableAlign.left,
color: '#0000FF',
);
// after reorder
// Row 0: align: center, color: 0x00FF00
// Row 1: align: left, color: 0x0000FF
// Row 2: align: right, color: 0xFF0000
await editorState.reorderRow(tableNode, fromIndex: 0, toIndex: 2);
expect(tableNode.columnLength, 2);
expect(tableNode.rowLength, 3);
expect(tableNode.rowAligns, {
"0": TableAlign.center.key,
"1": TableAlign.left.key,
"2": TableAlign.right.key,
});
expect(tableNode.rowColors, {
"0": '#00FF00',
"1": '#0000FF',
"2": '#FF0000',
});
});
});
});
}

View File

@ -1,10 +1,11 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
(EditorState editorState, Node tableNode) createEditorStateAndTable({
required int rowCount,
required int columnCount,
String? defaultContent,
String Function(int rowIndex, int columnIndex)? contentBuilder,
}) {
final document = Document.blank()
..insert(
@ -14,9 +15,72 @@ import 'package:appflowy_editor/appflowy_editor.dart';
columnCount: columnCount,
rowCount: rowCount,
defaultContent: defaultContent,
contentBuilder: contentBuilder,
),
],
);
final editorState = EditorState(document: document);
return (editorState, document.nodeAtPath([0])!);
}
Future<void> updateTableColumnAttributes(
EditorState editorState,
Node tableNode, {
required int columnIndex,
TableAlign? align,
String? color,
double? width,
}) async {
final cell = tableNode.getTableCellNode(
rowIndex: 0,
columnIndex: columnIndex,
)!;
if (align != null) {
await editorState.updateColumnAlign(
tableCellNode: cell,
align: align,
);
}
if (color != null) {
await editorState.updateColumnBackgroundColor(
tableCellNode: cell,
color: color,
);
}
if (width != null) {
await editorState.updateColumnWidth(
tableCellNode: cell,
width: width,
);
}
}
Future<void> updateTableRowAttributes(
EditorState editorState,
Node tableNode, {
required int rowIndex,
TableAlign? align,
String? color,
}) async {
final cell = tableNode.getTableCellNode(
rowIndex: rowIndex,
columnIndex: 0,
)!;
if (align != null) {
await editorState.updateRowAlign(
tableCellNode: cell,
align: align,
);
}
if (color != null) {
await editorState.updateRowBackgroundColor(
tableCellNode: cell,
color: color,
);
}
}