mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: image block revamp (#3615)
This commit is contained in:
parent
40dcd13394
commit
36f47f3636
@ -1,4 +1,5 @@
|
|||||||
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
|
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
|
||||||
|
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.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_style.dart';
|
||||||
import 'package:appflowy/plugins/inline_actions/handlers/date_reference.dart';
|
import 'package:appflowy/plugins/inline_actions/handlers/date_reference.dart';
|
||||||
@ -267,10 +268,11 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
|||||||
),
|
),
|
||||||
textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level),
|
textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level),
|
||||||
),
|
),
|
||||||
ImageBlockKeys.type: ImageBlockComponentBuilder(
|
ImageBlockKeys.type: CustomImageBlockComponentBuilder(
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
showMenu: true,
|
showMenu: true,
|
||||||
menuBuilder: (node, state) => Positioned(
|
menuBuilder: (Node node, CustomImageBlockComponentState state) =>
|
||||||
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 10,
|
right: 10,
|
||||||
child: ImageMenu(
|
child: ImageMenu(
|
||||||
|
@ -22,7 +22,7 @@ final customizeFontToolbarItem = ToolbarItem(
|
|||||||
onClose: () => keepEditorFocusNotifier.value -= 1,
|
onClose: () => keepEditorFocusNotifier.value -= 1,
|
||||||
showResetButton: true,
|
showResetButton: true,
|
||||||
onFontFamilyChanged: (fontFamily) async {
|
onFontFamilyChanged: (fontFamily) async {
|
||||||
await popoverController.close();
|
popoverController.close();
|
||||||
try {
|
try {
|
||||||
await editorState.formatDelta(selection, {
|
await editorState.formatDelta(selection, {
|
||||||
AppFlowyRichTextKeys.fontFamily: fontFamily,
|
AppFlowyRichTextKeys.fontFamily: fontFamily,
|
||||||
|
@ -0,0 +1,248 @@
|
|||||||
|
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
typedef CustomImageBlockComponentMenuBuilder = Widget Function(
|
||||||
|
Node node,
|
||||||
|
CustomImageBlockComponentState state,
|
||||||
|
);
|
||||||
|
|
||||||
|
class CustomImageBlockComponentBuilder extends BlockComponentBuilder {
|
||||||
|
CustomImageBlockComponentBuilder({
|
||||||
|
super.configuration,
|
||||||
|
this.showMenu = false,
|
||||||
|
this.menuBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Whether to show the menu of this block component.
|
||||||
|
final bool showMenu;
|
||||||
|
|
||||||
|
///
|
||||||
|
final CustomImageBlockComponentMenuBuilder? menuBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||||
|
final node = blockComponentContext.node;
|
||||||
|
return CustomImageBlockComponent(
|
||||||
|
key: node.key,
|
||||||
|
node: node,
|
||||||
|
showActions: showActions(node),
|
||||||
|
configuration: configuration,
|
||||||
|
actionBuilder: (context, state) => actionBuilder(
|
||||||
|
blockComponentContext,
|
||||||
|
state,
|
||||||
|
),
|
||||||
|
showMenu: showMenu,
|
||||||
|
menuBuilder: menuBuilder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool validate(Node node) => node.delta == null && node.children.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomImageBlockComponent extends BlockComponentStatefulWidget {
|
||||||
|
const CustomImageBlockComponent({
|
||||||
|
super.key,
|
||||||
|
required super.node,
|
||||||
|
super.showActions,
|
||||||
|
super.actionBuilder,
|
||||||
|
super.configuration = const BlockComponentConfiguration(),
|
||||||
|
this.showMenu = false,
|
||||||
|
this.menuBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Whether to show the menu of this block component.
|
||||||
|
final bool showMenu;
|
||||||
|
|
||||||
|
final CustomImageBlockComponentMenuBuilder? menuBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CustomImageBlockComponent> createState() =>
|
||||||
|
CustomImageBlockComponentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
||||||
|
with SelectableMixin, BlockComponentConfigurable {
|
||||||
|
@override
|
||||||
|
BlockComponentConfiguration get configuration => widget.configuration;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Node get node => widget.node;
|
||||||
|
|
||||||
|
final imageKey = GlobalKey();
|
||||||
|
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
|
||||||
|
|
||||||
|
late final editorState = Provider.of<EditorState>(context, listen: false);
|
||||||
|
|
||||||
|
final showActionsNotifier = ValueNotifier<bool>(false);
|
||||||
|
|
||||||
|
bool alwaysShowMenu = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final node = widget.node;
|
||||||
|
final attributes = node.attributes;
|
||||||
|
final src = attributes[ImageBlockKeys.url];
|
||||||
|
|
||||||
|
final alignment = AlignmentExtension.fromString(
|
||||||
|
attributes[ImageBlockKeys.align] ?? 'center',
|
||||||
|
);
|
||||||
|
final width = attributes[ImageBlockKeys.width]?.toDouble() ??
|
||||||
|
MediaQuery.of(context).size.width;
|
||||||
|
final height = attributes[ImageBlockKeys.height]?.toDouble();
|
||||||
|
|
||||||
|
Widget child = src.isEmpty
|
||||||
|
? ImagePlaceholder(
|
||||||
|
node: node,
|
||||||
|
)
|
||||||
|
: ResizableImage(
|
||||||
|
src: src,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
editable: editorState.editable,
|
||||||
|
alignment: alignment,
|
||||||
|
onResize: (width) {
|
||||||
|
final transaction = editorState.transaction
|
||||||
|
..updateNode(node, {
|
||||||
|
ImageBlockKeys.width: width,
|
||||||
|
});
|
||||||
|
editorState.apply(transaction);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
child = BlockSelectionContainer(
|
||||||
|
node: node,
|
||||||
|
delegate: this,
|
||||||
|
listenable: editorState.selectionNotifier,
|
||||||
|
blockColor: editorState.editorStyle.selectionColor,
|
||||||
|
supportTypes: const [
|
||||||
|
BlockSelectionType.block,
|
||||||
|
],
|
||||||
|
child: Padding(
|
||||||
|
key: imageKey,
|
||||||
|
padding: padding,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (widget.showActions && widget.actionBuilder != null) {
|
||||||
|
child = BlockComponentActionWrapper(
|
||||||
|
node: node,
|
||||||
|
actionBuilder: widget.actionBuilder!,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.showMenu && widget.menuBuilder != null) {
|
||||||
|
child = MouseRegion(
|
||||||
|
onEnter: (_) => showActionsNotifier.value = true,
|
||||||
|
onExit: (_) {
|
||||||
|
if (!alwaysShowMenu) {
|
||||||
|
showActionsNotifier.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hitTestBehavior: HitTestBehavior.opaque,
|
||||||
|
opaque: false,
|
||||||
|
child: ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: showActionsNotifier,
|
||||||
|
builder: (context, value, child) {
|
||||||
|
final url = node.attributes[ImageBlockKeys.url];
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
BlockSelectionContainer(
|
||||||
|
node: node,
|
||||||
|
delegate: this,
|
||||||
|
listenable: editorState.selectionNotifier,
|
||||||
|
cursorColor: editorState.editorStyle.cursorColor,
|
||||||
|
selectionColor: editorState.editorStyle.selectionColor,
|
||||||
|
child: child!,
|
||||||
|
),
|
||||||
|
if (value && url.isNotEmpty == true)
|
||||||
|
widget.menuBuilder!(
|
||||||
|
widget.node,
|
||||||
|
this,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Position start() => Position(path: widget.node.path, offset: 0);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Position end() => Position(path: widget.node.path, offset: 1);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Position getPositionInOffset(Offset start) => end();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get shouldCursorBlink => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
CursorStyle get cursorStyle => CursorStyle.cover;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Rect getBlockRect({
|
||||||
|
bool shiftWithBaseOffset = false,
|
||||||
|
}) {
|
||||||
|
final imageBox = imageKey.currentContext?.findRenderObject();
|
||||||
|
if (imageBox is RenderBox) {
|
||||||
|
return Offset.zero & imageBox.size;
|
||||||
|
}
|
||||||
|
return Rect.zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Rect? getCursorRectInPosition(
|
||||||
|
Position position, {
|
||||||
|
bool shiftWithBaseOffset = false,
|
||||||
|
}) {
|
||||||
|
if (_renderBox == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final size = _renderBox!.size;
|
||||||
|
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Rect> getRectsInSelection(
|
||||||
|
Selection selection, {
|
||||||
|
bool shiftWithBaseOffset = false,
|
||||||
|
}) {
|
||||||
|
if (_renderBox == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
final parentBox = context.findRenderObject();
|
||||||
|
final imageBox = imageKey.currentContext?.findRenderObject();
|
||||||
|
if (parentBox is RenderBox && imageBox is RenderBox) {
|
||||||
|
return [
|
||||||
|
imageBox.localToGlobal(Offset.zero, ancestor: parentBox) &
|
||||||
|
imageBox.size,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [Offset.zero & _renderBox!.size];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
|
||||||
|
path: widget.node.path,
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Offset localToGlobal(
|
||||||
|
Offset offset, {
|
||||||
|
bool shiftWithBaseOffset = false,
|
||||||
|
}) =>
|
||||||
|
_renderBox!.localToGlobal(offset);
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class EmbedImageUrlWidget extends StatefulWidget {
|
||||||
|
const EmbedImageUrlWidget({
|
||||||
|
super.key,
|
||||||
|
required this.onSubmit,
|
||||||
|
});
|
||||||
|
|
||||||
|
final void Function(String url) onSubmit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EmbedImageUrlWidget> createState() => _EmbedImageUrlWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmbedImageUrlWidgetState extends State<EmbedImageUrlWidget> {
|
||||||
|
String inputText = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
FlowyTextField(
|
||||||
|
autoFocus: true,
|
||||||
|
hintText: LocaleKeys.document_imageBlock_embedLink_placeholder.tr(),
|
||||||
|
onChanged: (value) => inputText = value,
|
||||||
|
onEditingComplete: () => widget.onSubmit(inputText),
|
||||||
|
),
|
||||||
|
const VSpace(5),
|
||||||
|
SizedBox(
|
||||||
|
width: 160,
|
||||||
|
child: FlowyButton(
|
||||||
|
margin: const EdgeInsets.all(8.0),
|
||||||
|
text: FlowyText(
|
||||||
|
LocaleKeys.document_imageBlock_embedLink_label.tr(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
onTap: () => widget.onSubmit(inputText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||||
|
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
|
||||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||||
@ -18,7 +19,7 @@ class ImageMenu extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final Node node;
|
final Node node;
|
||||||
final ImageBlockComponentWidgetState state;
|
final CustomImageBlockComponentState state;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ImageMenu> createState() => _ImageMenuState();
|
State<ImageMenu> createState() => _ImageMenuState();
|
||||||
@ -109,7 +110,7 @@ class _ImageAlignButton extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final Node node;
|
final Node node;
|
||||||
final ImageBlockComponentWidgetState state;
|
final CustomImageBlockComponentState state;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_ImageAlignButton> createState() => _ImageAlignButtonState();
|
State<_ImageAlignButton> createState() => _ImageAlignButtonState();
|
||||||
|
@ -0,0 +1,134 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||||
|
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||||
|
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart';
|
||||||
|
import 'package:appflowy/startup/startup.dart';
|
||||||
|
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
|
||||||
|
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||||
|
import 'package:appflowy_backend/log.dart';
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart' hide Log, UploadImageMenu;
|
||||||
|
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flowy_infra/uuid.dart';
|
||||||
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
|
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:string_validator/string_validator.dart';
|
||||||
|
|
||||||
|
class ImagePlaceholder extends StatefulWidget {
|
||||||
|
const ImagePlaceholder({
|
||||||
|
super.key,
|
||||||
|
required this.node,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Node node;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ImagePlaceholder> createState() => _ImagePlaceholderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||||
|
final controller = PopoverController();
|
||||||
|
late final editorState = context.read<EditorState>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppFlowyPopover(
|
||||||
|
controller: controller,
|
||||||
|
direction: PopoverDirection.bottomWithCenterAligned,
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
maxWidth: 540,
|
||||||
|
maxHeight: 260,
|
||||||
|
minHeight: 80,
|
||||||
|
),
|
||||||
|
popupBuilder: (context) {
|
||||||
|
return UploadImageMenu(
|
||||||
|
onPickFile: insertLocalImage,
|
||||||
|
onSubmit: insertNetworkImage,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: FlowyHover(
|
||||||
|
style: HoverStyle(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const HSpace(10),
|
||||||
|
const FlowySvg(
|
||||||
|
FlowySvgs.image_placeholder_s,
|
||||||
|
size: Size.square(24),
|
||||||
|
),
|
||||||
|
const HSpace(10),
|
||||||
|
FlowyText(
|
||||||
|
LocaleKeys.document_plugins_image_addAnImage.tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> insertLocalImage(String? url) async {
|
||||||
|
if (url == null || url.isEmpty) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
final transaction = editorState.transaction;
|
||||||
|
transaction.updateNode(widget.node, {
|
||||||
|
ImageBlockKeys.url: copyToPath,
|
||||||
|
});
|
||||||
|
await editorState.apply(transaction);
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('cannot copy image file', e);
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> insertNetworkImage(String url) async {
|
||||||
|
if (url.isEmpty || !isURL(url)) {
|
||||||
|
// show error
|
||||||
|
showSnackBarMessage(
|
||||||
|
context,
|
||||||
|
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final transaction = editorState.transaction;
|
||||||
|
transaction.updateNode(widget.node, {
|
||||||
|
ImageBlockKeys.url: url,
|
||||||
|
});
|
||||||
|
await editorState.apply(transaction);
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,4 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:appflowy/startup/startup.dart';
|
|
||||||
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
|
|
||||||
import 'package:appflowy_backend/log.dart';
|
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
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(
|
final customImageMenuItem = SelectionMenuItem(
|
||||||
name: AppFlowyEditorLocalizations.current.image,
|
name: AppFlowyEditorLocalizations.current.image,
|
||||||
@ -16,44 +8,7 @@ final customImageMenuItem = SelectionMenuItem(
|
|||||||
style: style,
|
style: style,
|
||||||
),
|
),
|
||||||
keywords: ['image', 'picture', 'img', 'photo'],
|
keywords: ['image', 'picture', 'img', 'photo'],
|
||||||
handler: (editorState, menuService, context) {
|
handler: (editorState, menuService, context) async {
|
||||||
final container = Overlay.of(context);
|
return await editorState.insertImageNode('');
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,150 @@
|
|||||||
|
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:unsplash_client/unsplash_client.dart';
|
||||||
|
|
||||||
|
class UnsplashImageWidget extends StatefulWidget {
|
||||||
|
const UnsplashImageWidget({
|
||||||
|
super.key,
|
||||||
|
required this.onSelectUnsplashImage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final void Function(String url) onSelectUnsplashImage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UnsplashImageWidget> createState() => _UnsplashImageWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnsplashImageWidgetState extends State<UnsplashImageWidget> {
|
||||||
|
final client = UnsplashClient(
|
||||||
|
settings: const ClientSettings(
|
||||||
|
credentials: AppCredentials(
|
||||||
|
// TODO: there're the demo keys, we should replace them with the production keys when releasing and inject them with env file.
|
||||||
|
accessKey: 'YyD-LbW5bVolHWZBq5fWRM_3ezkG2XchRFjhNTnK9TE',
|
||||||
|
secretKey: '5z4EnxaXjWjWMnuBhc0Ku0uYW2bsYCZlO-REZaqmV6A',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
late Future<List<Photo>> randomPhotos;
|
||||||
|
|
||||||
|
String query = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
randomPhotos = client.photos
|
||||||
|
.random(count: 18, orientation: PhotoOrientation.landscape)
|
||||||
|
.goAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
client.close();
|
||||||
|
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
FlowyTextField(
|
||||||
|
autoFocus: true,
|
||||||
|
hintText: LocaleKeys.document_imageBlock_searchForAnImage.tr(),
|
||||||
|
// textAlign: TextAlign.left,
|
||||||
|
onChanged: (value) => query = value,
|
||||||
|
onEditingComplete: () => setState(() {
|
||||||
|
randomPhotos = client.photos
|
||||||
|
.random(
|
||||||
|
count: 18,
|
||||||
|
orientation: PhotoOrientation.landscape,
|
||||||
|
query: query,
|
||||||
|
)
|
||||||
|
.goAndGet();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const HSpace(12.0),
|
||||||
|
Expanded(
|
||||||
|
child: FutureBuilder(
|
||||||
|
future: randomPhotos,
|
||||||
|
builder: (context, value) {
|
||||||
|
final data = value.data;
|
||||||
|
if (!value.hasData || data == null || data.isEmpty) {
|
||||||
|
return const CircularProgressIndicator.adaptive();
|
||||||
|
}
|
||||||
|
return GridView.count(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
mainAxisSpacing: 16.0,
|
||||||
|
crossAxisSpacing: 10.0,
|
||||||
|
childAspectRatio: 4 / 3,
|
||||||
|
children: data
|
||||||
|
.map(
|
||||||
|
(photo) => _UnsplashImage(
|
||||||
|
photo: photo,
|
||||||
|
onTap: () => widget.onSelectUnsplashImage(
|
||||||
|
photo.urls.regular.toString(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnsplashImage extends StatelessWidget {
|
||||||
|
const _UnsplashImage({
|
||||||
|
required this.photo,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Photo photo;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Image.network(
|
||||||
|
photo.urls.thumb.toString(),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FlowyText(
|
||||||
|
'by ${photo.name}',
|
||||||
|
fontSize: 10.0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on Photo {
|
||||||
|
String get name {
|
||||||
|
if (user.username.isNotEmpty) {
|
||||||
|
return user.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.name.isNotEmpty) {
|
||||||
|
return user.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.email?.isNotEmpty == true) {
|
||||||
|
return user.email!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.id;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||||
|
import 'package:appflowy/startup/startup.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flowy_infra/file_picker/file_picker_service.dart';
|
||||||
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
|
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class UploadImageFileWidget extends StatelessWidget {
|
||||||
|
const UploadImageFileWidget({
|
||||||
|
super.key,
|
||||||
|
required this.onPickFile,
|
||||||
|
this.allowedExtensions = const ['jpg', 'png', 'jpeg'],
|
||||||
|
});
|
||||||
|
|
||||||
|
final void Function(String? path) onPickFile;
|
||||||
|
final List<String> allowedExtensions;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FlowyHover(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTapDown: (_) async {
|
||||||
|
final result = await getIt<FilePickerService>().pickFiles(
|
||||||
|
dialogTitle: '',
|
||||||
|
allowMultiple: false,
|
||||||
|
type: FileType.image,
|
||||||
|
allowedExtensions: allowedExtensions,
|
||||||
|
);
|
||||||
|
onPickFile(result?.files.firstOrNull?.path);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||||
|
width: 1.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: FlowyText(
|
||||||
|
LocaleKeys.document_imageBlock_upload_placeholder.tr(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,122 @@
|
|||||||
|
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||||
|
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart';
|
||||||
|
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart';
|
||||||
|
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
|
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum UploadImageType {
|
||||||
|
local,
|
||||||
|
url,
|
||||||
|
unsplash,
|
||||||
|
ai;
|
||||||
|
|
||||||
|
String get description {
|
||||||
|
switch (this) {
|
||||||
|
case UploadImageType.local:
|
||||||
|
return LocaleKeys.document_imageBlock_upload_label.tr();
|
||||||
|
case UploadImageType.url:
|
||||||
|
return LocaleKeys.document_imageBlock_embedLink_label.tr();
|
||||||
|
case UploadImageType.unsplash:
|
||||||
|
return 'Unsplash';
|
||||||
|
case UploadImageType.ai:
|
||||||
|
return 'Generate from AI';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UploadImageMenu extends StatefulWidget {
|
||||||
|
const UploadImageMenu({
|
||||||
|
super.key,
|
||||||
|
required this.onPickFile,
|
||||||
|
required this.onSubmit,
|
||||||
|
});
|
||||||
|
|
||||||
|
final void Function(String? path) onPickFile;
|
||||||
|
final void Function(String url) onSubmit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UploadImageMenu> createState() => _UploadImageMenuState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UploadImageMenuState extends State<UploadImageMenu> {
|
||||||
|
int currentTabIndex = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DefaultTabController(
|
||||||
|
length: 3, // UploadImageType.values.length, // ai is not implemented yet
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TabBar(
|
||||||
|
onTap: (value) => setState(() {
|
||||||
|
currentTabIndex = value;
|
||||||
|
}),
|
||||||
|
indicatorSize: TabBarIndicatorSize.label,
|
||||||
|
isScrollable: true,
|
||||||
|
overlayColor: MaterialStatePropertyAll(
|
||||||
|
Theme.of(context).colorScheme.secondary,
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
// splashBorderRadius: BorderRadius.circular(4),
|
||||||
|
tabs: UploadImageType.values
|
||||||
|
.where(
|
||||||
|
(element) => element != UploadImageType.ai,
|
||||||
|
) // ai is not implemented yet
|
||||||
|
.map(
|
||||||
|
(e) => FlowyHover(
|
||||||
|
style: const HoverStyle(borderRadius: BorderRadius.zero),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12.0,
|
||||||
|
vertical: 8.0,
|
||||||
|
),
|
||||||
|
child: FlowyText(e.description),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const Divider(
|
||||||
|
height: 2,
|
||||||
|
),
|
||||||
|
_buildTab(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTab() {
|
||||||
|
final type = UploadImageType.values[currentTabIndex];
|
||||||
|
switch (type) {
|
||||||
|
case UploadImageType.local:
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: UploadImageFileWidget(
|
||||||
|
onPickFile: widget.onPickFile,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case UploadImageType.url:
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: EmbedImageUrlWidget(
|
||||||
|
onSubmit: widget.onSubmit,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case UploadImageType.unsplash:
|
||||||
|
return Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: UnsplashImageWidget(
|
||||||
|
onSelectUnsplashImage: widget.onSubmit,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case UploadImageType.ai:
|
||||||
|
return const FlowyText.medium('ai');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -55,7 +55,9 @@ class _SmartEditActionListState extends State<SmartEditActionList> {
|
|||||||
actions: SmartEditAction.values
|
actions: SmartEditAction.values
|
||||||
.map((action) => SmartEditActionWrapper(action))
|
.map((action) => SmartEditActionWrapper(action))
|
||||||
.toList(),
|
.toList(),
|
||||||
|
onClosed: () => keepEditorFocusNotifier.value -= 1,
|
||||||
buildChild: (controller) {
|
buildChild: (controller) {
|
||||||
|
keepEditorFocusNotifier.value += 1;
|
||||||
return FlowyIconButton(
|
return FlowyIconButton(
|
||||||
hoverColor: Colors.transparent,
|
hoverColor: Colors.transparent,
|
||||||
tooltipText: isOpenAIEnabled
|
tooltipText: isOpenAIEnabled
|
||||||
|
@ -32,7 +32,7 @@ class SettingsLanguageView extends StatelessWidget {
|
|||||||
|
|
||||||
class LanguageSelector extends StatelessWidget {
|
class LanguageSelector extends StatelessWidget {
|
||||||
final Locale currentLocale;
|
final Locale currentLocale;
|
||||||
|
|
||||||
const LanguageSelector({
|
const LanguageSelector({
|
||||||
super.key,
|
super.key,
|
||||||
required this.currentLocale,
|
required this.currentLocale,
|
||||||
|
@ -1,17 +1,18 @@
|
|||||||
import 'package:appflowy_popover/src/layout.dart';
|
import 'package:appflowy_popover/src/layout.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'mask.dart';
|
import 'mask.dart';
|
||||||
import 'mutex.dart';
|
import 'mutex.dart';
|
||||||
|
|
||||||
class PopoverController {
|
class PopoverController {
|
||||||
PopoverState? _state;
|
PopoverState? _state;
|
||||||
|
|
||||||
close() {
|
void close() {
|
||||||
_state?.close();
|
_state?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
show() {
|
void show() {
|
||||||
_state?.showOverlay();
|
_state?.showOverlay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -153,8 +153,8 @@ class AFThemeExtension extends ThemeExtension<AFThemeExtension> {
|
|||||||
Color.lerp(toggleButtonBGColor, other.toggleButtonBGColor, t)!,
|
Color.lerp(toggleButtonBGColor, other.toggleButtonBGColor, t)!,
|
||||||
calendarWeekendBGColor:
|
calendarWeekendBGColor:
|
||||||
Color.lerp(calendarWeekendBGColor, other.calendarWeekendBGColor, t)!,
|
Color.lerp(calendarWeekendBGColor, other.calendarWeekendBGColor, t)!,
|
||||||
gridRowCountColor: Color.lerp(
|
gridRowCountColor:
|
||||||
gridRowCountColor, other.gridRowCountColor, t)!,
|
Color.lerp(gridRowCountColor, other.gridRowCountColor, t)!,
|
||||||
code: other.code,
|
code: other.code,
|
||||||
callout: other.callout,
|
callout: other.callout,
|
||||||
caption: other.caption,
|
caption: other.caption,
|
||||||
|
@ -54,8 +54,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: "0fdca2f"
|
ref: e996c92
|
||||||
resolved-ref: "0fdca2f702485eeec1bfbe50127c06f2a8fd8b1e"
|
resolved-ref: e996c9279d873f55a1b6aa919144763a60f83d32
|
||||||
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
|
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
|
||||||
source: git
|
source: git
|
||||||
version: "1.4.3"
|
version: "1.4.3"
|
||||||
@ -1447,7 +1447,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0"
|
version: "1.2.0"
|
||||||
string_validator:
|
string_validator:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: string_validator
|
name: string_validator
|
||||||
sha256: b419cf5d21d608522e6e7cafed4deb34b6f268c43df866e63c320bab98a08cf6
|
sha256: b419cf5d21d608522e6e7cafed4deb34b6f268c43df866e63c320bab98a08cf6
|
||||||
@ -1615,6 +1615,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0+1"
|
version: "1.0.0+1"
|
||||||
|
unsplash_client:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: unsplash_client
|
||||||
|
sha256: "832011981ef358ef4f816f356375620791d24dc6e1afb33a37f066df9aaea537"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
url_launcher:
|
url_launcher:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -47,7 +47,7 @@ dependencies:
|
|||||||
appflowy_editor:
|
appflowy_editor:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/AppFlowy-IO/appflowy-editor.git
|
url: https://github.com/AppFlowy-IO/appflowy-editor.git
|
||||||
ref: '0fdca2f'
|
ref: 'e996c92'
|
||||||
appflowy_popover:
|
appflowy_popover:
|
||||||
path: packages/appflowy_popover
|
path: packages/appflowy_popover
|
||||||
|
|
||||||
@ -108,6 +108,8 @@ dependencies:
|
|||||||
hive_flutter: ^1.1.0
|
hive_flutter: ^1.1.0
|
||||||
super_clipboard: ^0.6.3
|
super_clipboard: ^0.6.3
|
||||||
go_router: ^10.1.2
|
go_router: ^10.1.2
|
||||||
|
string_validator: ^1.0.0
|
||||||
|
unsplash_client: ^2.1.1
|
||||||
|
|
||||||
# Notifications
|
# Notifications
|
||||||
# TODO: Consider implementing custom package
|
# TODO: Consider implementing custom package
|
||||||
|
5
frontend/resources/flowy_icons/16x/image_placeholder.svg
Normal file
5
frontend/resources/flowy_icons/16x/image_placeholder.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="1.5" y="3" width="13" height="10" rx="1.5" stroke="#333333"/>
|
||||||
|
<circle cx="5.5" cy="6.5" r="1" stroke="#333333"/>
|
||||||
|
<path d="M5 13L10.112 8.45603C10.4211 8.18126 10.8674 8.12513 11.235 8.31482L14.5 10" stroke="#333333"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 330 B |
@ -616,7 +616,8 @@
|
|||||||
"defaultColor": "Default"
|
"defaultColor": "Default"
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"copiedToPasteBoard": "The image link has been copied to the clipboard"
|
"copiedToPasteBoard": "The image link has been copied to the clipboard",
|
||||||
|
"addAnImage": "Add an image"
|
||||||
},
|
},
|
||||||
"outline": {
|
"outline": {
|
||||||
"addHeadingToCreateOutline": "Add headings to create a table of contents."
|
"addHeadingToCreateOutline": "Add headings to create a table of contents."
|
||||||
@ -657,7 +658,12 @@
|
|||||||
"invalidImageSize": "Image size must be less than 5MB",
|
"invalidImageSize": "Image size must be less than 5MB",
|
||||||
"invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, GIF, SVG",
|
"invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, GIF, SVG",
|
||||||
"invalidImageUrl": "Invalid image URL"
|
"invalidImageUrl": "Invalid image URL"
|
||||||
}
|
},
|
||||||
|
"embedLink": {
|
||||||
|
"label": "Embed link",
|
||||||
|
"placeholder": "Paste or type an image link"
|
||||||
|
},
|
||||||
|
"searchForAnImage": "Search for an image"
|
||||||
},
|
},
|
||||||
"codeBlock": {
|
"codeBlock": {
|
||||||
"language": {
|
"language": {
|
||||||
|
Loading…
Reference in New Issue
Block a user