mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-12-11 07:01:52 +00:00
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:
parent
b54e3dd243
commit
f19e354418
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: () {},
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
1
frontend/resources/flowy_icons/16x/turninto.svg
Normal file
1
frontend/resources/flowy_icons/16x/turninto.svg
Normal 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 |
Loading…
x
Reference in New Issue
Block a user