feat: support 'turn into' in doc (#6516)

* feat: customize animation for popover

* chore: code refactor

* feat: using popover direction calculate the popover animation translate direction

* feat: integrate the animated popover in appflowy_popover and popover_action

* fix: close popover assertion

* chore: format code

* chore: code refactor

* feat: optimize the popover listener

* feat: clear popover when hot-reloading

* chore: refactor code

* feat: integrate animated popover in block action button

* fix: integration test

* feat: add turn into entry

* fix: popover asBarrier issue

* feat: move biz logic from widget to cubit

* feat: add turn into menu

* chore: remove unused code

* feat: support h1-h3

* feat: add block conversions

* fix: integration test

* feat: implement block conversions

* fix: outline test

* test: add turn into tests

* test: add turn into nested list test

* test: add turn into nested list test

* fix: flutter analyze

* chore: replace turninto icon

* feat: integrate animated popover in color option button

* chore: split the block option action into separate files

* test: add integration test

* fix: outline block test

* fix: integration test

* fix: shortcut test
This commit is contained in:
Lucas 2024-10-10 14:40:38 +08:00 committed by GitHub
parent b54e3dd243
commit f19e354418
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1712 additions and 819 deletions

View File

@ -1,3 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -7,9 +11,23 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// +, ... button beside the block component.
group('document with option action button', () {
testWidgets(
'click + to add a block after current selection, and click + and option key to add a block before current selection',
group('block option action:', () {
Future<void> turnIntoBlock(
WidgetTester tester,
Path path, {
required String menuText,
required String afterType,
}) async {
await tester.editor.openTurnIntoMenu(path);
await tester.tapButton(
find.findTextInFlowyText(menuText),
);
final node = tester.editor.getCurrentEditorState().getNodeAtPath(path);
expect(node?.type, afterType);
}
testWidgets('''click + to add a block after current selection,
and click + and option key to add a block before current selection''',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@ -40,5 +58,44 @@ void main() {
expect(editorState.getNodeAtPath([0])?.delta?.toPlainText(), isEmpty);
expect(editorState.getNodeAtPath([2])?.delta?.toPlainText(), isEmpty);
});
testWidgets('turn into', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
const name = 'Test Document';
await tester.createNewPageWithNameUnderParent(name: name);
await tester.openPage(name);
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('turn into');
// click the block option button to convert it to another blocks
final values = {
LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_bulletedList.tr():
BulletedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_numberedList.tr():
NumberedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type,
LocaleKeys.document_slashMenu_name_todoList.tr():
TodoListBlockKeys.type,
LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type,
LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type,
};
for (final value in values.entries) {
final menuText = value.key;
final afterType = value.value;
await turnIntoBlock(
tester,
[0],
menuText: menuText,
afterType: afterType,
);
}
});
});
}

View File

@ -1,9 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -182,13 +179,9 @@ Future<void> hoverAndClickDepthOptionAction(
List<int> path,
int level,
) async {
await tester.editor.hoverAndClickOptionMenuButton([3]);
await tester.tap(find.byType(AppFlowyPopover).hitTestable().last);
await tester.pumpAndSettle();
// Find a total of 4 HoverButtons under the [BlockOptionButton],
// in addition to 3 HoverButtons under the [DepthOptionAction] - (child of BlockOptionButton)
await tester.tap(find.byType(HoverButton).hitTestable().at(3 + level));
await tester.editor.openDepthMenu(path);
final type = OptionDepthType.fromLevel(level);
await tester.tapButton(find.findTextInFlowyText(type.description));
await tester.pumpAndSettle();
}

View File

@ -6,6 +6,7 @@ import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_title.dart';
@ -15,6 +16,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/uplo
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@ -275,10 +277,32 @@ class EditorOperations {
widget.blockComponentContext.node.path.equals(path),
),
);
await tester.pumpUntilFound(find.byType(PopoverActionList));
},
);
}
/// open the turn into menu
Future<void> openTurnIntoMenu(Path path) async {
await hoverAndClickOptionMenuButton(path);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
),
);
await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu));
}
Future<void> openDepthMenu(Path path) async {
await hoverAndClickOptionMenuButton(path);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_depth.tr(),
),
);
await tester.pumpUntilFound(find.byType(DepthOptionMenu));
}
/// Drag block
///
/// [offset] is the offset to move the block.

View File

