fix: photo gallery improvements + launch review fixes (#5830)

* fix: photo gallery improvements

* fix: improve when to show billing/plan pages

* feat: add grid photo gallery layout

* fix: close inline actions menu
This commit is contained in:
Mathias Mogensen 2024-07-30 09:28:40 +02:00 committed by GitHub
parent 175c90e379
commit d5a5a64fcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 877 additions and 271 deletions

View File

@ -8,9 +8,10 @@ import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
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/multi_image_block_component/image_render.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';

View File

@ -0,0 +1,42 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:flowy_infra/size.dart';
@visibleForTesting
class ImageRender extends StatelessWidget {
const ImageRender({
super.key,
required this.image,
this.userProfile,
this.fit = BoxFit.cover,
this.borderRadius = Corners.s6Border,
});
final ImageBlockData image;
final UserProfilePB? userProfile;
final BoxFit fit;
final BorderRadius? borderRadius;
@override
Widget build(BuildContext context) {
final child = switch (image.type) {
CustomImageType.internal || CustomImageType.external => FlowyNetworkImage(
url: image.url,
userProfilePB: userProfile,
fit: fit,
),
CustomImageType.local => Image.file(File(image.url), fit: fit),
};
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(borderRadius: borderRadius),
child: child,
);
}
}

View File

@ -7,13 +7,13 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage;
import 'package:collection/collection.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
@ -25,25 +25,10 @@ import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:provider/provider.dart';
import '../image_render.dart';
const _thumbnailItemSize = 100.0;
abstract class ImageBlockMultiLayout extends StatefulWidget {
const ImageBlockMultiLayout({
super.key,
required this.node,
required this.editorState,
required this.images,
required this.indexNotifier,
required this.isLocalMode,
});
final Node node;
final EditorState editorState;
final List<ImageBlockData> images;
final ValueNotifier<int> indexNotifier;
final bool isLocalMode;
}
class ImageBrowserLayout extends ImageBlockMultiLayout {
const ImageBrowserLayout({
super.key,
@ -97,115 +82,118 @@ class _ImageBrowserLayoutState extends State<ImageBrowserLayout> {
(constraints.maxWidth / (_thumbnailItemSize + 4)).floor();
final items = widget.images.take(maxItems).toList();
return Wrap(
children: items.mapIndexed((index, image) {
final isLast = items.last == image;
final amountLeft = widget.images.length - items.length;
if (isLast && amountLeft > 0) {
return Center(
child: Wrap(
children: items.mapIndexed((index, image) {
final isLast = items.last == image;
final amountLeft = widget.images.length - items.length;
if (isLast && amountLeft > 0) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => _openInteractiveViewer(
context,
maxItems - 1,
),
child: Container(
width: _thumbnailItemSize,
height: _thumbnailItemSize,
padding: const EdgeInsets.all(2),
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
borderRadius: Corners.s8Border,
border: Border.all(
width: 2,
color: Theme.of(context).dividerColor,
),
),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: Corners.s6Border,
image: image.type == CustomImageType.local
? DecorationImage(
image: FileImage(File(image.url)),
fit: BoxFit.cover,
opacity: 0.5,
)
: null,
),
child: Stack(
children: [
if (image.type != CustomImageType.local)
Positioned.fill(
child: Container(
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(
borderRadius: Corners.s6Border,
),
child: FlowyNetworkImage(
url: image.url,
userProfilePB: _userProfile,
),
),
),
DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
),
child: Center(
child: FlowyText(
'+$amountLeft',
color: AFThemeExtension.of(context)
.strongText,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
),
);
}
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => _openInteractiveViewer(
context,
maxItems - 1,
),
child: Container(
width: _thumbnailItemSize,
height: _thumbnailItemSize,
padding: const EdgeInsets.all(2),
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
borderRadius: Corners.s8Border,
border: Border.all(
width: 2,
color: Theme.of(context).dividerColor,
),
),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: Corners.s6Border,
image: image.type == CustomImageType.local
? DecorationImage(
image: FileImage(File(image.url)),
fit: BoxFit.cover,
opacity: 0.5,
)
: null,
),
child: Stack(
children: [
if (image.type != CustomImageType.local)
Positioned.fill(
child: Container(
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(
borderRadius: Corners.s6Border,
),
child: FlowyNetworkImage(
url: image.url,
userProfilePB: _userProfile,
),
),
),
DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
),
child: Center(
child: FlowyText(
'+$amountLeft',
color: AFThemeExtension.of(context)
.strongText,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
onTap: () => widget.onIndexChanged(index),
child: ThumbnailItem(
images: widget.images,
index: index,
selectedIndex: widget.indexNotifier.value,
userProfile: _userProfile,
onDeleted: () async {
final transaction =
widget.editorState.transaction;
final images = widget.images.toList();
images.removeAt(index);
transaction.updateNode(
widget.node,
{
MultiImageBlockKeys.images:
images.map((e) => e.toJson()).toList(),
MultiImageBlockKeys.layout: widget.node
.attributes[MultiImageBlockKeys.layout],
},
);
await widget.editorState.apply(transaction);
widget.onIndexChanged(
widget.indexNotifier.value > 0
? widget.indexNotifier.value - 1
: 0,
);
},
),
),
);
}
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => widget.onIndexChanged(index),
child: ThumbnailItem(
images: widget.images,
index: index,
selectedIndex: widget.indexNotifier.value,
userProfile: _userProfile,
onDeleted: () async {
final transaction = widget.editorState.transaction;
final images = widget.images.toList();
images.removeAt(index);
transaction.updateNode(
widget.node,
{
MultiImageBlockKeys.images:
images.map((e) => e.toJson()).toList(),
MultiImageBlockKeys.layout: widget.node
.attributes[MultiImageBlockKeys.layout],
},
);
await widget.editorState.apply(transaction);
widget.onIndexChanged(
widget.indexNotifier.value > 0
? widget.indexNotifier.value - 1
: 0,
);
},
),
),
);
}).toList(),
}).toList(),
),
);
},
),
@ -324,7 +312,8 @@ class _ImageBrowserLayoutState extends State<ImageBrowserLayout> {
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images: imagesJson,
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
MultiImageBlockKeys.layout:
widget.node.attributes[MultiImageBlockKeys.layout],
});
await widget.editorState.apply(transaction);
@ -417,35 +406,3 @@ class _ThumbnailItemState extends State<ThumbnailItem> {
);
}
}
@visibleForTesting
class ImageRender extends StatelessWidget {
const ImageRender({
super.key,
required this.image,
this.userProfile,
this.fit = BoxFit.cover,
});
final ImageBlockData image;
final UserProfilePB? userProfile;
final BoxFit fit;
@override
Widget build(BuildContext context) {
final child = switch (image.type) {
CustomImageType.internal || CustomImageType.external => FlowyNetworkImage(
url: image.url,
userProfilePB: userProfile,
fit: fit,
),
CustomImageType.local => Image.file(File(image.url), fit: fit),
};
return Container(
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(borderRadius: Corners.s6Border),
child: child,
);
}
}

