diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/text_robot.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/text_robot.dart index bc4a28dbdd..68e1c8e5eb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/text_robot.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/text_robot.dart @@ -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 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); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart index a3a306abf6..6f2e124fd8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart @@ -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 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 createState() => - _SimpleTableColumnResizeHandleState(); -} - -class _SimpleTableColumnResizeHandleState - extends State { - bool isStartDragging = false; - - @override - Widget build(BuildContext context) { - return MouseRegion( - cursor: SystemMouseCursors.resizeColumn, - onEnter: (event) => context - .read() - .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().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().updateColumnWidthInMemory( - tableCellNode: widget.node, - deltaX: details.delta.dx, - ); - }, - onHorizontalDragEnd: (details) { - if (!isStartDragging) { - return; - } - context.read().hoveringOnResizeHandle.value = - null; - isStartDragging = false; - context.read().updateColumnWidth( - tableCellNode: widget.node, - width: widget.node.columnWidth, - ); - }, - child: ValueListenableBuilder( - valueListenable: context.read().hoveringTableCell, - builder: (context, hoveringCell, child) { - return ValueListenableBuilder( - valueListenable: - context.read().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, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart index c5e2e5872e..f5eb5ea187 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart @@ -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 @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 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 _buildRows() { - final List 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; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart index 1d99356696..70e618a186 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart @@ -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 late SimpleTableContext? simpleTableContext = context.read(); + late final borderBuilder = SimpleTableBorderBuilder( + context: context, + simpleTableContext: simpleTableContext!, + node: node, + ); ValueNotifier isEditingCellNotifier = ValueNotifier(false); @@ -120,17 +126,19 @@ class SimpleTableCellBlockWidgetState extends State 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 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( 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 } 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 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 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 return Theme.of(context).colorScheme.surface; } - Border? _buildBorder() { - if (SimpleTableConstants.borderType != SimpleTableBorderRenderType.cell) { - return null; - } - - final tableContext = context.watch(); - 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 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(() {}); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart index cbf941f231..8cf73a8bcb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart @@ -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 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 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 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 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 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 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 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 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 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; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_more_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_more_action.dart index 86a0915f01..380a62791e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_more_action.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_more_action.dart @@ -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 { @override Widget build(BuildContext context) { + final simpleTableContext = context.read(); return Align( alignment: widget.type == SimpleTableMoreActionType.row ? Alignment.centerLeft @@ -151,9 +161,24 @@ class _SimpleTableMoreActionMenuState extends State { valueListenable: isShowingMenu, builder: (context, isShowingMenu, child) { return ValueListenableBuilder( - valueListenable: - context.read().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 { 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 { late final editorState = context.read(); + SelectionGestureInterceptor? gestureInterceptor; RenderBox? get renderBox => context.findRenderObject() as RenderBox?; @@ -230,67 +255,85 @@ class _SimpleTableMoreActionPopupState @override Widget build(BuildContext context) { - final tableCellNode = - context.read().hoveringTableCell.value; + final simpleTableContext = context.read(); + 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().selectingColumn.value = - tableCellNode?.columnIndex; - case SimpleTableMoreActionType.row: - context.read().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().selectingColumn.value = null; - context.read().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(), - ), - Provider.value( - value: context.read(), - ), - ], - 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(), + ), + Provider.value( + value: context.read(), + ), + ], + 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().selectingColumn.value = + tableCellNode?.columnIndex; + case SimpleTableMoreActionType.row: + context.read().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().selectingColumn.value = null; + context.read().selectingRow.value = null; + } + bool _isTapInBounds(Offset offset) { if (renderBox == null) { return false; @@ -506,22 +549,16 @@ class _SimpleTableMoreActionItemState extends State { _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 { _duplicateRow(); break; } - break; default: break; } @@ -581,6 +617,8 @@ class _SimpleTableMoreActionItemState extends State { isEnableHeader.value, ); } + + PopoverContainer.of(context).close(); } void _clearContent() { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart index dc05e3f2c1..5be39f5df4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart @@ -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 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; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart index 70f67bfe3b..c7e1f73ae2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart @@ -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; + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart index ba5f6b47bd..c5bb8bac83 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart @@ -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'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_reorder_operation.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_reorder_operation.dart new file mode 100644 index 0000000000..1bc2ad4ba7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_reorder_operation.dart @@ -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 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 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); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_border_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_border_builder.dart new file mode 100644 index 0000000000..b3f7a4629c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_border_builder.dart @@ -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, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_column_resize_handle.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_column_resize_handle.dart new file mode 100644 index 0000000000..5555c39de4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_column_resize_handle.dart @@ -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 createState() => + _SimpleTableColumnResizeHandleState(); +} + +class _SimpleTableColumnResizeHandleState + extends State { + bool isStartDragging = false; + + @override + Widget build(BuildContext context) { + final simpleTableContext = context.read(); + + 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().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().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().updateColumnWidthInMemory( + tableCellNode: widget.node, + deltaX: details.delta.dx, + ); + } + + void _onHorizontalDragEnd(DragEndDetails details) { + if (!isStartDragging) { + return; + } + + isStartDragging = false; + context.read().hoveringOnResizeHandle.value = null; + + // apply the updated column width + context.read().updateColumnWidth( + tableCellNode: widget.node, + width: widget.node.columnWidth, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_reorder_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_reorder_button.dart new file mode 100644 index 0000000000..5a51103822 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_reorder_button.dart @@ -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 isShowingMenu; + final SimpleTableMoreActionType type; + final EditorState editorState; + final SimpleTableContext simpleTableContext; + + @override + Widget build(BuildContext context) { + return Draggable( + 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 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 createState() => _SimpleTableFeedbackState(); +} + +class _SimpleTableFeedbackState extends State { + 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, + }, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_widget.dart new file mode 100644 index 0000000000..62acf509e1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_widget.dart @@ -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 createState() => _SimpleTableWidgetState(); +} + +class _SimpleTableWidgetState extends State { + SimpleTableContext get simpleTableContext => widget.simpleTableContext; + + final scrollController = ScrollController(); + late final editorState = context.read(); + + @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 _buildRows() { + final List 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; + } +} diff --git a/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_reorder_operation_test.dart b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_reorder_operation_test.dart new file mode 100644 index 0000000000..703a63b40d --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_reorder_operation_test.dart @@ -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', + }); + }); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_test_helper.dart b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_test_helper.dart index 8c97d53564..e190925bee 100644 --- a/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_test_helper.dart +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_test_helper.dart @@ -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 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 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, + ); + } +}