@ -18,6 +18,48 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
enum EditorOptionActionType {
turnInto,
color,
align,
depth;
Set<String> get supportTypes {
switch (this) {
case EditorOptionActionType.turnInto:
return {
ParagraphBlockKeys.type,
HeadingBlockKeys.type,
QuoteBlockKeys.type,
CalloutBlockKeys.type,
BulletedListBlockKeys.type,
NumberedListBlockKeys.type,
TodoListBlockKeys.type,
};
case EditorOptionActionType.color:
return {
ParagraphBlockKeys.type,
HeadingBlockKeys.type,
BulletedListBlockKeys.type,
NumberedListBlockKeys.type,
QuoteBlockKeys.type,
TodoListBlockKeys.type,
CalloutBlockKeys.type,
OutlineBlockKeys.type,
ToggleListBlockKeys.type,
};
case EditorOptionActionType.align:
return {
ImageBlockKeys.type,
};
case EditorOptionActionType.depth:
return {
OutlineBlockKeys.type,
};
}
}
}
Map<String, BlockComponentBuilder> getEditorBuilderMap({
required BuildContext context,
required EditorState editorState,
@ -285,30 +327,21 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
}
final builder = entry.value;
// customize the action builder.
final supportColorBuilderTypes = [
ParagraphBlockKeys.type,
HeadingBlockKeys.type,
BulletedListBlockKeys.type,
NumberedListBlockKeys.type,
QuoteBlockKeys.type,
TodoListBlockKeys.type,
CalloutBlockKeys.type,
OutlineBlockKeys.type,
ToggleListBlockKeys.type,
];
final supportAlignBuilderType = [ImageBlockKeys.type];
final supportDepthBuilderType = [OutlineBlockKeys.type];
final colorAction = [OptionAction.divider, OptionAction.color];
final alignAction = [OptionAction.divider, OptionAction.align];
final depthAction = [OptionAction.depth];
final turnIntoAction = [OptionAction.turnInto];
final List<OptionAction> actions = [
...standardActions,
if (supportColorBuilderTypes.contains(entry.key)) ...colorAction,
if (supportAlignBuilderType.contains(entry.key)) ...alignAction,
if (supportDepthBuilderType.contains(entry.key)) ...depthAction,
if (EditorOptionActionType.turnInto.supportTypes.contains(entry.key))
...turnIntoAction,
if (EditorOptionActionType.color.supportTypes.contains(entry.key))
...colorAction,
if (EditorOptionActionType.align.supportTypes.contains(entry.key))
...alignAction,
if (EditorOptionActionType.depth.supportTypes.contains(entry.key))
...depthAction,
];
if (UniversalPlatform.isDesktop) {

View File

@ -1,6 +1,6 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';

View File

@ -1,29 +1,15 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.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/snap_bar.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:toastification/toastification.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'drag_to_reorder/draggable_option_button.dart';
class BlockOptionButton extends StatefulWidget {
class BlockOptionButton extends StatelessWidget {
const BlockOptionButton({
super.key,
required this.blockComponentContext,
@ -40,286 +26,92 @@ class BlockOptionButton extends StatefulWidget {
final Map<String, BlockComponentBuilder> blockComponentBuilder;
@override
State<BlockOptionButton> createState() => _BlockOptionButtonState();
}
Widget build(BuildContext context) {
final direction =
context.read<AppearanceSettingsCubit>().state.layoutDirection ==
LayoutDirection.rtlLayout
? PopoverDirection.rightWithCenterAligned
: PopoverDirection.leftWithCenterAligned;
return BlocProvider(
create: (context) => BlockActionOptionCubit(
editorState: editorState,
blockComponentBuilder: blockComponentBuilder,
),
child: BlocBuilder<BlockActionOptionCubit, BlockActionOptionState>(
builder: (context, _) => PopoverActionList<PopoverAction>(
actions: _buildPopoverActions(context),
popoverMutex: PopoverMutex(),
animationDuration: Durations.short3,
slideDistance: 5,
beginScaleFactor: 1.0,
beginOpacity: 0.8,
direction: direction,
onPopupBuilder: _onPopoverBuilder,
onClosed: () => _onPopoverClosed(context),
onSelected: (action, controller) => _onActionSelected(
context,
action,
controller,
),
buildChild: (controller) => DraggableOptionButton(
controller: controller,
editorState: editorState,
blockComponentContext: blockComponentContext,
blockComponentBuilder: blockComponentBuilder,
),
),
),
);
}
class _BlockOptionButtonState extends State<BlockOptionButton> {
late final List<PopoverAction> popoverActions;
@override
void initState() {
super.initState();
popoverActions = widget.actions.map((e) {
List<PopoverAction> _buildPopoverActions(BuildContext context) {
return actions.map((e) {
switch (e) {
case OptionAction.divider:
return DividerOptionAction();
case OptionAction.color:
return ColorOptionAction(editorState: widget.editorState);
return ColorOptionAction(editorState: editorState);
case OptionAction.align:
return AlignOptionAction(editorState: widget.editorState);
return AlignOptionAction(editorState: editorState);
case OptionAction.depth:
return DepthOptionAction(editorState: widget.editorState);
return DepthOptionAction(editorState: editorState);
case OptionAction.turnInto:
return TurnIntoOptionAction(
editorState: editorState,
blockComponentBuilder: blockComponentBuilder,
);
default:
return OptionActionWrapper(e);
}
}).toList();
}
@override
Widget build(BuildContext context) {
return PopoverActionList<PopoverAction>(
popoverMutex: PopoverMutex(),
actions: popoverActions,
direction:
context.read<AppearanceSettingsCubit>().state.layoutDirection ==
LayoutDirection.rtlLayout
? PopoverDirection.rightWithCenterAligned
: PopoverDirection.leftWithCenterAligned,
onPopupBuilder: () {
keepEditorFocusNotifier.increase();
widget.blockComponentState.alwaysShowActions = true;
},
onClosed: () {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
if (!mounted) {
return;
}
widget.editorState.selectionType = null;
widget.editorState.selection = null;
widget.blockComponentState.alwaysShowActions = false;
keepEditorFocusNotifier.decrease();
});
},
onSelected: (action, controller) {
if (action is OptionActionWrapper) {
_onSelectAction(context, action.inner);
controller.close();
}
},
buildChild: (controller) => DraggableOptionButton(
controller: controller,
editorState: widget.editorState,
blockComponentContext: widget.blockComponentContext,
blockComponentBuilder: widget.blockComponentBuilder,
),
);
void _onPopoverBuilder() {
keepEditorFocusNotifier.increase();
blockComponentState.alwaysShowActions = true;
}
Future<void> _onSelectAction(
BuildContext context,
OptionAction action,
) async {
final node = widget.blockComponentContext.node;
final transaction = widget.editorState.transaction;
switch (action) {
case OptionAction.delete:
transaction.deleteNode(node);
break;
case OptionAction.duplicate:
await _duplicateBlock(context, transaction, node);
break;
case OptionAction.turnInto:
break;
case OptionAction.moveUp:
transaction.moveNode(node.path.previous, node);
break;
case OptionAction.moveDown:
transaction.moveNode(node.path.next.next, node);
break;
case OptionAction.copyLinkToBlock:
await _copyLinkToBlock(context, node);
break;
case OptionAction.align:
case OptionAction.color:
case OptionAction.divider:
case OptionAction.depth:
throw UnimplementedError();
}
await widget.editorState.apply(transaction);
void _onPopoverClosed(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
editorState.selectionType = null;
editorState.selection = null;
blockComponentState.alwaysShowActions = false;
});
}
Future<void> _duplicateBlock(
void _onActionSelected(
BuildContext context,
Transaction transaction,
Node node,
) async {
// 1. verify the node integrity
final type = node.type;
final builder = widget.editorState.renderer.blockComponentBuilder(type);
if (builder == null) {
Log.error('Block type $type is not supported');
PopoverAction action,
PopoverController controller,
) {
if (action is! OptionActionWrapper) {
return;
}
final valid = builder.validate(node);
if (!valid) {
Log.error('Block type $type is not valid');
}
// 2. duplicate the node
// the _copyBlock will fix the table block
Node newNode = _copyBlock(context, node);
// 3. if the node is sub page, duplicate the view
if (node.type == SubPageBlockKeys.type) {
final viewId = await _handleDuplicateSubPage(context, node);
if (viewId == null) {
return;
}
newNode = newNode.copyWith(attributes: {SubPageBlockKeys.viewId: viewId});
}
// 4. insert the node to the next of the current node
transaction.insertNode(node.path.next, newNode);
}
Node _copyBlock(BuildContext context, Node node) {
Node copiedNode = node.copyWith();
final type = node.type;
final builder = widget.editorState.renderer.blockComponentBuilder(type);
if (builder == null) {
Log.error('Block type $type is not supported');
} else {
final valid = builder.validate(node);
if (!valid) {
Log.error('Block type $type is not valid');
if (node.type == TableBlockKeys.type) {
copiedNode = _fixTableBlock(node);
}
}
}
return copiedNode;
}
Node _fixTableBlock(Node node) {
if (node.type != TableBlockKeys.type) {
return node;
}
// the table node should contains colsLen and rowsLen
final colsLen = node.attributes[TableBlockKeys.colsLen];
final rowsLen = node.attributes[TableBlockKeys.rowsLen];
if (colsLen == null || rowsLen == null) {
return node;
}
final newChildren = <Node>[];
final children = node.children;
// based on the colsLen and rowsLen, iterate the children and fix the data
for (var i = 0; i < rowsLen; i++) {
for (var j = 0; j < colsLen; j++) {
final cell = children
.where(
(n) =>
n.attributes[TableCellBlockKeys.rowPosition] == i &&
n.attributes[TableCellBlockKeys.colPosition] == j,
)
.firstOrNull;
if (cell != null) {
newChildren.add(cell.copyWith());
} else {
newChildren.add(
tableCellNode('', i, j),
);
}
}
}
return node.copyWith(
children: newChildren,
attributes: {
...node.attributes,
TableBlockKeys.colsLen: colsLen,
TableBlockKeys.rowsLen: rowsLen,
},
);
}
Future<void> _copyLinkToBlock(BuildContext context, Node node) async {
final viewId = context.read<DocumentBloc>().documentId;
final workspace = await FolderEventReadCurrentWorkspace().send();
final workspaceId = workspace.fold(
(l) => l.id,
(r) => '',
);
if (workspaceId.isEmpty || viewId.isEmpty) {
Log.error('Failed to get workspace id: $workspaceId or view id: $viewId');
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(),
type: ToastificationType.error,
context.read<BlockActionOptionCubit>().handleAction(
action.inner,
blockComponentContext.node,
);
}
return;
}
final link = ShareConstants.buildShareUrl(
workspaceId: workspaceId,
viewId: viewId,
blockId: node.id,
);
await getIt<ClipboardService>().setData(
ClipboardServiceData(plainText: link),
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkToBlockSuccess.tr(),
);
}
}
/// Handles duplicating a SubPage.
///
/// If the duplication fails for any reason, this method will return false, and inserting
/// the duplicate node should be aborted.
///
Future<String?> _handleDuplicateSubPage(
BuildContext context,
Node node,
) async {
final viewId = node.attributes[SubPageBlockKeys.viewId];
if (viewId == null) {
return null;
}
final view = (await ViewBackendService.getView(viewId)).toNullable();
if (view == null) {
return null;
}
final result = await ViewBackendService.duplicate(
view: view,
openAfterDuplicate: false,
includeChildren: true,
parentViewId: view.parentViewId,
syncAfterDuplicate: true,
);
return result.fold(
(view) => view.id,
(error) {
Log.error(error);
if (context.mounted) {
showSnapBar(
context,
LocaleKeys.document_plugins_subPage_errors_failedDuplicatePage.tr(),
);
}
return null;
},
);
controller.close();
}
}

View File

@ -0,0 +1,250 @@
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class BlockActionOptionState {}
class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
BlockActionOptionCubit({
required this.editorState,
required this.blockComponentBuilder,
}) : super(BlockActionOptionState());
final EditorState editorState;
final Map<String, BlockComponentBuilder> blockComponentBuilder;
Future<void> handleAction(OptionAction action, Node node) async {
final transaction = editorState.transaction;
switch (action) {
case OptionAction.delete:
transaction.deleteNode(node);
break;
case OptionAction.duplicate:
await _duplicateBlock(transaction, node);
break;
case OptionAction.moveUp:
transaction.moveNode(node.path.previous, node);
break;
case OptionAction.moveDown:
transaction.moveNode(node.path.next.next, node);
break;
case OptionAction.copyLinkToBlock:
await _copyLinkToBlock(node);
break;
case OptionAction.align:
case OptionAction.color:
case OptionAction.divider:
case OptionAction.depth:
case OptionAction.turnInto:
throw UnimplementedError();
}
await editorState.apply(transaction);
}
Future<void> _duplicateBlock(Transaction transaction, Node node) async {
final type = node.type;
final builder = editorState.renderer.blockComponentBuilder(type);
if (builder == null) {
Log.error('Block type $type is not supported');
return;
}
final valid = builder.validate(node);
if (!valid) {
Log.error('Block type $type is not valid');
}
Node newNode = _copyBlock(node);
if (node.type == SubPageBlockKeys.type) {
final viewId = await _handleDuplicateSubPage(node);
if (viewId == null) {
return;
}
newNode = newNode.copyWith(attributes: {SubPageBlockKeys.viewId: viewId});
}
transaction.insertNode(node.path.next, newNode);
}
Node _copyBlock(Node node) {
Node copiedNode = node.copyWith();
final type = node.type;
final builder = editorState.renderer.blockComponentBuilder(type);
if (builder == null) {
Log.error('Block type $type is not supported');
} else {
final valid = builder.validate(node);
if (!valid) {
Log.error('Block type $type is not valid');
if (node.type == TableBlockKeys.type) {
copiedNode = _fixTableBlock(node);
}
}
}
return copiedNode;
}
Node _fixTableBlock(Node node) {
if (node.type != TableBlockKeys.type) {
return node;
}
// the table node should contains colsLen and rowsLen
final colsLen = node.attributes[TableBlockKeys.colsLen];
final rowsLen = node.attributes[TableBlockKeys.rowsLen];
if (colsLen == null || rowsLen == null) {
return node;
}
final newChildren = <Node>[];
final children = node.children;
// based on the colsLen and rowsLen, iterate the children and fix the data
for (var i = 0; i < rowsLen; i++) {
for (var j = 0; j < colsLen; j++) {
final cell = children
.where(
(n) =>
n.attributes[TableCellBlockKeys.rowPosition] == i &&
n.attributes[TableCellBlockKeys.colPosition] == j,
)
.firstOrNull;
if (cell != null) {
newChildren.add(cell.copyWith());
} else {
newChildren.add(
tableCellNode('', i, j),
);
}
}
}
return node.copyWith(
children: newChildren,
attributes: {
...node.attributes,
TableBlockKeys.colsLen: colsLen,
TableBlockKeys.rowsLen: rowsLen,
},
);
}
Future<void> _copyLinkToBlock(Node node) async {
final viewId = getIt<DocumentBloc>().documentId;
final workspace = await FolderEventReadCurrentWorkspace().send();
final workspaceId = workspace.fold(
(l) => l.id,
(r) => '',
);
if (workspaceId.isEmpty || viewId.isEmpty) {
Log.error('Failed to get workspace id: $workspaceId or view id: $viewId');
emit(BlockActionOptionState()); // Emit a new state to trigger UI update
return;
}
final link = ShareConstants.buildShareUrl(
workspaceId: workspaceId,
viewId: viewId,
blockId: node.id,
);
await getIt<ClipboardService>().setData(
ClipboardServiceData(plainText: link),
);
emit(BlockActionOptionState()); // Emit a new state to trigger UI update
}
Future<String?> _handleDuplicateSubPage(Node node) async {
final viewId = node.attributes[SubPageBlockKeys.viewId];
if (viewId == null) {
return null;
}
final view = (await ViewBackendService.getView(viewId)).toNullable();
if (view == null) {
return null;
}
final result = await ViewBackendService.duplicate(
view: view,
openAfterDuplicate: false,
includeChildren: true,
parentViewId: view.parentViewId,
syncAfterDuplicate: true,
);
return result.fold(
(view) => view.id,
(error) {
Log.error(error);
emit(BlockActionOptionState()); // Emit a new state to trigger UI update
return null;
},
);
}
Future<bool> turnIntoBlock(
String type,
Node node, {
int? level,
}) async {
final toType = type;
Log.info(
'Turn into block: from ${node.type} to $type',
);
if (type == node.type && type != HeadingBlockKeys.type) {
Log.info('Block type is the same');
return false;
}
Node afterNode = node.copyWith(
type: type,
attributes: {
if (toType == HeadingBlockKeys.type) HeadingBlockKeys.level: level,
if (toType == TodoListBlockKeys.type) TodoListBlockKeys.checked: false,
blockComponentBackgroundColor:
node.attributes[blockComponentBackgroundColor],
blockComponentTextDirection:
node.attributes[blockComponentTextDirection],
blockComponentDelta: (node.delta ?? Delta()).toJson(),
},
);
final insertedNode = [];
// heading block and callout block should not have children
if ([HeadingBlockKeys.type, CalloutBlockKeys.type].contains(toType)) {
afterNode = afterNode.copyWith(
children: [],
);
insertedNode.addAll(node.children.map((e) => e.copyWith()));
}
final transaction = editorState.transaction;
transaction.insertNodes(node.path, [
afterNode,
...insertedNode,
]);
transaction.deleteNode(node);
await editorState.apply(transaction);
return true;
}
}

View File

@ -0,0 +1,152 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:styled_widget/styled_widget.dart';
enum OptionAlignType {
left,
center,
right;
static OptionAlignType fromString(String? value) {
switch (value) {
case 'left':
return OptionAlignType.left;
case 'center':
return OptionAlignType.center;
case 'right':
return OptionAlignType.right;
default:
return OptionAlignType.center;
}
}
FlowySvgData get svg {
switch (this) {
case OptionAlignType.left:
return FlowySvgs.align_left_s;
case OptionAlignType.center:
return FlowySvgs.align_center_s;
case OptionAlignType.right:
return FlowySvgs.align_right_s;
}
}
String get description {
switch (this) {
case OptionAlignType.left:
return LocaleKeys.document_plugins_optionAction_left.tr();
case OptionAlignType.center:
return LocaleKeys.document_plugins_optionAction_center.tr();
case OptionAlignType.right:
return LocaleKeys.document_plugins_optionAction_right.tr();
}
}
}
class AlignOptionAction extends PopoverActionCell {
AlignOptionAction({
required this.editorState,
});
final EditorState editorState;
@override
Widget? leftIcon(Color iconColor) {
return FlowySvg(
align.svg,
size: const Size.square(12),
).padding(all: 2.0);
}
@override
String get name {
return LocaleKeys.document_plugins_optionAction_align.tr();
}
@override
PopoverActionCellBuilder get builder =>
(context, parentController, controller) {
final selection = editorState.selection?.normalized;
if (selection == null) {
return const SizedBox.shrink();
}
final node = editorState.getNodeAtPath(selection.start.path);
if (node == null) {
return const SizedBox.shrink();
}
final children = buildAlignOptions(context, (align) async {
await onAlignChanged(align);
controller.close();
parentController.close();
});
return IntrinsicHeight(
child: IntrinsicWidth(
child: Column(
children: children,
),
),
);
};
List<Widget> buildAlignOptions(
BuildContext context,
void Function(OptionAlignType) onTap,
) {
return OptionAlignType.values.map((e) => OptionAlignWrapper(e)).map((e) {
final leftIcon = e.leftIcon(Theme.of(context).colorScheme.onSurface);
final rightIcon = e.rightIcon(Theme.of(context).colorScheme.onSurface);
return HoverButton(
onTap: () => onTap(e.inner),
itemHeight: ActionListSizes.itemHeight,
leftIcon: leftIcon,
name: e.name,
rightIcon: rightIcon,
);
}).toList();
}
OptionAlignType get align {
final selection = editorState.selection;
if (selection == null) {
return OptionAlignType.center;
}
final node = editorState.getNodeAtPath(selection.start.path);
final align = node?.attributes[blockComponentAlign];
return OptionAlignType.fromString(align);
}
Future<void> onAlignChanged(OptionAlignType align) async {
if (align == this.align) {
return;
}
final selection = editorState.selection;
if (selection == null) {
return;
}
final node = editorState.getNodeAtPath(selection.start.path);
if (node == null) {
return;
}
final transaction = editorState.transaction;
transaction.updateNode(node, {
blockComponentAlign: align.name,
});
await editorState.apply(transaction);
}
}
class OptionAlignWrapper extends ActionCell {
OptionAlignWrapper(this.inner);
final OptionAlignType inner;
@override
Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg);
@override
String get name => inner.description;
}

View File

@ -0,0 +1,125 @@
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';
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/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
const optionActionColorDefaultColor = 'appflowy_theme_default_color';
class ColorOptionAction extends CustomActionCell {
ColorOptionAction({
required this.editorState,
});
final EditorState editorState;
final PopoverController innerController = PopoverController();
@override
Widget buildWithContext(
BuildContext context,
PopoverController controller,
PopoverMutex? mutex,
) {
return AppFlowyPopover(
asBarrier: true,
controller: innerController,
mutex: mutex,
popupBuilder: (context) => _buildColorOptionMenu(
context,
controller,
),
direction: PopoverDirection.rightWithCenterAligned,
offset: const Offset(10, 0),
animationDuration: Durations.short3,
beginScaleFactor: 1.0,
beginOpacity: 0.8,
child: HoverButton(
itemHeight: ActionListSizes.itemHeight,
leftIcon: const FlowySvg(
FlowySvgs.color_format_m,
size: Size.square(15),
),
name: LocaleKeys.document_plugins_optionAction_color.tr(),
onTap: () {
innerController.show();
},
),
);
}
Widget _buildColorOptionMenu(
BuildContext context,
PopoverController controller,
) {
final selection = editorState.selection?.normalized;
if (selection == null) {
return const SizedBox.shrink();
}
final node = editorState.getNodeAtPath(selection.start.path);
if (node == null) {
return const SizedBox.shrink();
}
return _buildColorOptions(context, node, controller);
}
Widget _buildColorOptions(
BuildContext context,
Node node,
PopoverController controller,
) {
final selection = editorState.selection?.normalized;
if (selection == null) {
return const SizedBox.shrink();
}
final node = editorState.getNodeAtPath(selection.start.path);
if (node == null) {
return const SizedBox.shrink();
}
final bgColor = node.attributes[blockComponentBackgroundColor] as String?;
final selectedColor = bgColor?.tryToColor();
// get default background color for callout block from themeExtension
final defaultColor = node.type == CalloutBlockKeys.type
? AFThemeExtension.of(context).calloutBGColor
: Colors.transparent;
final colors = [
// reset to default background color
FlowyColorOption(
color: defaultColor,
i18n: LocaleKeys.document_plugins_optionAction_defaultColor.tr(),
id: optionActionColorDefaultColor,
),
...FlowyTint.values.map(
(e) => FlowyColorOption(
color: e.color(context),
i18n: e.tintName(AppFlowyEditorL10n.current),
id: e.id,
),
),
];
return FlowyColorPicker(
colors: colors,
selected: selectedColor,
border: Border.all(
color: AFThemeExtension.of(context).onBackground,
),
onTap: (option, index) async {
final transaction = editorState.transaction;
transaction.updateNode(node, {
blockComponentBackgroundColor: option.id,
});
await editorState.apply(transaction);
innerController.close();
controller.close();
},
);
}
}

View File

@ -0,0 +1,142 @@
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';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
enum OptionDepthType {
h1(1, 'H1'),
h2(2, 'H2'),
h3(3, 'H3'),
h4(4, 'H4'),
h5(5, 'H5'),
h6(6, 'H6');
const OptionDepthType(this.level, this.description);
final String description;
final int level;
static OptionDepthType fromLevel(int? level) {
switch (level) {
case 1:
return OptionDepthType.h1;
case 2:
return OptionDepthType.h2;
case 3:
default:
return OptionDepthType.h3;
}
}
}
class DepthOptionAction extends PopoverActionCell {
DepthOptionAction({
required this.editorState,
});
final EditorState editorState;
@override
Widget? leftIcon(Color iconColor) {
return FlowySvg(
OptionAction.depth.svg,
size: const Size.square(16),
);
}
@override
String get name => LocaleKeys.document_plugins_optionAction_depth.tr();
@override
PopoverActionCellBuilder get builder =>
(context, parentController, controller) {
return DepthOptionMenu(
onTap: (depth) async {
await onDepthChanged(depth);
parentController.close();
parentController.close();
},
);
};
OptionDepthType depth(Node node) {
final level = node.attributes[OutlineBlockKeys.depth];
return OptionDepthType.fromLevel(level);
}
Future<void> onDepthChanged(OptionDepthType depth) async {
final selection = editorState.selection;
final node = selection != null
? editorState.getNodeAtPath(selection.start.path)
: null;
if (node == null || depth == this.depth(node)) return;
final transaction = editorState.transaction;
transaction.updateNode(
node,
{OutlineBlockKeys.depth: depth.level},
);
await editorState.apply(transaction);
}
}
class DepthOptionMenu extends StatelessWidget {
const DepthOptionMenu({
super.key,
required this.onTap,
});
final Future<void> Function(OptionDepthType) onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 42,
child: Column(
mainAxisSize: MainAxisSize.min,
children: buildDepthOptions(context, onTap),
),
);
}
List<Widget> buildDepthOptions(
BuildContext context,
Future<void> Function(OptionDepthType) onTap,
) {
return OptionDepthType.values
.map((e) => OptionDepthWrapper(e))
.map(
(e) => HoverButton(
onTap: () => onTap(e.inner),
itemHeight: ActionListSizes.itemHeight,
name: e.name,
),
)
.toList();
}
}
class OptionDepthWrapper extends ActionCell {
OptionDepthWrapper(this.inner);
final OptionDepthType inner;
@override
String get name => inner.description;
}
class OptionActionWrapper extends ActionCell {
OptionActionWrapper(this.inner);
final OptionAction inner;
@override
Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg);
@override
String get name => inner.description;
}

View File

@ -0,0 +1,20 @@
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart';
class DividerOptionAction extends CustomActionCell {
@override
Widget buildWithContext(
BuildContext context,
PopoverController controller,
PopoverMutex? mutex,
) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 4.0),
child: Divider(
height: 1.0,
thickness: 1.0,
),
);
}
}