View File

@ -0,0 +1,323 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:collection/collection.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:provider/provider.dart';
class ImageGridLayout extends ImageBlockMultiLayout {
const ImageGridLayout({
super.key,
required super.node,
required super.editorState,
required super.images,
required super.indexNotifier,
required super.isLocalMode,
});
@override
State<ImageGridLayout> createState() => _ImageGridLayoutState();
}
class _ImageGridLayoutState extends State<ImageGridLayout> {
@override
Widget build(BuildContext context) {
return StaggeredGridBuilder(
images: widget.images,
onImageDoubleTapped: (index) {
_openInteractiveViewer(context, index);
},
);
}
void _openInteractiveViewer(BuildContext context, int index) => showDialog(
context: context,
builder: (_) => InteractiveImageViewer(
userProfile: context.read<DocumentBloc>().state.userProfilePB,
imageProvider: AFBlockImageProvider(
images: widget.images,
initialIndex: index,
onDeleteImage: (index) async {
final transaction = widget.editorState.transaction;
final newImages = widget.images.toList();
newImages.removeAt(index);
if (newImages.isNotEmpty) {
transaction.updateNode(
widget.node,
{
MultiImageBlockKeys.images:
newImages.map((e) => e.toJson()).toList(),
MultiImageBlockKeys.layout:
widget.node.attributes[MultiImageBlockKeys.layout],
},
);
} else {
transaction.deleteNode(widget.node);
}
await widget.editorState.apply(transaction);
},
),
),
);
}
/// Draws a staggered grid of images, where the pattern is based
/// on the amount of images to fill the grid at all times.
///
/// They will be alternating depending on the current index of the images, such that
/// the layout is reversed in odd segments.
///
/// If there are 4 images in the last segment, this layout will be used:
///
///
///
///
///
/// If there are 3 images in the last segment, this layout will be used:
///
///
///
///
///
/// If there are 2 images in the last segment, this layout will be used:
///
///
///
///
/// If there is 1 image in the last segment, this layout will be used:
///
///
///
class StaggeredGridBuilder extends StatefulWidget {
const StaggeredGridBuilder({
super.key,
required this.images,
required this.onImageDoubleTapped,
});
final List<ImageBlockData> images;
final void Function(int) onImageDoubleTapped;
@override
State<StaggeredGridBuilder> createState() => _StaggeredGridBuilderState();
}
class _StaggeredGridBuilderState extends State<StaggeredGridBuilder> {
late final UserProfilePB? _userProfile;
final List<List<ImageBlockData>> _splitImages = [];
@override
void initState() {
super.initState();
_userProfile = context.read<DocumentBloc>().state.userProfilePB;
for (int i = 0; i < widget.images.length; i += 4) {
final end = (i + 4 < widget.images.length) ? i + 4 : widget.images.length;
_splitImages.add(widget.images.sublist(i, end));
}
}
@override
void didUpdateWidget(covariant StaggeredGridBuilder oldWidget) {
if (widget.images.length != oldWidget.images.length) {
_splitImages.clear();
for (int i = 0; i < widget.images.length; i += 4) {
final end =
(i + 4 < widget.images.length) ? i + 4 : widget.images.length;
_splitImages.add(widget.images.sublist(i, end));
}
}
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return StaggeredGrid.count(
crossAxisCount: 4,
mainAxisSpacing: 6,
crossAxisSpacing: 6,
children:
_splitImages.indexed.map(_buildTilesForImages).flattened.toList(),
);
}
List<Widget> _buildTilesForImages((int, List<ImageBlockData>) data) {
final index = data.$1;
final images = data.$2;
final isReversed = index.isOdd;
if (images.length == 4) {
return [
StaggeredGridTile.count(
crossAxisCellCount: isReversed ? 1 : 2,
mainAxisCellCount: isReversed ? 1 : 2,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[0],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
StaggeredGridTile.count(
crossAxisCellCount: 1,
mainAxisCellCount: 1,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4 + 1;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[1],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
StaggeredGridTile.count(
crossAxisCellCount: isReversed ? 2 : 1,
mainAxisCellCount: isReversed ? 2 : 1,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4 + 2;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[2],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
StaggeredGridTile.count(
crossAxisCellCount: 2,
mainAxisCellCount: 1,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4 + 3;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[3],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
];
} else if (images.length == 3) {
return [
StaggeredGridTile.count(
crossAxisCellCount: 2,
mainAxisCellCount: isReversed ? 1 : 2,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[0],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
StaggeredGridTile.count(
crossAxisCellCount: 2,
mainAxisCellCount: isReversed ? 2 : 1,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4 + 1;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[1],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
StaggeredGridTile.count(
crossAxisCellCount: 2,
mainAxisCellCount: 1,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4 + 2;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[2],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
];
} else if (images.length == 2) {
return [
StaggeredGridTile.count(
crossAxisCellCount: 2,
mainAxisCellCount: 2,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[0],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
StaggeredGridTile.count(
crossAxisCellCount: 2,
mainAxisCellCount: 2,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4 + 1;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[1],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
];
} else {
return [
StaggeredGridTile.count(
crossAxisCellCount: 4,
mainAxisCellCount: 2,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[0],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
];
}
}
}

View File

@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage;
abstract class ImageBlockMultiLayout extends StatefulWidget {
const ImageBlockMultiLayout({
super.key,
required this.node,
required this.editorState,
required this.images,
required this.indexNotifier,
required this.isLocalMode,
});
final Node node;
final EditorState editorState;
final List<ImageBlockData> images;
final ValueNotifier<int> indexNotifier;
final bool isLocalMode;
}
class ImageLayoutRender extends StatelessWidget {
const ImageLayoutRender({
super.key,
required this.node,
required this.editorState,
required this.images,
required this.indexNotifier,
required this.isLocalMode,
required this.onIndexChanged,
});
final Node node;
final EditorState editorState;
final List<ImageBlockData> images;
final ValueNotifier<int> indexNotifier;
final bool isLocalMode;
final void Function(int) onIndexChanged;
@override
Widget build(BuildContext context) {
final layout = _getLayout();
return _buildLayout(layout);
}
MultiImageLayout _getLayout() {
return MultiImageLayout.fromIntValue(
node.attributes[MultiImageBlockKeys.layout] ?? 0,
);
}
Widget _buildLayout(MultiImageLayout layout) {
switch (layout) {
case MultiImageLayout.grid:
return ImageGridLayout(
node: node,
editorState: editorState,
images: images,
indexNotifier: indexNotifier,
isLocalMode: isLocalMode,
);
case MultiImageLayout.browser:
default:
return ImageBrowserLayout(
node: node,
editorState: editorState,
images: images,
indexNotifier: indexNotifier,
isLocalMode: isLocalMode,
onIndexChanged: onIndexChanged,
);
}
}
}

View File

@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:provider/provider.dart';
const kMultiImagePlaceholderKey = 'multiImagePlaceholderKey';
@ -109,6 +112,38 @@ class MultiImageBlockComponentState extends State<MultiImageBlockComponent>
bool alwaysShowMenu = false;
static const _interceptorKey = 'multi-image-block-interceptor';
late final interceptor = SelectionGestureInterceptor(
key: _interceptorKey,
canTap: (details) => _isTapInBounds(details.globalPosition),
canPanStart: (details) => _isTapInBounds(details.globalPosition),
);
@override
void initState() {
super.initState();
editorState.selectionService.registerGestureInterceptor(interceptor);
}
@override
void dispose() {
editorState.selectionService.unregisterGestureInterceptor(_interceptorKey);
super.dispose();
}
bool _isTapInBounds(Offset offset) {
if (_renderBox == null) {
// We shouldn't block any actions if the render box is not available.
// This has the potential to break taps on the editor completely if we
// accidentally return false here.
return true;
}
final localPosition = _renderBox!.globalToLocal(offset);
return !_renderBox!.paintBounds.contains(localPosition);
}
@override
Widget build(BuildContext context) {
final data = MultiImageData.fromJson(
@ -127,7 +162,7 @@ class MultiImageBlockComponentState extends State<MultiImageBlockComponent>
node: node,
);
} else {
child = ImageBrowserLayout(
child = ImageLayoutRender(
node: node,
images: data.images,
editorState: editorState,
@ -302,17 +337,14 @@ class MultiImageData {
enum MultiImageLayout {
browser,
masonry,
grid;
int toIntValue() {
switch (this) {
case MultiImageLayout.browser:
return 0;
case MultiImageLayout.masonry:
return 1;
case MultiImageLayout.grid:
return 2;
return 1;
}
}
@ -321,11 +353,19 @@ enum MultiImageLayout {
case 0:
return MultiImageLayout.browser;
case 1:
return MultiImageLayout.masonry;
case 2:
return MultiImageLayout.grid;
default:
throw UnimplementedError();
}
}
String get label => switch (this) {
browser => LocaleKeys.document_plugins_photoGallery_browserLayout.tr(),
grid => LocaleKeys.document_plugins_photoGallery_gridLayout.tr(),
};
FlowySvgData get icon => switch (this) {
browser => FlowySvgs.photo_layout_browser_s,
grid => FlowySvgs.photo_layout_grid_s,
};
}

View File

@ -21,6 +21,8 @@ import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu, Log;
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:http/http.dart';
@ -57,6 +59,7 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
);
final PopoverController controller = PopoverController();
final PopoverController layoutController = PopoverController();
late List<ImageBlockData> images;
late final EditorState editorState;
@ -73,6 +76,7 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
void dispose() {
allowMenuClose();
controller.close();
layoutController.close();
super.dispose();
}
@ -88,6 +92,9 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final layout = MultiImageLayout.fromIntValue(
widget.node.attributes[MultiImageBlockKeys.layout] ?? 0,
);
return Container(
height: 32,
decoration: BoxDecoration(
@ -104,11 +111,6 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
child: Row(
children: [
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(),
iconData: FlowySvgs.full_view_s,
onTap: openFullScreen,
),
AppFlowyPopover(
controller: controller,
direction: PopoverDirection.bottomWithRightAligned,
@ -141,9 +143,57 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
onTap: () {},
),
),
const HSpace(4),
AppFlowyPopover(
controller: layoutController,
onClose: allowMenuClose,
direction: PopoverDirection.bottomWithRightAligned,
offset: const Offset(0, 10),
constraints: const BoxConstraints(
maxHeight: 300,
maxWidth: 300,
),
popupBuilder: (context) {
preventMenuClose();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_LayoutSelector(
selectedLayout: layout,
onSelected: (layout) {
allowMenuClose();
layoutController.close();
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images:
widget.node.attributes[MultiImageBlockKeys.images],
MultiImageBlockKeys.layout: layout.toIntValue(),
});
editorState.apply(transaction);
},
),
],
);
},
child: MenuBlockButton(
tooltip: LocaleKeys
.document_plugins_photoGallery_changeLayoutTooltip
.tr(),
iconData: FlowySvgs.edit_layout_s,
onTap: () {},
),
),
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(),
iconData: FlowySvgs.full_view_s,
onTap: openFullScreen,
),
// disable the copy link button if the image is hosted on appflowy cloud
// because the url needs the verification token to be accessible
if (!images[widget.indexNotifier.value].url.isAppFlowyCloudUrl) ...[
if (layout == MultiImageLayout.browser &&
!images[widget.indexNotifier.value].url.isAppFlowyCloudUrl) ...[
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.editor_copyLink.tr(),
@ -153,7 +203,8 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
],
const _Divider(),
MenuBlockButton(
tooltip: LocaleKeys.button_delete.tr(),
tooltip: LocaleKeys.document_plugins_photoGallery_deleteBlockTooltip
.tr(),
iconData: FlowySvgs.delete_s,
onTap: deleteImage,
),
@ -202,8 +253,8 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
newImages.map((image) => image.toJson()).toList();
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images: imagesJson,
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
MultiImageBlockKeys.layout:
widget.node.attributes[MultiImageBlockKeys.layout],
});
await editorState.apply(transaction);
@ -242,12 +293,12 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
return;
}
newImages.addAll(images);
final imagesJson = newImages.map((image) => image.toJson()).toList();
final imagesJson =
[...images, ...newImages].map((i) => i.toJson()).toList();
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images: imagesJson,
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
MultiImageBlockKeys.layout:
widget.node.attributes[MultiImageBlockKeys.layout],
});
await editorState.apply(transaction);
@ -310,8 +361,8 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
final imagesJson = newImages.map((image) => image.toJson()).toList();
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images: imagesJson,
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
MultiImageBlockKeys.layout:
widget.node.attributes[MultiImageBlockKeys.layout],
});
await editorState.apply(transaction);
@ -330,3 +381,58 @@ class _Divider extends StatelessWidget {
);
}
}
class _LayoutSelector extends StatelessWidget {
const _LayoutSelector({
required this.selectedLayout,
required this.onSelected,
});
final MultiImageLayout selectedLayout;
final Function(MultiImageLayout) onSelected;
@override
Widget build(BuildContext context) {
return SeparatedRow(
separatorBuilder: () => const HSpace(6),
mainAxisSize: MainAxisSize.min,
children: MultiImageLayout.values
.map(
(layout) => MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => onSelected(layout),
child: Container(
height: 80,
width: 80,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(
width: 2,
color: selectedLayout == layout
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor,
),
borderRadius: Corners.s8Border,
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
FlowySvg(
layout.icon,
color: AFThemeExtension.of(context).strongText,
size: const Size.square(24),
),
const VSpace(6),
FlowyText(layout.label),
],
),
),
),
),
)
.toList(),
);
}
}

