From 11d05b303d01db36df602cf877318da2a1786ad8 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Sun, 2 Jul 2023 11:46:45 +0800 Subject: [PATCH] feat: support inserting local image (#2913) --- .../assets/translations/en.json | 6 + .../document/edit_document_test.dart | 2 +- .../document/application/doc_bloc.dart | 38 ++- .../document/presentation/editor_page.dart | 69 +++-- .../editor_plugins/image/image_menu.dart | 290 ++++++++++++++++++ .../image/image_selection_menu.dart | 59 ++++ .../outline/outline_block_component.dart | 11 +- .../presentation/editor_plugins/plugins.dart | 4 + .../document/presentation/editor_style.dart | 11 + .../more/cubit/document_appearance_cubit.dart | 10 + frontend/appflowy_flutter/pubspec.lock | 6 +- frontend/appflowy_flutter/pubspec.yaml | 5 +- 12 files changed, 484 insertions(+), 27 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_selection_menu.dart diff --git a/frontend/appflowy_flutter/assets/translations/en.json b/frontend/appflowy_flutter/assets/translations/en.json index e14c12d79e5e..f2be271214f6 100644 --- a/frontend/appflowy_flutter/assets/translations/en.json +++ b/frontend/appflowy_flutter/assets/translations/en.json @@ -452,6 +452,12 @@ "center": "Center", "right": "Right", "defaultColor": "Default" + }, + "image": { + "copiedToPasteBoard": "The image link has been copied to the clipboard" + }, + "outline": { + "addHeadingToCreateOutline": "Add headings to create a table of contents." } } }, diff --git a/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart b/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart index 10dd472362a8..993001469227 100644 --- a/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart @@ -118,7 +118,7 @@ const _sample = r''' --- [] Highlight any text, and use the editing menu to _style_ **your** writing `however` you ~~like.~~ -[] Type / followed by /bullet or /num to create a list. +[] Type followed by bullet or num to create a list. [x] Click `+ New Page` button at the bottom of your sidebar to add a new page. diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart index 98498781eff3..5fac22752110 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart @@ -9,7 +9,7 @@ import 'package:appflowy/plugins/document/application/doc_service.dart'; import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart'; import 'package:appflowy_editor/appflowy_editor.dart' - show EditorState, LogLevel, TransactionTime; + show EditorState, LogLevel, TransactionTime, Selection, paragraphNode; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:flutter/foundation.dart'; @@ -155,6 +155,9 @@ class DocumentBloc extends Bloc { return; } await _transactionAdapter.apply(event.$2, editorState); + + // check if the document is empty. + applyRules(); }); // output the log from the editor when debug mode @@ -166,6 +169,39 @@ class DocumentBloc extends Bloc { }; } } + + Future applyRules() async { + ensureAtLeastOneParagraphExists(); + ensureLastNodeIsEditable(); + } + + Future ensureLastNodeIsEditable() async { + final editorState = this.editorState; + if (editorState == null) { + return; + } + final document = editorState.document; + final lastNode = document.root.children.lastOrNull; + if (lastNode == null || lastNode.delta == null) { + final transaction = editorState.transaction; + transaction.insertNode([document.root.children.length], paragraphNode()); + await editorState.apply(transaction); + } + } + + Future ensureAtLeastOneParagraphExists() async { + final editorState = this.editorState; + if (editorState == null) { + return; + } + final document = editorState.document; + if (document.root.children.isEmpty) { + final transaction = editorState.transaction; + transaction.insertNode([0], paragraphNode()); + transaction.afterSelection = Selection.collapse([0], 0); + await editorState.apply(transaction); + } + } } @freezed diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index f5c488fbd7c4..1372de49dee3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -1,10 +1,9 @@ import 'package:appflowy/plugins/document/application/doc_bloc.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_list.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -55,20 +54,7 @@ class _AppFlowyEditorPageState extends State { highlightColorItem, ]; - late final slashMenuItems = [ - inlineGridMenuItem(documentBloc), - referencedGridMenuItem, - inlineBoardMenuItem(documentBloc), - referencedBoardMenuItem, - inlineCalendarMenuItem(documentBloc), - referencedCalendarMenuItem, - calloutItem, - mathEquationItem, - codeBlockItem, - emojiMenuItem, - autoGeneratorMenuItem, - outlineItem, - ]; + late final List slashMenuItems; late final Map blockComponentBuilders = _customAppFlowyBlockComponentBuilders(); @@ -119,6 +105,9 @@ class _AppFlowyEditorPageState extends State { @override void initState() { super.initState(); + + slashMenuItems = _customSlashMenuItems(); + effectiveScrollController = widget.scrollController ?? ScrollController(); } @@ -219,6 +208,15 @@ class _AppFlowyEditorPageState extends State { ), ImageBlockKeys.type: ImageBlockComponentBuilder( configuration: configuration, + showMenu: true, + menuBuilder: (node, state) => Positioned( + top: 0, + right: 10, + child: ImageMenu( + node: node, + state: state, + ), + ), ), DatabaseBlockKeys.gridType: DatabaseViewBlockComponentBuilder( configuration: configuration, @@ -254,8 +252,15 @@ class _AppFlowyEditorPageState extends State { ), AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(), SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(), - ToggleListBlockKeys.type: ToggleListBlockComponentBuilder(), - OutlineBlockKeys.type: OutlineBlockComponentBuilder(), + ToggleListBlockKeys.type: ToggleListBlockComponentBuilder( + configuration: configuration, + ), + OutlineBlockKeys.type: OutlineBlockComponentBuilder( + configuration: configuration.copyWith( + placeholderTextStyle: (_) => + styleCustomizer.outlineBlockPlaceholderStyleBuilder(), + ), + ), }; final builders = { @@ -325,6 +330,34 @@ class _AppFlowyEditorPageState extends State { return builders; } + List _customSlashMenuItems() { + final items = [...standardSelectionMenuItems]; + final imageItem = items.firstWhereOrNull( + (element) => element.name == AppFlowyEditorLocalizations.current.image, + ); + if (imageItem != null) { + final imageItemIndex = items.indexOf(imageItem); + if (imageItemIndex != -1) { + items[imageItemIndex] = customImageMenuItem; + } + } + return [ + ...items, + inlineGridMenuItem(documentBloc), + referencedGridMenuItem, + inlineBoardMenuItem(documentBloc), + referencedBoardMenuItem, + inlineCalendarMenuItem(documentBloc), + referencedCalendarMenuItem, + calloutItem, + outlineItem, + mathEquationItem, + codeBlockItem, + emojiMenuItem, + autoGeneratorMenuItem, + ]; + } + (bool, Selection?) _computeAutoFocusParameters() { if (widget.editorState.document.isEmpty) { return (true, Selection.collapse([0], 0)); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart new file mode 100644 index 000000000000..2ff4b2b1b70c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart @@ -0,0 +1,290 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/image.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +class ImageMenu extends StatefulWidget { + const ImageMenu({ + super.key, + required this.node, + required this.state, + }); + + final Node node; + final ImageBlockComponentWidgetState state; + + @override + State createState() => _ImageMenuState(); +} + +class _ImageMenuState extends State { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + height: 32, + decoration: BoxDecoration( + color: theme.cardColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withOpacity(0.1), + ), + ], + borderRadius: BorderRadius.circular(4.0), + ), + child: Row( + children: [ + const HSpace(4), + _ImageCopyLinkButton( + onTap: copyImageLink, + ), + const HSpace(4), + _ImageAlignButton( + node: widget.node, + state: widget.state, + ), + const _Divider(), + _ImageDeleteButton( + onTap: () => deleteImage(), + ), + const HSpace(4), + ], + ), + ); + } + + void copyImageLink() { + final url = widget.node.attributes[ImageBlockKeys.url]; + if (url != null) { + Clipboard.setData(ClipboardData(text: url)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: FlowyText( + LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), + ), + ), + ); + } + } + + Future deleteImage() async { + final node = widget.node; + final editorState = context.read(); + final transaction = editorState.transaction; + transaction.deleteNode(node); + transaction.afterSelection = null; + await editorState.apply(transaction); + } +} + +class _ImageCopyLinkButton extends StatelessWidget { + const _ImageCopyLinkButton({ + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: const FlowySvg( + name: 'editor/copy', + size: Size.square(16), + ), + ); + } +} + +class _ImageAlignButton extends StatefulWidget { + const _ImageAlignButton({ + required this.node, + required this.state, + }); + + final Node node; + final ImageBlockComponentWidgetState state; + + @override + State<_ImageAlignButton> createState() => _ImageAlignButtonState(); +} + +const interceptorKey = 'image-align'; + +class _ImageAlignButtonState extends State<_ImageAlignButton> { + final gestureInterceptor = SelectionGestureInterceptor( + key: interceptorKey, + canTap: (details) => false, + ); + + String get align => widget.node.attributes['align'] ?? 'center'; + final popoverController = PopoverController(); + late final EditorState editorState; + + @override + void initState() { + super.initState(); + + editorState = context.read(); + } + + @override + void dispose() { + allowMenuClose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnoreParentGestureWidget( + child: AppFlowyPopover( + onClose: allowMenuClose, + controller: popoverController, + windowPadding: const EdgeInsets.all(0), + margin: const EdgeInsets.all(0), + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 10), + child: buildAlignIcon(), + popupBuilder: (_) { + preventMenuClose(); + return _AlignButtons( + onAlignChanged: onAlignChanged, + ); + }, + ), + ); + } + + void onAlignChanged(String align) { + popoverController.close(); + + final transaction = editorState.transaction; + transaction.updateNode(widget.node, { + ImageBlockKeys.align: align, + }); + editorState.apply(transaction); + + allowMenuClose(); + } + + void preventMenuClose() { + widget.state.alwaysShowMenu = true; + editorState.service.selectionService.registerGestureInterceptor( + gestureInterceptor, + ); + } + + void allowMenuClose() { + widget.state.alwaysShowMenu = false; + editorState.service.selectionService.unregisterGestureInterceptor( + interceptorKey, + ); + } + + Widget buildAlignIcon() { + return FlowySvg( + name: 'editor/align/$align', + size: const Size.square(16), + ); + } +} + +class _AlignButtons extends StatelessWidget { + const _AlignButtons({ + required this.onAlignChanged, + }); + + final Function(String align) onAlignChanged; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const HSpace(4), + _AlignButton( + align: 'left', + onTap: () => onAlignChanged('left'), + ), + const _Divider(), + _AlignButton( + align: 'left', + onTap: () => onAlignChanged('center'), + ), + const _Divider(), + _AlignButton( + align: 'left', + onTap: () => onAlignChanged('right'), + ), + const HSpace(4), + ], + ), + ); + } +} + +class _AlignButton extends StatelessWidget { + const _AlignButton({ + required this.align, + required this.onTap, + }); + + final String align; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: FlowySvg( + name: 'editor/align/$align', + size: const Size.square(16), + ), + ); + } +} + +class _ImageDeleteButton extends StatelessWidget { + const _ImageDeleteButton({ + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: const FlowySvg( + name: 'editor/delete', + size: Size.square(16), + ), + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Container( + width: 1, + color: Colors.grey, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_selection_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_selection_menu.dart new file mode 100644 index 000000000000..56f236cf9ff1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_selection_menu.dart @@ -0,0 +1,59 @@ +import 'dart:io'; + +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/settings_location_cubit.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide Log; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +final customImageMenuItem = SelectionMenuItem( + name: AppFlowyEditorLocalizations.current.image, + icon: (editorState, isSelected, style) => SelectionMenuIconWidget( + name: 'image', + isSelected: isSelected, + style: style, + ), + keywords: ['image', 'picture', 'img', 'photo'], + handler: (editorState, menuService, context) { + final container = Overlay.of(context); + showImageMenu( + container, + editorState, + menuService, + onInsertImage: (url) async { + // if the url is http, we can insert it directly + // otherwise, if it's a file url, we need to copy the file to the app's document directory + + final regex = RegExp('^(http|https)://'); + if (regex.hasMatch(url)) { + await editorState.insertImageNode(url); + } else { + final path = await getIt().getPath(); + final imagePath = p.join( + path, + 'images', + ); + try { + // create the directory if not exists + final directory = Directory(imagePath); + if (!directory.existsSync()) { + await directory.create(recursive: true); + } + final copyToPath = p.join( + imagePath, + '${uuid()}${p.extension(url)}', + ); + await File(url).copy( + copyToPath, + ); + await editorState.insertImageNode(copyToPath); + } catch (e) { + Log.error('cannot copy image file', e); + } + } + }, + ); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart index 01b871c488ca..5f30728652b8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart @@ -120,6 +120,15 @@ class _OutlineBlockWidgetState extends State ), ) .toList(); + if (children.isEmpty) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + LocaleKeys.document_plugins_outline_addHeadingToCreateOutline.tr(), + style: configuration.placeholderTextStyle(node), + ), + ); + } return Container( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(8.0)), @@ -184,7 +193,7 @@ class OutlineItemWidget extends StatelessWidget { extension on Node { double get leftIndent { - assert(type != HeadingBlockKeys.type); + assert(type == HeadingBlockKeys.type); if (type != HeadingBlockKeys.type) { return 0.0; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart index f7fd337747bf..6c3d054a9843 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart @@ -16,3 +16,7 @@ export 'openai/widgets/smart_edit_toolbar_item.dart'; export 'toggle/toggle_block_component.dart'; export 'toggle/toggle_block_shortcut_event.dart'; export 'outline/outline_block_component.dart'; +export 'image/image_menu.dart'; +export 'image/image_selection_menu.dart'; +export 'actions/option_action.dart'; +export 'actions/block_action_list.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index 14f951af3578..c2c0b34094b3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -127,6 +127,17 @@ class EditorStyleCustomizer { ); } + TextStyle outlineBlockPlaceholderStyleBuilder() { + final theme = Theme.of(context); + final fontSize = context.read().state.fontSize; + return TextStyle( + fontFamily: 'poppins', + fontSize: fontSize, + height: 1.5, + color: theme.colorScheme.onBackground.withOpacity(0.6), + ); + } + SelectionMenuStyle selectionMenuStyleBuilder() { final theme = Theme.of(context); return SelectionMenuStyle( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/more/cubit/document_appearance_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/more/cubit/document_appearance_cubit.dart index 35cb598655f8..df27ce90fc60 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/more/cubit/document_appearance_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/more/cubit/document_appearance_cubit.dart @@ -25,6 +25,11 @@ class DocumentAppearanceCubit extends Cubit { void fetch() async { final prefs = await SharedPreferences.getInstance(); final fontSize = prefs.getDouble(_kDocumentAppearanceFontSize) ?? 16.0; + + if (isClosed) { + return; + } + emit( state.copyWith( fontSize: fontSize, @@ -35,6 +40,11 @@ class DocumentAppearanceCubit extends Cubit { void syncFontSize(double fontSize) async { final prefs = await SharedPreferences.getInstance(); prefs.setDouble(_kDocumentAppearanceFontSize, fontSize); + + if (isClosed) { + return; + } + emit( state.copyWith( fontSize: fontSize, diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 272b0a1123f1..dfa33eb8d1aa 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -53,9 +53,9 @@ packages: dependency: "direct main" description: path: "." - ref: "250b1a5" - resolved-ref: "250b1a59856b337fc2d4b26a1dabdec265e80acf" - url: "https://github.com/AppFlowy-IO/appflowy-editor" + ref: "572a174" + resolved-ref: "572a174892267e2f78f9c3d7f1fe4ca71c9be0db" + url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "1.0.4" appflowy_popover: diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index b2bc5486e50d..9a0297d49272 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -45,9 +45,8 @@ dependencies: # appflowy_editor: ^1.0.4 appflowy_editor: git: - url: https://github.com/AppFlowy-IO/appflowy-editor - ref: 250b1a5 - + url: https://github.com/AppFlowy-IO/appflowy-editor.git + ref: 572a174 appflowy_popover: path: packages/appflowy_popover