View File

@ -0,0 +1,74 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
export 'align_option_action.dart';
export 'color_option_action.dart';
export 'depth_option_action.dart';
export 'divider_option_action.dart';
export 'turn_into_option_action.dart';
enum OptionAction {
delete,
duplicate,
turnInto,
moveUp,
moveDown,
copyLinkToBlock,
/// callout background color
color,
divider,
align,
depth;
FlowySvgData get svg {
switch (this) {
case OptionAction.delete:
return FlowySvgs.trash_s;
case OptionAction.duplicate:
return FlowySvgs.copy_s;
case OptionAction.turnInto:
return FlowySvgs.turninto_s;
case OptionAction.moveUp:
return const FlowySvgData('editor/move_up');
case OptionAction.moveDown:
return const FlowySvgData('editor/move_down');
case OptionAction.color:
return const FlowySvgData('editor/color');
case OptionAction.divider:
return const FlowySvgData('editor/divider');
case OptionAction.align:
return FlowySvgs.m_aa_bulleted_list_s;
case OptionAction.depth:
return FlowySvgs.tag_s;
case OptionAction.copyLinkToBlock:
return FlowySvgs.share_tab_copy_s;
}
}
String get description {
switch (this) {
case OptionAction.delete:
return LocaleKeys.document_plugins_optionAction_delete.tr();
case OptionAction.duplicate:
return LocaleKeys.document_plugins_optionAction_duplicate.tr();
case OptionAction.turnInto:
return LocaleKeys.document_plugins_optionAction_turnInto.tr();
case OptionAction.moveUp:
return LocaleKeys.document_plugins_optionAction_moveUp.tr();
case OptionAction.moveDown:
return LocaleKeys.document_plugins_optionAction_moveDown.tr();
case OptionAction.color:
return LocaleKeys.document_plugins_optionAction_color.tr();
case OptionAction.align:
return LocaleKeys.document_plugins_optionAction_align.tr();
case OptionAction.depth:
return LocaleKeys.document_plugins_optionAction_depth.tr();
case OptionAction.copyLinkToBlock:
return LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr();
case OptionAction.divider:
throw UnsupportedError('Divider does not have description');
}
}
}