View File

@ -222,8 +222,9 @@ class MultiImagePlaceholderState extends State<MultiImagePlaceholder> {
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images: imagesJson,
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
MultiImageBlockKeys.layout:
widget.node.attributes[MultiImageBlockKeys.layout] ??
MultiImageLayout.browser.toIntValue(),
});
await editorState.apply(transaction);
@ -282,8 +283,9 @@ class MultiImagePlaceholderState extends State<MultiImagePlaceholder> {
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images:
images.map((image) => image.toJson()).toList(),
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
MultiImageBlockKeys.layout:
widget.node.attributes[MultiImageBlockKeys.layout] ??
MultiImageLayout.browser.toIntValue(),
});
await editorState.apply(transaction);
}

View File

@ -49,7 +49,7 @@ extension _StartWithsSort on List<InlineActionsResult> {
);
}
const _invalidSearchesAmount = 20;
const _invalidSearchesAmount = 10;
class InlineActionsHandler extends StatefulWidget {
const InlineActionsHandler({
@ -81,8 +81,6 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
final _focusNode = FocusNode(debugLabel: 'inline_actions_menu_handler');
final _scrollController = ScrollController();
Timer? _debounce;
late List<InlineActionsResult> results = widget.results;
int invalidCounter = 0;
late int startOffset;
@ -90,8 +88,7 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
String _search = '';
set search(String search) {
_search = search;
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 200), _doSearch);
_doSearch();
}
Future<void> _doSearch() async {
@ -109,10 +106,13 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
: 0;
if (invalidCounter >= _invalidSearchesAmount) {
widget.onDismiss();
// Workaround to bring focus back to editor
await widget.editorState
.updateSelectionWithReason(widget.editorState.selection);
return widget.onDismiss();
return;
}
_resetSelection();
@ -143,7 +143,6 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
void dispose() {
_scrollController.dispose();
_focusNode.dispose();
_debounce?.cancel();
super.dispose();
}

View File

@ -208,11 +208,6 @@ void _resolveFolderDeps(GetIt getIt) {
),
);
// Settings
getIt.registerFactoryParam<SettingsDialogBloc, UserProfilePB, void>(
(user, _) => SettingsDialogBloc(user),
);
// User
getIt.registerFactoryParam<SettingsUserViewBloc, UserProfilePB, void>(
(user, _) => SettingsUserViewBloc(user),

View File

@ -43,6 +43,9 @@ class SettingsAIBloc extends Bloc<SettingsAIEvent, SettingsAIState> {
emit(state.copyWith(userProfile: userProfile));
},
toggleAISearch: () {
emit(
state.copyWith(enableSearchIndexing: !state.enableSearchIndexing),
);
_updateUserWorkspaceSetting(
disableSearchIndexing:
!(state.aiSettings?.disableSearchIndexing ?? false),

View File

@ -1,7 +1,12 @@
import 'package:flutter/foundation.dart';
import 'package:appflowy/user/application/user_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@ -27,7 +32,8 @@ enum SettingsPage {
class SettingsDialogBloc
extends Bloc<SettingsDialogEvent, SettingsDialogState> {
SettingsDialogBloc(
this.userProfile, {
this.userProfile,
this.workspaceMember, {
SettingsPage? initPage,
}) : _userListener = UserListener(userProfile: userProfile),
super(SettingsDialogState.initial(userProfile, initPage)) {
@ -35,6 +41,7 @@ class SettingsDialogBloc
}
final UserProfilePB userProfile;
final WorkspaceMemberPB? workspaceMember;
final UserListener _userListener;
@override
@ -49,6 +56,12 @@ class SettingsDialogBloc
await event.when(
initial: () async {
_userListener.start(onProfileUpdated: _profileUpdated);
final isBillingEnabled =
await _isBillingEnabled(userProfile, workspaceMember);
if (isBillingEnabled) {
emit(state.copyWith(isBillingEnabled: true));
}
},
didReceiveUserProfile: (UserProfilePB newUserProfile) {
emit(state.copyWith(userProfile: newUserProfile));
@ -70,6 +83,45 @@ class SettingsDialogBloc
(err) => Log.error(err),
);
}
Future<bool> _isBillingEnabled(
UserProfilePB userProfile, [
WorkspaceMemberPB? member,
]) async {
if ([
AuthenticatorPB.Local,
AuthenticatorPB.Supabase,
].contains(userProfile.authenticator)) {
return false;
}
if (member == null || member.role != AFRolePB.Owner) {
return false;
}
if (kDebugMode) {
return true;
}
final result = await UserEventGetCloudConfig().send();
return result.fold(
(cloudSetting) {
final whiteList = [
"https://beta.appflowy.cloud",
"https://test.appflowy.cloud",
];
if (kDebugMode) {
whiteList.add("http://localhost:8000");
}
return whiteList.contains(cloudSetting.serverUrl);
},
(err) {
Log.error("Failed to get cloud config: $err");
return false;
},
);
}
}
@freezed
@ -87,6 +139,7 @@ class SettingsDialogState with _$SettingsDialogState {
const factory SettingsDialogState({
required UserProfilePB userProfile,
required SettingsPage page,
required bool isBillingEnabled,
}) = _SettingsDialogState;
factory SettingsDialogState.initial(
@ -96,5 +149,6 @@ class SettingsDialogState with _$SettingsDialogState {
SettingsDialogState(
userProfile: userProfile,
page: page ?? SettingsPage.account,
isBillingEnabled: false,
);
}

View File

@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart';
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
@ -7,9 +10,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:expandable/expandable.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class LocalAISetting extends StatefulWidget {
@ -108,20 +108,18 @@ class LocalAISettingHeader extends StatelessWidget {
value: isEnabled,
onChanged: (value) {
if (isEnabled) {
showDialog(
showConfirmDialog(
context: context,
barrierDismissible: false,
useRootNavigator: false,
builder: (dialogContext) {
return _ToggleLocalAIDialog(
onOkPressed: () {
context
.read<LocalAIToggleBloc>()
.add(const LocalAIToggleEvent.toggle());
},
onCancelPressed: () {},
);
},
title: LocaleKeys
.settings_aiPage_keys_disableLocalAITitle
.tr(),
description: LocaleKeys
.settings_aiPage_keys_disableLocalAIDescription
.tr(),
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () => context
.read<LocalAIToggleBloc>()
.add(const LocalAIToggleEvent.toggle()),
);
} else {
context
@ -138,24 +136,3 @@ class LocalAISettingHeader extends StatelessWidget {
);
}
}
class _ToggleLocalAIDialog extends StatelessWidget {
const _ToggleLocalAIDialog({
required this.onOkPressed,
required this.onCancelPressed,
});
final VoidCallback onOkPressed;
final VoidCallback onCancelPressed;
@override
Widget build(BuildContext context) {
return NavigatorOkCancelDialog(
message: LocaleKeys.settings_aiPage_keys_disableLocalAIDialog.tr(),
okTitle: LocaleKeys.button_confirm.tr(),
cancelTitle: LocaleKeys.button_cancel.tr(),
onOkPressed: onOkPressed,
onCancelPressed: onCancelPressed,
titleUpperCase: false,
);
}
}

View File

@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_billing_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_manage_data_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_view.dart';
@ -25,7 +25,7 @@ class SettingsDialog extends StatelessWidget {
required this.dismissDialog,
required this.didLogout,
required this.restartApp,
this.initPage,
this.initPage,
}) : super(key: ValueKey(user.id));
final VoidCallback dismissDialog;
@ -39,6 +39,7 @@ class SettingsDialog extends StatelessWidget {
return BlocProvider<SettingsDialogBloc>(
create: (context) => SettingsDialogBloc(
user,
context.read<UserWorkspaceBloc>().state.currentWorkspaceMember,
initPage: initPage,
)..add(const SettingsDialogEvent.initial()),
child: BlocBuilder<SettingsDialogBloc, SettingsDialogState>(
@ -60,6 +61,7 @@ class SettingsDialog extends StatelessWidget {
.add(SettingsDialogEvent.setSelectedPage(index)),
currentPage:
context.read<SettingsDialogBloc>().state.page,
isBillingEnabled: state.isBillingEnabled,
member: context
.read<UserWorkspaceBloc>()
.state

View File

@ -1,4 +1,3 @@
import 'package:appflowy_backend/log.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@ -13,7 +12,9 @@ import 'package:appflowy/workspace/presentation/settings/widgets/_restart_app_bu
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
@ -355,25 +356,25 @@ class BillingGateGuard extends StatelessWidget {
Future<bool> isBillingEnabled() async {
final result = await UserEventGetCloudConfig().send();
return result.fold((cloudSetting) {
final whiteList = [
"https://beta.appflowy.cloud",
"https://test.appflowy.cloud",
];
if (kDebugMode) {
whiteList.add("http://localhost:8000");
}
return result.fold(
(cloudSetting) {
final whiteList = [
"https://beta.appflowy.cloud",
"https://test.appflowy.cloud",
];
if (kDebugMode) {
whiteList.add("http://localhost:8000");
}
if (whiteList.contains(cloudSetting.serverUrl)) {
return true;
} else {
Log.warn(
"Billing is not enabled for this server:${cloudSetting.serverUrl}",
);
final isWhiteListed = whiteList.contains(cloudSetting.serverUrl);
if (!isWhiteListed) {
Log.warn("Billing is not enabled for server ${cloudSetting.serverUrl}");
}
return isWhiteListed;
},
(err) {
Log.error("Failed to get cloud config: $err");
return false;
}
}, (err) {
Log.error("Failed to get cloud config: $err");
return false;
});
},
);
}

View File

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart';
@ -17,12 +16,14 @@ class SettingsMenu extends StatelessWidget {
required this.changeSelectedPage,
required this.currentPage,
required this.userProfile,
required this.isBillingEnabled,
this.member,
});
final Function changeSelectedPage;
final SettingsPage currentPage;
final UserProfilePB userProfile;
final bool isBillingEnabled;
final WorkspaceMemberPB? member;
@override
@ -112,11 +113,7 @@ class SettingsMenu extends StatelessWidget {
),
changeSelectedPage: changeSelectedPage,
),
if (FeatureFlag.planBilling.isOn &&
userProfile.authenticator ==
AuthenticatorPB.AppFlowyCloud &&
member != null &&
member!.role.isOwner) ...[
if (FeatureFlag.planBilling.isOn && isBillingEnabled) ...[
SettingsMenuElement(
page: SettingsPage.plan,
selectedPage: currentPage,

View File

@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
@ -14,7 +16,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart';
@ -190,7 +191,7 @@ class InteractiveImageToolbar extends StatelessWidget {
tooltip: LocaleKeys
.document_imageBlock_interactiveViewer_toolbar_closeViewer
.tr(),
icon: FlowySvgs.close_s,
icon: FlowySvgs.close_viewer_s,
onTap: () => Navigator.of(context).pop(),
),
],
@ -291,11 +292,12 @@ class _ToolbarItem extends StatelessWidget {
isDisabled ? Colors.transparent : Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
width: 32,
height: 32,
padding: const EdgeInsets.all(8),
child: FlowySvg(
icon,
size: const Size.square(16),
color: isDisabled ? Colors.grey : Colors.white,
),
),

View File

@ -110,10 +110,14 @@ class _InteractiveImageViewerState extends State<InteractiveImageViewer> {
child: SizedBox(
height: size.height,
width: size.width,
child: widget.imageProvider.renderImage(
context,
currentIndex,
userProfile,
child: GestureDetector(
// We can consider adding zoom behavior instead in a later iteration
onDoubleTap: () => Navigator.of(context).pop(),
child: widget.imageProvider.renderImage(
context,
currentIndex,
userProfile,
),
),
),
),

View File

@ -806,6 +806,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.1"
flutter_staggered_grid_view:
dependency: "direct main"
description:
name: flutter_staggered_grid_view
sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_sticky_header:
dependency: transitive
description:

View File

@ -149,6 +149,8 @@ dependencies:
desktop_drop: ^0.4.4
cross_file: ^0.3.4+1
flutter_staggered_grid_view: ^0.7.0
# Window Manager for MacOS and Linux
window_manager: ^0.3.9

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="11.6765" y="3.27295" width="1.48562" height="11.8849" rx="0.742808" transform="rotate(45 11.6765 3.27295)" fill="#333333"/>
<rect x="12.7271" y="11.6766" width="1.48562" height="11.8849" rx="0.742808" transform="rotate(135 12.7271 11.6766)" fill="#333333"/>
</svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M215.74-528Q186-528 165-549.5 144-571 144-600v-144q0-29.7 21.18-50.85Q186.35-816 216.09-816h144.17Q390-816 411-794.85q21 21.15 21 50.85v144q0 29-21.18 50.5-21.17 21.5-50.91 21.5H215.74Zm0 384Q186-144 165-165.18q-21-21.17-21-50.91v-144.17Q144-390 165.18-411q21.17-21 50.91-21h144.17Q390-432 411-410.82q21 21.17 21 50.91v144.17Q432-186 410.82-165q-21.17 21-50.91 21H215.74ZM600-528q-29 0-50.5-21.5T528-600v-144q0-29.7 21.5-50.85Q571-816 600-816h144q29.7 0 50.85 21.15Q816-773.7 816-744v144q0 29-21.15 50.5T744-528H600Zm0 384q-29 0-50.5-21.18-21.5-21.17-21.5-50.91v-144.17Q528-390 549.5-411q21.5-21 50.5-21h144q29.7 0 50.85 21.18Q816-389.65 816-359.91v144.17Q816-186 794.85-165 773.7-144 744-144H600ZM216-600h144v-144H216v144Zm384 0h144v-144H600v144Zm0 384h144v-144H600v144Zm-384 0h144v-144H216v144Zm384-384Zm0 240Zm-240 0Zm0-240Z"/></svg>

After

Width:  |  Height:  |  Size: 952 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M96-384v-192q0-29.7 21.21-50.85 21.21-21.15 51-21.15T219-626.85q21 21.15 21 50.85v192q0 29.7-21.21 50.85-21.21 21.15-51 21.15T117-333.15Q96-354.3 96-384Zm264 144q-33 0-52.5-19.5T288-312v-336q0-33 19.5-52.5T360-720h240q33 0 52.5 19.5T672-648v336q0 33-19.5 52.5T600-240H360Zm360-144v-192q0-29.7 21.21-50.85 21.21-21.15 51-21.15T843-626.85q21 21.15 21 50.85v192q0 29.7-21.21 50.85-21.21 21.15-51 21.15T741-333.15Q720-354.3 720-384Zm-360 72h240v-336H360v336Zm120-168Z"/></svg>

After

Width:  |  Height:  |  Size: 588 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M216-144q-29 0-50.5-21.5T144-216v-528q0-29.7 21.5-50.85Q187-816 216-816h528q29.7 0 50.85 21.15Q816-773.7 816-744v528q0 29-21.15 50.5T744-144H216Zm0-72h228v-528H216v528Zm300 0h228v-264H516v264Zm0-336h228v-192H516v192Z"/></svg>

After

Width:  |  Height:  |  Size: 341 B

View File

@ -664,7 +664,8 @@
"localAIStopped": "Local AI stopped",
"failToLoadLocalAI": "Failed to start local AI",
"restartLocalAI": "Restart Local AI",
"disableLocalAIDialog": "Do you want to disable local AI?",
"disableLocalAITitle": "Disable local AI",
"disableLocalAIDescription": "Do you want to disable local AI?",
"localAIToggleTitle": "Toggle to enable or disable local AI",
"fetchLocalModel": "Fetch local model configuration",
"openModelDirectory": "Open folder"
@ -1505,7 +1506,11 @@
"photoKeyword": "photo",
"photoBrowserKeyword": "photo browser",
"galleryKeyword": "gallery",
"addImageTooltip": "Add image"
"addImageTooltip": "Add image",
"changeLayoutTooltip": "Change layout",
"browserLayout": "Browser",
"gridLayout": "Grid",
"deleteBlockTooltip": "Delete whole gallery"
},
"math": {
"copiedToPasteBoard": "The math equation has been copied to the clipboard"