diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b58100e9..954e319b52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Release Notes +## Version 0.0.5.3 - 09/26/2022 + +New features +- Open the next page automatically after deleting the current page +- Refresh the Kanban board after altering a property type + +### Bug Fixes +- Fix switch board bug +- Fix delete the Kanban board's row error +- Remove duplicate time format +- Fix can't delete field in property edit panel +- Adjust some display UI issues + + +## Version 0.0.5.2 - 09/16/2022 + +New features +- Enable adding a new card to the "No Status" group +- Fix some bugs + +### Bug Fixes +- Fix cannot open AppFlowy error +- Fix delete the Kanban board's row error + + ## Version 0.0.5.1 - 09/14/2022 New features diff --git a/frontend/app_flowy/assets/translations/en.json b/frontend/app_flowy/assets/translations/en.json index cd7932945b..2381d5b288 100644 --- a/frontend/app_flowy/assets/translations/en.json +++ b/frontend/app_flowy/assets/translations/en.json @@ -199,7 +199,8 @@ "delete": "Delete", "textPlaceholder": "Empty", "copyProperty": "Copied property to clipboard", - "count": "Count" + "count": "Count", + "newRow": "New row" }, "selectOption": { "create": "Create", @@ -231,4 +232,4 @@ "create_new_card": "New" } } -} \ No newline at end of file +} diff --git a/frontend/app_flowy/assets/translations/fr-FR.json b/frontend/app_flowy/assets/translations/fr-FR.json index 2a3ad60b7b..a35f854523 100644 --- a/frontend/app_flowy/assets/translations/fr-FR.json +++ b/frontend/app_flowy/assets/translations/fr-FR.json @@ -149,7 +149,7 @@ "grid": { "settings": { "filter": "Filtrer", - "sortBy": "Trier par", + "sortBy": "Filtrer par", "Properties": "Propriétés" }, "field": { diff --git a/frontend/app_flowy/assets/translations/zh-CN.json b/frontend/app_flowy/assets/translations/zh-CN.json index f5fee6ab23..6271d138cd 100644 --- a/frontend/app_flowy/assets/translations/zh-CN.json +++ b/frontend/app_flowy/assets/translations/zh-CN.json @@ -94,7 +94,14 @@ }, "tooltip": { "lightMode": "切换到亮色模式", - "darkMode": "切换到暗色模式" + "darkMode": "切换到暗色模式", + "openAsPage": "作为页面打开", + "addNewRow": "增加一行", + "openMenu": "点击打开菜单" + }, + "sideBar": { + "openSidebar": "打开侧边栏", + "closeSidebar": "关闭侧边栏" }, "notifications": { "export": { @@ -149,15 +156,12 @@ "darkLabel": "夜间模式" } }, - "sideBar": { - "openSidebar": "打开侧边栏", - "closeSidebar": "关闭侧边栏" - }, "grid": { "settings": { "filter": "过滤器", "sortBy": "排序", - "Properties": "属性" + "Properties": "属性", + "group": "组" }, "field": { "hide": "隐藏", @@ -186,13 +190,17 @@ "addSelectOption": "添加一个标签", "optionTitle": "标签", "addOption": "添加标签", - "editProperty": "编辑列属性" + "editProperty": "编辑列属性", + "newColumn": "增加一列", + "deleteFieldPromptMessage": "确定要删除这个属性吗? " }, "row": { "duplicate": "复制", "delete": "删除", "textPlaceholder": "空", - "copyProperty": "复制列" + "copyProperty": "复制列", + "count": "数量", + "newRow": "添加一行" }, "selectOption": { "create": "新建", @@ -218,5 +226,11 @@ "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" } + }, + "board": { + "column": { + "create_new_card": "新建" + }, + "menuName": "看板" } } \ No newline at end of file diff --git a/frontend/app_flowy/lib/plugins/board/application/board_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/board_bloc.dart index 673c159c8d..afb04e8f38 100644 --- a/frontend/app_flowy/lib/plugins/board/application/board_bloc.dart +++ b/frontend/app_flowy/lib/plugins/board/application/board_bloc.dart @@ -36,21 +36,21 @@ class BoardBloc extends Bloc { super(BoardState.initial(view.id)) { boardController = AppFlowyBoardController( onMoveGroup: ( - fromColumnId, + fromGroupId, fromIndex, - toColumnId, + toGroupId, toIndex, ) { - _moveGroup(fromColumnId, toColumnId); + _moveGroup(fromGroupId, toGroupId); }, onMoveGroupItem: ( - columnId, + groupId, fromIndex, toIndex, ) { - final fromRow = groupControllers[columnId]?.rowAtIndex(fromIndex); - final toRow = groupControllers[columnId]?.rowAtIndex(toIndex); - _moveRow(fromRow, columnId, toRow); + final fromRow = groupControllers[groupId]?.rowAtIndex(fromIndex); + final toRow = groupControllers[groupId]?.rowAtIndex(toIndex); + _moveRow(fromRow, groupId, toRow); }, onMoveGroupItemToGroup: ( fromGroupId, diff --git a/frontend/app_flowy/lib/plugins/board/board.dart b/frontend/app_flowy/lib/plugins/board/board.dart index 73fff5b639..a53739f00a 100644 --- a/frontend/app_flowy/lib/plugins/board/board.dart +++ b/frontend/app_flowy/lib/plugins/board/board.dart @@ -68,9 +68,11 @@ class GridPluginDisplay extends PluginDisplay { @override Widget buildWidget(PluginContext context) { notifier.isDeleted.addListener(() { - if (notifier.isDeleted.value) { - context.onDeleted(view); - } + notifier.isDeleted.value.fold(() => null, (deletedView) { + if (deletedView.hasIndex()) { + context.onDeleted(view, deletedView.index); + } + }); }); return BoardPage(key: ValueKey(view.id), view: view); diff --git a/frontend/app_flowy/lib/plugins/board/presentation/board_page.dart b/frontend/app_flowy/lib/plugins/board/presentation/board_page.dart index b6d92e2781..dd5e619b46 100644 --- a/frontend/app_flowy/lib/plugins/board/presentation/board_page.dart +++ b/frontend/app_flowy/lib/plugins/board/presentation/board_page.dart @@ -69,7 +69,6 @@ class BoardContent extends StatefulWidget { class _BoardContentState extends State { late AppFlowyBoardScrollController scrollManager; - final Map cardKeysCache = {}; final config = AppFlowyBoardConfig( groupBackgroundColor: HexColor.fromHex('#F7F8FC'), @@ -104,8 +103,8 @@ class _BoardContentState extends State { Widget _buildBoard(BuildContext context) { return ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), - child: Selector( + value: Provider.of(context, listen: true), + child: Selector( selector: (ctx, notifier) => notifier.theme, builder: (ctx, theme, child) => Expanded( child: AppFlowyBoard( @@ -139,7 +138,7 @@ class _BoardContentState extends State { .read() .add(BoardEvent.endEditRow(editingRow.row.id)); } else { - scrollManager.scrollToBottom(editingRow.columnId, () { + scrollManager.scrollToBottom(editingRow.columnId, (boardContext) { context .read() .add(BoardEvent.endEditRow(editingRow.row.id)); @@ -247,15 +246,8 @@ class _BoardContentState extends State { ); final groupItemId = columnItem.id + group.id; - ValueKey? key = cardKeysCache[groupItemId]; - if (key == null) { - final newKey = ValueKey(groupItemId); - cardKeysCache[groupItemId] = newKey; - key = newKey; - } - return AppFlowyGroupCard( - key: key, + key: ValueKey(groupItemId), margin: config.cardPadding, decoration: _makeBoxDecoration(context), child: BoardCard( @@ -331,8 +323,8 @@ class _ToolbarBlocAdaptor extends StatelessWidget { ); return ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), - child: Selector( + value: Provider.of(context, listen: true), + child: Selector( selector: (ctx, notifier) => notifier.theme, builder: (ctx, theme, child) { return BoardToolbar(toolbarContext: toolbarContext); diff --git a/frontend/app_flowy/lib/plugins/doc/document.dart b/frontend/app_flowy/lib/plugins/doc/document.dart index b9cd952bd8..d186513acc 100644 --- a/frontend/app_flowy/lib/plugins/doc/document.dart +++ b/frontend/app_flowy/lib/plugins/doc/document.dart @@ -74,15 +74,26 @@ class DocumentPlugin extends Plugin { class DocumentPluginDisplay extends PluginDisplay with NavigationItem { final ViewPluginNotifier notifier; ViewPB get view => notifier.view; + int? deletedViewIndex; DocumentPluginDisplay({required this.notifier, Key? key}); @override - Widget buildWidget(PluginContext context) => DocumentPage( - view: view, - onDeleted: () => context.onDeleted(view), - key: ValueKey(view.id), - ); + Widget buildWidget(PluginContext context) { + notifier.isDeleted.addListener(() { + notifier.isDeleted.value.fold(() => null, (deletedView) { + if (deletedView.hasIndex()) { + deletedViewIndex = deletedView.index; + } + }); + }); + + return DocumentPage( + view: view, + onDeleted: () => context.onDeleted(view, deletedViewIndex), + key: ValueKey(view.id), + ); + } @override Widget get leftBarItem => ViewLeftBarItem(view: view); @@ -120,8 +131,8 @@ class DocumentShareButton extends StatelessWidget { child: BlocBuilder( builder: (context, state) { return ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), - child: Selector( + value: Provider.of(context, listen: true), + child: Selector( selector: (ctx, notifier) => notifier.locale, builder: (ctx, _, child) => ConstrainedBox( constraints: const BoxConstraints.expand( diff --git a/frontend/app_flowy/lib/plugins/doc/document_page.dart b/frontend/app_flowy/lib/plugins/doc/document_page.dart index 1d745982be..8593766087 100644 --- a/frontend/app_flowy/lib/plugins/doc/document_page.dart +++ b/frontend/app_flowy/lib/plugins/doc/document_page.dart @@ -134,7 +134,7 @@ class _DocumentPageState extends State { Widget _renderToolbar(quill.QuillController controller) { return ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), + value: Provider.of(context, listen: true), child: EditorToolbar.basic( controller: controller, ), diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_format_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_format_bloc.dart index 7086f3385a..870eb2cdef 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_format_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_format_bloc.dart @@ -11,7 +11,10 @@ class NumberFormatBloc extends Bloc { event.map(setFilter: (_SetFilter value) { final List formats = List.from(NumberFormat.values); if (value.filter.isNotEmpty) { - formats.retainWhere((element) => element.title().toLowerCase().contains(value.filter.toLowerCase())); + formats.retainWhere((element) => element + .title() + .toLowerCase() + .contains(value.filter.toLowerCase())); } emit(state.copyWith(formats: formats, filter: value.filter)); }); @@ -91,7 +94,7 @@ extension NumberFormatExtension on NumberFormat { case NumberFormat.Percent: return "Percent"; case NumberFormat.PhilippinePeso: - return "Percent"; + return "PhilippinePeso"; case NumberFormat.Pound: return "Pound"; case NumberFormat.Rand: diff --git a/frontend/app_flowy/lib/plugins/grid/grid.dart b/frontend/app_flowy/lib/plugins/grid/grid.dart index 45dabb6fb5..5d7f4b3bdb 100644 --- a/frontend/app_flowy/lib/plugins/grid/grid.dart +++ b/frontend/app_flowy/lib/plugins/grid/grid.dart @@ -70,9 +70,11 @@ class GridPluginDisplay extends PluginDisplay { @override Widget buildWidget(PluginContext context) { notifier.isDeleted.addListener(() { - if (notifier.isDeleted.value) { - context.onDeleted(view); - } + notifier.isDeleted.value.fold(() => null, (deletedView) { + if (deletedView.hasIndex()) { + context.onDeleted(view, deletedView.index); + } + }); }); return GridPage(key: ValueKey(view.id), view: view); diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/url_cell.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/url_cell.dart index 5ed184aafb..04d10f9aa2 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/url_cell.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/url_cell.dart @@ -97,7 +97,6 @@ class GridURLCell extends GridCellWidget { class _GridURLCellState extends GridCellState { final _popoverController = PopoverController(); - GridURLCellController? _cellContext; late URLCellBloc _cellBloc; @override @@ -132,6 +131,7 @@ class _GridURLCellState extends GridCellState { controller: _popoverController, constraints: BoxConstraints.loose(const Size(300, 160)), direction: PopoverDirection.bottomWithLeftAligned, + triggerActions: PopoverTriggerFlags.none, offset: const Offset(0, 20), child: SizedBox.expand( child: GestureDetector( @@ -144,7 +144,8 @@ class _GridURLCellState extends GridCellState { ), popupBuilder: (BuildContext popoverContext) { return URLEditorPopover( - cellController: _cellContext!, + cellController: widget.cellControllerBuilder.build() + as GridURLCellController, ); }, onClose: () { @@ -166,17 +167,13 @@ class _GridURLCellState extends GridCellState { final uri = Uri.parse(url); if (url.isNotEmpty && await canLaunchUrl(uri)) { await launchUrl(uri); - } else { - _cellContext = - widget.cellControllerBuilder.build() as GridURLCellController; - widget.onCellEditing.value = true; - _popoverController.show(); } } @override void requestBeginFocus() { - _openUrlOrEdit(_cellBloc.state.url); + widget.onCellEditing.value = true; + _popoverController.show(); } @override diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/footer/grid_footer.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/footer/grid_footer.dart index 27d6a59cd6..338ada57fe 100755 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/footer/grid_footer.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/footer/grid_footer.dart @@ -1,4 +1,6 @@ +import 'package:app_flowy/generated/locale_keys.g.dart'; import 'package:app_flowy/plugins/grid/application/grid_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; @@ -13,7 +15,7 @@ class GridAddRowButton extends StatelessWidget { Widget build(BuildContext context) { final theme = context.watch(); return FlowyButton( - text: const FlowyText.medium('New row', fontSize: 12), + text: FlowyText.medium(LocaleKeys.grid_row_newRow.tr(), fontSize: 12), hoverColor: theme.shader6, onTap: () => context.read().add(const GridEvent.createRow()), leftIcon: svgWidget("home/add", color: theme.iconColor), diff --git a/frontend/app_flowy/lib/plugins/trash/menu.dart b/frontend/app_flowy/lib/plugins/trash/menu.dart index 15cfc398a1..73fff65ba3 100644 --- a/frontend/app_flowy/lib/plugins/trash/menu.dart +++ b/frontend/app_flowy/lib/plugins/trash/menu.dart @@ -33,8 +33,8 @@ class MenuTrash extends StatelessWidget { Widget _render(BuildContext context) { return Row(children: [ ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), - child: Selector( + value: Provider.of(context, listen: true), + child: Selector( selector: (ctx, notifier) => notifier.theme, builder: (ctx, theme, child) => SizedBox( width: 16, @@ -44,8 +44,8 @@ class MenuTrash extends StatelessWidget { ), const HSpace(6), ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), - child: Selector( + value: Provider.of(context, listen: true), + child: Selector( selector: (ctx, notifier) => notifier.locale, builder: (ctx, _, child) => FlowyText.medium(LocaleKeys.trash_text.tr(), fontSize: 12), diff --git a/frontend/app_flowy/lib/plugins/util.dart b/frontend/app_flowy/lib/plugins/util.dart index 9d3e5bf528..db57d7f526 100644 --- a/frontend/app_flowy/lib/plugins/util.dart +++ b/frontend/app_flowy/lib/plugins/util.dart @@ -1,15 +1,16 @@ import 'package:app_flowy/startup/plugin/plugin.dart'; import 'package:app_flowy/workspace/application/view/view_listener.dart'; +import 'package:dartz/dartz.dart'; import 'package:flowy_sdk/log.dart'; import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; -class ViewPluginNotifier extends PluginNotifier { +class ViewPluginNotifier extends PluginNotifier> { final ViewListener? _viewListener; ViewPB view; @override - final ValueNotifier isDeleted = ValueNotifier(false); + final ValueNotifier> isDeleted = ValueNotifier(none()); @override final ValueNotifier isDisplayChanged = ValueNotifier(0); @@ -27,9 +28,7 @@ class ViewPluginNotifier extends PluginNotifier { ); }, onViewMoveToTrash: (result) { result.fold( - (deletedView) { - isDeleted.value = true; - }, + (deletedView) => isDeleted.value = some(deletedView), (err) => Log.error(err), ); }); diff --git a/frontend/app_flowy/lib/startup/plugin/plugin.dart b/frontend/app_flowy/lib/startup/plugin/plugin.dart index 9c65f804d3..a8f58562fb 100644 --- a/frontend/app_flowy/lib/startup/plugin/plugin.dart +++ b/frontend/app_flowy/lib/startup/plugin/plugin.dart @@ -32,9 +32,9 @@ abstract class Plugin { } } -abstract class PluginNotifier { +abstract class PluginNotifier { /// Notify if the plugin get deleted - ValueNotifier get isDeleted; + ValueNotifier get isDeleted; /// Notify if the [PluginDisplay]'s content was changed ValueNotifier get isDisplayChanged; @@ -67,7 +67,7 @@ abstract class PluginDisplay with NavigationItem { class PluginContext { // calls when widget of the plugin get deleted - final Function(ViewPB) onDeleted; + final Function(ViewPB, int?) onDeleted; PluginContext({required this.onDeleted}); } diff --git a/frontend/app_flowy/lib/startup/tasks/app_widget.dart b/frontend/app_flowy/lib/startup/tasks/app_widget.dart index 9ca28e2f36..7ff53efeff 100644 --- a/frontend/app_flowy/lib/startup/tasks/app_widget.dart +++ b/frontend/app_flowy/lib/startup/tasks/app_widget.dart @@ -17,8 +17,8 @@ class InitAppWidgetTask extends LaunchTask { @override Future initialize(LaunchContext context) async { final widget = context.getIt().create(); - final setting = await UserSettingsService().getAppearanceSettings(); - final settingModel = AppearanceSettingModel(setting); + final setting = await SettingsFFIService().getAppearanceSetting(); + final settingModel = AppearanceSetting(setting); final app = ApplicationWidget( settingModel: settingModel, child: widget, @@ -58,7 +58,7 @@ class InitAppWidgetTask extends LaunchTask { class ApplicationWidget extends StatelessWidget { final Widget child; - final AppearanceSettingModel settingModel; + final AppearanceSetting settingModel; const ApplicationWidget({ Key? key, @@ -75,10 +75,10 @@ class ApplicationWidget extends StatelessWidget { const minWidth = 600.0; setWindowMinSize(const Size(minWidth, minWidth / ratio)); settingModel.readLocaleWhenAppLaunch(context); - AppTheme theme = context.select( + AppTheme theme = context.select( (value) => value.theme, ); - Locale locale = context.select( + Locale locale = context.select( (value) => value.locale, ); diff --git a/frontend/app_flowy/lib/user/application/user_settings_service.dart b/frontend/app_flowy/lib/user/application/user_settings_service.dart index 28309d202c..b420af505c 100644 --- a/frontend/app_flowy/lib/user/application/user_settings_service.dart +++ b/frontend/app_flowy/lib/user/application/user_settings_service.dart @@ -4,8 +4,8 @@ import 'package:flowy_sdk/flowy_sdk.dart'; import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-user/user_setting.pb.dart'; -class UserSettingsService { - Future getAppearanceSettings() async { +class SettingsFFIService { + Future getAppearanceSetting() async { final result = await UserEventGetAppearanceSetting().send(); return result.fold( @@ -18,7 +18,8 @@ class UserSettingsService { ); } - Future> setAppearanceSettings(AppearanceSettingsPB settings) { - return UserEventSetAppearanceSetting(settings).send(); + Future> setAppearanceSetting( + AppearanceSettingsPB setting) { + return UserEventSetAppearanceSetting(setting).send(); } } diff --git a/frontend/app_flowy/lib/workspace/application/appearance.dart b/frontend/app_flowy/lib/workspace/application/appearance.dart index a9b498fb00..1d3a13c7af 100644 --- a/frontend/app_flowy/lib/workspace/application/appearance.dart +++ b/frontend/app_flowy/lib/workspace/application/appearance.dart @@ -8,72 +8,114 @@ import 'package:flowy_sdk/protobuf/flowy-user/user_setting.pb.dart'; import 'package:flutter/material.dart'; import 'package:easy_localization/easy_localization.dart'; -class AppearanceSettingModel extends ChangeNotifier with EquatableMixin { - AppearanceSettingsPB setting; +/// [AppearanceSetting] is used to modify the appear setting of AppFlowy application. Including the [Locale], [AppTheme], etc. +class AppearanceSetting extends ChangeNotifier with EquatableMixin { + final AppearanceSettingsPB _setting; AppTheme _theme; Locale _locale; - Timer? _saveOperation; + Timer? _debounceSaveOperation; - AppearanceSettingModel(this.setting) - : _theme = AppTheme.fromName(name: setting.theme), - _locale = - Locale(setting.locale.languageCode, setting.locale.countryCode); + AppearanceSetting(AppearanceSettingsPB setting) + : _setting = setting, + _theme = AppTheme.fromName(name: setting.theme), + _locale = Locale( + setting.locale.languageCode, + setting.locale.countryCode, + ); + /// Returns the current [AppTheme] AppTheme get theme => _theme; + + /// Returns the current [Locale] Locale get locale => _locale; - Future save() async { - _saveOperation?.cancel(); - _saveOperation = Timer(const Duration(seconds: 2), () async { - await UserSettingsService().setAppearanceSettings(setting); - }); - } - - @override - List get props { - return [setting.hashCode]; - } - - void swapTheme() { - final themeType = - (_theme.ty == ThemeType.light ? ThemeType.dark : ThemeType.light); - - if (_theme.ty != themeType) { - _theme = AppTheme.fromType(themeType); - setting.theme = themeTypeToString(themeType); - notifyListeners(); - save(); + /// Updates the current theme and notify the listeners the theme was changed. + /// Do nothing if the passed in themeType equal to the current theme type. + /// + void setTheme(ThemeType themeType) { + if (_theme.ty == themeType) { + return; } + + _theme = AppTheme.fromType(themeType); + _setting.theme = themeTypeToString(themeType); + _saveAppearSetting(); + + notifyListeners(); } + /// Updates the current locale and notify the listeners the locale was changed + /// Fallback to [en] locale If the newLocale is not supported. + /// void setLocale(BuildContext context, Locale newLocale) { if (!context.supportedLocales.contains(newLocale)) { - Log.warn("Unsupported locale: $newLocale"); + Log.warn("Unsupported locale: $newLocale, Fallback to locale: en"); newLocale = const Locale('en'); - Log.debug("Fallback to locale: $newLocale"); } context.setLocale(newLocale); if (_locale != newLocale) { _locale = newLocale; - setting.locale.languageCode = _locale.languageCode; - setting.locale.countryCode = _locale.countryCode ?? ""; + _setting.locale.languageCode = _locale.languageCode; + _setting.locale.countryCode = _locale.countryCode ?? ""; + _saveAppearSetting(); + notifyListeners(); - save(); } } - void readLocaleWhenAppLaunch(BuildContext context) { - if (setting.resetAsDefault) { - setting.resetAsDefault = false; - save(); + /// Saves key/value setting to disk. + /// Removes the key if the passed in value is null + void setKeyValue(String key, String? value) { + if (key.isEmpty) { + Log.warn("The key should not be empty"); + return; + } + if (value == null) { + _setting.settingKeyValue.remove(key); + } + + if (_setting.settingKeyValue[key] != value) { + if (value == null) { + _setting.settingKeyValue.remove(key); + } else { + _setting.settingKeyValue[key] = value; + } + + _saveAppearSetting(); + notifyListeners(); + } + } + + /// Called when the application launch. + /// Uses the device locale when open the application for the first time + void readLocaleWhenAppLaunch(BuildContext context) { + if (_setting.resetToDefault) { + _setting.resetToDefault = false; + _saveAppearSetting(); setLocale(context, context.deviceLocale); return; } - // when opening app the first time setLocale(context, _locale); } + + Future _saveAppearSetting() async { + _debounceSaveOperation?.cancel(); + _debounceSaveOperation = Timer( + const Duration(seconds: 1), + () { + SettingsFFIService().setAppearanceSetting(_setting).then((result) { + result.fold((l) => null, (error) => Log.error(error)); + }); + }, + ); + } + + @override + List get props { + return [_setting.hashCode]; + } } diff --git a/frontend/app_flowy/lib/workspace/application/view/view_listener.dart b/frontend/app_flowy/lib/workspace/application/view/view_listener.dart index 7629b71edc..6c575375f3 100644 --- a/frontend/app_flowy/lib/workspace/application/view/view_listener.dart +++ b/frontend/app_flowy/lib/workspace/application/view/view_listener.dart @@ -16,7 +16,7 @@ typedef UpdateViewNotifiedValue = Either; // Restore the view from trash typedef RestoreViewNotifiedValue = Either; // Move the view to trash -typedef MoveToTrashNotifiedValue = Either; +typedef MoveToTrashNotifiedValue = Either; class ViewListener { StreamSubscription? _subscription; @@ -98,8 +98,8 @@ class ViewListener { break; case FolderNotification.ViewMoveToTrash: result.fold( - (payload) => - _moveToTrashNotifier.value = left(ViewIdPB.fromBuffer(payload)), + (payload) => _moveToTrashNotifier.value = + left(DeletedViewPB.fromBuffer(payload)), (error) => _moveToTrashNotifier.value = right(error), ); break; diff --git a/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart b/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart index 657aa64691..1d46ade005 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart @@ -34,19 +34,6 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - ViewPB? initialView; - - @override - void initState() { - super.initState(); - } - - @override - void didUpdateWidget(covariant HomeScreen oldWidget) { - initialView = null; - super.didUpdateWidget(oldWidget); - } - @override Widget build(BuildContext context) { return MultiBlocProvider( @@ -129,26 +116,29 @@ class _HomeScreenState extends State { required BuildContext context, required HomeState state}) { final workspaceSetting = state.workspaceSetting; - if (initialView == null && workspaceSetting.hasLatestView()) { - initialView = workspaceSetting.latestView; - final plugin = makePlugin( - pluginType: initialView!.pluginType, - data: initialView, - ); - getIt().setPlugin(plugin); - } - final homeMenu = HomeMenu( user: widget.user, workspaceSetting: workspaceSetting, collapsedNotifier: getIt().collapsedNotifier, ); - final latestView = - workspaceSetting.hasLatestView() ? workspaceSetting.latestView : null; - if (getIt().latestOpenView == null) { - /// AppFlowy will open the view that the last time the user opened it. The _buildHomeMenu will get called when AppFlowy's screen resizes. So we only set the latestOpenView when it's null. - getIt().latestOpenView = latestView; + // Only open the last opened view if the [HomeStackManager] current opened + // plugin is blank and the last opened view is not null. + // + // All opened widgets that display on the home screen are in the form + // of plugins. There is a list of built-in plugins defined in the + // [PluginType] enum, including board, grid and trash. + if (getIt().plugin.ty == PluginType.blank) { + // Open the last opened view. + if (workspaceSetting.hasLatestView()) { + final view = workspaceSetting.latestView; + final plugin = makePlugin( + pluginType: view.pluginType, + data: view, + ); + getIt().setPlugin(plugin); + getIt().latestOpenView = view; + } } return FocusTraversalGroup(child: RepaintBoundary(child: homeMenu)); @@ -261,14 +251,18 @@ class HomeScreenStackAdaptor extends HomeStackDelegate { }); @override - void didDeleteStackWidget(ViewPB view) { + void didDeleteStackWidget(ViewPB view, int? index) { final homeService = HomeService(); homeService.readApp(appId: view.appId).then((result) { result.fold( (appPB) { final List views = appPB.belongings.items; if (views.isNotEmpty) { - final lastView = views.last; + var lastView = views.last; + if (index != null && index != 0 && views.length > index - 1) { + lastView = views[index - 1]; + } + final plugin = makePlugin( pluginType: lastView.pluginType, data: lastView, diff --git a/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart b/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart index a26565b566..4ae0420b36 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart @@ -18,7 +18,7 @@ import 'home_layout.dart'; typedef NavigationCallback = void Function(String id); abstract class HomeStackDelegate { - void didDeleteStackWidget(ViewPB view); + void didDeleteStackWidget(ViewPB view, int? index); } class HomeStack extends StatelessWidget { @@ -41,9 +41,11 @@ class HomeStack extends StatelessWidget { child: Container( color: theme.surface, child: FocusTraversalGroup( - child: getIt().stackWidget(onDeleted: (view) { - delegate.didDeleteStackWidget(view); - }), + child: getIt().stackWidget( + onDeleted: (view, index) { + delegate.didDeleteStackWidget(view, index); + }, + ), ), ), ), @@ -144,6 +146,7 @@ class HomeStackManager { } PublishNotifier get collapsedNotifier => _notifier.collapsedNotifier; + Plugin get plugin => _notifier.plugin; void setPlugin(Plugin newPlugin) { _notifier.plugin = newPlugin; @@ -167,7 +170,7 @@ class HomeStackManager { ); } - Widget stackWidget({required Function(ViewPB) onDeleted}) { + Widget stackWidget({required Function(ViewPB, int?) onDeleted}) { return MultiProvider( providers: [ChangeNotifierProvider.value(value: _notifier)], child: Consumer(builder: (ctx, HomeStackNotifier notifier, child) { diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/menu_app.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/menu_app.dart index 7c2e2e4d87..c7ee59d03e 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/menu_app.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/menu_app.dart @@ -89,8 +89,7 @@ class _MenuAppState extends State { hasIcon: false, ), header: ChangeNotifierProvider.value( - value: - Provider.of(context, listen: true), + value: Provider.of(context, listen: true), child: MenuAppHeader(widget.app), ), expanded: ViewSection(appViewData: viewDataContext), diff --git a/frontend/app_flowy/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/app_flowy/lib/workspace/presentation/settings/settings_dialog.dart index 6de69029a5..42a51b7da1 100644 --- a/frontend/app_flowy/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/app_flowy/lib/workspace/presentation/settings/settings_dialog.dart @@ -33,8 +33,7 @@ class SettingsDialog extends StatelessWidget { ..add(const SettingsDialogEvent.initial()), child: BlocBuilder( builder: (context, state) => ChangeNotifierProvider.value( - value: Provider.of(context, - listen: true), + value: Provider.of(context, listen: true), child: FlowyDialog( title: Text( LocaleKeys.settings_title.tr(), diff --git a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart index 85b78ae3b0..ea885fc86a 100644 --- a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart +++ b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart @@ -13,7 +13,7 @@ class SettingsAppearanceView extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = context.watch(); + final theme = context.read(); return SingleChildScrollView( child: Column( @@ -30,9 +30,7 @@ class SettingsAppearanceView extends StatelessWidget { ), Toggle( value: theme.isDark, - onChanged: (val) { - context.read().swapTheme(); - }, + onChanged: (_) => setTheme(context), style: ToggleStyle.big(theme), ), Text( @@ -48,4 +46,13 @@ class SettingsAppearanceView extends StatelessWidget { ), ); } + + void setTheme(BuildContext context) { + final theme = context.read(); + if (theme.isDark) { + context.read().setTheme(ThemeType.light); + } else { + context.read().setTheme(ThemeType.dark); + } + } } diff --git a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_language_view.dart b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_language_view.dart index bb1b419da0..457d3b2f59 100644 --- a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_language_view.dart +++ b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_language_view.dart @@ -13,7 +13,7 @@ class SettingsLanguageView extends StatelessWidget { Widget build(BuildContext context) { context.watch(); return ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), + value: Provider.of(context, listen: true), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -43,7 +43,8 @@ class LanguageSelectorDropdown extends StatefulWidget { }) : super(key: key); @override - State createState() => _LanguageSelectorDropdownState(); + State createState() => + _LanguageSelectorDropdownState(); } class _LanguageSelectorDropdownState extends State { @@ -77,10 +78,10 @@ class _LanguageSelectorDropdownState extends State { ), child: DropdownButtonHideUnderline( child: DropdownButton( - value: context.read().locale, + value: context.read().locale, onChanged: (val) { setState(() { - context.read().setLocale(context, val!); + context.read().setLocale(context, val!); }); }, icon: const Visibility( diff --git a/frontend/app_flowy/packages/appflowy_board/CHANGELOG.md b/frontend/app_flowy/packages/appflowy_board/CHANGELOG.md index 56115c698b..2a29982421 100644 --- a/frontend/app_flowy/packages/appflowy_board/CHANGELOG.md +++ b/frontend/app_flowy/packages/appflowy_board/CHANGELOG.md @@ -1,3 +1,5 @@ +# 0.0.8 +* Enable drag and drop group # 0.0.7 * Rename some classes * Add documentation @@ -7,7 +9,7 @@ # 0.0.5 * Optimize insert card animation -* Enable insert card at the end of the column +* Enable insert card at the end of the group * Fix some bugs # 0.0.4 @@ -24,6 +26,5 @@ # 0.0.1 -* Support drag and drop column -* Support drag and drop column items from one to another +* Support drag and drop group items from one to another diff --git a/frontend/app_flowy/packages/appflowy_board/example/lib/main.dart b/frontend/app_flowy/packages/appflowy_board/example/lib/main.dart index c881370e03..9e42611e7e 100644 --- a/frontend/app_flowy/packages/appflowy_board/example/lib/main.dart +++ b/frontend/app_flowy/packages/appflowy_board/example/lib/main.dart @@ -34,7 +34,7 @@ class _MyAppState extends State { appBar: AppBar( title: const Text('AppFlowy Board'), ), - body: _examples[_currentIndex], + body: Container(color: Colors.white, child: _examples[_currentIndex]), bottomNavigationBar: BottomNavigationBar( fixedColor: _bottomNavigationColor, showSelectedLabels: true, diff --git a/frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart b/frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart index 8bd7ae98e3..2a256a51c4 100644 --- a/frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart +++ b/frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart @@ -21,8 +21,11 @@ class _MultiBoardListExampleState extends State { }, ); + late AppFlowyBoardScrollController boardController; + @override void initState() { + boardController = AppFlowyBoardScrollController(); final group1 = AppFlowyGroupData(id: "To Do", name: "To Do", items: [ TextItem("Card 1"), TextItem("Card 2"), @@ -67,12 +70,16 @@ class _MultiBoardListExampleState extends State { child: _buildCard(groupItem), ); }, + boardScrollController: boardController, footerBuilder: (context, columnData) { return AppFlowyGroupFooter( icon: const Icon(Icons.add, size: 20), title: const Text('New'), height: 50, margin: config.groupItemPadding, + onAddButtonClick: () { + boardController.scrollToBottom(columnData.id, (p0) {}); + }, ); }, headerBuilder: (context, columnData) { diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/utils/log.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/utils/log.dart index 9c23060b26..0b8d436d81 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/utils/log.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/utils/log.dart @@ -8,25 +8,28 @@ class Log { static void info(String? message) { if (enableLog) { - debugPrint('ℹ️[Info]=> $message'); + debugPrint('AppFlowyBoard: ℹ️[Info]=> $message'); } } static void debug(String? message) { if (enableLog) { - debugPrint('🐛[Debug] - ${DateTime.now().second}=> $message'); + debugPrint( + 'AppFlowyBoard: 🐛[Debug] - ${DateTime.now().second}=> $message'); } } static void warn(String? message) { if (enableLog) { - debugPrint('🐛[Warn] - ${DateTime.now().second} => $message'); + debugPrint( + 'AppFlowyBoard: 🐛[Warn] - ${DateTime.now().second} => $message'); } } static void trace(String? message) { if (enableLog) { - debugPrint('❗️[Trace] - ${DateTime.now().second}=> $message'); + debugPrint( + 'AppFlowyBoard: ❗️[Trace] - ${DateTime.now().second}=> $message'); } } } diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart index 4a71595ea6..8aaf94e6d6 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart @@ -13,10 +13,8 @@ import '../rendering/board_overlay.dart'; class AppFlowyBoardScrollController { AppFlowyBoardState? _groupState; - void scrollToBottom(String groupId, VoidCallback? completed) { - _groupState - ?.getReorderFlexState(groupId: groupId) - ?.scrollToBottom(completed); + void scrollToBottom(String groupId, void Function(BuildContext)? completed) { + _groupState?.reorderFlexActionMap[groupId]?.scrollToBottom(completed); } } @@ -133,7 +131,7 @@ class AppFlowyBoard extends StatelessWidget { dataController: controller, scrollController: scrollController, scrollManager: boardScrollController, - columnsState: _groupState, + groupState: _groupState, background: background, delegate: _phantomController, groupConstraints: groupConstraints, @@ -158,7 +156,7 @@ class _AppFlowyBoardContent extends StatefulWidget { final ReorderFlexConfig reorderFlexConfig; final BoxConstraints groupConstraints; final AppFlowyBoardScrollController? scrollManager; - final AppFlowyBoardState columnsState; + final AppFlowyBoardState groupState; final AppFlowyBoardCardBuilder cardBuilder; final AppFlowyBoardHeaderBuilder? headerBuilder; final AppFlowyBoardFooterBuilder? footerBuilder; @@ -171,7 +169,7 @@ class _AppFlowyBoardContent extends StatefulWidget { required this.delegate, required this.dataController, required this.scrollManager, - required this.columnsState, + required this.groupState, this.scrollController, this.background, required this.groupConstraints, @@ -192,8 +190,6 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> { GlobalKey(debugLabel: '$_AppFlowyBoardContent overlay key'); late BoardOverlayEntry _overlayEntry; - final Map _reorderFlexKeys = {}; - @override void initState() { _overlayEntry = BoardOverlayEntry( @@ -202,7 +198,7 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> { reorderFlexId: widget.dataController.identifier, acceptedReorderFlexId: widget.dataController.groupIds, delegate: widget.delegate, - columnsState: widget.columnsState, + columnsState: widget.groupState, ); final reorderFlex = ReorderFlex( @@ -212,7 +208,7 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> { dataSource: widget.dataController, direction: Axis.horizontal, interceptor: interceptor, - reorderable: false, + reorderable: true, children: _buildColumns(), ); @@ -257,18 +253,16 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> { dataController: widget.dataController, ); - if (_reorderFlexKeys[columnData.id] == null) { - _reorderFlexKeys[columnData.id] = GlobalObjectKey(columnData.id); - } + final reorderFlexAction = ReorderFlexActionImpl(); + widget.groupState.reorderFlexActionMap[columnData.id] = + reorderFlexAction; - GlobalObjectKey reorderFlexKey = _reorderFlexKeys[columnData.id]!; return ChangeNotifierProvider.value( key: ValueKey(columnData.id), value: widget.dataController.getGroupController(columnData.id), child: Consumer( builder: (context, value, child) { final boardColumn = AppFlowyBoardGroup( - reorderFlexKey: reorderFlexKey, // key: PageStorageKey(columnData.id), margin: _marginFromIndex(columnIndex), itemMargin: widget.config.groupItemPadding, @@ -281,11 +275,11 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> { onReorder: widget.dataController.moveGroupItem, cornerRadius: widget.config.cornerRadius, backgroundColor: widget.config.groupBackgroundColor, - dragStateStorage: widget.columnsState, - dragTargetIndexKeyStorage: widget.columnsState, + dragStateStorage: widget.groupState, + dragTargetKeys: widget.groupState, + reorderFlexAction: reorderFlexAction, ); - widget.columnsState.addGroup(columnData.id, boardColumn); return ConstrainedBox( constraints: widget.groupConstraints, child: boardColumn, @@ -356,71 +350,61 @@ class AppFlowyGroupContext { } class AppFlowyBoardState extends DraggingStateStorage - with ReorderDragTargetIndexKeyStorage { + with ReorderDragTargeKeys { + final Map groupDragStates = {}; + final Map> groupDragTargetKeys = {}; + /// Quick access to the [AppFlowyBoardGroup], the [GlobalKey] is bind to the /// AppFlowyBoardGroup's [ReorderFlex] widget. - final Map groupReorderFlexKeys = {}; - final Map groupDragStates = {}; - final Map> groupDragDragTargets = {}; - - void addGroup(String groupId, AppFlowyBoardGroup groupWidget) { - groupReorderFlexKeys[groupId] = groupWidget.reorderFlexKey; - } - - ReorderFlexState? getReorderFlexState({required String groupId}) { - final flexGlobalKey = groupReorderFlexKeys[groupId]; - if (flexGlobalKey == null) return null; - if (flexGlobalKey.currentState is! ReorderFlexState) return null; - final state = flexGlobalKey.currentState as ReorderFlexState; - return state; - } - - ReorderFlex? getReorderFlex({required String groupId}) { - final flexGlobalKey = groupReorderFlexKeys[groupId]; - if (flexGlobalKey == null) return null; - if (flexGlobalKey.currentWidget is! ReorderFlex) return null; - final widget = flexGlobalKey.currentWidget as ReorderFlex; - return widget; - } + final Map reorderFlexActionMap = {}; @override - DraggingState? read(String reorderFlexId) { + DraggingState? readState(String reorderFlexId) { return groupDragStates[reorderFlexId]; } @override - void write(String reorderFlexId, DraggingState state) { + void insertState(String reorderFlexId, DraggingState state) { Log.trace('$reorderFlexId Write dragging state: $state'); groupDragStates[reorderFlexId] = state; } @override - void remove(String reorderFlexId) { + void removeState(String reorderFlexId) { groupDragStates.remove(reorderFlexId); } @override - void addKey( + void insertDragTarget( String reorderFlexId, String key, GlobalObjectKey> value, ) { - Map? group = groupDragDragTargets[reorderFlexId]; + Map? group = groupDragTargetKeys[reorderFlexId]; if (group == null) { group = {}; - groupDragDragTargets[reorderFlexId] = group; + groupDragTargetKeys[reorderFlexId] = group; } group[key] = value; } @override - GlobalObjectKey>? readKey( - String reorderFlexId, String key) { - Map? group = groupDragDragTargets[reorderFlexId]; + GlobalObjectKey>? getDragTarget( + String reorderFlexId, + String key, + ) { + Map? group = groupDragTargetKeys[reorderFlexId]; if (group != null) { return group[key]; } else { return null; } } + + @override + void removeDragTarget(String reorderFlexId) { + groupDragTargetKeys.remove(reorderFlexId); + } } + +class ReorderFlexActionImpl extends ReorderFlexAction {} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart index 9b2222af59..2fd2f1f3a1 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart @@ -217,15 +217,15 @@ class AppFlowyBoardController extends ChangeNotifier final fromGroupItem = fromGroupController.removeAt(fromGroupIndex); if (toGroupController.items.length > toGroupIndex) { assert(toGroupController.items[toGroupIndex] is PhantomGroupItem); - } - toGroupController.replace(toGroupIndex, fromGroupItem); - onMoveGroupItemToGroup?.call( - fromGroupId, - fromGroupIndex, - toGroupId, - toGroupIndex, - ); + toGroupController.replace(toGroupIndex, fromGroupItem); + onMoveGroupItemToGroup?.call( + fromGroupId, + fromGroupIndex, + toGroupId, + toGroupIndex, + ); + } } @override diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group.dart index a4b130f6ec..880a81f666 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group.dart @@ -90,21 +90,21 @@ class AppFlowyBoardGroup extends StatefulWidget { final DraggingStateStorage? dragStateStorage; - final ReorderDragTargetIndexKeyStorage? dragTargetIndexKeyStorage; + final ReorderDragTargeKeys? dragTargetKeys; - final GlobalObjectKey reorderFlexKey; + final ReorderFlexAction? reorderFlexAction; const AppFlowyBoardGroup({ Key? key, - required this.reorderFlexKey, this.headerBuilder, this.footerBuilder, required this.cardBuilder, required this.onReorder, required this.dataSource, required this.phantomController, + this.reorderFlexAction, this.dragStateStorage, - this.dragTargetIndexKeyStorage, + this.dragTargetKeys, this.scrollController, this.onDragStarted, this.onDragEnded, @@ -112,7 +112,7 @@ class AppFlowyBoardGroup extends StatefulWidget { this.itemMargin = EdgeInsets.zero, this.cornerRadius = 0.0, this.backgroundColor = Colors.transparent, - }) : config = const ReorderFlexConfig(setStateWhenEndDrag: false), + }) : config = const ReorderFlexConfig(), super(key: key); @override @@ -146,9 +146,9 @@ class _AppFlowyBoardGroupState extends State { ); Widget reorderFlex = ReorderFlex( - key: widget.reorderFlexKey, + key: ValueKey(widget.groupId), dragStateStorage: widget.dragStateStorage, - dragTargetIndexKeyStorage: widget.dragTargetIndexKeyStorage, + dragTargetKeys: widget.dragTargetKeys, scrollController: widget.scrollController, config: widget.config, onDragStarted: (index) { @@ -168,6 +168,7 @@ class _AppFlowyBoardGroupState extends State { }, dataSource: widget.dataSource, interceptor: interceptor, + reorderFlexAction: widget.reorderFlexAction, children: children, ); diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group_data.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group_data.dart index 7bc3bf8657..5223e10e90 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group_data.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group_data.dart @@ -41,7 +41,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin { void updateGroupName(String newName) { if (groupData.headerData.groupName != newName) { groupData.headerData.groupName = newName; - notifyListeners(); + _notify(); } } @@ -56,7 +56,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin { Log.debug('[$AppFlowyGroupController] $groupData remove item at $index'); final item = groupData._items.removeAt(index); if (notify) { - notifyListeners(); + _notify(); } return item; } @@ -81,7 +81,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin { '[$AppFlowyGroupController] $groupData move item from $fromIndex to $toIndex'); final item = groupData._items.removeAt(fromIndex); groupData._items.insert(toIndex, item); - notifyListeners(); + _notify(); return true; } @@ -102,7 +102,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin { groupData._items.add(item); } - if (notify) notifyListeners(); + if (notify) _notify(); return true; } } @@ -112,7 +112,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin { return false; } else { groupData._items.add(item); - if (notify) notifyListeners(); + if (notify) _notify(); return true; } } @@ -124,6 +124,8 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin { Log.debug('[$AppFlowyGroupController] $groupData add $newItem'); } else { if (index >= groupData._items.length) { + Log.warn( + '[$AppFlowyGroupController] unexpected items length, index should less than the count of the items. Index: $index, items count: ${items.length}'); return; } @@ -133,7 +135,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin { '[$AppFlowyGroupController] $groupData replace $removedItem with $newItem at $index'); } - notifyListeners(); + _notify(); } void replaceOrInsertItem(AppFlowyGroupItem newItem) { @@ -141,10 +143,10 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin { if (index != -1) { groupData._items.removeAt(index); groupData._items.insert(index, newItem); - notifyListeners(); + _notify(); } else { groupData._items.add(newItem); - notifyListeners(); + _notify(); } } @@ -152,6 +154,10 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin { return groupData._items.indexWhere((element) => element.id == item.id) != -1; } + + void _notify() { + notifyListeners(); + } } /// [AppFlowyGroupData] represents the data of each group of the Board. diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_state.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_state.dart index f795efa6b3..8e1a61be1a 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_state.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_state.dart @@ -22,6 +22,8 @@ class FlexDragTargetData extends DragTargetData { Size? get feedbackSize => _state.feedbackSize; + bool get isDragging => _state.isDragging(); + final String dragTargetId; Offset dragTargetOffset = Offset.zero; @@ -48,47 +50,28 @@ class FlexDragTargetData extends DragTargetData { bool isOverlapWithWidgets(List widgetKeys) { final renderBox = dragTargetIndexKey.currentContext?.findRenderObject(); - if (renderBox == null) return false; if (renderBox is! RenderBox) return false; final size = feedbackSize ?? Size.zero; - final Rect rect = dragTargetOffset & size; + final Rect dragTargetRect = renderBox.localToGlobal(Offset.zero) & size; for (final widgetKey in widgetKeys) { final renderObject = widgetKey.currentContext?.findRenderObject(); if (renderObject != null && renderObject is RenderBox) { Rect widgetRect = renderObject.localToGlobal(Offset.zero) & renderObject.size; - // return rect.overlaps(widgetRect); - if (rect.right <= widgetRect.left || widgetRect.right <= rect.left) { - return false; - } - - if (rect.bottom <= widgetRect.top || widgetRect.bottom <= rect.top) { - return false; - } - return true; + return dragTargetRect.overlaps(widgetRect); } } - // final HitTestResult result = HitTestResult(); - // WidgetsBinding.instance.hitTest(result, position); - // for (final HitTestEntry entry in result.path) { - // final HitTestTarget target = entry.target; - // if (target is RenderMetaData) { - // print(target.metaData); - // } - // print(target); - // } - return false; } } abstract class DraggingStateStorage { - void write(String reorderFlexId, DraggingState state); - void remove(String reorderFlexId); - DraggingState? read(String reorderFlexId); + void insertState(String reorderFlexId, DraggingState state); + void removeState(String reorderFlexId); + DraggingState? readState(String reorderFlexId); } class DraggingState { @@ -113,7 +96,7 @@ class DraggingState { int currentIndex = -1; /// The widget to move the dragging widget too after the current index. - int nextIndex = 0; + int nextIndex = -1; /// Whether or not we are currently scrolling this view to show a widget. bool scrolling = false; @@ -149,6 +132,7 @@ class DraggingState { dragStartIndex = -1; phantomIndex = -1; currentIndex = -1; + nextIndex = -1; _draggingWidget = null; } diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target.dart index 9da9e393ad..fde9c3470a 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target.dart @@ -1,3 +1,4 @@ +import 'package:appflowy_board/src/utils/log.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; @@ -72,11 +73,15 @@ class ReorderDragTarget extends StatefulWidget { final ReorderFlexDraggableTargetBuilder? draggableTargetBuilder; final AnimationController insertAnimationController; + final AnimationController deleteAnimationController; final bool useMoveAnimation; + final bool draggable; + final double draggingOpacity; + const ReorderDragTarget({ Key? key, required this.child, @@ -93,6 +98,7 @@ class ReorderDragTarget extends StatefulWidget { this.onAccept, this.onLeave, this.draggableTargetBuilder, + this.draggingOpacity = 0.3, }) : super(key: key); @override @@ -163,6 +169,7 @@ class _ReorderDragTargetState feedback: feedbackBuilder, childWhenDragging: IgnorePointerWidget( useIntrinsicSize: !widget.useMoveAnimation, + opacity: widget.draggingOpacity, child: widget.child, ), onDragStarted: () { @@ -184,8 +191,9 @@ class _ReorderDragTargetState /// When the drag does not end inside a DragTarget widget, the /// drag fails, but we still reorder the widget to the last position it /// had been dragged to. - onDraggableCanceled: (Velocity velocity, Offset offset) => - widget.onDragEnded(widget.dragTargetData), + onDraggableCanceled: (Velocity velocity, Offset offset) { + widget.onDragEnded(widget.dragTargetData); + }, child: widget.child, ); @@ -193,7 +201,10 @@ class _ReorderDragTargetState } Widget _buildDraggableFeedback( - BuildContext context, BoxConstraints constraints, Widget child) { + BuildContext context, + BoxConstraints constraints, + Widget child, + ) { return Transform( transform: Matrix4.rotationZ(0), alignment: FractionalOffset.topLeft, @@ -203,7 +214,7 @@ class _ReorderDragTargetState clipBehavior: Clip.hardEdge, child: ConstrainedBox( constraints: constraints, - child: Opacity(opacity: 0.3, child: child), + child: Opacity(opacity: widget.draggingOpacity, child: child), ), ), ); @@ -221,8 +232,11 @@ class DragTargetAnimation { // where the widget used to be. late AnimationController phantomController; + // Uses to simulate the insert animation when card was moved from on group to + // another group. Check out the [FakeDragTarget]. late AnimationController insertController; + // Used to remove the phantom late AnimationController deleteController; DragTargetAnimation({ @@ -238,7 +252,7 @@ class DragTargetAnimation { value: 0, vsync: vsync, duration: reorderAnimationDuration); insertController = AnimationController( - value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 200)); + value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 100)); deleteController = AnimationController( value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 1)); @@ -269,8 +283,11 @@ class DragTargetAnimation { class IgnorePointerWidget extends StatelessWidget { final Widget? child; final bool useIntrinsicSize; + final double opacity; + const IgnorePointerWidget({ required this.child, + required this.opacity, this.useIntrinsicSize = false, Key? key, }) : super(key: key); @@ -281,11 +298,10 @@ class IgnorePointerWidget extends StatelessWidget { ? child : SizedBox(width: 0.0, height: 0.0, child: child); - final opacity = useIntrinsicSize ? 0.3 : 0.0; return IgnorePointer( ignoring: true, child: Opacity( - opacity: opacity, + opacity: useIntrinsicSize ? opacity : 0.0, child: sizedChild, ), ); @@ -295,8 +311,10 @@ class IgnorePointerWidget extends StatelessWidget { class AbsorbPointerWidget extends StatelessWidget { final Widget? child; final bool useIntrinsicSize; + final double opacity; const AbsorbPointerWidget({ required this.child, + required this.opacity, this.useIntrinsicSize = false, Key? key, }) : super(key: key); @@ -307,10 +325,9 @@ class AbsorbPointerWidget extends StatelessWidget { ? child : SizedBox(width: 0.0, height: 0.0, child: child); - final opacity = useIntrinsicSize ? 0.3 : 0.0; return AbsorbPointer( child: Opacity( - opacity: opacity, + opacity: useIntrinsicSize ? opacity : 0.0, child: sizedChild, ), ); @@ -423,7 +440,6 @@ abstract class FakeDragTargetEventData { } class FakeDragTarget extends StatefulWidget { - final Duration animationDuration; final FakeDragTargetEventTrigger eventTrigger; final FakeDragTargetEventData eventData; final DragTargetOnStarted onDragStarted; @@ -442,7 +458,6 @@ class FakeDragTarget extends StatefulWidget { required this.insertAnimationController, required this.deleteAnimationController, required this.child, - this.animationDuration = const Duration(milliseconds: 250), }) : super(key: key); @override @@ -468,6 +483,7 @@ class _FakeDragTargetState // }); widget.eventTrigger.fakeOnDragEnded(() { + Log.trace("[$FakeDragTarget] on drag end"); WidgetsBinding.instance.addPostFrameCallback((_) { widget.onDragEnded(widget.eventData.dragTargetData as T); }); @@ -476,6 +492,13 @@ class _FakeDragTargetState super.initState(); } + @override + void dispose() { + widget.insertAnimationController + .removeStatusListener(_onInsertedAnimationStatusChanged); + super.dispose(); + } + @override Widget build(BuildContext context) { if (simulateDragging) { @@ -483,6 +506,7 @@ class _FakeDragTargetState sizeFactor: widget.deleteAnimationController, axis: Axis.vertical, child: AbsorbPointerWidget( + opacity: 0.3, child: widget.child, ), ); @@ -492,6 +516,7 @@ class _FakeDragTargetState axis: Axis.vertical, child: AbsorbPointerWidget( useIntrinsicSize: true, + opacity: 0.3, child: widget.child, ), ); @@ -503,14 +528,18 @@ class _FakeDragTargetState WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; setState(() { - simulateDragging = true; - widget.deleteAnimationController.reverse(from: 1.0); - widget.onWillAccept(widget.eventData.dragTargetData as T); - widget.onDragStarted( - widget.child, - widget.eventData.index, - widget.eventData.feedbackSize, - ); + if (widget.onWillAccept(widget.eventData.dragTargetData as T)) { + Log.trace("[$FakeDragTarget] on drag start"); + simulateDragging = true; + widget.deleteAnimationController.reverse(from: 1.0); + widget.onDragStarted( + widget.child, + widget.eventData.index, + widget.eventData.feedbackSize, + ); + } else { + Log.trace("[$FakeDragTarget] cancel start drag"); + } }); }); } diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart index bce94502bd..21a67a3c7d 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart @@ -35,7 +35,7 @@ abstract class DragTargetInterceptor { abstract class OverlapDragTargetDelegate { void cancel(); - void moveTo( + void dragTargetDidMoveToReorderFlex( String reorderFlexId, FlexDragTargetData dragTargetData, int dragTargetIndex, @@ -81,7 +81,7 @@ class OverlappingDragTargetInterceptor extends DragTargetInterceptor { delegate.cancel(); } else { // Ignore the event if the dragTarget overlaps with the other column's dragTargets. - final columnKeys = columnsState.groupDragDragTargets[dragTargetId]; + final columnKeys = columnsState.groupDragTargetKeys[dragTargetId]; if (columnKeys != null) { final keys = columnKeys.values.toList(); if (dragTargetData.isOverlapWithWidgets(keys)) { @@ -99,10 +99,10 @@ class OverlappingDragTargetInterceptor extends DragTargetInterceptor { if (index != -1) { Log.trace( '[$OverlappingDragTargetInterceptor] move to $dragTargetId at $index'); - delegate.moveTo(dragTargetId, dragTargetData, index); + delegate.dragTargetDidMoveToReorderFlex( + dragTargetId, dragTargetData, index); - columnsState - .getReorderFlexState(groupId: dragTargetId) + columnsState.reorderFlexActionMap[dragTargetId] ?.resetDragTargetIndex(index); } }); @@ -153,7 +153,7 @@ class CrossReorderFlexDragTargetInterceptor extends DragTargetInterceptor { /// it means the dragTarget is dragging on the top of its own list. /// Otherwise, it means the dargTarget was moved to another list. Log.trace( - "[$CrossReorderFlexDragTargetInterceptor] $reorderFlexId accept ${dragTargetData.reorderFlexId} ${reorderFlexId != dragTargetData.reorderFlexId}"); + "[$CrossReorderFlexDragTargetInterceptor] $reorderFlexId should accept ${dragTargetData.reorderFlexId} : ${reorderFlexId != dragTargetData.reorderFlexId}"); return reorderFlexId != dragTargetData.reorderFlexId; } else { Log.trace( diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart index dda8aa7011..69d763804d 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart @@ -31,27 +31,43 @@ abstract class ReoderFlexItem { String get id; } -abstract class ReorderDragTargetIndexKeyStorage { - void addKey(String reorderFlexId, String key, GlobalObjectKey value); - GlobalObjectKey? readKey(String reorderFlexId, String key); +/// Cache each dragTarget's key. +/// For the moment, the key is used to locate the render object that will +/// be passed in the [ScrollPosition]'s [ensureVisible] function. +/// +abstract class ReorderDragTargeKeys { + void insertDragTarget( + String reorderFlexId, + String key, + GlobalObjectKey value, + ); + + GlobalObjectKey? getDragTarget( + String reorderFlexId, + String key, + ); + + void removeDragTarget(String reorderFlexId); +} + +abstract class ReorderFlexAction { + void Function(void Function(BuildContext)?)? _scrollToBottom; + void Function(void Function(BuildContext)?) get scrollToBottom => + _scrollToBottom!; + + void Function(int)? _resetDragTargetIndex; + void Function(int) get resetDragTargetIndex => _resetDragTargetIndex!; } class ReorderFlexConfig { /// The opacity of the dragging widget - final double draggingWidgetOpacity = 0.3; + final double draggingWidgetOpacity = 0.4; // How long an animation to reorder an element - final Duration reorderAnimationDuration = const Duration(milliseconds: 300); + final Duration reorderAnimationDuration = const Duration(milliseconds: 200); // How long an animation to scroll to an off-screen element - final Duration scrollAnimationDuration = const Duration(milliseconds: 300); - - /// Determines if setSatte method needs to be called when the drag is complete. - /// Default value is [true]. - /// - /// If the [ReorderFlex] needs to be rebuild after the [ReorderFlex] end dragging, - /// the [setStateWhenEndDrag] should set to [true]. - final bool setStateWhenEndDrag; + final Duration scrollAnimationDuration = const Duration(milliseconds: 200); final bool useMoveAnimation; @@ -59,7 +75,6 @@ class ReorderFlexConfig { const ReorderFlexConfig({ this.useMoveAnimation = true, - this.setStateWhenEndDrag = true, }) : useMovePlaceholder = !useMoveAnimation; } @@ -86,9 +101,12 @@ class ReorderFlex extends StatefulWidget { final DragTargetInterceptor? interceptor; + /// Save the [DraggingState] if the current [ReorderFlex] get reinitialize. final DraggingStateStorage? dragStateStorage; - final ReorderDragTargetIndexKeyStorage? dragTargetIndexKeyStorage; + final ReorderDragTargeKeys? dragTargetKeys; + + final ReorderFlexAction? reorderFlexAction; final bool reorderable; @@ -101,10 +119,11 @@ class ReorderFlex extends StatefulWidget { required this.onReorder, this.reorderable = true, this.dragStateStorage, - this.dragTargetIndexKeyStorage, + this.dragTargetKeys, this.onDragStarted, this.onDragEnded, this.interceptor, + this.reorderFlexAction, this.direction = Axis.vertical, }) : assert(children.every((Widget w) => w.key != null), 'All child must have a key.'), @@ -117,7 +136,7 @@ class ReorderFlex extends StatefulWidget { } class ReorderFlexState extends State - with ReorderFlexMinxi, TickerProviderStateMixin { + with ReorderFlexMixin, TickerProviderStateMixin { /// Controls scrolls and measures scroll progress. late ScrollController _scrollController; @@ -139,22 +158,31 @@ class ReorderFlexState extends State void initState() { _notifier = ReorderFlexNotifier(); final flexId = widget.reorderFlexId; - dragState = widget.dragStateStorage?.read(flexId) ?? + dragState = widget.dragStateStorage?.readState(flexId) ?? DraggingState(widget.reorderFlexId); Log.trace('[DragTarget] init dragState: $dragState'); - widget.dragStateStorage?.remove(flexId); + widget.dragStateStorage?.removeState(flexId); _animation = DragTargetAnimation( reorderAnimationDuration: widget.config.reorderAnimationDuration, entranceAnimateStatusChanged: (status) { if (status == AnimationStatus.completed) { + if (dragState.nextIndex == -1) return; setState(() => _requestAnimationToNextIndex()); } }, vsync: this, ); + widget.reorderFlexAction?._scrollToBottom = (fn) { + scrollToBottom(fn); + }; + + widget.reorderFlexAction?._resetDragTargetIndex = (index) { + resetDragTargetIndex(index); + }; + super.initState(); } @@ -191,7 +219,7 @@ class ReorderFlexState extends State final indexKey = GlobalObjectKey(child.key!); // Save the index key for quick access - widget.dragTargetIndexKeyStorage?.addKey( + widget.dragTargetKeys?.insertDragTarget( widget.reorderFlexId, item.id, indexKey, @@ -243,8 +271,12 @@ class ReorderFlexState extends State /// [childIndex]: the index of the child in a list Widget _wrap(Widget child, int childIndex, GlobalObjectKey indexKey) { return Builder(builder: (context) { - final ReorderDragTarget dragTarget = - _buildDragTarget(context, child, childIndex, indexKey); + final ReorderDragTarget dragTarget = _buildDragTarget( + context, + child, + childIndex, + indexKey, + ); int shiftedIndex = childIndex; if (dragState.isOverlapWithPhantom()) { @@ -349,6 +381,15 @@ class ReorderFlexState extends State }); } + static ReorderFlexState of(BuildContext context) { + if (context is StatefulElement && context.state is ReorderFlexState) { + return context.state as ReorderFlexState; + } + final ReorderFlexState? result = + context.findAncestorStateOfType(); + return result!; + } + ReorderDragTarget _buildDragTarget( BuildContext builderContext, Widget child, @@ -371,18 +412,24 @@ class ReorderFlexState extends State "[DragTarget] Group:[${widget.dataSource.identifier}] start dragging item at $draggingIndex"); _startDragging(draggingWidget, draggingIndex, size); widget.onDragStarted?.call(draggingIndex); - widget.dragStateStorage?.remove(widget.reorderFlexId); + widget.dragStateStorage?.removeState(widget.reorderFlexId); }, onDragMoved: (dragTargetData, offset) { dragTargetData.dragTargetOffset = offset; }, onDragEnded: (dragTargetData) { - if (!mounted) return; + if (!mounted) { + Log.warn( + "[DragTarget]: Group:[${widget.dataSource.identifier}] end dragging but current widget was unmounted"); + return; + } Log.debug( "[DragTarget]: Group:[${widget.dataSource.identifier}] end dragging"); - _notifier.updateDragTargetIndex(-1); - onDragEnded() { + _notifier.updateDragTargetIndex(-1); + _animation.insertController.stop(); + + setState(() { if (dragTargetData.reorderFlexId == widget.reorderFlexId) { _onReordered( dragState.dragStartIndex, @@ -391,13 +438,7 @@ class ReorderFlexState extends State } dragState.endDragging(); widget.onDragEnded?.call(); - } - - if (widget.config.setStateWhenEndDrag) { - setState(() => onDragEnded()); - } else { - onDragEnded(); - } + }); }, onWillAccept: (FlexDragTargetData dragTargetData) { // Do not receive any events if the Insert item is animating. @@ -405,19 +446,23 @@ class ReorderFlexState extends State return false; } - assert(widget.dataSource.items.length > dragTargetIndex); - if (_interceptDragTarget(dragTargetData, (interceptor) { - interceptor.onWillAccept( - context: builderContext, - reorderFlexState: this, - dragTargetData: dragTargetData, - dragTargetId: reorderFlexItem.id, - dragTargetIndex: dragTargetIndex, - ); - })) { - return true; + if (dragTargetData.isDragging) { + assert(widget.dataSource.items.length > dragTargetIndex); + if (_interceptDragTarget(dragTargetData, (interceptor) { + interceptor.onWillAccept( + context: builderContext, + reorderFlexState: this, + dragTargetData: dragTargetData, + dragTargetId: reorderFlexItem.id, + dragTargetIndex: dragTargetIndex, + ); + })) { + return true; + } else { + return handleOnWillAccept(builderContext, dragTargetIndex); + } } else { - return handleOnWillAccept(builderContext, dragTargetIndex); + return false; } }, onAccept: (dragTargetData) { @@ -438,6 +483,7 @@ class ReorderFlexState extends State draggableTargetBuilder: widget.interceptor?.draggableTargetBuilder, useMoveAnimation: widget.config.useMoveAnimation, draggable: widget.reorderable, + draggingOpacity: widget.config.draggingWidgetOpacity, child: child, ); } @@ -485,8 +531,12 @@ class ReorderFlexState extends State } void resetDragTargetIndex(int dragTargetIndex) { + if (dragTargetIndex > widget.dataSource.items.length) { + return; + } + dragState.setStartDraggingIndex(dragTargetIndex); - widget.dragStateStorage?.write( + widget.dragStateStorage?.insertState( widget.reorderFlexId, dragState, ); @@ -521,6 +571,9 @@ class ReorderFlexState extends State } void _onReordered(int fromIndex, int toIndex) { + if (toIndex == -1) return; + if (fromIndex == -1) return; + if (fromIndex != toIndex) { widget.onReorder.call(fromIndex, toIndex); } @@ -577,46 +630,46 @@ class ReorderFlexState extends State } } - void scrollToBottom(VoidCallback? completed) { + void scrollToBottom(void Function(BuildContext)? completed) { if (_scrolling) { - completed?.call(); + completed?.call(context); return; } if (widget.dataSource.items.isNotEmpty) { final item = widget.dataSource.items.last; - final indexKey = widget.dragTargetIndexKeyStorage?.readKey( + final dragTargetKey = widget.dragTargetKeys?.getDragTarget( widget.reorderFlexId, item.id, ); - if (indexKey == null) { - completed?.call(); + if (dragTargetKey == null) { + completed?.call(context); return; } - final indexContext = indexKey.currentContext; - if (indexContext == null || _scrollController.hasClients == false) { - completed?.call(); + final dragTargetContext = dragTargetKey.currentContext; + if (dragTargetContext == null || _scrollController.hasClients == false) { + completed?.call(context); return; } - final renderObject = indexContext.findRenderObject(); - if (renderObject != null) { + final dragTargetRenderObject = dragTargetContext.findRenderObject(); + if (dragTargetRenderObject != null) { _scrolling = true; _scrollController.position .ensureVisible( - renderObject, + dragTargetRenderObject, alignment: 0.5, duration: const Duration(milliseconds: 120), ) .then((value) { setState(() { _scrolling = false; - completed?.call(); + completed?.call(context); }); }); } else { - completed?.call(); + completed?.call(context); } } } diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_mixin.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_mixin.dart index accdaa866b..742d5dd7a6 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_mixin.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_mixin.dart @@ -3,7 +3,7 @@ import 'package:flutter/widgets.dart'; import '../transitions.dart'; import 'drag_target.dart'; -mixin ReorderFlexMinxi { +mixin ReorderFlexMixin { @protected Widget makeAppearingWidget( Widget child, diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart index 29c469c4a1..ad2778f6d8 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart @@ -94,7 +94,8 @@ class BoardPhantomController extends OverlapDragTargetDelegate /// Remove the phantom in the group if it contains phantom void _removePhantom(String groupId) { - if (delegate.removePhantom(groupId)) { + final didRemove = delegate.removePhantom(groupId); + if (didRemove) { phantomState.notifyDidRemovePhantom(groupId); phantomState.removeGroupListener(groupId); } @@ -195,7 +196,7 @@ class BoardPhantomController extends OverlapDragTargetDelegate } @override - void moveTo( + void dragTargetDidMoveToReorderFlex( String reorderFlexId, FlexDragTargetData dragTargetData, int dragTargetIndex, diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_state.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_state.dart index 0aeeb86a7a..a00b1a2aa0 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_state.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_state.dart @@ -1,50 +1,54 @@ +import 'package:appflowy_board/src/utils/log.dart'; + import 'phantom_controller.dart'; import 'package:flutter/material.dart'; class GroupPhantomState { - final _states = {}; + final _groupStates = {}; + final _groupIsDragging = {}; void setGroupIsDragging(String groupId, bool isDragging) { - _stateWithId(groupId).isDragging = isDragging; + _groupIsDragging[groupId] = isDragging; } bool isDragging(String groupId) { - return _stateWithId(groupId).isDragging; + return _groupIsDragging[groupId] ?? false; } void addGroupListener(String groupId, PassthroughPhantomListener listener) { - _stateWithId(groupId).notifier.addListener( - onInserted: (index) => listener.onInserted?.call(index), - onDeleted: () => listener.onDragEnded?.call(), - ); + if (_groupStates[groupId] == null) { + Log.debug("[$GroupPhantomState] add group listener: $groupId"); + _groupStates[groupId] = GroupState(); + _groupStates[groupId]?.notifier.addListener( + onInserted: (index) => listener.onInserted?.call(index), + onDeleted: () => listener.onDragEnded?.call(), + ); + } } void removeGroupListener(String groupId) { - _stateWithId(groupId).notifier.dispose(); - _states.remove(groupId); + Log.debug("[$GroupPhantomState] remove group listener: $groupId"); + final groupState = _groupStates.remove(groupId); + groupState?.dispose(); } void notifyDidInsertPhantom(String groupId, int index) { - _stateWithId(groupId).notifier.insert(index); + _groupStates[groupId]?.notifier.insert(index); } void notifyDidRemovePhantom(String groupId) { - _stateWithId(groupId).notifier.remove(); - } - - GroupState _stateWithId(String groupId) { - var state = _states[groupId]; - if (state == null) { - state = GroupState(); - _states[groupId] = state; - } - return state; + Log.debug("[$GroupPhantomState] $groupId remove phantom"); + _groupStates[groupId]?.notifier.remove(); } } class GroupState { bool isDragging = false; final notifier = PassthroughPhantomNotifier(); + + void dispose() { + notifier.dispose(); + } } abstract class PassthroughPhantomListener { diff --git a/frontend/app_flowy/packages/appflowy_board/pubspec.yaml b/frontend/app_flowy/packages/appflowy_board/pubspec.yaml index 90907edca5..35c7aa2b67 100644 --- a/frontend/app_flowy/packages/appflowy_board/pubspec.yaml +++ b/frontend/app_flowy/packages/appflowy_board/pubspec.yaml @@ -1,6 +1,6 @@ name: appflowy_board description: AppFlowyBoard is a board-style widget that consists of multi-groups. It supports drag and drop between different groups. -version: 0.0.7 +version: 0.0.8 homepage: https://github.com/AppFlowy-IO/AppFlowy repository: https://github.com/AppFlowy-IO/AppFlowy/tree/main/frontend/app_flowy/packages/appflowy_board diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart index fd5ccdeff3..744359052f 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:example/plugin/code_block_node_widget.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -116,9 +117,17 @@ class _MyHomePageState extends State { editorState: _editorState!, editorStyle: _editorStyle, editable: true, + customBuilders: { + 'text/code_block': CodeBlockNodeWidgetBuilder(), + }, shortcutEvents: [ + enterInCodeBlock, + ignoreKeysInCodeBlock, underscoreToItalic, ], + selectionMenuItems: [ + codeBlockItem, + ], ), ); } else { diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart new file mode 100644 index 0000000000..3949073756 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart @@ -0,0 +1,277 @@ +import 'dart:collection'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:highlight/highlight.dart' as highlight; +import 'package:highlight/languages/all.dart'; + +ShortcutEvent enterInCodeBlock = ShortcutEvent( + key: 'Enter in code block', + command: 'enter', + handler: _enterInCodeBlockHandler, +); + +ShortcutEvent ignoreKeysInCodeBlock = ShortcutEvent( + key: 'White space in code block', + command: 'space,slash,shift+underscore', + handler: _ignorekHandler, +); + +ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) { + final selection = editorState.service.selectionService.currentSelection.value; + final nodes = editorState.service.selectionService.currentSelectedNodes; + final codeBlockNode = + nodes.whereType().where((node) => node.id == 'text/code_block'); + if (codeBlockNode.length != 1 || selection == null) { + return KeyEventResult.ignored; + } + if (selection.isCollapsed) { + TransactionBuilder(editorState) + ..insertText(codeBlockNode.first, selection.end.offset, '\n') + ..commit(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; +}; + +ShortcutEventHandler _ignorekHandler = (editorState, event) { + final nodes = editorState.service.selectionService.currentSelectedNodes; + final codeBlockNodes = + nodes.whereType().where((node) => node.id == 'text/code_block'); + if (codeBlockNodes.length == 1) { + return KeyEventResult.skipRemainingHandlers; + } + return KeyEventResult.ignored; +}; + +SelectionMenuItem codeBlockItem = SelectionMenuItem( + name: 'Code Block', + icon: const Icon(Icons.abc), + keywords: ['code block'], + handler: (editorState, _, __) { + final selection = + editorState.service.selectionService.currentSelection.value; + final textNodes = editorState.service.selectionService.currentSelectedNodes + .whereType(); + if (selection == null || textNodes.isEmpty) { + return; + } + if (textNodes.first.toRawString().isEmpty) { + TransactionBuilder(editorState) + ..updateNode(textNodes.first, { + 'subtype': 'code_block', + 'theme': 'vs', + 'language': null, + }) + ..afterSelection = selection + ..commit(); + } else { + TransactionBuilder(editorState) + ..insertNode( + selection.end.path.next, + TextNode( + type: 'text', + children: LinkedList(), + attributes: { + 'subtype': 'code_block', + 'theme': 'vs', + 'language': null, + }, + delta: Delta()..insert('\n'), + ), + ) + ..afterSelection = selection + ..commit(); + } + }, +); + +class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + return _CodeBlockNodeWidge( + key: context.node.key, + textNode: context.node, + editorState: context.editorState, + ); + } + + @override + NodeValidator get nodeValidator => (node) { + return node is TextNode && node.attributes['theme'] is String; + }; +} + +class _CodeBlockNodeWidge extends StatefulWidget { + const _CodeBlockNodeWidge({ + Key? key, + required this.textNode, + required this.editorState, + }) : super(key: key); + + final TextNode textNode; + final EditorState editorState; + + @override + State<_CodeBlockNodeWidge> createState() => __CodeBlockNodeWidgeState(); +} + +class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge> + with SelectableMixin, DefaultSelectable { + final _richTextKey = GlobalKey(debugLabel: 'code_block_text'); + final _padding = const EdgeInsets.only(left: 20, top: 20, bottom: 20); + String? get _language => widget.textNode.attributes['language'] as String?; + String? _detectLanguage; + + @override + SelectableMixin get forward => + _richTextKey.currentState as SelectableMixin; + + @override + GlobalKey>? get iconKey => null; + + @override + Offset get baseOffset => super.baseOffset + _padding.topLeft; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + _buildCodeBlock(context), + _buildSwitchCodeButton(context), + ], + ); + } + + Widget _buildCodeBlock(BuildContext context) { + final result = highlight.highlight.parse( + widget.textNode.toRawString(), + language: _language, + autoDetection: _language == null, + ); + _detectLanguage = _language ?? result.language; + final code = result.nodes; + final codeTextSpan = _convert(code!); + return Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + color: Colors.grey.withOpacity(0.1), + ), + padding: _padding, + width: MediaQuery.of(context).size.width, + child: FlowyRichText( + key: _richTextKey, + textNode: widget.textNode, + editorState: widget.editorState, + textSpanDecorator: (textSpan) => TextSpan( + style: widget.editorState.editorStyle.textStyle.defaultTextStyle, + children: codeTextSpan, + ), + ), + ); + } + + Widget _buildSwitchCodeButton(BuildContext context) { + return Positioned( + top: -5, + right: 0, + child: DropdownButton( + value: _detectLanguage, + onChanged: (value) { + TransactionBuilder(widget.editorState) + ..updateNode(widget.textNode, { + 'language': value, + }) + ..commit(); + }, + items: allLanguages.keys.map>((String value) { + return DropdownMenuItem( + value: value, + child: Text( + value, + style: const TextStyle(fontSize: 12.0), + ), + ); + }).toList(growable: false), + ), + ); + } + + // Copy from flutter.highlight package. + // https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart + List _convert(List nodes) { + List spans = []; + var currentSpans = spans; + List> stack = []; + + _traverse(highlight.Node node) { + if (node.value != null) { + currentSpans.add(node.className == null + ? TextSpan(text: node.value) + : TextSpan( + text: node.value, + style: _builtInCodeBlockTheme[node.className!])); + } else if (node.children != null) { + List tmp = []; + currentSpans.add(TextSpan( + children: tmp, style: _builtInCodeBlockTheme[node.className!])); + stack.add(currentSpans); + currentSpans = tmp; + + for (var n in node.children!) { + _traverse(n); + if (n == node.children!.last) { + currentSpans = stack.isEmpty ? spans : stack.removeLast(); + } + } + } + } + + for (var node in nodes) { + _traverse(node); + } + + return spans; + } +} + +const _builtInCodeBlockTheme = { + 'root': + TextStyle(backgroundColor: Color(0xffffffff), color: Color(0xff000000)), + 'comment': TextStyle(color: Color(0xff007400)), + 'quote': TextStyle(color: Color(0xff007400)), + 'tag': TextStyle(color: Color(0xffaa0d91)), + 'attribute': TextStyle(color: Color(0xffaa0d91)), + 'keyword': TextStyle(color: Color(0xffaa0d91)), + 'selector-tag': TextStyle(color: Color(0xffaa0d91)), + 'literal': TextStyle(color: Color(0xffaa0d91)), + 'name': TextStyle(color: Color(0xffaa0d91)), + 'variable': TextStyle(color: Color(0xff3F6E74)), + 'template-variable': TextStyle(color: Color(0xff3F6E74)), + 'code': TextStyle(color: Color(0xffc41a16)), + 'string': TextStyle(color: Color(0xffc41a16)), + 'meta-string': TextStyle(color: Color(0xffc41a16)), + 'regexp': TextStyle(color: Color(0xff0E0EFF)), + 'link': TextStyle(color: Color(0xff0E0EFF)), + 'title': TextStyle(color: Color(0xff1c00cf)), + 'symbol': TextStyle(color: Color(0xff1c00cf)), + 'bullet': TextStyle(color: Color(0xff1c00cf)), + 'number': TextStyle(color: Color(0xff1c00cf)), + 'section': TextStyle(color: Color(0xff643820)), + 'meta': TextStyle(color: Color(0xff643820)), + 'type': TextStyle(color: Color(0xff5c2699)), + 'built_in': TextStyle(color: Color(0xff5c2699)), + 'builtin-name': TextStyle(color: Color(0xff5c2699)), + 'params': TextStyle(color: Color(0xff5c2699)), + 'attr': TextStyle(color: Color(0xff836C28)), + 'subst': TextStyle(color: Color(0xff000000)), + 'formula': TextStyle( + backgroundColor: Color(0xffeeeeee), fontStyle: FontStyle.italic), + 'addition': TextStyle(backgroundColor: Color(0xffbaeeba)), + 'deletion': TextStyle(backgroundColor: Color(0xffffc8bd)), + 'selector-id': TextStyle(color: Color(0xff9b703f)), + 'selector-class': TextStyle(color: Color(0xff9b703f)), + 'doctag': TextStyle(fontWeight: FontWeight.bold), + 'strong': TextStyle(fontWeight: FontWeight.bold), + 'emphasis': TextStyle(fontStyle: FontStyle.italic), +}; diff --git a/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml b/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml index 3c3f51632e..9f7b4e805b 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml +++ b/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: sdk: flutter file_picker: ^5.0.1 universal_html: ^2.0.8 + highlight: ^0.7.0 dev_dependencies: flutter_test: diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart index 0c9f447145..b594262e95 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart @@ -28,4 +28,8 @@ export 'src/service/shortcut_event/keybinding.dart'; export 'src/service/shortcut_event/shortcut_event.dart'; export 'src/service/shortcut_event/shortcut_event_handler.dart'; export 'src/extensions/attributes_extension.dart'; +export 'src/extensions/path_extensions.dart'; +export 'src/render/rich_text/default_selectable.dart'; +export 'src/render/rich_text/flowy_rich_text.dart'; +export 'src/render/selection_menu/selection_menu_widget.dart'; export 'src/l10n/l10n.dart'; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_nl_NL.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_nl_NL.arb new file mode 100644 index 0000000000..814e22655d --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_nl_NL.arb @@ -0,0 +1,35 @@ +{ + "@@locale": "nl-NL", + "bold": "Vet", + "@bold": {}, + "bulletedList": "Opsommingstekens", + "@bulletedList": {}, + "checkbox": "Selectievakje", + "@checkbox": {}, + "embedCode": "Invoegcode", + "@embedCode": {}, + "heading1": "H1", + "@heading1": {}, + "heading2": "H2", + "@heading2": {}, + "heading3": "H3", + "@heading3": {}, + "highlight": "Highlight", + "@highlight": {}, + "image": "Afbeelding", + "@image": {}, + "italic": "Cursief", + "@italic": {}, + "link": "", + "@link": {}, + "numberedList": "Nummering", + "@numberedList": {}, + "quote": "Quote", + "@quote": {}, + "strikethrough": "Doorhalen", + "@strikethrough": {}, + "text": "Tekst", + "@text": {}, + "underline": "Onderstrepen", + "@underline": {} +} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/edit_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/edit_text.dart new file mode 100644 index 0000000000..2e1310ca2c --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/edit_text.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:appflowy_editor/src/commands/text_command_infra.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/operation/transaction_builder.dart'; +import 'package:flutter/widgets.dart'; + +Future insertContextInText( + EditorState editorState, + int index, + String content, { + Path? path, + TextNode? textNode, +}) async { + final result = getTextNodeToBeFormatted( + editorState, + path: path, + textNode: textNode, + ); + + final completer = Completer(); + + TransactionBuilder(editorState) + ..insertText(result, index, content) + ..commit(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + completer.complete(); + }); + + return completer.future; +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_built_in_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_built_in_text.dart new file mode 100644 index 0000000000..dcce054351 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_built_in_text.dart @@ -0,0 +1,83 @@ +import 'package:appflowy_editor/src/commands/format_text.dart'; +import 'package:appflowy_editor/src/commands/text_command_infra.dart'; +import 'package:appflowy_editor/src/document/attributes.dart'; +import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; + +Future formatBuiltInTextAttributes( + EditorState editorState, + String key, + Attributes attributes, { + Selection? selection, + Path? path, + TextNode? textNode, +}) async { + final result = getTextNodeToBeFormatted( + editorState, + path: path, + textNode: textNode, + ); + if (BuiltInAttributeKey.globalStyleKeys.contains(key)) { + // remove all the existing style + final newAttributes = result.attributes + ..removeWhere((key, value) { + if (BuiltInAttributeKey.globalStyleKeys.contains(key)) { + return true; + } + return false; + }) + ..addAll(attributes) + ..addAll({ + BuiltInAttributeKey.subtype: key, + }); + return updateTextNodeAttributes( + editorState, + newAttributes, + textNode: textNode, + ); + } else if (BuiltInAttributeKey.partialStyleKeys.contains(key)) { + return updateTextNodeDeltaAttributes( + editorState, + selection, + attributes, + textNode: textNode, + ); + } +} + +Future formatTextToCheckbox( + EditorState editorState, + bool check, { + Path? path, + TextNode? textNode, +}) async { + return formatBuiltInTextAttributes( + editorState, + BuiltInAttributeKey.checkbox, + { + BuiltInAttributeKey.checkbox: check, + }, + path: path, + textNode: textNode, + ); +} + +Future formatLinkInText( + EditorState editorState, + String? link, { + Path? path, + TextNode? textNode, +}) async { + return formatBuiltInTextAttributes( + editorState, + BuiltInAttributeKey.href, + { + BuiltInAttributeKey.href: link, + }, + path: path, + textNode: textNode, + ); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_text.dart new file mode 100644 index 0000000000..0ec9e7b61a --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_text.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:appflowy_editor/src/commands/text_command_infra.dart'; +import 'package:appflowy_editor/src/document/attributes.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/operation/transaction_builder.dart'; +import 'package:flutter/widgets.dart'; + +Future updateTextNodeAttributes( + EditorState editorState, + Attributes attributes, { + Path? path, + TextNode? textNode, +}) async { + final result = getTextNodeToBeFormatted( + editorState, + path: path, + textNode: textNode, + ); + + final completer = Completer(); + + TransactionBuilder(editorState) + ..updateNode(result, attributes) + ..commit(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + completer.complete(); + }); + + return completer.future; +} + +Future updateTextNodeDeltaAttributes( + EditorState editorState, + Selection? selection, + Attributes attributes, { + Path? path, + TextNode? textNode, +}) { + final result = getTextNodeToBeFormatted( + editorState, + path: path, + textNode: textNode, + ); + final newSelection = getSelection(editorState, selection: selection); + + final completer = Completer(); + + TransactionBuilder(editorState) + ..formatText( + result, + newSelection.startIndex, + newSelection.length, + attributes, + ) + ..commit(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + completer.complete(); + }); + + return completer.future; +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text_command_infra.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text_command_infra.dart new file mode 100644 index 0000000000..d54a84a3e0 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text_command_infra.dart @@ -0,0 +1,43 @@ +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; + +// get formatted [TextNode] +TextNode getTextNodeToBeFormatted( + EditorState editorState, { + Path? path, + TextNode? textNode, +}) { + final currentSelection = + editorState.service.selectionService.currentSelection.value; + TextNode result; + if (textNode != null) { + result = textNode; + } else if (path != null) { + result = editorState.document.nodeAtPath(path) as TextNode; + } else if (currentSelection != null && currentSelection.isCollapsed) { + result = editorState.document.nodeAtPath(currentSelection.start.path) + as TextNode; + } else { + throw Exception('path and textNode cannot be null at the same time'); + } + return result; +} + +Selection getSelection( + EditorState editorState, { + Selection? selection, +}) { + final currentSelection = + editorState.service.selectionService.currentSelection.value; + Selection result; + if (selection != null) { + result = selection; + } else if (currentSelection != null) { + result = currentSelection; + } else { + throw Exception('path and textNode cannot be null at the same time'); + } + return result; +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart index 81e87399b1..63e6525754 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart @@ -93,12 +93,14 @@ class Node extends ChangeNotifier with LinkedListEntry { } void updateAttributes(Attributes attributes) { - bool shouldNotifyParent = _attributes['subtype'] != attributes['subtype']; - + final oldAttributes = {..._attributes}; _attributes = composeAttributes(_attributes, attributes) ?? {}; + // Notifies the new attributes // if attributes contains 'subtype', should notify parent to rebuild node // else, just notify current node. + bool shouldNotifyParent = + _attributes['subtype'] != oldAttributes['subtype']; shouldNotifyParent ? parent?.notifyListeners() : notifyListeners(); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart index ea451b46dd..99248dc167 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart @@ -53,6 +53,10 @@ class Selection { Selection get reversed => copyWith(start: end, end: start); + int get startIndex => normalize.start.offset; + int get endIndex => normalize.end.offset; + int get length => endIndex - startIndex; + Selection collapse({bool atStart = false}) { if (atStart) { return Selection(start: start, end: start); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart index 4f2ca39a84..4aeb7ab599 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart @@ -72,6 +72,8 @@ class EditorState { // TODO: only for testing. bool disableSealTimer = false; + bool editable = true; + Selection? get cursorSelection { return _cursorSelection; } @@ -112,6 +114,9 @@ class EditorState { /// should record the transaction in undo/redo stack. apply(Transaction transaction, [ApplyOptions options = const ApplyOptions()]) { + if (!editable) { + return; + } // TODO: validate the transation. for (final op in transaction.operations) { _applyOperation(op); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart index 13396a33c4..3a1785391b 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart @@ -31,6 +31,7 @@ class _LinkMenuState extends State { void initState() { super.initState(); _textEditingController.text = widget.linkText ?? ''; + _focusNode.requestFocus(); _focusNode.addListener(_onFocusChange); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart index 2ca7531d2b..10b17d6b36 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart @@ -1,15 +1,8 @@ -import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart'; -import 'package:appflowy_editor/src/document/node.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/commands/format_built_in_text.dart'; import 'package:appflowy_editor/src/infra/flowy_svg.dart'; import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart'; -import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; -import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart'; -import 'package:appflowy_editor/src/render/selection/selectable.dart'; -import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart'; -import 'package:appflowy_editor/src/service/render_plugin_service.dart'; -import 'package:appflowy_editor/src/extensions/attributes_extension.dart'; import 'package:appflowy_editor/src/extensions/text_style_extension.dart'; import 'package:flutter/material.dart'; @@ -81,8 +74,12 @@ class _CheckboxNodeWidgetState extends State padding: iconPadding, name: check ? 'check' : 'uncheck', ), - onTap: () { - formatCheckbox(widget.editorState, !check); + onTap: () async { + await formatTextToCheckbox( + widget.editorState, + !check, + textNode: widget.textNode, + ); }, ), Flexible( diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart index 7dba4852ed..99a6d08918 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart @@ -18,6 +18,8 @@ import 'package:appflowy_editor/src/extensions/attributes_extension.dart'; import 'package:appflowy_editor/src/render/selection/selectable.dart'; import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; +const _kRichTextDebugMode = false; + typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan); class FlowyRichText extends StatefulWidget { @@ -261,6 +263,17 @@ class _FlowyRichTextState extends State with SelectableMixin { ), ); } + if (_kRichTextDebugMode) { + textSpans.add( + TextSpan( + text: '${widget.textNode.path}', + style: const TextStyle( + backgroundColor: Colors.red, + fontSize: 16.0, + ), + ), + ); + } return TextSpan( children: textSpans, ); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart index 02eaddc68d..6407f81cb3 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart @@ -1,4 +1,5 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/commands/format_built_in_text.dart'; import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart'; import 'package:appflowy_editor/src/infra/flowy_svg.dart'; import 'package:appflowy_editor/src/render/link_menu/link_menu.dart'; @@ -8,7 +9,6 @@ import 'package:appflowy_editor/src/service/default_text_operations/format_rich_ import 'package:flutter/material.dart'; import 'package:rich_clipboard/rich_clipboard.dart'; -import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart'; typedef ToolbarItemEventHandler = void Function( EditorState editorState, BuildContext context); @@ -120,7 +120,7 @@ List defaultToolbarItems = [ name: 'toolbar/bold', color: isHighlight ? Colors.lightBlue : null, ), - validator: _showInTextSelection, + validator: _showInBuiltInTextSelection, highlightCallback: (editorState) => _allSatisfy( editorState, BuiltInAttributeKey.bold, @@ -136,7 +136,7 @@ List defaultToolbarItems = [ name: 'toolbar/italic', color: isHighlight ? Colors.lightBlue : null, ), - validator: _showInTextSelection, + validator: _showInBuiltInTextSelection, highlightCallback: (editorState) => _allSatisfy( editorState, BuiltInAttributeKey.italic, @@ -152,7 +152,7 @@ List defaultToolbarItems = [ name: 'toolbar/underline', color: isHighlight ? Colors.lightBlue : null, ), - validator: _showInTextSelection, + validator: _showInBuiltInTextSelection, highlightCallback: (editorState) => _allSatisfy( editorState, BuiltInAttributeKey.underline, @@ -168,7 +168,7 @@ List defaultToolbarItems = [ name: 'toolbar/strikethrough', color: isHighlight ? Colors.lightBlue : null, ), - validator: _showInTextSelection, + validator: _showInBuiltInTextSelection, highlightCallback: (editorState) => _allSatisfy( editorState, BuiltInAttributeKey.strikethrough, @@ -184,7 +184,7 @@ List defaultToolbarItems = [ name: 'toolbar/code', color: isHighlight ? Colors.lightBlue : null, ), - validator: _showInTextSelection, + validator: _showInBuiltInTextSelection, highlightCallback: (editorState) => _allSatisfy( editorState, BuiltInAttributeKey.code, @@ -248,7 +248,7 @@ List defaultToolbarItems = [ name: 'toolbar/highlight', color: isHighlight ? Colors.lightBlue : null, ), - validator: _showInTextSelection, + validator: _showInBuiltInTextSelection, highlightCallback: (editorState) => _allSatisfy( editorState, BuiltInAttributeKey.backgroundColor, @@ -262,13 +262,22 @@ List defaultToolbarItems = [ ]; ToolbarItemValidator _onlyShowInSingleTextSelection = (editorState) { + final result = _showInBuiltInTextSelection(editorState); + if (!result) { + return false; + } final nodes = editorState.service.selectionService.currentSelectedNodes; return (nodes.length == 1 && nodes.first is TextNode); }; -ToolbarItemValidator _showInTextSelection = (editorState) { +ToolbarItemValidator _showInBuiltInTextSelection = (editorState) { final nodes = editorState.service.selectionService.currentSelectedNodes - .whereType(); + .whereType() + .where( + (textNode) => + BuiltInAttributeKey.globalStyleKeys.contains(textNode.subtype) || + textNode.subtype == null, + ); return nodes.isNotEmpty; }; @@ -337,11 +346,8 @@ void showLinkMenu( onOpenLink: () async { await safeLaunchUrl(linkText); }, - onSubmitted: (text) { - TransactionBuilder(editorState) - ..formatText( - textNode, index, length, {BuiltInAttributeKey.href: text}) - ..commit(); + onSubmitted: (text) async { + await formatLinkInText(editorState, text, textNode: textNode); _dismissLinkMenu(); }, onCopyLink: () { @@ -369,6 +375,7 @@ void showLinkMenu( Overlay.of(context)?.insert(_linkMenuOverlay!); editorState.service.scrollService?.disable(); + editorState.service.keyboardService?.disable(); editorState.service.selectionService.currentSelection .addListener(_dismissLinkMenu); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart index 23ddc75a69..053d9e542a 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart @@ -103,13 +103,17 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) { final builder = TransactionBuilder(editorState); for (final textNode in textNodes) { + var newAttributes = {...textNode.attributes}; + for (final globalStyleKey in BuiltInAttributeKey.globalStyleKeys) { + if (newAttributes.keys.contains(globalStyleKey)) { + newAttributes[globalStyleKey] = null; + } + } + newAttributes.addAll(attributes); builder ..updateNode( textNode, - Attributes.fromIterable( - BuiltInAttributeKey.globalStyleKeys, - value: (_) => null, - )..addAll(attributes), + newAttributes, ) ..afterSelection = Selection.collapsed( Position( diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart index 2655717c1d..3d9599383d 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart @@ -72,6 +72,7 @@ class _AppFlowyEditorState extends State { editorState.selectionMenuItems = widget.selectionMenuItems; editorState.editorStyle = widget.editorStyle; editorState.service.renderPluginService = _createRenderPlugin(); + editorState.editable = widget.editable; } @override @@ -84,6 +85,7 @@ class _AppFlowyEditorState extends State { } editorState.editorStyle = widget.editorStyle; + editorState.editable = widget.editable; services = null; } @@ -118,8 +120,8 @@ class _AppFlowyEditorState extends State { key: editorState.service.keyboardServiceKey, editable: widget.editable, shortcutEvents: [ - ...builtInShortcutEvents, ...widget.shortcutEvents, + ...builtInShortcutEvents, ], editorState: editorState, child: FlowyToolbar( diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart index 7f1a4718f5..d6a3420099 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart @@ -297,7 +297,11 @@ class _AppFlowyInputState extends State _updateCaretPosition(textNodes.first, selection); } } else { - // close(); + // https://github.com/flutter/flutter/issues/104944 + // Disable IME for the Web. + if (kIsWeb) { + close(); + } } } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart index a981470f6e..c31b8c3699 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart @@ -117,12 +117,17 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler = makeFollowingNodesIncremental(editorState, insertPath, afterSelection, beginNum: prevNumber); } else { + bool needCopyAttributes = ![ + BuiltInAttributeKey.heading, + BuiltInAttributeKey.quote, + ].contains(subtype); TransactionBuilder(editorState) ..insertNode( textNode.path, textNode.copyWith( children: LinkedList(), delta: Delta(), + attributes: needCopyAttributes ? null : {}, ), ) ..afterSelection = afterSelection @@ -173,7 +178,9 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler = Attributes _attributesFromPreviousLine(TextNode textNode) { final prevAttributes = textNode.attributes; final subType = textNode.subtype; - if (subType == null || subType == BuiltInAttributeKey.heading) { + if (subType == null || + subType == BuiltInAttributeKey.heading || + subType == BuiltInAttributeKey.quote) { return {}; } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/space_on_web_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/space_on_web_handler.dart new file mode 100644 index 0000000000..6d942135ba --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/space_on_web_handler.dart @@ -0,0 +1,21 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/commands/edit_text.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +ShortcutEventHandler spaceOnWebHandler = (editorState, event) { + final selection = editorState.service.selectionService.currentSelection.value; + final textNodes = editorState.service.selectionService.currentSelectedNodes + .whereType() + .toList(growable: false); + if (selection == null || + !selection.isCollapsed || + !kIsWeb || + textNodes.length != 1) { + return KeyEventResult.ignored; + } + + insertContextInText(editorState, selection.startIndex, ' '); + + return KeyEventResult.handled; +}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart index 0eb36fff17..0291fc34a5 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart @@ -13,12 +13,19 @@ ShortcutEventHandler tabHandler = (editorState, event) { final textNode = textNodes.first; final previous = textNode.previous; - if (textNode.subtype != BuiltInAttributeKey.bulletedList || - previous == null || - previous.subtype != BuiltInAttributeKey.bulletedList) { + + if (textNode.subtype != BuiltInAttributeKey.bulletedList) { + TransactionBuilder(editorState) + ..insertText(textNode, selection.end.offset, ' ' * 4) + ..commit(); return KeyEventResult.handled; } + if (previous == null || + previous.subtype != BuiltInAttributeKey.bulletedList) { + return KeyEventResult.ignored; + } + final path = previous.path + [previous.children.length]; final afterSelection = Selection( start: selection.start.copyWith(path: path), diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart index 5259872b95..d5154bc2b5 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart @@ -124,6 +124,8 @@ class _AppFlowyKeyboardState extends State final result = shortcutEvent.handler(widget.editorState, event); if (result == KeyEventResult.handled) { return KeyEventResult.handled; + } else if (result == KeyEventResult.skipRemainingHandlers) { + return KeyEventResult.skipRemainingHandlers; } continue; } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart index c76262810e..827b057700 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart @@ -10,9 +10,11 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_und import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/format_style_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/space_on_web_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/tab_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart'; import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart'; +import 'package:flutter/foundation.dart'; // List builtInShortcutEvents = [ @@ -255,4 +257,14 @@ List builtInShortcutEvents = [ command: 'backquote', handler: backquoteToCodeHandler, ), + // https://github.com/flutter/flutter/issues/104944 + // Workaround: Using space editing on the web platform often results in errors, + // so adding a shortcut event to handle the space input instead of using the + // `input_service`. + if (kIsWeb) + ShortcutEvent( + key: 'Space on the Web', + command: 'space', + handler: spaceOnWebHandler, + ), ]; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart index 6575dced25..3d3def574e 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart @@ -38,14 +38,17 @@ class _FlowyToolbarState extends State @override void showInOffset(Offset offset, LayerLink layerLink) { hide(); - + final items = _filterItems(defaultToolbarItems); + if (items.isEmpty) { + return; + } _toolbarOverlay = OverlayEntry( builder: (context) => ToolbarWidget( key: _toolbarWidgetKey, editorState: widget.editorState, layerLink: layerLink, offset: offset, - items: _filterItems(defaultToolbarItems), + items: items, ), ); Overlay.of(context)?.insert(_toolbarOverlay!); @@ -102,9 +105,4 @@ class _FlowyToolbarState extends State } return dividedItems; } - - // List _highlightItems( - // List items, - // Selection selection, - // ) {} } diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart index cb2d10ea2f..916541025d 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart @@ -2,7 +2,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../infra/test_editor.dart'; -import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart'; void main() async { setUpAll(() { @@ -171,13 +170,27 @@ Future _testStyleNeedToBeCopy(WidgetTester tester, String style) async { LogicalKeyboardKey.enter, ); expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0)); - expect(editor.nodeAtPath([4])?.subtype, style); - await editor.pressLogicKey( - LogicalKeyboardKey.enter, - ); - expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0)); - expect(editor.nodeAtPath([4])?.subtype, null); + if ([BuiltInAttributeKey.heading, BuiltInAttributeKey.quote] + .contains(style)) { + expect(editor.nodeAtPath([4])?.subtype, null); + + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + ); + expect( + editor.documentSelection, Selection.single(path: [5], startOffset: 0)); + expect(editor.nodeAtPath([5])?.subtype, null); + } else { + expect(editor.nodeAtPath([4])?.subtype, style); + + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + ); + expect( + editor.documentSelection, Selection.single(path: [4], startOffset: 0)); + expect(editor.nodeAtPath([4])?.subtype, null); + } } Future _testMultipleSelection( diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart new file mode 100644 index 0000000000..fbbe016d30 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart @@ -0,0 +1,45 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('space_on_web_handler.dart', () { + testWidgets('Presses space key on web', (tester) async { + if (!kIsWeb) return; + const count = 10; + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor; + for (var i = 0; i < count; i++) { + editor.insertTextNode(text); + } + await editor.startTesting(); + + for (var i = 0; i < count; i++) { + await editor.updateSelection( + Selection.single(path: [i], startOffset: 1), + ); + await editor.pressLogicKey(LogicalKeyboardKey.space); + expect( + (editor.nodeAtPath([i]) as TextNode).toRawString(), + 'W elcome to Appflowy 😁', + ); + } + for (var i = 0; i < count; i++) { + await editor.updateSelection( + Selection.single(path: [i], startOffset: text.length + 1), + ); + await editor.pressLogicKey(LogicalKeyboardKey.space); + expect( + (editor.nodeAtPath([i]) as TextNode).toRawString(), + 'W elcome to Appflowy 😁 ', + ); + } + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart index 1374869deb..641282c55f 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart @@ -15,23 +15,24 @@ void main() async { ..insertTextNode(text) ..insertTextNode(text); await editor.startTesting(); - final document = editor.document; var selection = Selection.single(path: [0], startOffset: 0); await editor.updateSelection(selection); await editor.pressLogicKey(LogicalKeyboardKey.tab); - // nothing happens - expect(editor.documentSelection, selection); - expect(editor.document.toJson(), document.toJson()); + expect( + editor.documentSelection, + Selection.single(path: [0], startOffset: 4), + ); selection = Selection.single(path: [1], startOffset: 0); await editor.updateSelection(selection); await editor.pressLogicKey(LogicalKeyboardKey.tab); - // nothing happens - expect(editor.documentSelection, selection); - expect(editor.document.toJson(), document.toJson()); + expect( + editor.documentSelection, + Selection.single(path: [1], startOffset: 4), + ); }); testWidgets('press tab in bulleted list', (tester) async { @@ -63,7 +64,10 @@ void main() async { await editor.pressLogicKey(LogicalKeyboardKey.tab); // nothing happens - expect(editor.documentSelection, selection); + expect( + editor.documentSelection, + Selection.single(path: [0], startOffset: 0), + ); expect(editor.document.toJson(), document.toJson()); // Before diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 66d576a455..4abc6f80ef 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -1444,9 +1444,9 @@ checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7" [[package]] name = "hashbrown" -version = "0.11.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "heck" @@ -1610,9 +1610,9 @@ checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" [[package]] name = "indexmap" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" +checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" dependencies = [ "autocfg", "hashbrown", diff --git a/frontend/rust-lib/flowy-folder/src/entities/view.rs b/frontend/rust-lib/flowy-folder/src/entities/view.rs index af66502907..61e63c53cb 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/view.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/view.rs @@ -206,6 +206,15 @@ impl std::convert::From<&str> for ViewIdPB { } } +#[derive(Default, ProtoBuf, Clone, Debug)] +pub struct DeletedViewPB { + #[pb(index = 1)] + pub view_id: String, + + #[pb(index = 2, one_of)] + pub index: Option, +} + impl std::ops::Deref for ViewIdPB { type Target = str; diff --git a/frontend/rust-lib/flowy-folder/src/services/view/controller.rs b/frontend/rust-lib/flowy-folder/src/services/view/controller.rs index 402a68813b..3f2bf9aee0 100644 --- a/frontend/rust-lib/flowy-folder/src/services/view/controller.rs +++ b/frontend/rust-lib/flowy-folder/src/services/view/controller.rs @@ -1,5 +1,5 @@ pub use crate::entities::view::ViewDataTypePB; -use crate::entities::{ViewInfoPB, ViewLayoutTypePB}; +use crate::entities::{DeletedViewPB, ViewInfoPB, ViewLayoutTypePB}; use crate::manager::{ViewDataProcessor, ViewDataProcessorMap}; use crate::{ dart_notification::{send_dart_notification, FolderNotification}, @@ -122,12 +122,12 @@ impl ViewController { .await } - #[tracing::instrument(level = "debug", skip(self, view_id), fields(view_id = %view_id.value), err)] - pub(crate) async fn read_view(&self, view_id: ViewIdPB) -> Result { + #[tracing::instrument(level = "debug", skip(self, view_id), err)] + pub(crate) async fn read_view(&self, view_id: &str) -> Result { let view_rev = self .persistence .begin_transaction(|transaction| { - let view = transaction.read_view(&view_id.value)?; + let view = transaction.read_view(view_id)?; let trash_ids = self.trash_controller.read_trash_ids(&transaction)?; if trash_ids.contains(&view.id) { return Err(FlowyError::record_not_found()); @@ -135,7 +135,6 @@ impl ViewController { Ok(view) }) .await?; - let _ = self.read_view_on_server(view_id); Ok(view_rev) } @@ -201,9 +200,26 @@ impl ViewController { let _ = KV::remove(LATEST_VIEW_ID); } } - let view_id_pb = ViewIdPB::from(view_id.as_str()); + + let deleted_view = self + .persistence + .begin_transaction(|transaction| { + let view = transaction.read_view(&view_id)?; + let views = read_belonging_views_on_local(&view.app_id, self.trash_controller.clone(), &transaction)?; + + let index = views + .iter() + .position(|view| view.id == view_id) + .map(|index| index as i32); + Ok(DeletedViewPB { + view_id: view_id.clone(), + index, + }) + }) + .await?; + send_dart_notification(&view_id, FolderNotification::ViewMoveToTrash) - .payload(view_id_pb) + .payload(deleted_view) .send(); let processor = self.get_data_processor_from_view_id(&view_id).await?; diff --git a/frontend/rust-lib/flowy-folder/src/services/view/event_handler.rs b/frontend/rust-lib/flowy-folder/src/services/view/event_handler.rs index be7d61d9fe..2a7736e05e 100644 --- a/frontend/rust-lib/flowy-folder/src/services/view/event_handler.rs +++ b/frontend/rust-lib/flowy-folder/src/services/view/event_handler.rs @@ -31,7 +31,7 @@ pub(crate) async fn read_view_handler( controller: AppData>, ) -> DataResult { let view_id: ViewIdPB = data.into_inner(); - let view_rev = controller.read_view(view_id.clone()).await?; + let view_rev = controller.read_view(&view_id.value).await?; data_result(view_rev.into()) } diff --git a/frontend/rust-lib/flowy-grid/Cargo.toml b/frontend/rust-lib/flowy-grid/Cargo.toml index 709359560b..b52bb4582d 100644 --- a/frontend/rust-lib/flowy-grid/Cargo.toml +++ b/frontend/rust-lib/flowy-grid/Cargo.toml @@ -34,7 +34,7 @@ rayon = "1.5.2" serde = { version = "1.0", features = ["derive"] } serde_json = {version = "1.0"} serde_repr = "0.1" -indexmap = {version = "1.8.1", features = ["serde"]} +indexmap = {version = "1.9.1", features = ["serde"]} fancy-regex = "0.10.0" regex = "1.5.6" url = { version = "2"} diff --git a/frontend/rust-lib/flowy-grid/src/entities/cell_entities.rs b/frontend/rust-lib/flowy-grid/src/entities/cell_entities.rs index 3493f0940c..48886f1918 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/cell_entities.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/cell_entities.rs @@ -1,3 +1,4 @@ +use crate::entities::FieldType; use flowy_derive::ProtoBuf; use flowy_error::ErrorCode; use flowy_grid_data_model::parser::NotEmptyStr; @@ -74,15 +75,20 @@ pub struct GridCellPB { #[pb(index = 1)] pub field_id: String, + // The data was encoded in field_type's data type #[pb(index = 2)] pub data: Vec, + + #[pb(index = 3, one_of)] + pub field_type: Option, } impl GridCellPB { - pub fn new(field_id: &str, data: Vec) -> Self { + pub fn new(field_id: &str, field_type: FieldType, data: Vec) -> Self { Self { field_id: field_id.to_owned(), data, + field_type: Some(field_type), } } @@ -90,6 +96,7 @@ impl GridCellPB { Self { field_id: field_id.to_owned(), data: vec![], + field_type: None, } } } diff --git a/frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs b/frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs index 63b883d570..b6c9aafaff 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs @@ -98,6 +98,7 @@ pub struct MoveGroupPayloadPB { pub to_group_id: String, } +#[derive(Debug)] pub struct MoveGroupParams { pub view_id: String, pub from_group_id: String, diff --git a/frontend/rust-lib/flowy-grid/src/services/cell/cell_operation.rs b/frontend/rust-lib/flowy-grid/src/services/cell/cell_operation.rs index 3d6f951dfd..43b2eb420d 100644 --- a/frontend/rust-lib/flowy-grid/src/services/cell/cell_operation.rs +++ b/frontend/rust-lib/flowy-grid/src/services/cell/cell_operation.rs @@ -24,14 +24,28 @@ pub trait CellDisplayable { decoded_field_type: &FieldType, field_rev: &FieldRevision, ) -> FlowyResult; + + fn display_string( + &self, + cell_data: CellData, + decoded_field_type: &FieldType, + field_rev: &FieldRevision, + ) -> FlowyResult; } // CD: Short for CellData. This type is the type return by apply_changeset function. // CS: Short for Changeset. Parse the string into specific Changeset type. pub trait CellDataOperation { - /// The cell_data is able to parse into the specific data if CD impl the FromCellData trait. - /// For example: - /// URLCellData, DateCellData. etc. + /// Decode the cell data into `CD` that is certain type of data. + /// + /// Each `CD` type represents as a specific field type data. For example: + /// FieldType::URL => URLCellData + /// FieldType::Date=> DateCellData + /// + /// `decoded_field_type`: the field type of the cell data + /// + /// Returns the error if the cell data can't be parsed into `CD`. + /// fn decode_cell_data( &self, cell_data: CellData, @@ -77,16 +91,16 @@ pub fn apply_cell_data_changeset>( pub fn decode_any_cell_data + Debug>( data: T, field_rev: &FieldRevision, -) -> CellBytes { +) -> (FieldType, CellBytes) { + let to_field_type = field_rev.ty.into(); match data.try_into() { Ok(any_cell_data) => { let AnyCellData { data, field_type } = any_cell_data; - let to_field_type = field_rev.ty.into(); - match try_decode_cell_data(data.into(), field_rev, &field_type, &to_field_type) { - Ok(cell_bytes) => cell_bytes, + match try_decode_cell_data(data.into(), &field_type, &to_field_type, field_rev) { + Ok(cell_bytes) => (field_type, cell_bytes), Err(e) => { tracing::error!("Decode cell data failed, {:?}", e); - CellBytes::default() + (field_type, CellBytes::default()) } } } @@ -94,42 +108,93 @@ pub fn decode_any_cell_data + Debug> // It's okay to ignore this error, because it's okay that the current cell can't // display the existing cell data. For example, the UI of the text cell will be blank if // the type of the data of cell is Number. - CellBytes::default() + + (to_field_type, CellBytes::default()) } } } +pub fn decode_cell_data_to_string( + cell_data: CellData, + from_field_type: &FieldType, + to_field_type: &FieldType, + field_rev: &FieldRevision, +) -> FlowyResult { + let cell_data = cell_data.try_into_inner()?; + let get_cell_display_str = || { + let field_type: FieldTypeRevision = to_field_type.into(); + let result = match to_field_type { + FieldType::RichText => field_rev + .get_type_option::(field_type)? + .display_string(cell_data.into(), from_field_type, field_rev), + FieldType::Number => field_rev + .get_type_option::(field_type)? + .display_string(cell_data.into(), from_field_type, field_rev), + FieldType::DateTime => field_rev + .get_type_option::(field_type)? + .display_string(cell_data.into(), from_field_type, field_rev), + FieldType::SingleSelect => field_rev + .get_type_option::(field_type)? + .display_string(cell_data.into(), from_field_type, field_rev), + FieldType::MultiSelect => field_rev + .get_type_option::(field_type)? + .display_string(cell_data.into(), from_field_type, field_rev), + FieldType::Checkbox => field_rev + .get_type_option::(field_type)? + .display_string(cell_data.into(), from_field_type, field_rev), + FieldType::URL => field_rev + .get_type_option::(field_type)? + .display_string(cell_data.into(), from_field_type, field_rev), + }; + Some(result) + }; + + match get_cell_display_str() { + Some(Ok(s)) => Ok(s), + Some(Err(err)) => { + tracing::error!("{:?}", err); + Ok("".to_owned()) + } + None => Ok("".to_owned()), + } +} + +/// Use the `to_field_type`'s TypeOption to parse the cell data into `from_field_type` type's data. +/// +/// Each `FieldType` has its corresponding `TypeOption` that implements the `CellDisplayable` +/// and `CellDataOperation` traits. +/// pub fn try_decode_cell_data( cell_data: CellData, + from_field_type: &FieldType, + to_field_type: &FieldType, field_rev: &FieldRevision, - s_field_type: &FieldType, - t_field_type: &FieldType, ) -> FlowyResult { let cell_data = cell_data.try_into_inner()?; let get_cell_data = || { - let field_type: FieldTypeRevision = t_field_type.into(); - let data = match t_field_type { + let field_type: FieldTypeRevision = to_field_type.into(); + let data = match to_field_type { FieldType::RichText => field_rev .get_type_option::(field_type)? - .decode_cell_data(cell_data.into(), s_field_type, field_rev), + .decode_cell_data(cell_data.into(), from_field_type, field_rev), FieldType::Number => field_rev .get_type_option::(field_type)? - .decode_cell_data(cell_data.into(), s_field_type, field_rev), + .decode_cell_data(cell_data.into(), from_field_type, field_rev), FieldType::DateTime => field_rev .get_type_option::(field_type)? - .decode_cell_data(cell_data.into(), s_field_type, field_rev), + .decode_cell_data(cell_data.into(), from_field_type, field_rev), FieldType::SingleSelect => field_rev .get_type_option::(field_type)? - .decode_cell_data(cell_data.into(), s_field_type, field_rev), + .decode_cell_data(cell_data.into(), from_field_type, field_rev), FieldType::MultiSelect => field_rev .get_type_option::(field_type)? - .decode_cell_data(cell_data.into(), s_field_type, field_rev), + .decode_cell_data(cell_data.into(), from_field_type, field_rev), FieldType::Checkbox => field_rev .get_type_option::(field_type)? - .decode_cell_data(cell_data.into(), s_field_type, field_rev), + .decode_cell_data(cell_data.into(), from_field_type, field_rev), FieldType::URL => field_rev .get_type_option::(field_type)? - .decode_cell_data(cell_data.into(), s_field_type, field_rev), + .decode_cell_data(cell_data.into(), from_field_type, field_rev), }; Some(data) }; @@ -224,6 +289,12 @@ where } } +impl std::convert::From for CellData { + fn from(n: usize) -> Self { + CellData(Some(n.to_string())) + } +} + impl std::convert::From for CellData { fn from(val: T) -> Self { CellData(Some(val)) diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs index fbb1ba64e8..e6d7b39d48 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs @@ -48,6 +48,16 @@ impl CellDisplayable for CheckboxTypeOptionPB { let cell_data = cell_data.try_into_inner()?; Ok(CellBytes::new(cell_data)) } + + fn display_string( + &self, + cell_data: CellData, + _decoded_field_type: &FieldType, + _field_rev: &FieldRevision, + ) -> FlowyResult { + let cell_data = cell_data.try_into_inner()?; + Ok(cell_data.to_string()) + } } impl CellDataOperation for CheckboxTypeOptionPB { diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_type_option.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_type_option.rs index e79ae3574c..f883790524 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_type_option.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_type_option.rs @@ -127,6 +127,17 @@ impl CellDisplayable for DateTypeOptionPB { let date_cell_data = self.today_desc_from_timestamp(timestamp); CellBytes::from(date_cell_data) } + + fn display_string( + &self, + cell_data: CellData, + _decoded_field_type: &FieldType, + _field_rev: &FieldRevision, + ) -> FlowyResult { + let timestamp = cell_data.try_into_inner()?; + let date_cell_data = self.today_desc_from_timestamp(timestamp); + Ok(date_cell_data.date) + } } impl CellDataOperation for DateTypeOptionPB { diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/format.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/format.rs index 00de383096..0a8029e869 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/format.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/format.rs @@ -70,6 +70,15 @@ define_currency_set!( symbol: "RUB", symbol_first: false, }, + PERCENT : { + code: "", + exponent: 2, + locale: EnIn, + minor_units: 1, + name: "percent", + symbol: "%", + symbol_first: false, + }, USD : { code: "USD", exponent: 2, @@ -435,7 +444,7 @@ impl NumberFormat { NumberFormat::Leu => number_currency::RON, NumberFormat::ArgentinePeso => number_currency::ARS, NumberFormat::UruguayanPeso => number_currency::UYU, - NumberFormat::Percent => number_currency::USD, + NumberFormat::Percent => number_currency::PERCENT, } } diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_tests.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_tests.rs index 41d4cb212c..f92e8c6d90 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_tests.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_tests.rs @@ -93,6 +93,11 @@ mod tests { assert_number(&type_option, "€0.5", "€0,5", &field_type, &field_rev); assert_number(&type_option, "€1844", "€1.844", &field_type, &field_rev); } + NumberFormat::Percent => { + assert_number(&type_option, "1", "1%", &field_type, &field_rev); + assert_number(&type_option, "10.1", "10.1%", &field_type, &field_rev); + assert_number(&type_option, "100", "100%", &field_type, &field_rev); + } _ => {} } } diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_type_option.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_type_option.rs index fe4ddcf5fa..d35e812d77 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_type_option.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_type_option.rs @@ -1,6 +1,6 @@ use crate::entities::FieldType; use crate::impl_type_option; -use crate::services::cell::{CellBytes, CellData, CellDataChangeset, CellDataOperation}; +use crate::services::cell::{CellBytes, CellData, CellDataChangeset, CellDataOperation, CellDisplayable}; use crate::services::field::type_options::number_type_option::format::*; use crate::services::field::{BoxTypeOptionBuilder, NumberCellData, TypeOptionBuilder}; use bytes::Bytes; @@ -77,7 +77,7 @@ impl NumberTypeOptionPB { pub(crate) fn format_cell_data(&self, s: &str) -> FlowyResult { match self.format { - NumberFormat::Num | NumberFormat::Percent => match Decimal::from_str(s) { + NumberFormat::Num => match Decimal::from_str(s) { Ok(value, ..) => Ok(NumberCellData::from_decimal(value)), Err(_) => Ok(NumberCellData::new()), }, @@ -102,17 +102,13 @@ pub(crate) fn strip_currency_symbol(s: T) -> String { s } -impl CellDataOperation for NumberTypeOptionPB { - fn decode_cell_data( +impl CellDisplayable for NumberTypeOptionPB { + fn display_data( &self, cell_data: CellData, - decoded_field_type: &FieldType, + _decoded_field_type: &FieldType, _field_rev: &FieldRevision, ) -> FlowyResult { - if decoded_field_type.is_date() { - return Ok(CellBytes::default()); - } - let cell_data: String = cell_data.try_into_inner()?; match self.format_cell_data(&cell_data) { Ok(num) => Ok(CellBytes::new(num.to_string())), @@ -120,6 +116,31 @@ impl CellDataOperation for NumberTypeOptionPB { } } + fn display_string( + &self, + cell_data: CellData, + _decoded_field_type: &FieldType, + _field_rev: &FieldRevision, + ) -> FlowyResult { + let cell_data: String = cell_data.try_into_inner()?; + Ok(cell_data) + } +} + +impl CellDataOperation for NumberTypeOptionPB { + fn decode_cell_data( + &self, + cell_data: CellData, + decoded_field_type: &FieldType, + field_rev: &FieldRevision, + ) -> FlowyResult { + if decoded_field_type.is_date() { + return Ok(CellBytes::default()); + } + + self.display_data(cell_data, decoded_field_type, field_rev) + } + fn apply_changeset( &self, changeset: CellDataChangeset, diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/select_option.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/select_option.rs index ff579f8172..7d40903fe8 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/select_option.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/select_option.rs @@ -120,6 +120,21 @@ where ) -> FlowyResult { CellBytes::from(self.selected_select_option(cell_data)) } + + fn display_string( + &self, + cell_data: CellData, + _decoded_field_type: &FieldType, + _field_rev: &FieldRevision, + ) -> FlowyResult { + Ok(self + .selected_select_option(cell_data) + .select_options + .into_iter() + .map(|option| option.name) + .collect::>() + .join(SELECTION_IDS_SEPARATOR)) + } } pub fn select_option_operation(field_rev: &FieldRevision) -> FlowyResult> { diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/mod.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/mod.rs index c7e518f103..b6bf9a1a1b 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/mod.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/mod.rs @@ -1,3 +1,5 @@ #![allow(clippy::module_inception)] +mod text_tests; mod text_type_option; + pub use text_type_option::*; diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_tests.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_tests.rs new file mode 100644 index 0000000000..2f2a5ed188 --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_tests.rs @@ -0,0 +1,49 @@ +#[cfg(test)] +mod tests { + use crate::entities::FieldType; + use crate::services::cell::CellDataOperation; + use crate::services::field::FieldBuilder; + use crate::services::field::*; + + // Test parser the cell data which field's type is FieldType::Date to cell data + // which field's type is FieldType::Text + #[test] + fn date_type_to_text_type() { + let type_option = RichTextTypeOptionPB::default(); + let field_type = FieldType::DateTime; + let field_rev = FieldBuilder::from_field_type(&field_type).build(); + + assert_eq!( + type_option + .decode_cell_data(1647251762.into(), &field_type, &field_rev) + .unwrap() + .parser::() + .unwrap() + .as_ref(), + "Mar 14,2022" + ); + } + + // Test parser the cell data which field's type is FieldType::SingleSelect to cell data + // which field's type is FieldType::Text + #[test] + fn single_select_to_text_type() { + let type_option = RichTextTypeOptionPB::default(); + + let field_type = FieldType::SingleSelect; + let done_option = SelectOptionPB::new("Done"); + let option_id = done_option.id.clone(); + let single_select = SingleSelectTypeOptionBuilder::default().add_option(done_option.clone()); + let field_rev = FieldBuilder::new(single_select).build(); + + assert_eq!( + type_option + .decode_cell_data(option_id.into(), &field_type, &field_rev) + .unwrap() + .parser::() + .unwrap() + .to_string(), + done_option.name, + ); + } +} diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_type_option.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_type_option.rs index 2890289254..ab33c1345b 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_type_option.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_type_option.rs @@ -1,8 +1,8 @@ use crate::entities::FieldType; use crate::impl_type_option; use crate::services::cell::{ - try_decode_cell_data, CellBytes, CellBytesParser, CellData, CellDataChangeset, CellDataOperation, CellDisplayable, - FromCellString, + decode_cell_data_to_string, CellBytes, CellBytesParser, CellData, CellDataChangeset, CellDataOperation, + CellDisplayable, FromCellString, }; use crate::services::field::{BoxTypeOptionBuilder, TypeOptionBuilder}; use bytes::Bytes; @@ -44,6 +44,16 @@ impl CellDisplayable for RichTextTypeOptionPB { let cell_str: String = cell_data.try_into_inner()?; Ok(CellBytes::new(cell_str)) } + + fn display_string( + &self, + cell_data: CellData, + _decoded_field_type: &FieldType, + _field_rev: &FieldRevision, + ) -> FlowyResult { + let cell_str: String = cell_data.try_into_inner()?; + Ok(cell_str) + } } impl CellDataOperation for RichTextTypeOptionPB { @@ -57,8 +67,10 @@ impl CellDataOperation for RichTextTypeOptionPB { || decoded_field_type.is_single_select() || decoded_field_type.is_multi_select() || decoded_field_type.is_number() + || decoded_field_type.is_url() { - try_decode_cell_data(cell_data, field_rev, decoded_field_type, decoded_field_type) + let s = decode_cell_data_to_string(cell_data, decoded_field_type, decoded_field_type, field_rev); + Ok(CellBytes::new(s.unwrap_or_else(|_| "".to_owned()))) } else { self.display_data(cell_data, decoded_field_type, field_rev) } @@ -85,6 +97,14 @@ impl AsRef for TextCellData { } } +impl std::ops::Deref for TextCellData { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + impl FromCellString for TextCellData { fn from_cell_str(s: &str) -> FlowyResult where @@ -94,6 +114,12 @@ impl FromCellString for TextCellData { } } +impl ToString for TextCellData { + fn to_string(&self) -> String { + self.0.clone() + } +} + pub struct TextCellDataParser(); impl CellBytesParser for TextCellDataParser { type Object = TextCellData; @@ -104,85 +130,3 @@ impl CellBytesParser for TextCellDataParser { } } } - -#[cfg(test)] -mod tests { - use crate::entities::FieldType; - use crate::services::cell::CellDataOperation; - - use crate::services::field::FieldBuilder; - use crate::services::field::*; - - #[test] - fn text_description_test() { - let type_option = RichTextTypeOptionPB::default(); - - // date - let field_type = FieldType::DateTime; - let date_time_field_rev = FieldBuilder::from_field_type(&field_type).build(); - - assert_eq!( - type_option - .decode_cell_data(1647251762.to_string().into(), &field_type, &date_time_field_rev) - .unwrap() - .parser::() - .unwrap() - .date, - "Mar 14,2022".to_owned() - ); - - // Single select - let done_option = SelectOptionPB::new("Done"); - let done_option_id = done_option.id.clone(); - let single_select = SingleSelectTypeOptionBuilder::default().add_option(done_option.clone()); - let single_select_field_rev = FieldBuilder::new(single_select).build(); - - assert_eq!( - type_option - .decode_cell_data( - done_option_id.into(), - &FieldType::SingleSelect, - &single_select_field_rev - ) - .unwrap() - .parser::() - .unwrap() - .select_options, - vec![done_option], - ); - - // Multiple select - let google_option = SelectOptionPB::new("Google"); - let facebook_option = SelectOptionPB::new("Facebook"); - let ids = vec![google_option.id.clone(), facebook_option.id.clone()].join(SELECTION_IDS_SEPARATOR); - let cell_data_changeset = SelectOptionCellChangeset::from_insert(&ids).to_str(); - let multi_select = MultiSelectTypeOptionBuilder::default() - .add_option(google_option.clone()) - .add_option(facebook_option.clone()); - let multi_select_field_rev = FieldBuilder::new(multi_select).build(); - let multi_type_option = MultiSelectTypeOptionPB::from(&multi_select_field_rev); - let cell_data = multi_type_option - .apply_changeset(cell_data_changeset.into(), None) - .unwrap(); - assert_eq!( - type_option - .decode_cell_data(cell_data.into(), &FieldType::MultiSelect, &multi_select_field_rev) - .unwrap() - .parser::() - .unwrap() - .select_options, - vec![google_option, facebook_option] - ); - - //Number - let number = NumberTypeOptionBuilder::default().set_format(NumberFormat::USD); - let number_field_rev = FieldBuilder::new(number).build(); - assert_eq!( - type_option - .decode_cell_data("18443".to_owned().into(), &FieldType::Number, &number_field_rev) - .unwrap() - .to_string(), - "$18,443".to_owned() - ); - } -} diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_type_option.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_type_option.rs index d34dfc12d3..5e426f368f 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_type_option.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_type_option.rs @@ -42,6 +42,16 @@ impl CellDisplayable for URLTypeOptionPB { let cell_data: URLCellDataPB = cell_data.try_into_inner()?; CellBytes::from(cell_data) } + + fn display_string( + &self, + cell_data: CellData, + _decoded_field_type: &FieldType, + _field_rev: &FieldRevision, + ) -> FlowyResult { + let cell_data: URLCellDataPB = cell_data.try_into_inner()?; + Ok(cell_data.content) + } } impl CellDataOperation for URLTypeOptionPB { diff --git a/frontend/rust-lib/flowy-grid/src/services/grid_editor.rs b/frontend/rust-lib/flowy-grid/src/services/grid_editor.rs index ccad53bf5c..29f7b759b8 100644 --- a/frontend/rust-lib/flowy-grid/src/services/grid_editor.rs +++ b/frontend/rust-lib/flowy-grid/src/services/grid_editor.rs @@ -368,6 +368,7 @@ impl GridRevisionEditor { Ok(row_pb) } + #[tracing::instrument(level = "trace", skip_all, err)] pub async fn move_group(&self, params: MoveGroupParams) -> FlowyResult<()> { let _ = self.view_manager.move_group(params).await?; Ok(()) @@ -435,14 +436,18 @@ impl GridRevisionEditor { } pub async fn get_cell(&self, params: &GridCellIdParams) -> Option { - let cell_bytes = self.get_cell_bytes(params).await?; - Some(GridCellPB::new(¶ms.field_id, cell_bytes.to_vec())) + let (field_type, cell_bytes) = self.decode_any_cell_data(params).await?; + Some(GridCellPB::new(¶ms.field_id, field_type, cell_bytes.to_vec())) } pub async fn get_cell_bytes(&self, params: &GridCellIdParams) -> Option { + let (_, cell_data) = self.decode_any_cell_data(params).await?; + Some(cell_data) + } + + async fn decode_any_cell_data(&self, params: &GridCellIdParams) -> Option<(FieldType, CellBytes)> { let field_rev = self.get_field_rev(¶ms.field_id).await?; let row_rev = self.block_manager.get_row_rev(¶ms.row_id).await.ok()??; - let cell_rev = row_rev.cells.get(¶ms.field_id)?.clone(); Some(decode_any_cell_data(cell_rev.data, &field_rev)) } diff --git a/frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs b/frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs index 0889aec43e..6316135f5a 100644 --- a/frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs +++ b/frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs @@ -173,6 +173,7 @@ impl GridViewRevisionEditor { Ok(groups.into_iter().map(GroupPB::from).collect()) } + #[tracing::instrument(level = "trace", skip(self), err)] pub(crate) async fn move_group(&self, params: MoveGroupParams) -> FlowyResult<()> { let _ = self .group_controller @@ -180,7 +181,7 @@ impl GridViewRevisionEditor { .await .move_group(¶ms.from_group_id, ¶ms.to_group_id)?; match self.group_controller.read().await.get_group(¶ms.from_group_id) { - None => {} + None => tracing::warn!("Can not find the group with id: {}", params.from_group_id), Some((index, group)) => { let inserted_group = InsertedGroupPB { group: GroupPB::from(group), @@ -201,6 +202,10 @@ impl GridViewRevisionEditor { Ok(()) } + pub(crate) async fn group_id(&self) -> String { + self.group_controller.read().await.field_id().to_owned() + } + pub(crate) async fn get_setting(&self) -> GridSettingPB { let field_revs = self.field_delegate.get_field_revs().await; let grid_setting = make_grid_setting(&*self.pad.read().await, &field_revs); @@ -224,7 +229,11 @@ impl GridViewRevisionEditor { let _ = self .modify(|pad| { let configuration = default_group_configuration(&field_rev); - let changeset = pad.insert_group(¶ms.field_id, ¶ms.field_type_rev, configuration)?; + let changeset = pad.insert_or_update_group_configuration( + ¶ms.field_id, + ¶ms.field_type_rev, + configuration, + )?; Ok(changeset) }) .await?; @@ -492,10 +501,11 @@ impl GroupConfigurationWriter for GroupConfigurationWriterImpl { let field_id = field_id.to_owned(); wrap_future(async move { - let changeset = view_pad - .write() - .await - .insert_group(&field_id, &field_type, group_configuration)?; + let changeset = view_pad.write().await.insert_or_update_group_configuration( + &field_id, + &field_type, + group_configuration, + )?; if let Some(changeset) = changeset { let _ = apply_change(&user_id, rev_manager, changeset).await?; diff --git a/frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs b/frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs index d548cbc9cb..98b8ee51b9 100644 --- a/frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs +++ b/frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs @@ -178,12 +178,16 @@ impl GridViewManager { #[tracing::instrument(level = "trace", skip(self), err)] pub(crate) async fn did_update_field(&self, field_id: &str, is_type_option_changed: bool) -> FlowyResult<()> { let view_editor = self.get_default_view_editor().await?; + // Only the field_id of the updated field is equal to the field_id of the group. + // Update the group + if view_editor.group_id().await != field_id { + return Ok(()); + } if is_type_option_changed { let _ = view_editor.group_by_field(field_id).await?; } else { let _ = view_editor.did_update_field(field_id).await?; } - Ok(()) } diff --git a/frontend/rust-lib/flowy-grid/src/services/group/configuration.rs b/frontend/rust-lib/flowy-grid/src/services/group/configuration.rs index 07e5ba45d4..8f3df66727 100644 --- a/frontend/rust-lib/flowy-grid/src/services/group/configuration.rs +++ b/frontend/rust-lib/flowy-grid/src/services/group/configuration.rs @@ -1,5 +1,5 @@ use crate::entities::{GroupPB, GroupViewChangesetPB}; -use crate::services::group::{default_group_configuration, GeneratedGroup, Group}; +use crate::services::group::{default_group_configuration, make_default_group, GeneratedGroup, Group}; use flowy_error::{FlowyError, FlowyResult}; use flowy_grid_data_model::revision::{ FieldRevision, FieldTypeRevision, GroupConfigurationContentSerde, GroupConfigurationRevision, GroupRevision, @@ -29,10 +29,7 @@ impl std::fmt::Display for GroupContext { self.groups_map.iter().for_each(|(_, group)| { let _ = f.write_fmt(format_args!("Group:{} has {} rows \n", group.id, group.rows.len())); }); - let _ = f.write_fmt(format_args!( - "Default group has {} rows \n", - self.default_group.rows.len() - )); + Ok(()) } } @@ -44,7 +41,7 @@ pub struct GroupContext { field_rev: Arc, groups_map: IndexMap, /// default_group is used to store the rows that don't belong to any groups. - default_group: Group, + // default_group: Group, writer: Arc, } @@ -59,16 +56,6 @@ where reader: Arc, writer: Arc, ) -> FlowyResult { - let default_group_id = format!("{}_default_group", view_id); - let default_group = Group { - id: default_group_id, - field_id: field_rev.id.clone(), - name: format!("No {}", field_rev.name), - is_default: true, - is_visible: true, - rows: vec![], - filter_content: "".to_string(), - }; let configuration = match reader.get_configuration().await { None => { let default_configuration = default_group_configuration(&field_rev); @@ -80,24 +67,22 @@ where Some(configuration) => configuration, }; - // let configuration = C::from_configuration_content(&configuration_rev.content)?; Ok(Self { view_id, field_rev, groups_map: IndexMap::new(), - default_group, writer, configuration, configuration_content: PhantomData, }) } - pub(crate) fn get_default_group(&self) -> &Group { - &self.default_group + pub(crate) fn get_default_group(&self) -> Option<&Group> { + self.groups_map.get(&self.field_rev.id) } - pub(crate) fn get_mut_default_group(&mut self) -> &mut Group { - &mut self.default_group + pub(crate) fn get_mut_default_group(&mut self) -> Option<&mut Group> { + self.groups_map.get_mut(&self.field_rev.id) } /// Returns the groups without the default group @@ -122,8 +107,6 @@ where self.groups_map.iter_mut().for_each(|(_, group)| { each(group); }); - - each(&mut self.default_group); } pub(crate) fn move_group(&mut self, from_id: &str, to_id: &str) -> FlowyResult<()> { @@ -131,18 +114,23 @@ where let to_index = self.groups_map.get_index_of(to_id); match (from_index, to_index) { (Some(from_index), Some(to_index)) => { - self.groups_map.swap_indices(from_index, to_index); + self.groups_map.move_index(from_index, to_index); + self.mut_configuration(|configuration| { let from_index = configuration.groups.iter().position(|group| group.id == from_id); let to_index = configuration.groups.iter().position(|group| group.id == to_id); - if let (Some(from), Some(to)) = (from_index, to_index) { - configuration.groups.swap(from, to); + tracing::info!("Configuration groups: {:?} ", configuration.groups); + if let (Some(from), Some(to)) = &(from_index, to_index) { + tracing::trace!("Move group from index:{:?} to index:{:?}", from_index, to_index); + let group = configuration.groups.remove(*from); + configuration.groups.insert(*to, group); } - true + + from_index.is_some() && to_index.is_some() })?; Ok(()) } - _ => Err(FlowyError::out_of_bounds()), + _ => Err(FlowyError::record_not_found().context("Moving group failed. Groups are not exist")), } } @@ -150,7 +138,6 @@ where pub(crate) fn init_groups( &mut self, generated_groups: Vec, - reset: bool, ) -> FlowyResult> { let mut new_groups = vec![]; let mut filter_content_map = HashMap::new(); @@ -159,16 +146,17 @@ where new_groups.push(generate_group.group_rev); }); + let mut old_groups = self.configuration.groups.clone(); + if !old_groups.iter().any(|group| group.id == self.field_rev.id) { + old_groups.push(make_default_group(&self.field_rev)); + } + let MergeGroupResult { mut all_group_revs, new_group_revs, updated_group_revs: _, deleted_group_revs, - } = if reset { - merge_groups(&[], new_groups) - } else { - merge_groups(&self.configuration.groups, new_groups) - }; + } = merge_groups(old_groups, new_groups); let deleted_group_ids = deleted_group_revs .into_iter() @@ -197,31 +185,23 @@ where Some(pos) => { let mut old_group = configuration.groups.remove(pos); group_rev.update_with_other(&old_group); + is_changed = is_group_changed(group_rev, &old_group); - // Take the GroupRevision if the name has changed - if is_group_changed(group_rev, &old_group) { - old_group.name = group_rev.name.clone(); - is_changed = true; - configuration.groups.insert(pos, old_group); - } + old_group.name = group_rev.name.clone(); + configuration.groups.insert(pos, old_group); } } } is_changed })?; - // The len of the filter_content_map should equal to the len of the all_group_revs - debug_assert_eq!(filter_content_map.len(), all_group_revs.len()); all_group_revs.into_iter().for_each(|group_rev| { - if let Some(filter_content) = filter_content_map.get(&group_rev.id) { - let group = Group::new( - group_rev.id, - self.field_rev.id.clone(), - group_rev.name, - filter_content.clone(), - ); - self.groups_map.insert(group.id.clone(), group); - } + let filter_content = filter_content_map + .get(&group_rev.id) + .cloned() + .unwrap_or_else(|| "".to_owned()); + let group = Group::new(group_rev.id, self.field_rev.id.clone(), group_rev.name, filter_content); + self.groups_map.insert(group.id.clone(), group); }); let new_groups = new_group_revs @@ -269,6 +249,7 @@ where Ok(()) } + #[tracing::instrument(level = "trace", skip_all, err)] pub fn save_configuration(&self) -> FlowyResult<()> { let configuration = (&*self.configuration).clone(); let writer = self.writer.clone(); @@ -311,13 +292,14 @@ where } } -fn merge_groups(old_groups: &[GroupRevision], new_groups: Vec) -> MergeGroupResult { +fn merge_groups(old_groups: Vec, new_groups: Vec) -> MergeGroupResult { let mut merge_result = MergeGroupResult::new(); - if old_groups.is_empty() { - merge_result.all_group_revs = new_groups.clone(); - merge_result.new_group_revs = new_groups; - return merge_result; - } + // if old_groups.is_empty() { + // merge_result.all_group_revs.extend(new_groups.clone()); + // merge_result.all_group_revs.push(default_group); + // merge_result.new_group_revs = new_groups; + // return merge_result; + // } // group_map is a helper map is used to filter out the new groups. let mut new_group_map: IndexMap = IndexMap::new(); @@ -329,19 +311,20 @@ fn merge_groups(old_groups: &[GroupRevision], new_groups: Vec) -> for old in old_groups { if let Some(new) = new_group_map.remove(&old.id) { merge_result.all_group_revs.push(new.clone()); - if is_group_changed(&new, old) { + if is_group_changed(&new, &old) { merge_result.updated_group_revs.push(new); } } else { - merge_result.deleted_group_revs.push(old.clone()); + merge_result.all_group_revs.push(old); } } // Find out the new groups + new_group_map.reverse(); let new_groups = new_group_map.into_values(); for (_, group) in new_groups.into_iter().enumerate() { - merge_result.all_group_revs.push(group.clone()); - merge_result.new_group_revs.push(group); + merge_result.all_group_revs.insert(0, group.clone()); + merge_result.new_group_revs.insert(0, group); } merge_result } diff --git a/frontend/rust-lib/flowy-grid/src/services/group/controller.rs b/frontend/rust-lib/flowy-grid/src/services/group/controller.rs index f65b87e829..1a7bc8d218 100644 --- a/frontend/rust-lib/flowy-grid/src/services/group/controller.rs +++ b/frontend/rust-lib/flowy-grid/src/services/group/controller.rs @@ -88,7 +88,7 @@ where pub async fn new(field_rev: &Arc, mut configuration: GroupContext) -> FlowyResult { let type_option = field_rev.get_type_option::(field_rev.ty); let groups = G::generate_groups(&field_rev.id, &configuration, &type_option); - let _ = configuration.init_groups(groups, true)?; + let _ = configuration.init_groups(groups)?; Ok(Self { field_id: field_rev.id.clone(), @@ -105,8 +105,8 @@ where &mut self, row_rev: &RowRevision, other_group_changesets: &[GroupChangesetPB], - ) -> GroupChangesetPB { - let default_group = self.group_ctx.get_mut_default_group(); + ) -> Option { + let default_group = self.group_ctx.get_mut_default_group()?; // [other_group_inserted_row] contains all the inserted rows except the default group. let other_group_inserted_row = other_group_changesets @@ -163,7 +163,7 @@ where } default_group.rows.retain(|row| !deleted_row_ids.contains(&row.id)); changeset.deleted_rows.extend(deleted_row_ids); - changeset + Some(changeset) } } @@ -182,11 +182,14 @@ where fn groups(&self) -> Vec { if self.use_default_group() { - let mut groups: Vec = self.group_ctx.groups().into_iter().cloned().collect(); - groups.push(self.group_ctx.get_default_group().clone()); - groups - } else { self.group_ctx.groups().into_iter().cloned().collect() + } else { + self.group_ctx + .groups() + .into_iter() + .filter(|group| group.id != self.field_id) + .cloned() + .collect::>() } } @@ -205,7 +208,7 @@ where if let Some(cell_rev) = cell_rev { let mut grouped_rows: Vec = vec![]; - let cell_bytes = decode_any_cell_data(cell_rev.data, field_rev); + let cell_bytes = decode_any_cell_data(cell_rev.data, field_rev).1; let cell_data = cell_bytes.parser::

