feat: support table plugin (#3280)

This commit is contained in:
Mohammad Zolfaghari 2023-09-01 10:15:21 +03:30 committed by GitHub
parent abb6eff23d
commit df8642d446
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 538 additions and 43 deletions

View File

@ -193,6 +193,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
if (lastNode == null || lastNode.delta == null) {
final transaction = editorState.transaction;
transaction.insertNode([document.root.children.length], paragraphNode());
transaction.afterSelection = transaction.beforeSelection;
await editorState.apply(transaction);
}
}

View File

@ -1,5 +1,7 @@
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'dart:async';
import 'package:appflowy/plugins/document/application/doc_service.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
@ -12,10 +14,9 @@ import 'package:appflowy_editor/appflowy_editor.dart'
DeleteOperation,
PathExtensions,
Node,
Path,
composeAttributes;
import 'package:collection/collection.dart';
import 'dart:async';
import 'package:nanoid/nanoid.dart';
/// Uses to adjust the data structure between the editor and the backend.
@ -46,7 +47,7 @@ class TransactionAdapter {
}
}
extension on Operation {
extension BlockAction on Operation {
List<BlockActionPB> toBlockAction(EditorState editorState) {
final op = this;
if (op is InsertOperation) {
@ -61,19 +62,22 @@ extension on Operation {
}
extension on InsertOperation {
List<BlockActionPB> toBlockAction(EditorState editorState) {
List<BlockActionPB> toBlockAction(
EditorState editorState, {
Node? previousNode,
}) {
Path currentPath = path;
final List<BlockActionPB> actions = [];
// store the previous node for continuous insertion.
// because the backend needs to know the previous node's id.
Node? previousNode;
for (final node in nodes) {
final parentId =
node.parent?.id ?? editorState.getNodeAtPath(path.parent)?.id ?? '';
final parentId = node.parent?.id ??
editorState.getNodeAtPath(currentPath.parent)?.id ??
'';
var prevId = previousNode?.id ??
editorState.getNodeAtPath(path.previous)?.id ??
editorState.getNodeAtPath(currentPath.previous)?.id ??
'';
assert(parentId.isNotEmpty);
if (path.equals(path.previous) && !path.equals([0])) {
if (currentPath.equals(currentPath.previous) &&
!currentPath.equals([0])) {
prevId = '';
} else {
assert(prevId.isNotEmpty && prevId != node.id);
@ -89,12 +93,17 @@ extension on InsertOperation {
..payload = payload,
);
if (node.children.isNotEmpty) {
final childrenActions = node.children
.map((e) => InsertOperation(e.path, [e]).toBlockAction(editorState))
.expand((element) => element);
actions.addAll(childrenActions);
Node? prevChild;
for (final child in node.children) {
actions.addAll(
InsertOperation(currentPath + child.path, [child])
.toBlockAction(editorState, previousNode: prevChild),
);
prevChild = child;
}
}
previousNode = node;
currentPath = currentPath.next;
}
return actions;
}

View File

@ -57,13 +57,18 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
];
final List<ToolbarItem> toolbarItems = [
smartEditItem,
paragraphItem,
...headingItems,
smartEditItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
paragraphItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
...(headingItems
..forEach(
(e) => e.isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
)),
...markdownFormatItems,
quoteItem,
bulletedListItem,
numberedListItem,
quoteItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
bulletedListItem
..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
numberedListItem
..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
inlineMathEquationItem,
linkItem,
alignToolbarItem,
@ -241,6 +246,28 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
),
),
),
TableBlockKeys.type: TableBlockComponentBuilder(
menuBuilder: (node, editorState, position, dir, onBuild, onClose) =>
TableMenu(
node: node,
editorState: editorState,
position: position,
dir: dir,
onBuild: onBuild,
onClose: onClose,
),
),
TableCellBlockKeys.type: TableCellBlockComponentBuilder(
menuBuilder: (node, editorState, position, dir, onBuild, onClose) =>
TableMenu(
node: node,
editorState: editorState,
position: position,
dir: dir,
onBuild: onBuild,
onClose: onClose,
),
),
DatabaseBlockKeys.gridType: DatabaseViewBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (_) => const EdgeInsets.symmetric(vertical: 10),
@ -338,7 +365,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
if (supportAlignBuilderType.contains(entry.key)) ...alignAction,
];
builder.showActions = (_) => true;
builder.showActions =
(node) => node.parent?.type != TableCellBlockKeys.type;
builder.actionBuilder = (context, state) {
final top = builder.configuration.padding(context.node).top;
final padding = context.node.type == HeadingBlockKeys.type

View File

@ -0,0 +1,29 @@
import 'package:appflowy_editor/appflowy_editor.dart';
bool notShowInTable(EditorState editorState) {
final selection = editorState.selection;
if (selection == null) {
return false;
}
final nodes = editorState.getNodesInSelection(selection);
return nodes.every((element) {
if (element.type == TableBlockKeys.type) {
return false;
}
var parent = element.parent;
while (parent != null) {
if (parent.type == TableBlockKeys.type) {
return false;
}
parent = parent.parent;
}
return true;
});
}
bool onlyShowInSingleTextTypeSelectionAndExcludeTable(
EditorState editorState,
) {
return onlyShowInSingleSelectionAndTextType(editorState) &&
notShowInTable(editorState);
}

View File

@ -195,7 +195,7 @@ class _CalloutBlockComponentWidgetState
child: child,
);
if (widget.actionBuilder != null) {
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: widget.node,
actionBuilder: widget.actionBuilder!,

View File

@ -203,7 +203,7 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
child: child,
);
if (widget.actionBuilder != null) {
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: widget.node,
actionBuilder: widget.actionBuilder!,

View File

@ -88,7 +88,7 @@ class _DatabaseBlockComponentWidgetState
child: child,
);
if (widget.actionBuilder != null) {
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: widget.node,
actionBuilder: widget.actionBuilder!,

View File

@ -144,7 +144,7 @@ class _MathEquationBlockComponentWidgetState
),
);
if (widget.actionBuilder != null) {
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,
actionBuilder: widget.actionBuilder!,

View File

@ -1,26 +1,19 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
final ToolbarItem smartEditItem = ToolbarItem(
id: 'appflowy.editor.smart_edit',
group: 0,
isActive: (editorState) {
final selection = editorState.selection;
if (selection == null) {
return false;
}
final nodes = editorState.getNodesInSelection(selection);
return nodes.every((element) => element.delta != null);
},
isActive: onlyShowInSingleSelectionAndTextType,
builder: (context, editorState, _) => SmartEditActionList(
editorState: editorState,
),

View File

@ -1,5 +1,7 @@
export 'actions/block_action_list.dart';
export 'actions/option_action.dart';
export 'align_toolbar_item/align_toolbar_item.dart';
export 'base/toolbar_extension.dart';
export 'callout/callout_block_component.dart';
export 'code_block/code_block_component.dart';
export 'code_block/code_block_shortcut_event.dart';
@ -19,11 +21,12 @@ export 'image/image_menu.dart';
export 'image/image_selection_menu.dart';
export 'inline_math_equation/inline_math_equation.dart';
export 'inline_math_equation/inline_math_equation_toolbar_item.dart';
export 'align_toolbar_item/align_toolbar_item.dart';
export 'math_equation/math_equation_block_component.dart';
export 'openai/widgets/auto_completion_node_widget.dart';
export 'openai/widgets/smart_edit_node_widget.dart';
export 'openai/widgets/smart_edit_toolbar_item.dart';
export 'outline/outline_block_component.dart';
export 'table/table_menu.dart';
export 'table/table_option_action.dart';
export 'toggle/toggle_block_component.dart';
export 'toggle/toggle_block_shortcut_event.dart';

View File

@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/table/table_option_action.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'dart:math' as math;
const tableActions = <TableOptionAction>[
TableOptionAction.addAfter,
TableOptionAction.addBefore,
TableOptionAction.delete,
TableOptionAction.duplicate,
TableOptionAction.clear,
TableOptionAction.bgColor,
];
class TableMenu extends StatelessWidget {
const TableMenu({
super.key,
required this.node,
required this.editorState,
required this.position,
required this.dir,
this.onBuild,
this.onClose,
});
final Node node;
final EditorState editorState;
final int position;
final TableDirection dir;
final VoidCallback? onBuild;
final VoidCallback? onClose;
@override
Widget build(BuildContext context) {
final actions = tableActions.map((action) {
switch (action) {
case TableOptionAction.bgColor:
return TableColorOptionAction(
node: node,
editorState: editorState,
position: position,
dir: dir,
);
default:
return TableOptionActionWrapper(action);
}
}).toList();
return PopoverActionList<PopoverAction>(
direction: dir == TableDirection.col
? PopoverDirection.bottomWithCenterAligned
: PopoverDirection.rightWithTopAligned,
actions: actions,
onPopupBuilder: onBuild,
onClosed: onClose,
onSelected: (action, controller) {
if (action is TableOptionActionWrapper) {
_onSelectAction(action.inner);
controller.close();
}
},
buildChild: (controller) => _buildOptionButton(controller, context),
);
}
Widget _buildOptionButton(
PopoverController controller,
BuildContext context,
) {
return Card(
elevation: 1.0,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => controller.show(),
child: Transform.rotate(
angle: dir == TableDirection.col ? math.pi / 2 : 0,
child: const FlowySvg(
FlowySvgs.drag_element_s,
size: Size.square(18.0),
),
),
),
),
);
}
void _onSelectAction(TableOptionAction action) {
switch (action) {
case TableOptionAction.addAfter:
TableActions.add(node, position + 1, editorState, dir);
break;
case TableOptionAction.addBefore:
TableActions.add(node, position, editorState, dir);
break;
case TableOptionAction.delete:
TableActions.delete(node, position, editorState, dir);
break;
case TableOptionAction.clear:
TableActions.clear(node, position, editorState, dir);
break;
case TableOptionAction.duplicate:
TableActions.duplicate(node, position, editorState, dir);
break;
default:
}
}
}

View File

@ -0,0 +1,154 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/extensions/flowy_tint_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/extension.dart';
import 'package:flutter/material.dart';
enum TableOptionAction {
addAfter,
addBefore,
delete,
duplicate,
clear,
/// row|cell background color
bgColor;
Widget icon(Color? color) {
switch (this) {
case TableOptionAction.addAfter:
return const FlowySvg(FlowySvgs.add_s);
case TableOptionAction.addBefore:
return const FlowySvg(FlowySvgs.add_s);
case TableOptionAction.delete:
return const FlowySvg(FlowySvgs.delete_s);
case TableOptionAction.duplicate:
return const FlowySvg(FlowySvgs.copy_s);
case TableOptionAction.clear:
return const FlowySvg(FlowySvgs.close_s);
case TableOptionAction.bgColor:
return const FlowySvg(
FlowySvgs.color_format_m,
size: Size.square(12),
).padding(all: 2.0);
}
}
String get description {
switch (this) {
case TableOptionAction.addAfter:
return LocaleKeys.document_plugins_table_addAfter.tr();
case TableOptionAction.addBefore:
return LocaleKeys.document_plugins_table_addBefore.tr();
case TableOptionAction.delete:
return LocaleKeys.document_plugins_table_delete.tr();
case TableOptionAction.duplicate:
return LocaleKeys.document_plugins_table_duplicate.tr();
case TableOptionAction.clear:
return LocaleKeys.document_plugins_table_clear.tr();
case TableOptionAction.bgColor:
return LocaleKeys.document_plugins_table_bgColor.tr();
}
}
}
class TableOptionActionWrapper extends ActionCell {
TableOptionActionWrapper(this.inner);
final TableOptionAction inner;
@override
Widget? leftIcon(Color iconColor) => inner.icon(iconColor);
@override
String get name => inner.description;
}
class TableColorOptionAction extends PopoverActionCell {
TableColorOptionAction({
required this.node,
required this.editorState,
required this.position,
required this.dir,
});
final Node node;
final EditorState editorState;
final int position;
final TableDirection dir;
@override
Widget? leftIcon(Color iconColor) =>
TableOptionAction.bgColor.icon(iconColor);
@override
String get name => TableOptionAction.bgColor.description;
@override
Widget Function(
BuildContext context,
PopoverController parentController,
PopoverController controller,
) get builder => (context, parentController, controller) {
int row = 0, col = position;
if (dir == TableDirection.row) {
col = 0;
row = position;
}
final cell = node.children.firstWhereOrNull(
(n) =>
n.attributes[TableCellBlockKeys.colPosition] == col &&
n.attributes[TableCellBlockKeys.rowPosition] == row,
);
final key = dir == TableDirection.col
? TableCellBlockKeys.colBackgroundColor
: TableCellBlockKeys.rowBackgroundColor;
final bgColor = cell?.attributes[key] as String?;
final selectedColor = bgColor?.toColor();
// get default background color from themeExtension
final defaultColor = AFThemeExtension.of(context).tableCellBGColor;
final colors = [
// reset to default background color
FlowyColorOption(
color: defaultColor,
name: LocaleKeys.document_plugins_optionAction_defaultColor.tr(),
),
...FlowyTint.values.map(
(e) => FlowyColorOption(
color: e.color(context),
name: e.tintName(AppFlowyEditorLocalizations.current),
),
),
];
return FlowyColorPicker(
colors: colors,
selected: selectedColor,
border: Border.all(
color: Theme.of(context).colorScheme.onBackground,
width: 1,
),
onTap: (color, index) async {
final backgroundColor = selectedColor != color ? color.toHex() : "";
TableActions.setBgColor(
node,
position,
editorState,
backgroundColor,
dir,
);
controller.close();
parentController.close();
},
);
};
}

View File

@ -195,7 +195,7 @@ class _ToggleListBlockComponentWidgetState
child: child,
);
if (widget.actionBuilder != null) {
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,
actionBuilder: widget.actionBuilder!,

View File

@ -362,6 +362,7 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
fontColor: theme.shader3,
),
calloutBGColor: theme.hoverBG3,
tableCellBGColor: theme.surface,
caption: _getFontStyle(
fontFamily: fontFamily,
fontSize: FontSizes.s11,

View File

@ -23,6 +23,7 @@ class AFThemeExtension extends ThemeExtension<AFThemeExtension> {
final Color progressBarBGColor;
final Color toggleButtonBGColor;
final Color calloutBGColor;
final Color tableCellBGColor;
final TextStyle code;
final TextStyle callout;
@ -46,6 +47,7 @@ class AFThemeExtension extends ThemeExtension<AFThemeExtension> {
required this.toggleOffFill,
required this.textColor,
required this.calloutBGColor,
required this.tableCellBGColor,
required this.code,
required this.callout,
required this.caption,
@ -72,6 +74,7 @@ class AFThemeExtension extends ThemeExtension<AFThemeExtension> {
Color? tint9,
Color? textColor,
Color? calloutBGColor,
Color? tableCellBGColor,
Color? greyHover,
Color? greySelect,
Color? lightGreyHover,
@ -96,6 +99,7 @@ class AFThemeExtension extends ThemeExtension<AFThemeExtension> {
tint9: tint9 ?? this.tint9,
textColor: textColor ?? this.textColor,
calloutBGColor: calloutBGColor ?? this.calloutBGColor,
tableCellBGColor: tableCellBGColor ?? this.tableCellBGColor,
greyHover: greyHover ?? this.greyHover,
greySelect: greySelect ?? this.greySelect,
lightGreyHover: lightGreyHover ?? this.lightGreyHover,
@ -128,6 +132,8 @@ class AFThemeExtension extends ThemeExtension<AFThemeExtension> {
tint9: Color.lerp(tint9, other.tint9, t)!,
textColor: Color.lerp(textColor, other.textColor, t)!,
calloutBGColor: Color.lerp(calloutBGColor, other.calloutBGColor, t)!,
tableCellBGColor:
Color.lerp(tableCellBGColor, other.tableCellBGColor, t)!,
greyHover: Color.lerp(greyHover, other.greyHover, t)!,
greySelect: Color.lerp(greySelect, other.greySelect, t)!,
lightGreyHover: Color.lerp(lightGreyHover, other.lightGreyHover, t)!,

View File

@ -54,11 +54,11 @@ packages:
dependency: "direct main"
description:
path: "."
ref: a912c1c
resolved-ref: a912c1c96532ec561ea68d5138aee415fdecede2
ref: "0e55cce"
resolved-ref: "0e55cce14f2ead916a8942a123d08b818934e2fd"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "1.2.4"
version: "1.3.0"
appflowy_popover:
dependency: "direct main"
description:

View File

@ -48,7 +48,7 @@ dependencies:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: a912c1c
ref: 0e55cce
appflowy_popover:
path: packages/appflowy_popover

View File

@ -0,0 +1,152 @@
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
void main() {
group('TransactionAdapter', () {
test('toBlockAction insert node with children operation', () {
final editorState = EditorState.blank();
final transaction = editorState.transaction;
transaction.insertNode(
[0],
paragraphNode(
children: [
paragraphNode(text: '1', children: [paragraphNode(text: '1.1')]),
paragraphNode(text: '2'),
paragraphNode(text: '3', children: [paragraphNode(text: '3.1')]),
paragraphNode(text: '4'),
],
),
);
expect(transaction.operations.length, 1);
expect(transaction.operations[0] is InsertOperation, true);
final actions = transaction.operations[0].toBlockAction(editorState);
expect(actions.length, 7);
for (final action in actions) {
expect(action.action, BlockActionTypePB.Insert);
}
expect(
actions[0].payload.parentId,
editorState.document.root.id,
reason: '0 - parent id',
);
expect(
actions[0].payload.prevId,
editorState.document.root.children.first.id,
reason: '0 - prev id',
);
expect(
actions[1].payload.parentId,
actions[0].payload.block.id,
reason: '1 - parent id',
);
expect(
actions[1].payload.prevId,
'',
reason: '1 - prev id',
);
expect(
actions[2].payload.parentId,
actions[1].payload.block.id,
reason: '2 - parent id',
);
expect(
actions[2].payload.prevId,
'',
reason: '2 - prev id',
);
expect(
actions[3].payload.parentId,
actions[0].payload.block.id,
reason: '3 - parent id',
);
expect(
actions[3].payload.prevId,
actions[1].payload.block.id,
reason: '3 - prev id',
);
expect(
actions[4].payload.parentId,
actions[0].payload.block.id,
reason: '4 - parent id',
);
expect(
actions[4].payload.prevId,
actions[3].payload.block.id,
reason: '4 - prev id',
);
expect(
actions[5].payload.parentId,
actions[4].payload.block.id,
reason: '5 - parent id',
);
expect(
actions[5].payload.prevId,
'',
reason: '5 - prev id',
);
expect(
actions[6].payload.parentId,
actions[0].payload.block.id,
reason: '6 - parent id',
);
expect(
actions[6].payload.prevId,
actions[4].payload.block.id,
reason: '6 - prev id',
);
});
test('toBlockAction insert node before all children nodes', () {
final document = Document(
root: Node(
type: 'page',
children: [
paragraphNode(children: [paragraphNode(text: '1')])
],
),
);
final editorState = EditorState(document: document);
final transaction = editorState.transaction;
transaction.insertNodes([0, 0], [paragraphNode(), paragraphNode()]);
expect(transaction.operations.length, 1);
expect(transaction.operations[0] is InsertOperation, true);
final actions = transaction.operations[0].toBlockAction(editorState);
expect(actions.length, 2);
for (final action in actions) {
expect(action.action, BlockActionTypePB.Insert);
}
expect(
actions[0].payload.parentId,
editorState.document.root.children.first.id,
reason: '0 - parent id',
);
expect(
actions[0].payload.prevId,
'',
reason: '0 - prev id',
);
expect(
actions[1].payload.parentId,
editorState.document.root.children.first.id,
reason: '1 - parent id',
);
expect(
actions[1].payload.prevId,
actions[0].payload.block.id,
reason: '1 - prev id',
);
});
});
}

View File

@ -557,6 +557,14 @@
"outline": {
"addHeadingToCreateOutline": "Add headings to create a table of contents."
},
"table": {
"addAfter": "Add after",
"addBefore": "Add before",
"delete": "Delete",
"clear": "Clear content",
"duplicate": "Duplicate",
"bgColor": "Background color"
},
"contextMenu": {
"copy": "Copy",
"cut": "Cut",