View File

@ -0,0 +1,254 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.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/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class TurnIntoOptionAction extends CustomActionCell {
TurnIntoOptionAction({
required this.editorState,
required this.blockComponentBuilder,
});
final EditorState editorState;
final Map<String, BlockComponentBuilder> blockComponentBuilder;
final PopoverController innerController = PopoverController();
@override
Widget buildWithContext(
BuildContext context,
PopoverController controller,
PopoverMutex? mutex,
) {
return AppFlowyPopover(
asBarrier: true,
controller: innerController,
mutex: mutex,
popupBuilder: (context) => BlocProvider<BlockActionOptionCubit>(
create: (context) => BlockActionOptionCubit(
editorState: editorState,
blockComponentBuilder: blockComponentBuilder,
),
child: BlocBuilder<BlockActionOptionCubit, BlockActionOptionState>(
builder: (context, _) => _buildTurnIntoOptionMenu(context),
),
),
direction: PopoverDirection.rightWithCenterAligned,
offset: const Offset(10, 0),
animationDuration: Durations.short3,
beginScaleFactor: 1.0,
beginOpacity: 0.8,
child: HoverButton(
itemHeight: ActionListSizes.itemHeight,
// todo(lucas): replace the svg with the correct one
leftIcon: const FlowySvg(FlowySvgs.turninto_s),
name: LocaleKeys.document_plugins_optionAction_turnInto.tr(),
onTap: () {
innerController.show();
},
),
);
}
Widget _buildTurnIntoOptionMenu(BuildContext context) {
final selection = editorState.selection?.normalized;
// the selection may not be collapsed, for example, if a block contains some children,
// the selection will be the start from the current block and end at the last child block.
// we should take care of this case:
// converting a block that contains children to a heading block,
// we should move all the children under the heading block.
if (selection == null) {
return const SizedBox.shrink();
}
final node = editorState.getNodeAtPath(selection.start.path);
if (node == null) {
return const SizedBox.shrink();
}
return TurnIntoOptionMenu(node: node);
}
}
class TurnIntoOptionMenu extends StatelessWidget {
const TurnIntoOptionMenu({
super.key,
required this.node,
});
final Node node;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: _buildTurnIntoOptions(context, node),
);
}
List<Widget> _buildTurnIntoOptions(BuildContext context, Node node) {
final children = <Widget>[];
for (final type in EditorOptionActionType.turnInto.supportTypes) {
if (type != HeadingBlockKeys.type) {
children.add(
_TurnInfoButton(
type: type,
node: node,
),
);
} else {
// support h46
for (final i in [1, 2, 3]) {
children.add(
_TurnInfoButton(
type: type,
node: node,
level: i,
),
);
}
}
}
return children;
}
}
class _TurnInfoButton extends StatelessWidget {
const _TurnInfoButton({
required this.type,
required this.node,
this.level,
});
final String type;
final Node node;
final int? level;
@override
Widget build(BuildContext context) {
final name = _buildLocalization(
type,
level: level,
);
final leftIcon = _buildLeftIcon(
type,
level: level,
);
final rightIcon = _buildRightIcon(
type,
node,
level: level,
);
return HoverButton(
name: name,
leftIcon: FlowySvg(leftIcon),
rightIcon: rightIcon,
itemHeight: ActionListSizes.itemHeight,
onTap: () {
context.read<BlockActionOptionCubit>().turnIntoBlock(
type,
node,
level: level,
);
},
);
}
Widget? _buildRightIcon(
String type,
Node node, {
int? level,
}) {
if (type != node.type) {
return null;
}
if (node.type == HeadingBlockKeys.type) {
final nodeLevel = node.attributes[HeadingBlockKeys.level] ?? 1;
if (level != nodeLevel) {
return null;
}
}
return const FlowySvg(
FlowySvgs.workspace_selected_s,
blendMode: null,
);
}
FlowySvgData _buildLeftIcon(
String type, {
int? level,
}) {
if (type == ParagraphBlockKeys.type) {
return FlowySvgs.slash_menu_icon_text_s;
} else if (type == HeadingBlockKeys.type) {
switch (level) {
case 1:
return FlowySvgs.slash_menu_icon_h1_s;
case 2:
return FlowySvgs.slash_menu_icon_h2_s;
case 3:
return FlowySvgs.slash_menu_icon_h3_s;
// support h4h6
default:
return FlowySvgs.slash_menu_icon_text_s;
}
} else if (type == QuoteBlockKeys.type) {
return FlowySvgs.slash_menu_icon_quote_s;
} else if (type == BulletedListBlockKeys.type) {
return FlowySvgs.slash_menu_icon_bulleted_list_s;
} else if (type == NumberedListBlockKeys.type) {
return FlowySvgs.slash_menu_icon_numbered_list_s;
} else if (type == TodoListBlockKeys.type) {
return FlowySvgs.slash_menu_icon_checkbox_s;
} else if (type == CalloutBlockKeys.type) {
return FlowySvgs.slash_menu_icon_callout_s;
}
throw UnimplementedError('Unsupported block type: $type');
}
String _buildLocalization(
String type, {
int? level,
}) {
switch (type) {
case ParagraphBlockKeys.type:
return LocaleKeys.document_slashMenu_name_text.tr();
case HeadingBlockKeys.type:
switch (level) {
case 1:
return LocaleKeys.document_slashMenu_name_heading1.tr();
case 2:
return LocaleKeys.document_slashMenu_name_heading2.tr();
case 3:
return LocaleKeys.document_slashMenu_name_heading3.tr();
default:
return LocaleKeys.document_slashMenu_name_text.tr();
}
case QuoteBlockKeys.type:
return LocaleKeys.document_slashMenu_name_quote.tr();
case BulletedListBlockKeys.type:
return LocaleKeys.document_slashMenu_name_bulletedList.tr();
case NumberedListBlockKeys.type:
return LocaleKeys.document_slashMenu_name_numberedList.tr();
case TodoListBlockKeys.type:
return LocaleKeys.document_slashMenu_name_todoList.tr();
case CalloutBlockKeys.type:
return LocaleKeys.document_slashMenu_name_callout.tr();
}
throw UnimplementedError('Unsupported block type: $type');
}
}

