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/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_style.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),
|
||||
),
|
||||
ImageBlockKeys.type: ImageBlockComponentBuilder(
|
||||
ImageBlockKeys.type: CustomImageBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
showMenu: true,
|
||||
menuBuilder: (node, state) => Positioned(
|
||||
menuBuilder: (Node node, CustomImageBlockComponentState state) =>
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 10,
|
||||
child: ImageMenu(
|
||||
|
@ -22,7 +22,7 @@ final customizeFontToolbarItem = ToolbarItem(
|
||||
onClose: () => keepEditorFocusNotifier.value -= 1,
|
||||
showResetButton: true,
|
||||
onFontFamilyChanged: (fontFamily) async {
|
||||
await popoverController.close();
|
||||
popoverController.close();
|
||||
try {
|
||||
await editorState.formatDelta(selection, {
|
||||
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/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_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
@ -18,7 +19,7 @@ class ImageMenu extends StatefulWidget {
|
||||
});
|
||||
|
||||
final Node node;
|
||||
final ImageBlockComponentWidgetState state;
|
||||
final CustomImageBlockComponentState state;
|
||||
|
||||
@override
|
||||
State<ImageMenu> createState() => _ImageMenuState();
|
||||
@ -109,7 +110,7 @@ class _ImageAlignButton extends StatefulWidget {
|
||||
});
|
||||
|
||||
final Node node;
|
||||
final ImageBlockComponentWidgetState state;
|
||||
final CustomImageBlockComponentState state;
|
||||
|
||||
@override
|
||||
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:flowy_infra/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final customImageMenuItem = SelectionMenuItem(
|
||||
name: AppFlowyEditorLocalizations.current.image,
|
||||
@ -16,44 +8,7 @@ final customImageMenuItem = SelectionMenuItem(
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
handler: (editorState, menuService, context) async {
|
||||
return await editorState.insertImageNode('');
|
||||
},
|
||||
);
|
||||
|
@ -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
|
||||
.map((action) => SmartEditActionWrapper(action))
|
||||
.toList(),
|
||||
onClosed: () => keepEditorFocusNotifier.value -= 1,
|
||||
buildChild: (controller) {
|
||||
keepEditorFocusNotifier.value += 1;
|
||||
return FlowyIconButton(
|
||||
hoverColor: Colors.transparent,
|
||||
tooltipText: isOpenAIEnabled
|
||||
|
@ -32,7 +32,7 @@ class SettingsLanguageView extends StatelessWidget {
|
||||
|
||||
class LanguageSelector extends StatelessWidget {
|
||||
final Locale currentLocale;
|
||||
|
||||
|
||||
const LanguageSelector({
|
||||
super.key,
|
||||
required this.currentLocale,
|
||||
|
@ -1,17 +1,18 @@
|
||||
import 'package:appflowy_popover/src/layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'mask.dart';
|
||||
import 'mutex.dart';
|
||||
|
||||
class PopoverController {
|
||||
PopoverState? _state;
|
||||
|
||||
close() {
|
||||
void close() {
|
||||
_state?.close();
|
||||
}
|
||||
|
||||
show() {
|
||||
void show() {
|
||||
_state?.showOverlay();
|
||||
}
|
||||
}
|
||||
|
@ -153,8 +153,8 @@ class AFThemeExtension extends ThemeExtension<AFThemeExtension> {
|
||||
Color.lerp(toggleButtonBGColor, other.toggleButtonBGColor, t)!,
|
||||
calendarWeekendBGColor:
|
||||
Color.lerp(calendarWeekendBGColor, other.calendarWeekendBGColor, t)!,
|
||||
gridRowCountColor: Color.lerp(
|
||||
gridRowCountColor, other.gridRowCountColor, t)!,
|
||||
gridRowCountColor:
|
||||
Color.lerp(gridRowCountColor, other.gridRowCountColor, t)!,
|
||||
code: other.code,
|
||||
callout: other.callout,
|
||||
caption: other.caption,
|
||||
|
@ -54,8 +54,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "0fdca2f"
|
||||
resolved-ref: "0fdca2f702485eeec1bfbe50127c06f2a8fd8b1e"
|
||||
ref: e996c92
|
||||
resolved-ref: e996c9279d873f55a1b6aa919144763a60f83d32
|
||||
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
|
||||
source: git
|
||||
version: "1.4.3"
|
||||
@ -1447,7 +1447,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
string_validator:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: string_validator
|
||||
sha256: b419cf5d21d608522e6e7cafed4deb34b6f268c43df866e63c320bab98a08cf6
|
||||
@ -1615,6 +1615,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -47,7 +47,7 @@ dependencies:
|
||||
appflowy_editor:
|
||||
git:
|
||||
url: https://github.com/AppFlowy-IO/appflowy-editor.git
|
||||
ref: '0fdca2f'
|
||||
ref: 'e996c92'
|
||||
appflowy_popover:
|
||||
path: packages/appflowy_popover
|
||||
|
||||
@ -108,6 +108,8 @@ dependencies:
|
||||
hive_flutter: ^1.1.0
|
||||
super_clipboard: ^0.6.3
|
||||
go_router: ^10.1.2
|
||||
string_validator: ^1.0.0
|
||||
unsplash_client: ^2.1.1
|
||||
|
||||
# Notifications
|
||||
# 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"
|
||||
},
|
||||
"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": {
|
||||
"addHeadingToCreateOutline": "Add headings to create a table of contents."
|
||||
@ -657,7 +658,12 @@
|
||||
"invalidImageSize": "Image size must be less than 5MB",
|
||||
"invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, GIF, SVG",
|
||||
"invalidImageUrl": "Invalid image URL"
|
||||
}
|
||||
},
|
||||
"embedLink": {
|
||||
"label": "Embed link",
|
||||
"placeholder": "Paste or type an image link"
|
||||
},
|
||||
"searchForAnImage": "Search for an image"
|
||||
},
|
||||
"codeBlock": {
|
||||
"language": {
|
||||
|
Loading…
Reference in New Issue
Block a user