feat: support inserting local image (#2913)

This commit is contained in:
Lucas.Xu 2023-07-02 11:46:45 +08:00 committed by GitHub
parent c870dbeac4
commit 11d05b303d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 484 additions and 27 deletions

View File

@ -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."
}
}
},

View File

@ -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.

View File

@ -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<DocumentEvent, DocumentState> {
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<DocumentEvent, DocumentState> {
};
}
}
Future<void> applyRules() async {
ensureAtLeastOneParagraphExists();
ensureLastNodeIsEditable();
}
Future<void> 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<void> 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

View File

@ -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<AppFlowyEditorPage> {
highlightColorItem,
];
late final slashMenuItems = [
inlineGridMenuItem(documentBloc),
referencedGridMenuItem,
inlineBoardMenuItem(documentBloc),
referencedBoardMenuItem,
inlineCalendarMenuItem(documentBloc),
referencedCalendarMenuItem,
calloutItem,
mathEquationItem,
codeBlockItem,
emojiMenuItem,
autoGeneratorMenuItem,
outlineItem,
];
late final List<SelectionMenuItem> slashMenuItems;
late final Map<String, BlockComponentBuilder> blockComponentBuilders =
_customAppFlowyBlockComponentBuilders();
@ -119,6 +105,9 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
@override
void initState() {
super.initState();
slashMenuItems = _customSlashMenuItems();
effectiveScrollController = widget.scrollController ?? ScrollController();
}
@ -219,6 +208,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
),
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<AppFlowyEditorPage> {
),
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<AppFlowyEditorPage> {
return builders;
}
List<SelectionMenuItem> _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));

View File

@ -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<ImageMenu> createState() => _ImageMenuState();
}
class _ImageMenuState extends State<ImageMenu> {
@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<void> deleteImage() async {
final node = widget.node;
final editorState = context.read<EditorState>();
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<EditorState>();
}
@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,
),
);
}
}

View File

@ -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<ApplicationDataStorage>().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);
}
}
},
);
},
);

View File

@ -120,6 +120,15 @@ class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
),
)
.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;
}

View File

@ -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';

View File

@ -127,6 +127,17 @@ class EditorStyleCustomizer {
);
}
TextStyle outlineBlockPlaceholderStyleBuilder() {
final theme = Theme.of(context);
final fontSize = context.read<DocumentAppearanceCubit>().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(

View File

@ -25,6 +25,11 @@ class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
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<DocumentAppearance> {
void syncFontSize(double fontSize) async {
final prefs = await SharedPreferences.getInstance();
prefs.setDouble(_kDocumentAppearanceFontSize, fontSize);
if (isClosed) {
return;
}
emit(
state.copyWith(
fontSize: fontSize,

View File

@ -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:

View File

@ -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