View File

@ -1,430 +0,0 @@
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';
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/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:styled_widget/styled_widget.dart';
const optionActionColorDefaultColor = 'appflowy_theme_default_color';
enum OptionAction {
delete,
duplicate,
turnInto,
moveUp,
moveDown,
copyLinkToBlock,
/// callout background color
color,
divider,
align,
depth;
FlowySvgData get svg {
switch (this) {
case OptionAction.delete:
return FlowySvgs.trash_s;
case OptionAction.duplicate:
return FlowySvgs.copy_s;
case OptionAction.turnInto:
return const FlowySvgData('editor/turn_into');
case OptionAction.moveUp:
return const FlowySvgData('editor/move_up');
case OptionAction.moveDown:
return const FlowySvgData('editor/move_down');
case OptionAction.color:
return const FlowySvgData('editor/color');
case OptionAction.divider:
return const FlowySvgData('editor/divider');
case OptionAction.align:
return FlowySvgs.m_aa_bulleted_list_s;
case OptionAction.depth:
return FlowySvgs.tag_s;
case OptionAction.copyLinkToBlock:
return FlowySvgs.share_tab_copy_s;
}
}
String get description {
switch (this) {
case OptionAction.delete:
return LocaleKeys.document_plugins_optionAction_delete.tr();
case OptionAction.duplicate:
return LocaleKeys.document_plugins_optionAction_duplicate.tr();
case OptionAction.turnInto:
return LocaleKeys.document_plugins_optionAction_turnInto.tr();
case OptionAction.moveUp:
return LocaleKeys.document_plugins_optionAction_moveUp.tr();
case OptionAction.moveDown:
return LocaleKeys.document_plugins_optionAction_moveDown.tr();
case OptionAction.color:
return LocaleKeys.document_plugins_optionAction_color.tr();
case OptionAction.align:
return LocaleKeys.document_plugins_optionAction_align.tr();
case OptionAction.depth:
return LocaleKeys.document_plugins_optionAction_depth.tr();
case OptionAction.copyLinkToBlock:
return LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr();
case OptionAction.divider:
throw UnsupportedError('Divider does not have description');
}
}
}
enum OptionAlignType {
left,
center,
right;
static OptionAlignType fromString(String? value) {
switch (value) {
case 'left':
return OptionAlignType.left;
case 'center':
return OptionAlignType.center;
case 'right':
return OptionAlignType.right;
default:
return OptionAlignType.center;
}
}
FlowySvgData get svg {
switch (this) {
case OptionAlignType.left:
return FlowySvgs.align_left_s;
case OptionAlignType.center:
return FlowySvgs.align_center_s;
case OptionAlignType.right:
return FlowySvgs.align_right_s;
}
}
String get description {
switch (this) {
case OptionAlignType.left:
return LocaleKeys.document_plugins_optionAction_left.tr();
case OptionAlignType.center:
return LocaleKeys.document_plugins_optionAction_center.tr();
case OptionAlignType.right:
return LocaleKeys.document_plugins_optionAction_right.tr();
}
}
}
enum OptionDepthType {
h1(1, 'H1'),
h2(2, 'H2'),
h3(3, 'H3'),
h4(4, 'H4'),
h5(5, 'H5'),
h6(6, 'H6');
const OptionDepthType(this.level, this.description);
final String description;
final int level;
static OptionDepthType fromLevel(int? level) {
switch (level) {
case 1:
return OptionDepthType.h1;
case 2:
return OptionDepthType.h2;
case 3:
default:
return OptionDepthType.h3;
}
}
}
class DividerOptionAction extends CustomActionCell {
@override
Widget buildWithContext(BuildContext context, PopoverController controller) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 4.0),
child: Divider(
height: 1.0,
thickness: 1.0,
),
);
}
}
class AlignOptionAction extends PopoverActionCell {
AlignOptionAction({
required this.editorState,
});
final EditorState editorState;
@override
Widget? leftIcon(Color iconColor) {
return FlowySvg(
align.svg,
size: const Size.square(12),
).padding(all: 2.0);
}
@override
String get name {
return LocaleKeys.document_plugins_optionAction_align.tr();
}
@override
PopoverActionCellBuilder get builder =>
(context, parentController, controller) {
final selection = editorState.selection?.normalized;
if (selection == null) {
return const SizedBox.shrink();
}
final node = editorState.getNodeAtPath(selection.start.path);
if (node == null) {
return const SizedBox.shrink();
}
final children = buildAlignOptions(context, (align) async {
await onAlignChanged(align);
controller.close();
parentController.close();
});
return IntrinsicHeight(
child: IntrinsicWidth(
child: Column(
children: children,
),
),
);
};
List<Widget> buildAlignOptions(
BuildContext context,
void Function(OptionAlignType) onTap,
) {
return OptionAlignType.values.map((e) => OptionAlignWrapper(e)).map((e) {
final leftIcon = e.leftIcon(Theme.of(context).colorScheme.onSurface);
final rightIcon = e.rightIcon(Theme.of(context).colorScheme.onSurface);
return HoverButton(
onTap: () => onTap(e.inner),
itemHeight: ActionListSizes.itemHeight,
leftIcon: leftIcon,
name: e.name,
rightIcon: rightIcon,
);
}).toList();
}
OptionAlignType get align {
final selection = editorState.selection;
if (selection == null) {
return OptionAlignType.center;
}
final node = editorState.getNodeAtPath(selection.start.path);
final align = node?.attributes['align'];
return OptionAlignType.fromString(align);
}
Future<void> onAlignChanged(OptionAlignType align) async {
if (align == this.align) {
return;
}
final selection = editorState.selection;
if (selection == null) {
return;
}
final node = editorState.getNodeAtPath(selection.start.path);
if (node == null) {
return;
}
final transaction = editorState.transaction;
transaction.updateNode(node, {
'align': align.name,
});
await editorState.apply(transaction);
}
}
class ColorOptionAction extends PopoverActionCell {
ColorOptionAction({
required this.editorState,
});
final EditorState editorState;
@override
Widget? leftIcon(Color iconColor) {
return const FlowySvg(
FlowySvgs.color_format_m,
size: Size.square(12),
).padding(all: 2.0);
}
@override
String get name => LocaleKeys.document_plugins_optionAction_color.tr();
@override
Widget Function(
BuildContext context,
PopoverController parentController,
PopoverController controller,
) get builder => (context, parentController, controller) {
final selection = editorState.selection?.normalized;
if (selection == null) {
return const SizedBox.shrink();
}
final node = editorState.getNodeAtPath(selection.start.path);
if (node == null) {
return const SizedBox.shrink();
}
final bgColor =
node.attributes[blockComponentBackgroundColor] as String?;
final selectedColor = bgColor?.tryToColor();
// get default background color for callout block from themeExtension
final defaultColor = node.type == CalloutBlockKeys.type
? AFThemeExtension.of(context).calloutBGColor
: Colors.transparent;
final colors = [
// reset to default background color
FlowyColorOption(
color: defaultColor,
i18n: LocaleKeys.document_plugins_optionAction_defaultColor.tr(),
id: optionActionColorDefaultColor,
),
...FlowyTint.values.map(
(e) => FlowyColorOption(
color: e.color(context),
i18n: e.tintName(AppFlowyEditorL10n.current),
id: e.id,
),
),
];
return FlowyColorPicker(
colors: colors,
selected: selectedColor,
border: Border.all(
color: AFThemeExtension.of(context).onBackground,
),
onTap: (option, index) async {
final transaction = editorState.transaction;
transaction.updateNode(node, {
blockComponentBackgroundColor: option.id,
});
await editorState.apply(transaction);
controller.close();
parentController.close();
},
);
};
}
class DepthOptionAction extends PopoverActionCell {
DepthOptionAction({required this.editorState});
final EditorState editorState;
@override
Widget? leftIcon(Color iconColor) {
return FlowySvg(
OptionAction.depth.svg,
size: const Size.square(12),
).padding(all: 2.0);
}
@override
String get name => LocaleKeys.document_plugins_optionAction_depth.tr();
@override
PopoverActionCellBuilder get builder =>
(context, parentController, controller) {
final children = buildDepthOptions(context, (depth) async {
await onDepthChanged(depth);
controller.close();
parentController.close();
});
return SizedBox(
width: 42,
child: Column(
mainAxisSize: MainAxisSize.min,
children: children,
),
);
};
List<Widget> buildDepthOptions(
BuildContext context,
Future<void> Function(OptionDepthType) onTap,
) {
return OptionDepthType.values
.map((e) => OptionDepthWrapper(e))
.map(
(e) => HoverButton(
onTap: () => onTap(e.inner),
itemHeight: ActionListSizes.itemHeight,
name: e.name,
),
)
.toList();
}
OptionDepthType depth(Node node) {
final level = node.attributes[OutlineBlockKeys.depth];
return OptionDepthType.fromLevel(level);
}
Future<void> onDepthChanged(OptionDepthType depth) async {
final selection = editorState.selection;
final node = selection != null
? editorState.getNodeAtPath(selection.start.path)
: null;
if (node == null || depth == this.depth(node)) return;
final transaction = editorState.transaction;
transaction.updateNode(
node,
{OutlineBlockKeys.depth: depth.level},
);
await editorState.apply(transaction);
}
}
class OptionDepthWrapper extends ActionCell {
OptionDepthWrapper(this.inner);
final OptionDepthType inner;
@override
String get name => inner.description;
}
class OptionActionWrapper extends ActionCell {
OptionActionWrapper(this.inner);
final OptionAction inner;
@override
Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg);
@override
String get name => inner.description;
}
class OptionAlignWrapper extends ActionCell {
OptionAlignWrapper(this.inner);
final OptionAlignType inner;
@override
Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg);
@override
String get name => inner.description;
}