()?; for group in self.group_ctx.groups() { if self.can_group(&group.filter_content, &cell_data) { @@ -216,17 +219,18 @@ where } } - if grouped_rows.is_empty() { - self.group_ctx.get_mut_default_group().add_row(row_rev.into()); - } else { + if !grouped_rows.is_empty() { for group_row in grouped_rows { if let Some(group) = self.group_ctx.get_mut_group(&group_row.group_id) { group.add_row(group_row.row); } } + continue; } - } else { - self.group_ctx.get_mut_default_group().add_row(row_rev.into()); + } + match self.group_ctx.get_mut_default_group() { + None => {} + Some(default_group) => default_group.add_row(row_rev.into()), } } @@ -244,13 +248,14 @@ where field_rev: &FieldRevision, ) -> FlowyResult> { if let Some(cell_rev) = row_rev.cells.get(&self.field_id) { - let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev); + let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev).1; let cell_data = cell_bytes.parser::

()?; let mut changesets = self.add_row_if_match(row_rev, &cell_data); - let default_group_changeset = self.update_default_group(row_rev, &changesets); - tracing::trace!("default_group_changeset: {}", default_group_changeset); - if !default_group_changeset.is_empty() { - changesets.push(default_group_changeset); + if let Some(default_group_changeset) = self.update_default_group(row_rev, &changesets) { + tracing::trace!("default_group_changeset: {}", default_group_changeset); + if !default_group_changeset.is_empty() { + changesets.push(default_group_changeset); + } } Ok(changesets) } else { @@ -265,15 +270,16 @@ where ) -> FlowyResult> { // if the cell_rev is none, then the row must be crated from the default group. if let Some(cell_rev) = row_rev.cells.get(&self.field_id) { - let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev); + let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev).1; let cell_data = cell_bytes.parser::

()?; Ok(self.remove_row_if_match(row_rev, &cell_data)) - } else { - let group = self.group_ctx.get_default_group(); + } else if let Some(group) = self.group_ctx.get_default_group() { Ok(vec![GroupChangesetPB::delete( group.id.clone(), vec![row_rev.id.clone()], )]) + } else { + Ok(vec![]) } } @@ -285,7 +291,7 @@ where }; if let Some(cell_rev) = cell_rev { - let cell_bytes = decode_any_cell_data(cell_rev.data, context.field_rev); + let cell_bytes = decode_any_cell_data(cell_rev.data, context.field_rev).1; let cell_data = cell_bytes.parser::

()?; Ok(self.move_row(&cell_data, context)) } else { @@ -297,7 +303,7 @@ where fn did_update_field(&mut self, field_rev: &FieldRevision) -> FlowyResult> { let type_option = field_rev.get_type_option::(field_rev.ty); let groups = G::generate_groups(&field_rev.id, &self.group_ctx, &type_option); - let changeset = self.group_ctx.init_groups(groups, false)?; + let changeset = self.group_ctx.init_groups(groups)?; Ok(changeset) } } diff --git a/frontend/rust-lib/flowy-grid/src/services/group/entities.rs b/frontend/rust-lib/flowy-grid/src/services/group/entities.rs index c4687859f3..baa4842402 100644 --- a/frontend/rust-lib/flowy-grid/src/services/group/entities.rs +++ b/frontend/rust-lib/flowy-grid/src/services/group/entities.rs @@ -9,16 +9,17 @@ pub struct Group { pub is_visible: bool, pub(crate) rows: Vec, - /// [content] is used to determine which group the cell belongs to. + /// [filter_content] is used to determine which group the cell belongs to. pub filter_content: String, } impl Group { pub fn new(id: String, field_id: String, name: String, filter_content: String) -> Self { + let is_default = id == field_id; Self { id, field_id, - is_default: false, + is_default, is_visible: true, name, rows: vec![], diff --git a/frontend/rust-lib/flowy-grid/src/services/group/group_util.rs b/frontend/rust-lib/flowy-grid/src/services/group/group_util.rs index c15717e2a7..8a901fd869 100644 --- a/frontend/rust-lib/flowy-grid/src/services/group/group_util.rs +++ b/frontend/rust-lib/flowy-grid/src/services/group/group_util.rs @@ -8,8 +8,8 @@ use crate::services::group::{ use flowy_error::FlowyResult; use flowy_grid_data_model::revision::{ CheckboxGroupConfigurationRevision, DateGroupConfigurationRevision, FieldRevision, GroupConfigurationRevision, - LayoutRevision, NumberGroupConfigurationRevision, RowRevision, SelectOptionGroupConfigurationRevision, - TextGroupConfigurationRevision, UrlGroupConfigurationRevision, + GroupRevision, LayoutRevision, NumberGroupConfigurationRevision, RowRevision, + SelectOptionGroupConfigurationRevision, TextGroupConfigurationRevision, UrlGroupConfigurationRevision, }; use std::sync::Arc; @@ -79,7 +79,7 @@ pub fn default_group_configuration(field_rev: &FieldRevision) -> GroupConfigurat let field_id = field_rev.id.clone(); let field_type_rev = field_rev.ty; let field_type: FieldType = field_rev.ty.into(); - match field_type { + let mut group_configuration_rev = match field_type { FieldType::RichText => { GroupConfigurationRevision::new(field_id, field_type_rev, TextGroupConfigurationRevision::default()) .unwrap() @@ -112,5 +112,23 @@ pub fn default_group_configuration(field_rev: &FieldRevision) -> GroupConfigurat FieldType::URL => { GroupConfigurationRevision::new(field_id, field_type_rev, UrlGroupConfigurationRevision::default()).unwrap() } + }; + + // Append the no `status` group + let default_group_rev = GroupRevision { + id: field_rev.id.clone(), + name: format!("No {}", field_rev.name), + visible: true, + }; + + group_configuration_rev.groups.push(default_group_rev); + group_configuration_rev +} + +pub fn make_default_group(field_rev: &FieldRevision) -> GroupRevision { + GroupRevision { + id: field_rev.id.clone(), + name: format!("No {}", field_rev.name), + visible: true, } } diff --git a/frontend/rust-lib/flowy-grid/tests/grid/group_test/test.rs b/frontend/rust-lib/flowy-grid/tests/grid/group_test/test.rs index a205420498..42fdc6b61c 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/group_test/test.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/group_test/test.rs @@ -370,6 +370,28 @@ async fn group_move_group_test() { test.run_scripts(scripts).await; } +#[tokio::test] +async fn group_default_move_group_test() { + let mut test = GridGroupTest::new().await; + let group_0 = test.group_at_index(0).await; + let group_3 = test.group_at_index(3).await; + let scripts = vec![ + MoveGroup { + from_group_index: 3, + to_group_index: 0, + }, + AssertGroup { + group_index: 0, + expected_group: group_3, + }, + AssertGroup { + group_index: 1, + expected_group: group_0, + }, + ]; + test.run_scripts(scripts).await; +} + #[tokio::test] async fn group_insert_single_select_option_test() { let mut test = GridGroupTest::new().await; @@ -402,7 +424,7 @@ async fn group_group_by_other_field() { group_index: 1, row_count: 2, }, - AssertGroupCount(4), + AssertGroupCount(5), ]; test.run_scripts(scripts).await; } diff --git a/frontend/rust-lib/flowy-user/src/entities/user_setting.rs b/frontend/rust-lib/flowy-user/src/entities/user_setting.rs index 23c74b6d5f..1d4e77896b 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_setting.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_setting.rs @@ -1,5 +1,6 @@ use flowy_derive::ProtoBuf; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; #[derive(ProtoBuf, Default, Debug, Clone)] pub struct UserPreferencesPB { @@ -21,7 +22,11 @@ pub struct AppearanceSettingsPB { #[pb(index = 3)] #[serde(default = "DEFAULT_RESET_VALUE")] - pub reset_as_default: bool, + pub reset_to_default: bool, + + #[pb(index = 4)] + #[serde(default)] + pub setting_key_value: HashMap, } const DEFAULT_RESET_VALUE: fn() -> bool = || APPEARANCE_RESET_AS_DEFAULT; @@ -52,7 +57,8 @@ impl std::default::Default for AppearanceSettingsPB { AppearanceSettingsPB { theme: APPEARANCE_DEFAULT_THEME.to_owned(), locale: LocaleSettingsPB::default(), - reset_as_default: APPEARANCE_RESET_AS_DEFAULT, + reset_to_default: APPEARANCE_RESET_AS_DEFAULT, + setting_key_value: HashMap::default(), } } } diff --git a/shared-lib/Cargo.lock b/shared-lib/Cargo.lock index 79357da09b..e519a829cd 100644 --- a/shared-lib/Cargo.lock +++ b/shared-lib/Cargo.lock @@ -650,9 +650,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.11.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "heck" @@ -732,9 +732,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" +checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" dependencies = [ "autocfg", "hashbrown", diff --git a/shared-lib/flowy-grid-data-model/Cargo.toml b/shared-lib/flowy-grid-data-model/Cargo.toml index 3e640ec1af..671fdbb4e6 100644 --- a/shared-lib/flowy-grid-data-model/Cargo.toml +++ b/shared-lib/flowy-grid-data-model/Cargo.toml @@ -12,7 +12,7 @@ serde_json = {version = "1.0"} serde_repr = "0.1" nanoid = "0.4.0" flowy-error-code = { path = "../flowy-error-code"} -indexmap = {version = "1.8.1", features = ["serde"]} +indexmap = {version = "1.9.1", features = ["serde"]} tracing = { version = "0.1", features = ["log"] } [build-dependencies] diff --git a/shared-lib/flowy-grid-data-model/src/revision/group_rev.rs b/shared-lib/flowy-grid-data-model/src/revision/group_rev.rs index 0aded0d3c7..44a7a4e1f6 100644 --- a/shared-lib/flowy-grid-data-model/src/revision/group_rev.rs +++ b/shared-lib/flowy-grid-data-model/src/revision/group_rev.rs @@ -128,14 +128,6 @@ impl GroupRevision { } } - pub fn default_group(id: String, group_name: String) -> Self { - Self { - id, - name: group_name, - visible: true, - } - } - pub fn update_with_other(&mut self, other: &GroupRevision) { self.visible = other.visible } diff --git a/shared-lib/flowy-sync/src/client_grid/view_revision_pad.rs b/shared-lib/flowy-sync/src/client_grid/view_revision_pad.rs index 8613418c40..11e9d5a07e 100644 --- a/shared-lib/flowy-sync/src/client_grid/view_revision_pad.rs +++ b/shared-lib/flowy-sync/src/client_grid/view_revision_pad.rs @@ -66,7 +66,7 @@ impl GridViewRevisionPad { } #[tracing::instrument(level = "trace", skip_all, err)] - pub fn insert_group( + pub fn insert_or_update_group_configuration( &mut self, field_id: &str, field_type: &FieldTypeRevision,