View File

@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart' show LocaleKeys;
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@ -8,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart'
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
@ -95,10 +94,7 @@ class CalloutBlockComponentBuilder extends BlockComponentBuilder {
// validate the data of the node, if the result is false, the node will be rendered as a placeholder
@override
bool validate(Node node) =>
node.delta != null &&
node.children.isEmpty &&
node.attributes[CalloutBlockKeys.icon] is String;
bool validate(Node node) => node.delta != null && node.children.isEmpty;
}
// the main widget for rendering the callout block

View File

@ -1,5 +1,5 @@
export 'actions/block_action_list.dart';
export 'actions/option_action.dart';
export 'actions/option/option_actions.dart';
export 'align_toolbar_item/align_toolbar_item.dart';
export 'base/backtick_character_command.dart';
export 'base/cover_title_command.dart';

View File

@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
@ -17,6 +15,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
// text menu item
final textSlashMenuItem = SelectionMenuItem(

View File

@ -124,9 +124,11 @@ class ShortcutsCubit extends Cubit<ShortcutsState> {
// check if currentShortcut is a codeblock shortcut.
final isCodeBlockCommand = currentShortcut.isCodeBlockCommand;
for (final e in state.commandShortcutEvents) {
if (e.command == command && e.isCodeBlockCommand == isCodeBlockCommand) {
return e;
for (final shortcut in state.commandShortcutEvents) {
final keybindings = shortcut.command.split(',');
if (keybindings.contains(command) &&
shortcut.isCodeBlockCommand == isCodeBlockCommand) {
return shortcut;
}
}

View File

@ -90,7 +90,11 @@ class SpaceMoreActionTypeWrapper extends CustomActionCell {
final void Function(PopoverController controller, dynamic data) onTap;
@override
Widget buildWithContext(BuildContext context, PopoverController controller) {
Widget buildWithContext(
BuildContext context,
PopoverController controller,
PopoverMutex? mutex,
) {
if (inner == SpaceMoreActionType.divider) {
return _buildDivider();
} else if (inner == SpaceMoreActionType.changeIcon) {

View File

@ -73,7 +73,11 @@ class _WorkspaceMoreActionWrapper extends CustomActionCell {
final UserWorkspacePB workspace;
@override
Widget buildWithContext(BuildContext context, PopoverController controller) {
Widget buildWithContext(
BuildContext context,
PopoverController controller,
PopoverMutex? mutex,
) {
if (inner == WorkspaceMoreAction.divider) {
return const Divider();
}

View File

@ -137,7 +137,11 @@ class ViewMoreActionTypeWrapper extends CustomActionCell {
final Offset? moveActionOffset;
@override
Widget buildWithContext(BuildContext context, PopoverController controller) {
Widget buildWithContext(
BuildContext context,
PopoverController controller,
PopoverMutex? mutex,
) {
if (inner == ViewMoreActionType.divider) {
return _buildDivider();
} else if (inner == ViewMoreActionType.lastModified) {

View File

@ -5,7 +5,11 @@ import 'package:flutter/material.dart';
class SocialMediaSection extends CustomActionCell {
@override
Widget buildWithContext(BuildContext context, PopoverController controller) {
Widget buildWithContext(
BuildContext context,
PopoverController controller,
PopoverMutex? mutex,
) {
final List<Widget> children = [
Divider(
height: 1,

View File

@ -12,7 +12,11 @@ import 'package:styled_widget/styled_widget.dart';
class FlowyVersionSection extends CustomActionCell {
@override
Widget buildWithContext(BuildContext context, PopoverController controller) {
Widget buildWithContext(
BuildContext context,
PopoverController controller,
PopoverMutex? mutex,
) {
return FutureBuilder(
future: PackageInfo.fromPlatform(),
builder: (BuildContext context, AsyncSnapshot<dynamic> snapshot) {

View File

@ -41,6 +41,7 @@ class ViewAction extends StatelessWidget {
context,
// this is a dummy controller, we don't need to control the popover here.
PopoverController(),
null,
);
}

View File

@ -19,6 +19,10 @@ class PopoverActionList<T extends PopoverAction> extends StatefulWidget {
this.offset = Offset.zero,
this.animationDuration = const Duration(),
this.slideDistance = 20,
this.beginScaleFactor = 0.9,
this.endScaleFactor = 1.0,
this.beginOpacity = 0.0,
this.endOpacity = 1.0,
this.constraints = const BoxConstraints(
minWidth: 120,
maxWidth: 460,
@ -39,6 +43,10 @@ class PopoverActionList<T extends PopoverAction> extends StatefulWidget {
final BoxConstraints constraints;
final Duration animationDuration;
final double slideDistance;
final double beginScaleFactor;
final double endScaleFactor;
final double beginOpacity;
final double endOpacity;
@override
State<PopoverActionList<T>> createState() => _PopoverActionListState<T>();
@ -61,6 +69,10 @@ class _PopoverActionListState<T extends PopoverAction>
asBarrier: widget.asBarrier,
animationDuration: widget.animationDuration,
slideDistance: widget.slideDistance,
beginScaleFactor: widget.beginScaleFactor,
endScaleFactor: widget.endScaleFactor,
beginOpacity: widget.beginOpacity,
endOpacity: widget.endOpacity,
controller: popoverController,
constraints: widget.constraints,
direction: widget.direction,
@ -88,7 +100,11 @@ class _PopoverActionListState<T extends PopoverAction>
);
} else {
final custom = action as CustomActionCell;
return custom.buildWithContext(context, popoverController);
return custom.buildWithContext(
context,
popoverController,
widget.popoverMutex,
);
}
}).toList();
@ -129,7 +145,11 @@ abstract class PopoverActionCell extends PopoverAction {
}
abstract class CustomActionCell extends PopoverAction {
Widget buildWithContext(BuildContext context, PopoverController controller);
Widget buildWithContext(
BuildContext context,
PopoverController controller,
PopoverMutex? mutex,
);
}
abstract class PopoverAction {}

View File

@ -14,6 +14,10 @@ class Log {
bool _enabled = false;
// used to disable log in tests
@visibleForTesting
bool disableLog = false;
Log() {
_logger = Logger(
printer: PrettyPrinter(
@ -60,22 +64,42 @@ class Log {
}
static void info(dynamic msg, [dynamic error, StackTrace? stackTrace]) {
if (shared.disableLog) {
return;
}
_log(Level.info, 0, msg, error, stackTrace);
}
static void debug(dynamic msg, [dynamic error, StackTrace? stackTrace]) {
if (shared.disableLog) {
return;
}
_log(Level.debug, 1, msg, error, stackTrace);
}
static void warn(dynamic msg, [dynamic error, StackTrace? stackTrace]) {
if (shared.disableLog) {
return;
}
_log(Level.warning, 3, msg, error, stackTrace);
}
static void trace(dynamic msg, [dynamic error, StackTrace? stackTrace]) {
if (shared.disableLog) {
return;
}
_log(Level.trace, 2, msg, error, stackTrace);
}
static void error(dynamic msg, [dynamic error, StackTrace? stackTrace]) {
if (shared.disableLog) {
return;
}
_log(Level.error, 4, msg, error, stackTrace);
}
}

View File

@ -47,6 +47,8 @@ class _PopoverMenuState extends State<PopoverMenu> {
PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
mutex: popOverMutex,
offset: const Offset(10, 0),
asBarrier: true,
debugId: 'First',
popupBuilder: (BuildContext context) {
return const PopoverMenu();
},
@ -59,6 +61,8 @@ class _PopoverMenuState extends State<PopoverMenu> {
triggerActions:
PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
mutex: popOverMutex,
asBarrier: true,
debugId: 'Second',
offset: const Offset(10, 0),
popupBuilder: (BuildContext context) {
return const PopoverMenu();
@ -94,6 +98,7 @@ class ExampleButton extends StatelessWidget {
animationDuration: Durations.medium1,
offset: offset,
direction: direction,
debugId: label,
child: TextButton(
child: Text(label),
onPressed: () {},

View File

@ -15,14 +15,18 @@ class RootOverlayEntry {
void addEntry(
BuildContext context,
String id,
PopoverState newState,
OverlayEntry entry,
bool asBarrier,
AnimationController animationController,
) {
_entries[newState] = OverlayEntryContext(
id,
entry,
newState,
asBarrier,
animationController,
);
Overlay.of(context).insert(entry);
}
@ -32,30 +36,49 @@ class RootOverlayEntry {
removedEntry?.overlayEntry.remove();
}
PopoverState? popEntry() {
OverlayEntryContext? popEntry() {
if (isEmpty) {
return null;
}
final lastEntry = _entries.values.last;
_entries.remove(lastEntry.popoverState);
lastEntry.overlayEntry.remove();
lastEntry.popoverState.widget.onClose?.call();
lastEntry.animationController.reverse().then((_) {
lastEntry.overlayEntry.remove();
lastEntry.popoverState.widget.onClose?.call();
});
return lastEntry.asBarrier ? lastEntry.popoverState : popEntry();
return lastEntry.asBarrier ? lastEntry : popEntry();
}
bool isLastEntryAsBarrier() {
if (isEmpty) {
return false;
}
return _entries.values.last.asBarrier;
}
}
class OverlayEntryContext {
OverlayEntryContext(
this.id,
this.overlayEntry,
this.popoverState,
this.asBarrier,
this.animationController,
);
final String id;
final OverlayEntry overlayEntry;
final PopoverState popoverState;
final bool asBarrier;
final AnimationController animationController;
@override
String toString() {
return 'OverlayEntryContext(id: $id, asBarrier: $asBarrier, popoverState: ${popoverState.widget.debugId})';
}
}
class PopoverMask extends StatelessWidget {

View File

@ -73,9 +73,10 @@ class Popover extends StatefulWidget {
this.animationDuration = const Duration(milliseconds: 200),
this.beginOpacity = 0.0,
this.endOpacity = 1.0,
this.beginScaleFactor = 0.95,
this.beginScaleFactor = 1.0,
this.endScaleFactor = 1.0,
this.slideDistance = 20.0,
this.slideDistance = 5.0,
this.debugId,
this.maskDecoration = const BoxDecoration(
color: Color.fromARGB(0, 244, 67, 54),
),
@ -121,7 +122,7 @@ class Popover extends StatefulWidget {
final bool skipTraversal;
/// Animation time of the popover.
final Duration? animationDuration;
final Duration animationDuration;
/// The distance of the popover's slide animation.
final double slideDistance;
@ -134,6 +135,8 @@ class Popover extends StatefulWidget {
final double beginOpacity;
final double endOpacity;
final String? debugId;
/// The content area of the popover.
final Widget child;
@ -202,7 +205,7 @@ class PopoverState extends State<Popover> with SingleTickerProviderStateMixin {
}
void showOverlay() {
close();
close(withAnimation: true);
if (widget.mutex != null) {
widget.mutex?.state = this;
@ -211,14 +214,18 @@ class PopoverState extends State<Popover> with SingleTickerProviderStateMixin {
final shouldAddMask = rootEntry.isEmpty;
rootEntry.addEntry(
context,
widget.debugId ?? '',
this,
OverlayEntry(
builder: (context) => _buildOverlayContent(shouldAddMask),
),
widget.asBarrier,
animationController,
);
animationController.forward();
if (widget.animationDuration != Duration.zero) {
animationController.forward();
}
}
void close({
@ -233,7 +240,9 @@ class PopoverState extends State<Popover> with SingleTickerProviderStateMixin {
}
}
if (isDisposed || !withAnimation) {
if (isDisposed ||
!withAnimation ||
widget.animationDuration == Duration.zero) {
callback();
} else {
animationController.reverse().then((_) => callback());
@ -242,9 +251,7 @@ class PopoverState extends State<Popover> with SingleTickerProviderStateMixin {
}
void _removeRootOverlay() {
animationController.reverse().then((_) {
rootEntry.popEntry();
});
rootEntry.popEntry();
if (widget.mutex?.state == this) {
widget.mutex?.removeState();
@ -252,41 +259,43 @@ class PopoverState extends State<Popover> with SingleTickerProviderStateMixin {
}
Widget _buildChild(BuildContext context) {
Widget child = widget.child;
if (widget.triggerActions == 0) {
return widget.child;
return child;
}
return MouseRegion(
onEnter: (event) {
if (widget.triggerActions & PopoverTriggerFlags.hover != 0) {
child = _buildClickHandler(
child,
() {
widget.onOpen?.call();
if (widget.triggerActions & PopoverTriggerFlags.click != 0) {
showOverlay();
}
},
child: _buildClickHandler(
widget.child,
() {
widget.onOpen?.call();
if (widget.triggerActions & PopoverTriggerFlags.click != 0) {
showOverlay();
}
},
),
);
if (widget.triggerActions & PopoverTriggerFlags.hover != 0) {
child = MouseRegion(
onEnter: (event) => showOverlay(),
child: child,
);
}
return child;
}
Widget _buildClickHandler(Widget child, VoidCallback handler) {
switch (widget.clickHandler) {
case PopoverClickHandler.listener:
return Listener(
return switch (widget.clickHandler) {
PopoverClickHandler.listener => Listener(
onPointerDown: (_) => _callHandler(handler),
child: child,
);
case PopoverClickHandler.gestureDetector:
return GestureDetector(
),
PopoverClickHandler.gestureDetector => GestureDetector(
onTap: () => _callHandler(handler),
child: child,
);
}
),
};
}
void _callHandler(VoidCallback handler) {
@ -325,28 +334,34 @@ class PopoverState extends State<Popover> with SingleTickerProviderStateMixin {
}
Widget _buildPopoverContainer() {
return AnimatedBuilder(
animation: animationController,
builder: (context, child) {
return Opacity(
opacity: fadeAnimation.value,
child: Transform.scale(
scale: scaleAnimation.value,
child: Transform.translate(
offset: slideAnimation.value,
child: child,
),
),
);
},
child: PopoverContainer(
delegate: layoutDelegate,
popupBuilder: widget.popupBuilder,
skipTraversal: widget.skipTraversal,
onClose: close,
onCloseAll: _removeRootOverlay,
),
Widget child = PopoverContainer(
delegate: layoutDelegate,
popupBuilder: widget.popupBuilder,
skipTraversal: widget.skipTraversal,
onClose: close,
onCloseAll: _removeRootOverlay,
);
if (widget.animationDuration != Duration.zero) {
child = AnimatedBuilder(
animation: animationController,
builder: (context, child) {
return Opacity(
opacity: fadeAnimation.value,
child: Transform.scale(
scale: scaleAnimation.value,
child: Transform.translate(
offset: slideAnimation.value,
child: child,
),
),
);
},
child: child,
);
}
return child;
}
void _buildAnimations() {
@ -378,7 +393,7 @@ class PopoverState extends State<Popover> with SingleTickerProviderStateMixin {
).animate(
CurvedAnimation(
parent: animationController,
curve: Curves.easeOutCubic,
curve: Curves.easeInOut,
),
);
}
@ -391,7 +406,7 @@ class PopoverState extends State<Popover> with SingleTickerProviderStateMixin {
).animate(
CurvedAnimation(
parent: animationController,
curve: Curves.easeOutCubic,
curve: Curves.linear,
),
);
}

View File

@ -24,7 +24,11 @@ class AppFlowyPopover extends StatelessWidget {
this.decorationColor,
this.borderRadius,
this.animationDuration = const Duration(),
this.slideDistance = 20.0,
this.slideDistance = 5.0,
this.beginScaleFactor = 0.9,
this.endScaleFactor = 1.0,
this.beginOpacity = 0.0,
this.endOpacity = 1.0,
});
final Widget child;
@ -45,6 +49,10 @@ class AppFlowyPopover extends StatelessWidget {
final BorderRadius? borderRadius;
final Duration animationDuration;
final double slideDistance;
final double beginScaleFactor;
final double endScaleFactor;
final double beginOpacity;
final double endOpacity;
/// The widget that will be used to trigger the popover.
///
@ -63,6 +71,10 @@ class AppFlowyPopover extends StatelessWidget {
controller: controller,
animationDuration: animationDuration,
slideDistance: slideDistance,
beginScaleFactor: beginScaleFactor,
endScaleFactor: endScaleFactor,
beginOpacity: beginOpacity,
endOpacity: endOpacity,
onOpen: onOpen,
onClose: onClose,
canClose: canClose,

View File

@ -53,8 +53,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "98356fa"
resolved-ref: "98356fac4cbac7abefcc8641eb8481a9e527611f"
ref: "0a0d441"
resolved-ref: "0a0d44133981f87e233bcb102b1482488ef43e91"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "3.3.0"

View File

@ -170,7 +170,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "98356fa"
ref: "0a0d441"
appflowy_editor_plugins:
git:

View File

@ -0,0 +1,289 @@
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('turn into:', () {
Document createDocument(List<Node> nodes) {
final document = Document.blank();
document.insert([0], nodes);
return document;
}
Future<void> checkTurnInto(
Document document,
String originalType,
String originalText, {
String? toType,
void Function(EditorState editorState, Node node)? afterTurnInto,
}) async {
final editorState = EditorState(document: document);
final cubit = BlockActionOptionCubit(
editorState: editorState,
blockComponentBuilder: {},
);
final types = toType == null
? EditorOptionActionType.turnInto.supportTypes
: [toType];
for (final type in types) {
if (type == originalType) {
continue;
}
final node = editorState.getNodeAtPath([0])!;
expect(node.type, originalType);
final result = await cubit.turnIntoBlock(
type,
node,
);
expect(result, true);
final newNode = editorState.getNodeAtPath([0])!;
expect(newNode.type, type);
expect(newNode.delta!.toPlainText(), originalText);
afterTurnInto?.call(
editorState,
newNode,
);
// turn it back the originalType for the next test
await cubit.turnIntoBlock(
originalType,
newNode,
);
expect(result, true);
}
}
setUpAll(() {
Log.shared.disableLog = true;
});
tearDownAll(() {
Log.shared.disableLog = false;
});
test('from heading to another blocks', () async {
const text = 'Heading 1';
final document = createDocument([
headingNode(
level: 1,
text: text,
),
]);
await checkTurnInto(
document,
HeadingBlockKeys.type,
text,
);
});
test('from paragraph to another blocks', () async {
const text = 'Paragraph';
final document = createDocument([
paragraphNode(
text: text,
),
]);
await checkTurnInto(
document,
ParagraphBlockKeys.type,
text,
);
});
test('from quote list to another blocks', () async {
const text = 'Quote';
final document = createDocument([
quoteNode(
delta: Delta()..insert(text),
),
]);
await checkTurnInto(
document,
QuoteBlockKeys.type,
text,
);
});
test('from todo list to another blocks', () async {
const text = 'Todo';
final document = createDocument([
todoListNode(
checked: false,
text: text,
),
]);
await checkTurnInto(
document,
TodoListBlockKeys.type,
text,
);
});
test('from bulleted list to another blocks', () async {
const text = 'bulleted list';
final document = createDocument([
bulletedListNode(
text: text,
),
]);
await checkTurnInto(
document,
BulletedListBlockKeys.type,
text,
);
});
test('from numbered list to another blocks', () async {
const text = 'numbered list';
final document = createDocument([
numberedListNode(
delta: Delta()..insert(text),
),
]);
await checkTurnInto(
document,
NumberedListBlockKeys.type,
text,
);
});
test('from callout to another blocks', () async {
const text = 'callout';
final document = createDocument([
calloutNode(
delta: Delta()..insert(text),
),
]);
await checkTurnInto(
document,
CalloutBlockKeys.type,
text,
);
});
test('from nested list to heading', () async {
const text = 'bulleted list';
const nestedText1 = 'nested bulleted list 1';
const nestedText2 = 'nested bulleted list 2';
const nestedText3 = 'nested bulleted list 3';
final document = createDocument([
bulletedListNode(
text: text,
children: [
bulletedListNode(
text: nestedText1,
),
bulletedListNode(
text: nestedText2,
),
bulletedListNode(
text: nestedText3,
),
],
),
]);
await checkTurnInto(
document,
BulletedListBlockKeys.type,
text,
toType: HeadingBlockKeys.type,
afterTurnInto: (editorState, node) {
expect(node.type, HeadingBlockKeys.type);
expect(node.children.length, 0);
expect(node.delta!.toPlainText(), text);
expect(editorState.document.root.children.length, 4);
expect(
editorState.document.root.children[1].type,
BulletedListBlockKeys.type,
);
expect(
editorState.document.root.children[1].delta!.toPlainText(),
nestedText1,
);
expect(
editorState.document.root.children[2].type,
BulletedListBlockKeys.type,
);
expect(
editorState.document.root.children[2].delta!.toPlainText(),
nestedText2,
);
expect(
editorState.document.root.children[3].type,
BulletedListBlockKeys.type,
);
expect(
editorState.document.root.children[3].delta!.toPlainText(),
nestedText3,
);
},
);
});
test('from numbered list to heading', () async {
const text = 'numbered list';
const nestedText1 = 'nested numbered list 1';
const nestedText2 = 'nested numbered list 2';
const nestedText3 = 'nested numbered list 3';
final document = createDocument([
numberedListNode(
delta: Delta()..insert(text),
children: [
numberedListNode(
delta: Delta()..insert(nestedText1),
),
numberedListNode(
delta: Delta()..insert(nestedText2),
),
numberedListNode(
delta: Delta()..insert(nestedText3),
),
],
),
]);
await checkTurnInto(
document,
NumberedListBlockKeys.type,
text,
toType: HeadingBlockKeys.type,
afterTurnInto: (editorState, node) {
expect(node.type, HeadingBlockKeys.type);
expect(node.children.length, 0);
expect(node.delta!.toPlainText(), text);
expect(editorState.document.root.children.length, 4);
expect(
editorState.document.root.children[1].type,
NumberedListBlockKeys.type,
);
expect(
editorState.document.root.children[1].delta!.toPlainText(),
nestedText1,
);
expect(
editorState.document.root.children[2].type,
NumberedListBlockKeys.type,
);
expect(
editorState.document.root.children[2].delta!.toPlainText(),
nestedText2,
);
expect(
editorState.document.root.children[3].type,
NumberedListBlockKeys.type,
);
expect(
editorState.document.root.children[3].delta!.toPlainText(),
nestedText3,
);
},
);
});
});
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.5 -0.5 16 16" id="Transfer-Line--Streamline-Mingcute.svg" height="16" width="16"><desc>Transfer Line Streamline Icon: https://streamlinehq.com</desc><g fill="none" fill-rule="nonzero"><path d="M15 0v15H0V0h15ZM7.870625 14.536249999999999l-0.006874999999999999 0.00125 -0.044375 0.021875000000000002 -0.0125 0.0025 -0.00875 -0.0025 -0.044375 -0.021875000000000002c-0.00625 -0.0025 -0.011875 -0.000625 -0.015 0.003125l-0.0025 0.00625 -0.010625 0.2675 0.003125 0.0125 0.00625 0.008125 0.065 0.04625 0.009375 0.0025 0.0075 -0.0025 0.065 -0.04625 0.0075 -0.01 0.0025 -0.010625 -0.010625 -0.266875c-0.00125 -0.00625 -0.005625 -0.010625 -0.010625 -0.01125Zm0.16562500000000002 -0.07062500000000001 -0.008125 0.00125 -0.115625 0.058124999999999996 -0.00625 0.00625 -0.001875 0.006874999999999999 0.01125 0.26875 0.003125 0.0075 0.005 0.004375 0.12562500000000001 0.058124999999999996c0.0075 0.0025 0.014374999999999999 0 0.018125000000000002 -0.005l0.0025 -0.00875 -0.02125 -0.38375c-0.001875 -0.0075 -0.00625 -0.0125 -0.0125 -0.013749999999999998Zm-0.44687499999999997 0.00125a0.014374999999999999 0.014374999999999999 0 0 0 -0.016875 0.00375l-0.00375 0.00875 -0.02125 0.38375c0 0.0075 0.004375 0.0125 0.010625 0.015l0.009375 -0.00125 0.12562500000000001 -0.058124999999999996 0.00625 -0.005 0.0025 -0.006874999999999999 0.010625 -0.26875 -0.001875 -0.0075 -0.00625 -0.00625 -0.11499999999999999 -0.057499999999999996Z" stroke-width="1"></path><path fill="#000000" d="M12.5 8.75a0.625 0.625 0 0 1 0.07312500000000001 1.245625L12.5 10H4.00875l1.433125 1.433125a0.625 0.625 0 0 1 -0.8250000000000001 0.935625l-0.05875 -0.051875000000000004 -2.39375 -2.39375c-0.415625 -0.41500000000000004 -0.14937499999999998 -1.114375 0.41437500000000005 -1.169375L2.650625 8.75H12.5Zm-2.941875 -6.0668750000000005a0.625 0.625 0 0 1 0.8250000000000001 -0.051875000000000004l0.05875 0.051875000000000004 2.39375 2.39375c0.415625 0.41500000000000004 0.14937499999999998 1.114375 -0.41437500000000005 1.169375l-0.07187500000000001 0.00375H2.5a0.625 0.625 0 0 1 -0.07312500000000001 -1.245625L2.5 5h8.49125l-1.433125 -1.433125a0.625 0.625 0 0 1 0 -0.8837499999999999Z" stroke-width="1"></path></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB