diff --git a/CHANGELOG.md b/CHANGELOG.md index b485eb1211..e0b58100e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Release Notes +## Version 0.0.5.1 - 09/14/2022 + +New features +- Enable deleting a field in board +- Fix some bugs + + +## Version 0.0.5 - 09/08/2022 +New Features - Kanban Board like Notion and Trello beta +Boards are the best way to manage projects & tasks. Use them to group your databases by select, multiselect, and checkbox. + +

+ +- Set up columns that represent a specific phase of the project cycle and use cards to represent each project / task +- Drag and drop a card from one phase / column to another phase / column +- Update database properties in the Board view by clicking on a property and making edits on the card + +### Other Features & Improvements +- Settings allow users to change avatars +- Click and drag the right edge to resize your sidebar +- And many user interface improvements (link) + ## Version 0.0.5 - beta.2 - beta.1 - 09/01/2022 New features diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index 185dac15ad..bdecb73712 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -22,7 +22,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi" LIB_NAME = "dart_ffi" -CURRENT_APP_VERSION = "0.0.5" +CURRENT_APP_VERSION = "0.0.5.1" FEATURES = "flutter" PRODUCT_NAME = "AppFlowy" # CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html diff --git a/frontend/app_flowy/assets/translations/en.json b/frontend/app_flowy/assets/translations/en.json index 141999b4fd..873bbc93ee 100644 --- a/frontend/app_flowy/assets/translations/en.json +++ b/frontend/app_flowy/assets/translations/en.json @@ -191,7 +191,8 @@ "optionTitle": "Options", "addOption": "Add option", "editProperty": "Edit property", - "newColumn": "New column" + "newColumn": "New column", + "deleteFieldPromptMessage": "Are you sure? This property will be deleted" }, "row": { "duplicate": "Duplicate", 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 3ad8d670d7..3a34469cd7 100644 --- a/frontend/app_flowy/lib/plugins/board/presentation/board_page.dart +++ b/frontend/app_flowy/lib/plugins/board/presentation/board_page.dart @@ -287,8 +287,13 @@ class _BoardContentState extends State { ); } - void _openCard(String gridId, GridFieldController fieldController, - RowPB rowPB, GridRowCache rowCache, BuildContext context) { + void _openCard( + String gridId, + GridFieldController fieldController, + RowPB rowPB, + GridRowCache rowCache, + BuildContext context, + ) { final rowInfo = RowInfo( gridId: gridId, fields: UnmodifiableListView(fieldController.fieldContexts), diff --git a/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_setting.dart b/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_setting.dart index 6bf06c39b9..a64df31cc4 100644 --- a/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_setting.dart +++ b/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_setting.dart @@ -2,11 +2,12 @@ import 'package:app_flowy/generated/locale_keys.g.dart'; import 'package:app_flowy/plugins/board/application/toolbar/board_setting_bloc.dart'; import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; import 'package:app_flowy/plugins/grid/presentation/layout/sizes.dart'; +import 'package:app_flowy/plugins/grid/presentation/widgets/toolbar/grid_group.dart'; import 'package:app_flowy/plugins/grid/presentation/widgets/toolbar/grid_property.dart'; +import 'package:appflowy_popover/popover.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/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; @@ -141,10 +142,12 @@ extension _GridSettingExtension on BoardSettingAction { } class BoardSettingListPopover extends StatefulWidget { + final PopoverController popoverController; final BoardSettingContext settingContext; const BoardSettingListPopover({ Key? key, + required this.popoverController, required this.settingContext, }) : super(key: key); @@ -153,36 +156,33 @@ class BoardSettingListPopover extends StatefulWidget { } class _BoardSettingListPopoverState extends State { - bool _showGridPropertyList = false; + BoardSettingAction? _action; @override Widget build(BuildContext context) { - if (_showGridPropertyList) { - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(260, 400)), - child: GridPropertyList( - gridId: widget.settingContext.viewId, - fieldController: widget.settingContext.fieldController, - ), - ); + if (_action != null) { + switch (_action!) { + case BoardSettingAction.groups: + return GridGroupList( + viewId: widget.settingContext.viewId, + fieldController: widget.settingContext.fieldController, + onDismissed: () { + widget.popoverController.close(); + }, + ); + case BoardSettingAction.properties: + return GridPropertyList( + gridId: widget.settingContext.viewId, + fieldController: widget.settingContext.fieldController, + ); + } } - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(140, 400)), - child: BoardSettingList( - settingContext: widget.settingContext, - onAction: (action, settingContext) { - switch (action) { - case BoardSettingAction.groups: - break; - case BoardSettingAction.properties: - setState(() { - _showGridPropertyList = true; - }); - break; - } - }, - ), + return BoardSettingList( + settingContext: widget.settingContext, + onAction: (action, settingContext) { + setState(() => _action = action); + }, ); } } diff --git a/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_toolbar.dart b/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_toolbar.dart index f8cc0d30ca..27a59c69a6 100644 --- a/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_toolbar.dart +++ b/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_toolbar.dart @@ -2,6 +2,7 @@ import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; import 'package:appflowy_popover/popover.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; @@ -40,15 +41,30 @@ class BoardToolbar extends StatelessWidget { } } -class _SettingButton extends StatelessWidget { +class _SettingButton extends StatefulWidget { final BoardSettingContext settingContext; const _SettingButton({required this.settingContext, Key? key}) : super(key: key); + @override + State<_SettingButton> createState() => _SettingButtonState(); +} + +class _SettingButtonState extends State<_SettingButton> { + late PopoverController popoverController; + + @override + void initState() { + popoverController = PopoverController(); + super.initState(); + } + @override Widget build(BuildContext context) { final theme = context.read(); - return Popover( + return AppFlowyStylePopover( + controller: popoverController, + constraints: BoxConstraints.loose(const Size(260, 400)), triggerActions: PopoverTriggerActionFlags.click, child: FlowyIconButton( hoverColor: theme.hover, @@ -61,7 +77,8 @@ class _SettingButton extends StatelessWidget { ), popupBuilder: (BuildContext popoverContext) { return BoardSettingListPopover( - settingContext: settingContext, + settingContext: widget.settingContext, + popoverController: popoverController, ); }, ); diff --git a/frontend/app_flowy/lib/plugins/doc/application/doc_bloc.dart b/frontend/app_flowy/lib/plugins/doc/application/doc_bloc.dart index ed12227af0..89c87db455 100644 --- a/frontend/app_flowy/lib/plugins/doc/application/doc_bloc.dart +++ b/frontend/app_flowy/lib/plugins/doc/application/doc_bloc.dart @@ -90,7 +90,7 @@ class DocumentBloc extends Bloc { final result = await service.openDocument(docId: view.id); result.fold( (block) { - document = _decodeJsonToDocument(block.deltaStr); + document = _decodeJsonToDocument(block.snapshot); _subscription = document.changes.listen((event) { final delta = event.item2; final documentDelta = document.toDelta(); @@ -115,16 +115,12 @@ class DocumentBloc extends Bloc { void _composeDelta(Delta composedDelta, Delta documentDelta) async { final json = jsonEncode(composedDelta.toJson()); Log.debug("doc_id: $view.id - Send json: $json"); - final result = await service.composeDelta(docId: view.id, data: json); + final result = await service.applyEdit(docId: view.id, data: json); - result.fold((rustDoc) { - // final json = utf8.decode(doc.data); - final rustDelta = Delta.fromJson(jsonDecode(rustDoc.deltaStr)); - if (documentDelta != rustDelta) { - Log.error("Receive : $rustDelta"); - Log.error("Expected : $documentDelta"); - } - }, (r) => null); + result.fold( + (_) {}, + (r) => Log.error(r), + ); } Document _decodeJsonToDocument(String data) { diff --git a/frontend/app_flowy/lib/plugins/doc/application/doc_service.dart b/frontend/app_flowy/lib/plugins/doc/application/doc_service.dart index 659a99e371..067d51a510 100644 --- a/frontend/app_flowy/lib/plugins/doc/application/doc_service.dart +++ b/frontend/app_flowy/lib/plugins/doc/application/doc_service.dart @@ -4,22 +4,28 @@ import 'package:flowy_sdk/dispatch/dispatch.dart'; import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-sync/text_block.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-text-block/entities.pb.dart'; class DocumentService { - Future> openDocument({ + Future> openDocument({ required String docId, }) async { await FolderEventSetLatestView(ViewIdPB(value: docId)).send(); final payload = TextBlockIdPB(value: docId); - return TextBlockEventGetBlockData(payload).send(); + return TextBlockEventGetTextBlock(payload).send(); } - Future> composeDelta({required String docId, required String data}) { - final payload = TextBlockDeltaPB.create() - ..blockId = docId - ..deltaStr = data; - return TextBlockEventApplyDelta(payload).send(); + Future> applyEdit({ + required String docId, + required String data, + String operations = "", + }) { + final payload = EditPayloadPB.create() + ..textBlockId = docId + ..operations = operations + ..delta = data; + return TextBlockEventApplyEdit(payload).send(); } Future> closeDocument({required String docId}) { diff --git a/frontend/app_flowy/lib/plugins/doc/document.dart b/frontend/app_flowy/lib/plugins/doc/document.dart index ba209ddbb7..31488d3d2e 100644 --- a/frontend/app_flowy/lib/plugins/doc/document.dart +++ b/frontend/app_flowy/lib/plugins/doc/document.dart @@ -186,7 +186,8 @@ class DocumentShareButton extends StatelessWidget { 'Exported to: ${LocaleKeys.notifications_export_path.tr()}'); break; case ShareAction.copyLink: - FlowyAlertDialog(title: LocaleKeys.shareAction_workInProgress.tr()) + NavigatorAlertDialog( + title: LocaleKeys.shareAction_workInProgress.tr()) .show(context); break; } diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/link_button.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/link_button.dart index 428c45e400..22c9f108ec 100644 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/link_button.dart +++ b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/link_button.dart @@ -84,7 +84,7 @@ class FlowyLinkStyleButtonState extends State { value = values.first; } - TextFieldDialog( + NavigatorTextFieldDialog( title: 'URL', value: value, confirm: (newValue) { diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_editor_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_editor_bloc.dart index d7b024d67d..af50f47d67 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/field_editor_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/field_editor_bloc.dart @@ -2,6 +2,7 @@ import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'dart:async'; import 'package:dartz/dartz.dart'; +import 'field_service.dart'; import 'type_option/type_option_context.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -15,10 +16,11 @@ class FieldEditorBloc extends Bloc { FieldEditorBloc({ required String gridId, required String fieldName, + required bool isGroupField, required IFieldTypeOptionLoader loader, }) : dataController = TypeOptionDataController(gridId: gridId, loader: loader), - super(FieldEditorState.initial(gridId, fieldName)) { + super(FieldEditorState.initial(gridId, fieldName, isGroupField)) { on( (event, emit) async { await event.when( @@ -35,7 +37,23 @@ class FieldEditorBloc extends Bloc { emit(state.copyWith(name: name)); }, didReceiveFieldChanged: (FieldPB field) { - emit(state.copyWith(field: Some(field), name: field.name)); + emit(state.copyWith( + field: Some(field), + name: field.name, + canDelete: field.isPrimary, + )); + }, + deleteField: () { + state.field.fold( + () => null, + (field) { + final fieldService = FieldService( + gridId: gridId, + fieldId: field.id, + ); + fieldService.deleteField(); + }, + ); }, ); }, @@ -52,6 +70,7 @@ class FieldEditorBloc extends Bloc { class FieldEditorEvent with _$FieldEditorEvent { const factory FieldEditorEvent.initial() = _InitialField; const factory FieldEditorEvent.updateName(String name) = _UpdateName; + const factory FieldEditorEvent.deleteField() = _DeleteField; const factory FieldEditorEvent.didReceiveFieldChanged(FieldPB field) = _DidReceiveFieldChanged; } @@ -63,16 +82,21 @@ class FieldEditorState with _$FieldEditorState { required String errorText, required String name, required Option field, + required bool canDelete, + required bool isGroupField, }) = _FieldEditorState; factory FieldEditorState.initial( String gridId, String fieldName, + bool isGroupField, ) => FieldEditorState( gridId: gridId, errorText: '', field: none(), + canDelete: false, name: fieldName, + isGroupField: isGroupField, ); } diff --git a/frontend/app_flowy/lib/plugins/grid/application/row/row_detail_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/row/row_detail_bloc.dart index 6f59682f4d..10b4fe3cf8 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/row/row_detail_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/row/row_detail_bloc.dart @@ -1,4 +1,5 @@ import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'dart:async'; @@ -24,6 +25,13 @@ class RowDetailBloc extends Bloc { didReceiveCellDatas: (_DidReceiveCellDatas value) { emit(state.copyWith(gridCells: value.gridCells)); }, + deleteField: (_DeleteField value) { + final fieldService = FieldService( + gridId: dataController.rowInfo.gridId, + fieldId: value.fieldId, + ); + fieldService.deleteField(); + }, ); }, ); @@ -49,6 +57,7 @@ class RowDetailBloc extends Bloc { @freezed class RowDetailEvent with _$RowDetailEvent { const factory RowDetailEvent.initial() = _Initial; + const factory RowDetailEvent.deleteField(String fieldId) = _DeleteField; const factory RowDetailEvent.didReceiveCellDatas( List gridCells) = _DidReceiveCellDatas; } diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/grid_page.dart b/frontend/app_flowy/lib/plugins/grid/presentation/grid_page.dart index 42cf16edf7..a70fe840dd 100755 --- a/frontend/app_flowy/lib/plugins/grid/presentation/grid_page.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/grid_page.dart @@ -317,9 +317,7 @@ class _GridFooter extends StatelessWidget { height: GridSize.footerHeight, child: Padding( padding: GridSize.footerContentInsets, - child: const Expanded( - child: SizedBox(height: 40, child: GridAddRowButton()), - ), + child: const SizedBox(height: 40, child: GridAddRowButton()), ), ), ), diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/date_cell/date_cell.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/date_cell/date_cell.dart index 2a5c79b2f0..7b289d4a21 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/date_cell/date_cell.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/date_cell/date_cell.dart @@ -1,3 +1,4 @@ +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -62,10 +63,11 @@ class _DateCellState extends GridCellState { value: _cellBloc, child: BlocBuilder( builder: (context, state) { - return Popover( + return AppFlowyStylePopover( controller: _popover, offset: const Offset(0, 20), direction: PopoverDirection.bottomWithLeftAligned, + constraints: BoxConstraints.loose(const Size(320, 500)), child: SizedBox.expand( child: GestureDetector( behavior: HitTestBehavior.opaque, diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/date_cell/date_editor.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/date_cell/date_editor.dart index 9c57e7ed3b..1fe45f60c8 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/date_cell/date_editor.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/date_cell/date_editor.dart @@ -64,12 +64,9 @@ class _DateCellEditor extends State { return Container(); } - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(320, 500)), - child: _CellCalendarWidget( - cellContext: widget.cellController, - dateTypeOptionPB: _dateTypeOptionPB!, - ), + return _CellCalendarWidget( + cellContext: widget.cellController, + dateTypeOptionPB: _dateTypeOptionPB!, ); } } @@ -302,10 +299,11 @@ class _DateTypeOptionButton extends StatelessWidget { return BlocSelector( selector: (state) => state.dateTypeOptionPB, builder: (context, dateTypeOptionPB) { - return Popover( + return AppFlowyStylePopover( triggerActions: PopoverTriggerActionFlags.hover | PopoverTriggerActionFlags.click, offset: const Offset(20, 0), + constraints: BoxConstraints.loose(const Size(140, 100)), child: FlowyButton( text: FlowyText.medium(title, fontSize: 12), hoverColor: theme.hover, @@ -313,12 +311,9 @@ class _DateTypeOptionButton extends StatelessWidget { rightIcon: svgWidget("grid/more", color: theme.iconColor), ), popupBuilder: (BuildContext popContext) { - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(140, 100)), - child: _CalDateTimeSetting( - dateTypeOptionPB: dateTypeOptionPB, - onEvent: (event) => context.read().add(event), - ), + return _CalDateTimeSetting( + dateTypeOptionPB: dateTypeOptionPB, + onEvent: (event) => context.read().add(event), ); }, ); diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_cell.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_cell.dart index df7ddb0d9e..2e65226289 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_cell.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_cell.dart @@ -3,7 +3,7 @@ import 'package:app_flowy/plugins/grid/application/prelude.dart'; import 'package:appflowy_popover/popover.dart'; import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; // ignore: unused_import import 'package:flowy_sdk/log.dart'; @@ -194,8 +194,10 @@ class _SelectOptionWrapState extends State { alignment: AlignmentDirectional.center, fit: StackFit.expand, children: [ - Popover( + AppFlowyStylePopover( controller: _popover, + constraints: BoxConstraints.loose( + Size(SelectOptionCellEditor.editorPanelWidth, 300)), offset: const Offset(0, 20), direction: PopoverDirection.bottomWithLeftAligned, // triggerActions: PopoverTriggerActionFlags.c, @@ -203,18 +205,14 @@ class _SelectOptionWrapState extends State { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { widget.onFocus?.call(true); }); - return OverlayContainer( - constraints: BoxConstraints.loose( - Size(SelectOptionCellEditor.editorPanelWidth, 300)), - child: SizedBox( - width: SelectOptionCellEditor.editorPanelWidth, - child: SelectOptionCellEditor( - cellController: widget.cellControllerBuilder.build() - as GridSelectOptionCellController, - onDismissed: () { - widget.onFocus?.call(false); - }, - ), + return SizedBox( + width: SelectOptionCellEditor.editorPanelWidth, + child: SelectOptionCellEditor( + cellController: widget.cellControllerBuilder.build() + as GridSelectOptionCellController, + onDismissed: () { + widget.onFocus?.call(false); + }, ), ); }, diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart index 897b6f81c3..1c0cad85b1 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart @@ -251,9 +251,10 @@ class _SelectOptionCellState extends State<_SelectOptionCell> { @override Widget build(BuildContext context) { final theme = context.watch(); - return Popover( + return AppFlowyStylePopover( controller: _popoverController, offset: const Offset(20, 0), + constraints: BoxConstraints.loose(const Size(200, 300)), child: SizedBox( height: GridSize.typeOptionItemHeight, child: Row( @@ -286,23 +287,20 @@ class _SelectOptionCellState extends State<_SelectOptionCell> { ), ), popupBuilder: (BuildContext popoverContext) { - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(200, 300)), - child: SelectOptionTypeOptionEditor( - option: widget.option, - onDeleted: () { - context - .read() - .add(SelectOptionEditorEvent.deleteOption(widget.option)); - }, - onUpdated: (updatedOption) { - context - .read() - .add(SelectOptionEditorEvent.updateOption(updatedOption)); - }, - key: ValueKey(widget.option - .id), // Use ValueKey to refresh the UI, otherwise, it will remain the old value. - ), + return SelectOptionTypeOptionEditor( + option: widget.option, + onDeleted: () { + context + .read() + .add(SelectOptionEditorEvent.deleteOption(widget.option)); + }, + onUpdated: (updatedOption) { + context + .read() + .add(SelectOptionEditorEvent.updateOption(updatedOption)); + }, + key: ValueKey(widget.option + .id), // Use ValueKey to refresh the UI, otherwise, it will remain the old value. ); }, ); diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/cell_editor.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/cell_editor.dart index e390104187..17a899f3a1 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/cell_editor.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/cell_editor.dart @@ -1,6 +1,5 @@ import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; import 'package:app_flowy/plugins/grid/application/cell/url_cell_editor_bloc.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'dart:async'; @@ -79,15 +78,12 @@ class URLEditorPopover extends StatelessWidget { @override Widget build(BuildContext context) { - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(300, 160)), - child: SizedBox( - width: 200, - child: Padding( - padding: const EdgeInsets.all(6), - child: URLCellEditor( - cellController: cellController, - ), + return SizedBox( + width: 200, + child: Padding( + padding: const EdgeInsets.all(6), + child: URLCellEditor( + cellController: cellController, ), ), ); 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 27e7d4433c..155a75cae3 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 @@ -6,6 +6,7 @@ import 'package:appflowy_popover/popover.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/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -129,8 +130,9 @@ class _GridURLCellState extends GridCellState { ), ); - return Popover( + return AppFlowyStylePopover( controller: _popoverController, + constraints: BoxConstraints.loose(const Size(300, 160)), direction: PopoverDirection.bottomWithLeftAligned, offset: const Offset(0, 20), child: SizedBox.expand( @@ -214,7 +216,8 @@ class _EditURLAccessoryState extends State<_EditURLAccessory> @override Widget build(BuildContext context) { final theme = context.watch(); - return Popover( + return AppFlowyStylePopover( + constraints: BoxConstraints.loose(const Size(300, 160)), controller: _popoverController, direction: PopoverDirection.bottomWithLeftAligned, triggerActions: PopoverTriggerActionFlags.click, diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_cell_action_sheet.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_cell_action_sheet.dart index 8f11bb008f..4d412ed530 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_cell_action_sheet.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_cell_action_sheet.dart @@ -2,6 +2,7 @@ import 'package:app_flowy/plugins/grid/application/field/type_option/type_option import 'package:app_flowy/plugins/grid/presentation/widgets/header/field_editor.dart'; import 'package:app_flowy/startup/startup.dart'; import 'package:app_flowy/plugins/grid/application/prelude.dart'; +import 'package:app_flowy/workspace/presentation/widgets/dialogs.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -168,7 +169,7 @@ class FieldActionCell extends StatelessWidget { } }, leftIcon: svgWidget(action.iconName(), - color: enable ? theme.iconColor : theme.disableIconColor), + color: enable ? theme.iconColor : theme.disableIconColor), ); } } @@ -215,9 +216,15 @@ extension _FieldActionExtension on FieldAction { .add(const FieldActionSheetEvent.duplicateField()); break; case FieldAction.delete: - context - .read() - .add(const FieldActionSheetEvent.deleteField()); + NavigatorAlertDialog( + title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), + confirm: () { + context + .read() + .add(const FieldActionSheetEvent.deleteField()); + }, + ).show(context); + break; } } diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_editor.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_editor.dart index 8a8c445ec4..cc836809b8 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_editor.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_editor.dart @@ -2,8 +2,13 @@ import 'package:app_flowy/plugins/grid/application/field/field_editor_bloc.dart' import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; import 'package:appflowy_popover/popover.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:app_flowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flowy_sdk/log.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:app_flowy/generated/locale_keys.g.dart'; @@ -13,14 +18,16 @@ import 'field_type_option_editor.dart'; class FieldEditor extends StatefulWidget { final String gridId; final String fieldName; - final VoidCallback? onRemoved; + final bool isGroupField; + final Function(String)? onDeleted; final IFieldTypeOptionLoader typeOptionLoader; const FieldEditor({ required this.gridId, this.fieldName = "", required this.typeOptionLoader, - this.onRemoved, + this.isGroupField = false, + this.onDeleted, Key? key, }) : super(key: key); @@ -43,10 +50,10 @@ class _FieldEditorState extends State { create: (context) => FieldEditorBloc( gridId: widget.gridId, fieldName: widget.fieldName, + isGroupField: widget.isGroupField, loader: widget.typeOptionLoader, )..add(const FieldEditorEvent.initial()), child: BlocBuilder( - buildWhen: (p, c) => false, builder: (context, state) { return ListView( shrinkWrap: true, @@ -56,6 +63,16 @@ class _FieldEditorState extends State { const VSpace(10), const _FieldNameCell(), const VSpace(10), + _DeleteFieldButton( + popoverMutex: popoverMutex, + onDeleted: () { + state.field.fold( + () => Log.error('Can not delete the field'), + (field) => widget.onDeleted?.call(field.id), + ); + }, + ), + const VSpace(10), _FieldTypeOptionCell(popoverMutex: popoverMutex), ], ); @@ -114,3 +131,55 @@ class _FieldNameCell extends StatelessWidget { ); } } + +class _DeleteFieldButton extends StatelessWidget { + final PopoverMutex popoverMutex; + final VoidCallback? onDeleted; + + const _DeleteFieldButton({ + required this.popoverMutex, + required this.onDeleted, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final theme = context.watch(); + return BlocBuilder( + buildWhen: (previous, current) => previous != current, + builder: (context, state) { + final enable = !state.canDelete && !state.isGroupField; + Widget button = FlowyButton( + text: FlowyText.medium( + LocaleKeys.grid_field_delete.tr(), + fontSize: 12, + color: enable ? null : theme.shader4, + ), + ); + if (enable) button = _wrapPopover(button); + return button; + }, + ); + } + + Widget _wrapPopover(Widget widget) { + return AppFlowyStylePopover( + triggerActions: PopoverTriggerActionFlags.click, + constraints: BoxConstraints.loose(const Size(400, 240)), + mutex: popoverMutex, + direction: PopoverDirection.center, + popupBuilder: (popupContext) { + return PopoverAlertView( + title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), + cancel: () => popoverMutex.state?.close(), + confirm: () { + onDeleted?.call(); + popoverMutex.state?.close(); + }, + popoverMutex: popoverMutex, + ); + }, + child: widget, + ); + } +} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_list.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_list.dart index de9b3a809f..0cb18d411b 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_list.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_list.dart @@ -1,3 +1,4 @@ +import 'package:appflowy_popover/popover.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -25,7 +26,7 @@ class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate { fieldType: fieldType, onSelectField: (fieldType) { onSelectField(fieldType); - FlowyOverlay.of(context).remove(FieldTypeList.identifier()); + PopoverContainer.of(context).closeAll(); }, ); }).toList(); diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_option_editor.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_option_editor.dart index 4827a5ac6d..f541335844 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_option_editor.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_option_editor.dart @@ -64,19 +64,15 @@ class FieldTypeOptionEditor extends StatelessWidget { final theme = context.watch(); return SizedBox( height: GridSize.typeOptionItemHeight, - child: Popover( - triggerActions: - PopoverTriggerActionFlags.hover | PopoverTriggerActionFlags.click, + child: AppFlowyStylePopover( + constraints: BoxConstraints.loose(const Size(460, 440)), + triggerActions: PopoverTriggerActionFlags.click, mutex: popoverMutex, offset: const Offset(20, 0), popupBuilder: (context) { - final list = FieldTypeList(onSelectField: (newFieldType) { + return FieldTypeList(onSelectField: (newFieldType) { dataController.switchToField(newFieldType); }); - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(460, 440)), - child: list, - ); }, child: FlowyButton( text: FlowyText.medium(field.fieldType.title(), fontSize: 12), diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/grid_header.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/grid_header.dart index 154919d019..f9656e8dcc 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/grid_header.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/grid_header.dart @@ -7,7 +7,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:appflowy_popover/popover.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; @@ -176,9 +176,10 @@ class CreateFieldButton extends StatelessWidget { Widget build(BuildContext context) { final theme = context.watch(); - return Popover( + return AppFlowyStylePopover( triggerActions: PopoverTriggerActionFlags.click, direction: PopoverDirection.bottomWithRightAligned, + constraints: BoxConstraints.loose(const Size(240, 200)), child: FlowyButton( text: FlowyText.medium( LocaleKeys.grid_field_newColumn.tr(), @@ -192,13 +193,10 @@ class CreateFieldButton extends StatelessWidget { ), ), popupBuilder: (BuildContext popover) { - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(240, 200)), - child: FieldEditor( - gridId: gridId, - fieldName: "", - typeOptionLoader: NewFieldTypeOptionLoader(gridId: gridId), - ), + return FieldEditor( + gridId: gridId, + fieldName: "", + typeOptionLoader: NewFieldTypeOptionLoader(gridId: gridId), ); }, ); diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/builder.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/builder.dart index 02350f3a5e..f01efda675 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/builder.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/builder.dart @@ -54,9 +54,10 @@ Widget? makeTypeOptionWidget({ return builder.build(context); } -TypeOptionWidgetBuilder makeTypeOptionWidgetBuilder( - {required TypeOptionDataController dataController, - required PopoverMutex popoverMutex}) { +TypeOptionWidgetBuilder makeTypeOptionWidgetBuilder({ + required TypeOptionDataController dataController, + required PopoverMutex popoverMutex, +}) { final gridId = dataController.gridId; final fieldType = dataController.field.fieldType; diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/date.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/date.dart index ae3dcf8682..e8cbb00fba 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/date.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/date.dart @@ -62,23 +62,21 @@ class DateTypeOptionWidget extends TypeOptionWidget { } Widget _renderDateFormatButton(BuildContext context, DateFormat dataFormat) { - return Popover( + return AppFlowyStylePopover( mutex: popoverMutex, triggerActions: PopoverTriggerActionFlags.hover | PopoverTriggerActionFlags.click, offset: const Offset(20, 0), + constraints: BoxConstraints.loose(const Size(460, 440)), popupBuilder: (popoverContext) { - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(460, 440)), - child: DateFormatList( - selectedFormat: dataFormat, - onSelected: (format) { - context - .read() - .add(DateTypeOptionEvent.didSelectDateFormat(format)); - PopoverContainerState.of(popoverContext).closeAll(); - }, - ), + return DateFormatList( + selectedFormat: dataFormat, + onSelected: (format) { + context + .read() + .add(DateTypeOptionEvent.didSelectDateFormat(format)); + PopoverContainer.of(popoverContext).closeAll(); + }, ); }, child: const DateFormatButton(), @@ -86,22 +84,21 @@ class DateTypeOptionWidget extends TypeOptionWidget { } Widget _renderTimeFormatButton(BuildContext context, TimeFormat timeFormat) { - return Popover( + return AppFlowyStylePopover( mutex: popoverMutex, triggerActions: PopoverTriggerActionFlags.hover | PopoverTriggerActionFlags.click, offset: const Offset(20, 0), + constraints: BoxConstraints.loose(const Size(460, 440)), popupBuilder: (BuildContext popoverContext) { - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(460, 440)), - child: TimeFormatList( - selectedFormat: timeFormat, - onSelected: (format) { - context - .read() - .add(DateTypeOptionEvent.didSelectTimeFormat(format)); - PopoverContainerState.of(popoverContext).closeAll(); - }), + return TimeFormatList( + selectedFormat: timeFormat, + onSelected: (format) { + context + .read() + .add(DateTypeOptionEvent.didSelectTimeFormat(format)); + PopoverContainer.of(popoverContext).closeAll(); + }, ); }, child: TimeFormatButton(timeFormat: timeFormat), diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/multi_select.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/multi_select.dart index 75ac6eeaa0..2dc804111d 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/multi_select.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/multi_select.dart @@ -41,7 +41,7 @@ class MultiSelectTypeOptionWidget extends TypeOptionWidget { return SelectOptionTypeOptionWidget( options: selectOptionAction.typeOption.options, beginEdit: () { - PopoverContainerState.of(context).closeAll(); + PopoverContainer.of(context).closeAll(); }, popoverMutex: popoverMutex, typeOptionAction: selectOptionAction, diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/number.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/number.dart index cdc69eb4e6..80627d062d 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/number.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/number.dart @@ -55,11 +55,12 @@ class NumberTypeOptionWidget extends TypeOptionWidget { listener: (context, state) => typeOptionContext.typeOption = state.typeOption, builder: (context, state) { - return Popover( + return AppFlowyStylePopover( mutex: popoverMutex, triggerActions: PopoverTriggerActionFlags.hover | PopoverTriggerActionFlags.click, offset: const Offset(20, 0), + constraints: BoxConstraints.loose(const Size(460, 440)), child: FlowyButton( margin: GridSize.typeOptionContentInsets, hoverColor: theme.hover, @@ -76,17 +77,14 @@ class NumberTypeOptionWidget extends TypeOptionWidget { ), ), popupBuilder: (BuildContext popoverContext) { - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(460, 440)), - child: NumberFormatList( - onSelected: (format) { - context - .read() - .add(NumberTypeOptionEvent.didSelectFormat(format)); - PopoverContainerState.of(popoverContext).closeAll(); - }, - selectedFormat: state.typeOption.format, - ), + return NumberFormatList( + onSelected: (format) { + context + .read() + .add(NumberTypeOptionEvent.didSelectFormat(format)); + PopoverContainer.of(popoverContext).closeAll(); + }, + selectedFormat: state.typeOption.format, ); }, ); @@ -116,6 +114,7 @@ class NumberFormatList extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const _FilterTextField(), + const VSpace(10), BlocBuilder( builder: (context, state) { final cells = state.formats.map((format) { diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/select_option.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/select_option.dart index 7b0be75515..f8cf09d076 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/select_option.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/select_option.dart @@ -2,7 +2,7 @@ import 'package:app_flowy/plugins/grid/application/field/type_option/select_opti import 'package:appflowy_popover/popover.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; @@ -180,10 +180,11 @@ class _OptionCellState extends State<_OptionCell> { Widget build(BuildContext context) { final theme = context.watch(); - return Popover( + return AppFlowyStylePopover( controller: _popoverController, mutex: widget.popoverMutex, offset: const Offset(20, 0), + constraints: BoxConstraints.loose(const Size(460, 440)), child: SizedBox( height: GridSize.typeOptionItemHeight, child: SelectOptionTagCell( @@ -200,24 +201,21 @@ class _OptionCellState extends State<_OptionCell> { ), ), popupBuilder: (BuildContext popoverContext) { - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(460, 440)), - child: SelectOptionTypeOptionEditor( - option: widget.option, - onDeleted: () { - context - .read() - .add(SelectOptionTypeOptionEvent.deleteOption(widget.option)); - PopoverContainerState.of(popoverContext).closeAll(); - }, - onUpdated: (updatedOption) { - context - .read() - .add(SelectOptionTypeOptionEvent.updateOption(updatedOption)); - PopoverContainerState.of(popoverContext).closeAll(); - }, - key: ValueKey(widget.option.id), - ), + return SelectOptionTypeOptionEditor( + option: widget.option, + onDeleted: () { + context + .read() + .add(SelectOptionTypeOptionEvent.deleteOption(widget.option)); + PopoverContainer.of(popoverContext).closeAll(); + }, + onUpdated: (updatedOption) { + context + .read() + .add(SelectOptionTypeOptionEvent.updateOption(updatedOption)); + PopoverContainer.of(popoverContext).closeAll(); + }, + key: ValueKey(widget.option.id), ); }, ); diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/single_select.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/single_select.dart index 27ffabb286..477e573ff2 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/single_select.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/single_select.dart @@ -40,7 +40,7 @@ class SingleSelectTypeOptionWidget extends TypeOptionWidget { return SelectOptionTypeOptionWidget( options: selectOptionAction.typeOption.options, beginEdit: () { - PopoverContainerState.of(context).closeAll(); + PopoverContainer.of(context).closeAll(); }, popoverMutex: popoverMutex, typeOptionAction: selectOptionAction, diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/row_action_sheet.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/row_action_sheet.dart index 8c828ec627..e67908f544 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/row_action_sheet.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/row_action_sheet.dart @@ -1,4 +1,5 @@ import 'package:app_flowy/plugins/grid/application/row/row_action_sheet_bloc.dart'; +import 'package:app_flowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:app_flowy/generated/locale_keys.g.dart'; import 'package:flowy_infra/image.dart'; @@ -150,9 +151,15 @@ extension _RowActionExtension on _RowAction { .add(const RowActionSheetEvent.duplicateRow()); break; case _RowAction.delete: - context - .read() - .add(const RowActionSheetEvent.deleteRow()); + NavigatorAlertDialog( + title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), + confirm: () { + context + .read() + .add(const RowActionSheetEvent.deleteRow()); + }, + ).show(context); + break; } } diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/row_detail.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/row_detail.dart index fe148d9dcb..a1d65f89ae 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/row_detail.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/row_detail.dart @@ -133,6 +133,7 @@ class _PropertyList extends StatelessWidget { ), ), ), + const VSpace(10), _CreateFieldButton( viewId: viewId, onClosed: () { @@ -144,13 +145,16 @@ class _PropertyList extends StatelessWidget { ); }); }, - onOpened: () { - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(240, 200)), - child: FieldEditor( - gridId: viewId, - typeOptionLoader: NewFieldTypeOptionLoader(gridId: viewId), - ), + onOpened: (controller) { + return FieldEditor( + gridId: viewId, + typeOptionLoader: NewFieldTypeOptionLoader(gridId: viewId), + onDeleted: (fieldId) { + controller.close(); + context + .read() + .add(RowDetailEvent.deleteField(fieldId)); + }, ); }, ), @@ -161,10 +165,11 @@ class _PropertyList extends StatelessWidget { } } -class _CreateFieldButton extends StatelessWidget { +class _CreateFieldButton extends StatefulWidget { final String viewId; - final Widget Function() onOpened; + final Widget Function(PopoverController) onOpened; final VoidCallback onClosed; + const _CreateFieldButton({ required this.viewId, required this.onOpened, @@ -172,16 +177,32 @@ class _CreateFieldButton extends StatelessWidget { Key? key, }) : super(key: key); + @override + State<_CreateFieldButton> createState() => _CreateFieldButtonState(); +} + +class _CreateFieldButtonState extends State<_CreateFieldButton> { + late PopoverController popoverController; + + @override + void initState() { + popoverController = PopoverController(); + super.initState(); + } + @override Widget build(BuildContext context) { final theme = context.read(); - return Popover( + return AppFlowyStylePopover( + constraints: BoxConstraints.loose(const Size(240, 200)), + controller: popoverController, triggerActions: PopoverTriggerActionFlags.click, - direction: PopoverDirection.bottomWithLeftAligned, - onClose: onClosed, - child: SizedBox( + direction: PopoverDirection.topWithLeftAligned, + onClose: widget.onClosed, + child: Container( height: 40, + decoration: _makeBoxDecoration(context), child: FlowyButton( text: FlowyText.medium( LocaleKeys.grid_field_newColumn.tr(), @@ -192,7 +213,17 @@ class _CreateFieldButton extends StatelessWidget { leftIcon: svgWidget("home/add"), ), ), - popupBuilder: (BuildContext context) => onOpened(), + popupBuilder: (BuildContext context) => + widget.onOpened(popoverController), + ); + } + + BoxDecoration _makeBoxDecoration(BuildContext context) { + final theme = context.read(); + final borderSide = BorderSide(color: theme.shader6, width: 1.0); + return BoxDecoration( + color: theme.surface, + border: Border(top: borderSide), ); } } @@ -241,16 +272,23 @@ class _RowDetailCellState extends State<_RowDetailCell> { child: Popover( controller: popover, offset: const Offset(20, 0), - popupBuilder: (context) { + popupBuilder: (popoverContext) { return OverlayContainer( constraints: BoxConstraints.loose(const Size(240, 200)), child: FieldEditor( gridId: widget.cellId.gridId, fieldName: widget.cellId.fieldContext.field.name, + isGroupField: widget.cellId.fieldContext.isGroupField, typeOptionLoader: FieldTypeOptionLoader( gridId: widget.cellId.gridId, field: widget.cellId.fieldContext.field, ), + onDeleted: (fieldId) { + popover.close(); + context + .read() + .add(RowDetailEvent.deleteField(fieldId)); + }, ), ); }, diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_group.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_group.dart index b400cda0fe..8709cd3ea3 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_group.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_group.dart @@ -3,7 +3,6 @@ import 'package:app_flowy/plugins/grid/presentation/layout/sizes.dart'; import 'package:app_flowy/plugins/grid/presentation/widgets/header/field_type_extension.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; @@ -15,9 +14,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; class GridGroupList extends StatelessWidget { final String viewId; final GridFieldController fieldController; + final VoidCallback onDismissed; const GridGroupList({ required this.viewId, required this.fieldController, + required this.onDismissed, Key? key, }) : super(key: key); @@ -33,6 +34,7 @@ class GridGroupList extends StatelessWidget { final cells = state.fieldContexts.map((fieldContext) { Widget cell = _GridGroupCell( fieldContext: fieldContext, + onSelected: () => onDismissed(), key: ValueKey(fieldContext.id), ); @@ -56,29 +58,16 @@ class GridGroupList extends StatelessWidget { ), ); } - - void show(BuildContext context) { - FlowyOverlay.of(context).insertWithAnchor( - widget: OverlayContainer( - constraints: BoxConstraints.loose(const Size(260, 400)), - child: this, - ), - identifier: identifier(), - anchorContext: context, - anchorDirection: AnchorDirection.bottomRight, - style: FlowyOverlayStyle(blur: false), - ); - } - - static String identifier() { - return (GridGroupList).toString(); - } } class _GridGroupCell extends StatelessWidget { + final VoidCallback onSelected; final GridFieldContext fieldContext; - const _GridGroupCell({required this.fieldContext, Key? key}) - : super(key: key); + const _GridGroupCell({ + required this.fieldContext, + required this.onSelected, + Key? key, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -97,8 +86,10 @@ class _GridGroupCell extends StatelessWidget { child: FlowyButton( text: FlowyText.medium(fieldContext.name, fontSize: 12), hoverColor: theme.hover, - leftIcon: svgWidget(fieldContext.fieldType.iconName(), - color: theme.iconColor), + leftIcon: svgWidget( + fieldContext.fieldType.iconName(), + color: theme.iconColor, + ), rightIcon: rightIcon, onTap: () { context.read().add( @@ -107,7 +98,8 @@ class _GridGroupCell extends StatelessWidget { fieldContext.fieldType, ), ); - FlowyOverlay.of(context).remove(GridGroupList.identifier()); + onSelected(); + // FlowyOverlay.of(context).remove(GridGroupList.identifier()); }, ), ); diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_property.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_property.dart index 5e38efd9cb..724ec74cc1 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_property.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_property.dart @@ -116,10 +116,11 @@ class _GridPropertyCell extends StatelessWidget { } Widget _editFieldButton(AppTheme theme, BuildContext context) { - return Popover( + return AppFlowyStylePopover( mutex: popoverMutex, triggerActions: PopoverTriggerActionFlags.click, offset: const Offset(20, 0), + constraints: BoxConstraints.loose(const Size(240, 200)), child: FlowyButton( text: FlowyText.medium(fieldContext.name, fontSize: 12), hoverColor: theme.hover, @@ -127,14 +128,11 @@ class _GridPropertyCell extends StatelessWidget { color: theme.iconColor), ), popupBuilder: (BuildContext context) { - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(240, 200)), - child: FieldEditor( - gridId: gridId, - fieldName: fieldContext.name, - typeOptionLoader: FieldTypeOptionLoader( - gridId: gridId, field: fieldContext.field), - ), + return FieldEditor( + gridId: gridId, + fieldName: fieldContext.name, + typeOptionLoader: + FieldTypeOptionLoader(gridId: gridId, field: fieldContext.field), ); }, ); diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_toolbar.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_toolbar.dart index f5893c892f..7675eda943 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_toolbar.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_toolbar.dart @@ -53,7 +53,8 @@ class _SettingButton extends StatelessWidget { @override Widget build(BuildContext context) { final theme = context.watch(); - return Popover( + return AppFlowyStylePopover( + constraints: BoxConstraints.loose(const Size(260, 400)), triggerActions: PopoverTriggerActionFlags.click, offset: const Offset(0, 10), child: FlowyIconButton( @@ -87,25 +88,19 @@ class _GridSettingListPopoverState extends State<_GridSettingListPopover> { @override Widget build(BuildContext context) { if (_action == GridSettingAction.properties) { - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(260, 400)), - child: GridPropertyList( - gridId: widget.settingContext.gridId, - fieldController: widget.settingContext.fieldController, - ), + return GridPropertyList( + gridId: widget.settingContext.gridId, + fieldController: widget.settingContext.fieldController, ); } - return OverlayContainer( - constraints: BoxConstraints.loose(const Size(140, 400)), - child: GridSettingList( - settingContext: widget.settingContext, - onAction: (action, settingContext) { - setState(() { - _action = action; - }); - }, - ), + return GridSettingList( + settingContext: widget.settingContext, + onAction: (action, settingContext) { + setState(() { + _action = action; + }); + }, ); } } diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/create_button.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/create_button.dart index 503980ae86..bd6ab040e1 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/create_button.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/create_button.dart @@ -30,7 +30,7 @@ class NewAppButton extends StatelessWidget { } Future _showCreateAppDialog(BuildContext context) async { - return TextFieldDialog( + return NavigatorTextFieldDialog( title: LocaleKeys.newPageText.tr(), value: "", confirm: (newValue) { diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/header/header.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/header/header.dart index 9d02f1a013..48f4f0d399 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/header/header.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/header/header.dart @@ -126,7 +126,7 @@ class MenuAppHeader extends StatelessWidget { action.fold(() {}, (action) { switch (action) { case AppDisclosureAction.rename: - TextFieldDialog( + NavigatorTextFieldDialog( title: LocaleKeys.menuAppHeader_renameDialog.tr(), value: context.read().state.app.name, confirm: (newValue) { diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/item.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/item.dart index cb3146a70c..9c9b5a0895 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/item.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/item.dart @@ -109,7 +109,7 @@ class ViewSectionItem extends StatelessWidget { action.foldRight({}, (action, previous) { switch (action) { case ViewDisclosureAction.rename: - TextFieldDialog( + NavigatorTextFieldDialog( title: LocaleKeys.disclosureAction_rename.tr(), value: context.read().state.view.name, confirm: (newValue) { diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/dialogs.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/dialogs.dart index 3a5c1c79e5..4cb763e50d 100644 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/app_flowy/lib/workspace/presentation/widgets/dialogs.dart @@ -1,3 +1,4 @@ +import 'package:appflowy_popover/popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/text_style.dart'; import 'package:flowy_infra/theme.dart'; @@ -15,13 +16,13 @@ import 'package:textstyle_extensions/textstyle_extensions.dart'; export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; import 'package:app_flowy/generated/locale_keys.g.dart'; -class TextFieldDialog extends StatefulWidget { +class NavigatorTextFieldDialog extends StatefulWidget { final String value; final String title; final void Function()? cancel; final void Function(String) confirm; - const TextFieldDialog({ + const NavigatorTextFieldDialog({ required this.title, required this.value, required this.confirm, @@ -30,10 +31,10 @@ class TextFieldDialog extends StatefulWidget { }) : super(key: key); @override - State createState() => _CreateTextFieldDialog(); + State createState() => _CreateTextFieldDialog(); } -class _CreateTextFieldDialog extends State { +class _CreateTextFieldDialog extends State { String newValue = ""; @override @@ -56,7 +57,8 @@ class _CreateTextFieldDialog extends State { FlowyFormTextInput( hintText: LocaleKeys.dialogCreatePageNameHint.tr(), initialValue: widget.value, - textStyle: const TextStyle(fontSize: 24, fontWeight: FontWeight.w400), + textStyle: + const TextStyle(fontSize: 24, fontWeight: FontWeight.w400), autoFocus: true, onChanged: (text) { newValue = text; @@ -70,11 +72,13 @@ class _CreateTextFieldDialog extends State { OkCancelButton( onOkPressed: () { widget.confirm(newValue); + Navigator.of(context).pop(); }, onCancelPressed: () { if (widget.cancel != null) { widget.cancel!(); } + Navigator.of(context).pop(); }, ) ], @@ -83,12 +87,14 @@ class _CreateTextFieldDialog extends State { } } -class FlowyAlertDialog extends StatefulWidget { +class PopoverAlertView extends StatelessWidget { + final PopoverMutex popoverMutex; final String title; final void Function()? cancel; final void Function()? confirm; - const FlowyAlertDialog({ + const PopoverAlertView({ + required this.popoverMutex, required this.title, this.confirm, this.cancel, @@ -96,10 +102,46 @@ class FlowyAlertDialog extends StatefulWidget { }) : super(key: key); @override - State createState() => _CreateFlowyAlertDialog(); + Widget build(BuildContext context) { + final theme = context.watch(); + return StyledDialog( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ...[ + FlowyText.medium(title, color: theme.shader4), + ], + if (confirm != null) ...[ + const VSpace(20), + OkCancelButton( + onOkPressed: confirm, + onCancelPressed: cancel, + ) + ] + ], + ), + ); + } } -class _CreateFlowyAlertDialog extends State { +class NavigatorAlertDialog extends StatefulWidget { + final String title; + final void Function()? cancel; + final void Function()? confirm; + + const NavigatorAlertDialog({ + required this.title, + this.confirm, + this.cancel, + Key? key, + }) : super(key: key); + + @override + State createState() => _CreateFlowyAlertDialog(); +} + +class _CreateFlowyAlertDialog extends State { @override void initState() { super.initState(); @@ -118,10 +160,13 @@ class _CreateFlowyAlertDialog extends State { ], if (widget.confirm != null) ...[ const VSpace(20), - OkCancelButton( - onOkPressed: widget.confirm!, - onCancelPressed: widget.confirm, - ) + OkCancelButton(onOkPressed: () { + widget.confirm?.call(); + Navigator.of(context).pop(); + }, onCancelPressed: () { + widget.cancel?.call(); + Navigator.of(context).pop(); + }) ] ], ), @@ -129,7 +174,7 @@ class _CreateFlowyAlertDialog extends State { } } -class OkCancelDialog extends StatelessWidget { +class NavigatorOkCancelDialog extends StatelessWidget { final VoidCallback? onOkPressed; final VoidCallback? onCancelPressed; final String? okTitle; @@ -138,7 +183,7 @@ class OkCancelDialog extends StatelessWidget { final String message; final double? maxWidth; - const OkCancelDialog( + const NavigatorOkCancelDialog( {Key? key, this.onOkPressed, this.onCancelPressed, @@ -158,7 +203,7 @@ class OkCancelDialog extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (title != null) ...[ - Text(title!.toUpperCase(), style: TextStyles.T1.textColor(theme.shader1)), + FlowyText.medium(title!.toUpperCase(), color: theme.shader1), VSpace(Insets.sm * 1.5), Container(color: theme.bg1, height: 1), VSpace(Insets.m * 1.5), @@ -166,8 +211,14 @@ class OkCancelDialog extends StatelessWidget { Text(message, style: TextStyles.Body1.textHeight(1.5)), SizedBox(height: Insets.l), OkCancelButton( - onOkPressed: onOkPressed, - onCancelPressed: onCancelPressed, + onOkPressed: () { + onOkPressed?.call(); + Navigator.of(context).pop(); + }, + onCancelPressed: () { + onCancelPressed?.call(); + Navigator.of(context).pop(); + }, okTitle: okTitle?.toUpperCase(), cancelTitle: cancelTitle?.toUpperCase(), ) @@ -185,7 +236,12 @@ class OkCancelButton extends StatelessWidget { final double? minHeight; const OkCancelButton( - {Key? key, this.onOkPressed, this.onCancelPressed, this.okTitle, this.cancelTitle, this.minHeight}) + {Key? key, + this.onOkPressed, + this.onCancelPressed, + this.okTitle, + this.cancelTitle, + this.minHeight}) : super(key: key); @override @@ -198,20 +254,14 @@ class OkCancelButton extends StatelessWidget { if (onCancelPressed != null) SecondaryTextButton( cancelTitle ?? LocaleKeys.button_Cancel.tr(), - onPressed: () { - onCancelPressed!(); - AppGlobals.nav.pop(); - }, + onPressed: onCancelPressed, bigMode: true, ), HSpace(Insets.m), if (onOkPressed != null) PrimaryTextButton( okTitle ?? LocaleKeys.button_OK.tr(), - onPressed: () { - onOkPressed!(); - AppGlobals.nav.pop(); - }, + onPressed: onOkPressed, bigMode: true, ), ], diff --git a/frontend/app_flowy/macos/Runner.xcodeproj/project.pbxproj b/frontend/app_flowy/macos/Runner.xcodeproj/project.pbxproj index 219b81efe0..5634f236ea 100644 --- a/frontend/app_flowy/macos/Runner.xcodeproj/project.pbxproj +++ b/frontend/app_flowy/macos/Runner.xcodeproj/project.pbxproj @@ -416,6 +416,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; buildSettings = { + ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; @@ -549,6 +550,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; buildSettings = { + ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; @@ -573,6 +575,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; buildSettings = { + ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; 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 bcba14db11..eafee79a6d 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 @@ -58,122 +58,153 @@ List builtInShortcutEvents = [ key: 'Move cursor top', command: 'meta+arrow up', windowsCommand: 'ctrl+arrow up', + linuxCommand: 'ctrl+arrow up', handler: cursorTop, ), ShortcutEvent( key: 'Move cursor bottom', command: 'meta+arrow down', windowsCommand: 'ctrl+arrow down', + linuxCommand: 'ctrl+arrow down', handler: cursorBottom, ), ShortcutEvent( key: 'Move cursor begin', command: 'meta+arrow left', windowsCommand: 'ctrl+arrow left', + linuxCommand: 'ctrl+arrow left', handler: cursorBegin, ), ShortcutEvent( key: 'Move cursor end', command: 'meta+arrow right', windowsCommand: 'ctrl+arrow right', + linuxCommand: 'ctrl+arrow right', handler: cursorEnd, ), ShortcutEvent( key: 'Cursor top select', command: 'meta+shift+arrow up', windowsCommand: 'ctrl+shift+arrow up', + linuxCommand: 'ctrl+shift+arrow up', handler: cursorTopSelect, ), ShortcutEvent( key: 'Cursor bottom select', command: 'meta+shift+arrow down', windowsCommand: 'ctrl+shift+arrow down', + linuxCommand: 'ctrl+shift+arrow down', handler: cursorBottomSelect, ), ShortcutEvent( key: 'Cursor begin select', command: 'meta+shift+arrow left', windowsCommand: 'ctrl+shift+arrow left', + linuxCommand: 'ctrl+shift+arrow left', handler: cursorBeginSelect, ), ShortcutEvent( key: 'Cursor end select', command: 'meta+shift+arrow right', windowsCommand: 'ctrl+shift+arrow right', + linuxCommand: 'ctrl+shift+arrow right', handler: cursorEndSelect, ), ShortcutEvent( key: 'Redo', command: 'meta+shift+z', windowsCommand: 'ctrl+shift+z', + linuxCommand: 'ctrl+shift+z', handler: redoEventHandler, ), ShortcutEvent( key: 'Undo', command: 'meta+z', windowsCommand: 'ctrl+z', + linuxCommand: 'ctrl+z', handler: undoEventHandler, ), ShortcutEvent( key: 'Format bold', command: 'meta+b', windowsCommand: 'ctrl+b', + linuxCommand: 'ctrl+b', handler: formatBoldEventHandler, ), ShortcutEvent( key: 'Format italic', command: 'meta+i', windowsCommand: 'ctrl+i', + linuxCommand: 'ctrl+i', handler: formatItalicEventHandler, ), ShortcutEvent( key: 'Format underline', command: 'meta+u', windowsCommand: 'ctrl+u', + linuxCommand: 'ctrl+u', handler: formatUnderlineEventHandler, ), ShortcutEvent( key: 'Format strikethrough', command: 'meta+shift+s', windowsCommand: 'ctrl+shift+s', + linuxCommand: 'ctrl+shift+s', handler: formatStrikethroughEventHandler, ), ShortcutEvent( key: 'Format highlight', command: 'meta+shift+h', windowsCommand: 'ctrl+shift+h', + linuxCommand: 'ctrl+shift+h', handler: formatHighlightEventHandler, ), ShortcutEvent( key: 'Format embed code', command: 'meta+e', windowsCommand: 'ctrl+e', + linuxCommand: 'ctrl+e', handler: formatEmbedCodeEventHandler, ), ShortcutEvent( key: 'Format link', command: 'meta+k', windowsCommand: 'ctrl+k', + linuxCommand: 'ctrl+k', handler: formatLinkEventHandler, ), ShortcutEvent( key: 'Copy', command: 'meta+c', windowsCommand: 'ctrl+c', + linuxCommand: 'ctrl+c', handler: copyEventHandler, ), ShortcutEvent( key: 'Paste', command: 'meta+v', windowsCommand: 'ctrl+v', + linuxCommand: 'ctrl+v', handler: pasteEventHandler, ), ShortcutEvent( - key: 'Paste', + key: 'Cut', command: 'meta+x', windowsCommand: 'ctrl+x', + linuxCommand: 'ctrl+x', handler: cutEventHandler, ), + ShortcutEvent( + key: 'Home', + command: 'home', + handler: cursorBegin, + ), + ShortcutEvent( + key: 'End', + command: 'end', + handler: cursorEnd, + ), + // TODO: split the keys. ShortcutEvent( key: 'Delete Text', @@ -199,6 +230,7 @@ List builtInShortcutEvents = [ key: 'select all', command: 'meta+a', windowsCommand: 'ctrl+a', + linuxCommand: 'ctrl+a', handler: selectAllHandler, ), ShortcutEvent( diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart index 5000c3ec93..b9b163f489 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart @@ -287,7 +287,7 @@ void main() async { LogicalKeyboardKey.arrowDown, isShiftPressed: true, ); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey( LogicalKeyboardKey.arrowRight, isShiftPressed: true, @@ -321,7 +321,7 @@ void main() async { LogicalKeyboardKey.arrowUp, isShiftPressed: true, ); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey( LogicalKeyboardKey.arrowLeft, isShiftPressed: true, @@ -398,7 +398,7 @@ Future _testPressArrowKeyWithMetaInSelection( } } await editor.updateSelection(selection); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey( LogicalKeyboardKey.arrowLeft, isControlPressed: true, @@ -415,7 +415,7 @@ Future _testPressArrowKeyWithMetaInSelection( Selection.single(path: [0], startOffset: 0), ); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey( LogicalKeyboardKey.arrowRight, isControlPressed: true, @@ -432,7 +432,7 @@ Future _testPressArrowKeyWithMetaInSelection( Selection.single(path: [0], startOffset: text.length), ); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey( LogicalKeyboardKey.arrowUp, isControlPressed: true, @@ -449,7 +449,7 @@ Future _testPressArrowKeyWithMetaInSelection( Selection.single(path: [0], startOffset: 0), ); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey( LogicalKeyboardKey.arrowDown, isControlPressed: true, diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/format_style_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/format_style_handler_test.dart index 44ffb074d8..7240c186f3 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/format_style_handler_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/format_style_handler_test.dart @@ -94,7 +94,7 @@ Future _testUpdateTextStyleByCommandX( var selection = Selection.single(path: [1], startOffset: 2, endOffset: text.length - 2); await editor.updateSelection(selection); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey( key, isShiftPressed: isShiftPressed, @@ -121,7 +121,7 @@ Future _testUpdateTextStyleByCommandX( selection = Selection.single(path: [1], startOffset: 0, endOffset: text.length); await editor.updateSelection(selection); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey( key, isShiftPressed: isShiftPressed, @@ -146,7 +146,7 @@ Future _testUpdateTextStyleByCommandX( true); await editor.updateSelection(selection); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey( key, isShiftPressed: isShiftPressed, @@ -168,7 +168,7 @@ Future _testUpdateTextStyleByCommandX( end: Position(path: [2], offset: text.length), ); await editor.updateSelection(selection); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey( key, isShiftPressed: isShiftPressed, @@ -203,7 +203,7 @@ Future _testUpdateTextStyleByCommandX( await editor.updateSelection(selection); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey( key, isShiftPressed: isShiftPressed, @@ -249,7 +249,7 @@ Future _testLinkMenuInSingleTextSelection(WidgetTester tester) async { expect(find.byType(ToolbarWidget), findsOneWidget); // trigger the link menu - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey(LogicalKeyboardKey.keyK, isControlPressed: true); } else { await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true); @@ -272,7 +272,7 @@ Future _testLinkMenuInSingleTextSelection(WidgetTester tester) async { true); await editor.updateSelection(selection); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey(LogicalKeyboardKey.keyK, isControlPressed: true); } else { await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true); @@ -289,7 +289,7 @@ Future _testLinkMenuInSingleTextSelection(WidgetTester tester) async { expect(find.byType(LinkMenu), findsNothing); // Remove link - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey(LogicalKeyboardKey.keyK, isControlPressed: true); } else { await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true); diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/redo_undo_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/redo_undo_handler_test.dart index 6fa98e9997..b165f279e7 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/redo_undo_handler_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/redo_undo_handler_test.dart @@ -43,7 +43,7 @@ Future _testBackspaceUndoRedo( await editor.pressLogicKey(LogicalKeyboardKey.backspace); expect(editor.documentLength, 2); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey( LogicalKeyboardKey.keyZ, isControlPressed: true, @@ -59,7 +59,7 @@ Future _testBackspaceUndoRedo( expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text); expect(editor.documentSelection, selection); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey( LogicalKeyboardKey.keyZ, isControlPressed: true, diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/select_all_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/select_all_handler_test.dart index 6efd2064e9..937988da58 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/select_all_handler_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/select_all_handler_test.dart @@ -28,7 +28,7 @@ Future _testSelectAllHandler(WidgetTester tester, int lines) async { editor.insertTextNode(text); } await editor.startTesting(); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey(LogicalKeyboardKey.keyA, isControlPressed: true); } else { await editor.pressLogicKey(LogicalKeyboardKey.keyA, isMetaPressed: true); diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/shortcut_event/shortcut_event_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/shortcut_event/shortcut_event_test.dart index 79b2f4d26c..034d91a93e 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/shortcut_event/shortcut_event_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/shortcut_event/shortcut_event_test.dart @@ -39,7 +39,7 @@ void main() async { await editor.updateSelection( Selection.single(path: [1], startOffset: text.length), ); - if (Platform.isWindows) { + if (Platform.isWindows || Platform.isLinux) { await editor.pressLogicKey( LogicalKeyboardKey.arrowLeft, isControlPressed: true, @@ -62,11 +62,12 @@ void main() async { if (event.key == 'Move cursor begin') { event.updateCommand( windowsCommand: 'alt+arrow left', + linuxCommand: 'alt+arrow left', macOSCommand: 'alt+arrow left', ); } } - if (Platform.isWindows || Platform.isMacOS) { + if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { await editor.pressLogicKey( LogicalKeyboardKey.arrowLeft, isAltPressed: true, @@ -84,5 +85,139 @@ void main() async { tester.pumpAndSettle(); }); + + testWidgets('redefine move cursor end command', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [1], startOffset: 0), + ); + if (Platform.isWindows || Platform.isLinux) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowRight, + isControlPressed: true, + ); + } else { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowRight, + isMetaPressed: true, + ); + } + expect( + editor.documentSelection, + Selection.single(path: [1], startOffset: text.length), + ); + await editor.updateSelection( + Selection.single(path: [1], startOffset: 0), + ); + + for (final event in builtInShortcutEvents) { + if (event.key == 'Move cursor end') { + event.updateCommand( + windowsCommand: 'alt+arrow right', + linuxCommand: 'alt+arrow right', + macOSCommand: 'alt+arrow right', + ); + } + } + await editor.pressLogicKey( + LogicalKeyboardKey.arrowRight, + isAltPressed: true, + ); + expect( + editor.documentSelection, + Selection.single(path: [1], startOffset: text.length), + ); + }); + + testWidgets('Test Home Key to move to start of current text', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [1], startOffset: text.length), + ); + if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + await editor.pressLogicKey( + LogicalKeyboardKey.home, + ); + } + + expect( + editor.documentSelection, + Selection.single(path: [1], startOffset: 0), + ); + await editor.updateSelection( + Selection.single(path: [1], startOffset: text.length), + ); + + for (final event in builtInShortcutEvents) { + if (event.key == 'Move cursor begin') { + event.updateCommand( + windowsCommand: 'home', + linuxCommand: 'home', + macOSCommand: 'home', + ); + } + } + if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { + await editor.pressLogicKey( + LogicalKeyboardKey.home, + ); + } + expect( + editor.documentSelection, + Selection.single(path: [1], startOffset: 0), + ); + }); + + testWidgets('Test End Key to move to end of current text', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [1], startOffset: text.length), + ); + if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + await editor.pressLogicKey( + LogicalKeyboardKey.end, + ); + } + + expect( + editor.documentSelection, + Selection.single(path: [1], startOffset: text.length), + ); + await editor.updateSelection( + Selection.single(path: [1], startOffset: 0), + ); + + for (final event in builtInShortcutEvents) { + if (event.key == 'Move cursor end') { + event.updateCommand( + windowsCommand: 'end', + linuxCommand: 'end', + macOSCommand: 'end', + ); + } + } + if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { + await editor.pressLogicKey( + LogicalKeyboardKey.end, + ); + } + expect( + editor.documentSelection, + Selection.single(path: [1], startOffset: text.length), + ); + }); }); } diff --git a/frontend/app_flowy/packages/appflowy_popover/lib/popover.dart b/frontend/app_flowy/packages/appflowy_popover/lib/popover.dart index 2fc26aeb0c..6f9f4e9f33 100644 --- a/frontend/app_flowy/packages/appflowy_popover/lib/popover.dart +++ b/frontend/app_flowy/packages/appflowy_popover/lib/popover.dart @@ -59,7 +59,7 @@ class Popover extends StatefulWidget { final Decoration? maskDecoration; /// The function used to build the popover. - final Widget Function(BuildContext context) popupBuilder; + final Widget? Function(BuildContext context) popupBuilder; final int triggerActions; @@ -265,7 +265,7 @@ class _PopoverMaskState extends State<_PopoverMask> { } class PopoverContainer extends StatefulWidget { - final Widget Function(BuildContext context) popupBuilder; + final Widget? Function(BuildContext context) popupBuilder; final PopoverDirection direction; final PopoverLink popoverLink; final Offset offset; @@ -284,6 +284,15 @@ class PopoverContainer extends StatefulWidget { @override State createState() => PopoverContainerState(); + + static PopoverContainerState of(BuildContext context) { + if (context is StatefulElement && context.state is PopoverContainerState) { + return context.state as PopoverContainerState; + } + final PopoverContainerState? result = + context.findAncestorStateOfType(); + return result!; + } } class PopoverContainerState extends State { @@ -302,13 +311,4 @@ class PopoverContainerState extends State { close() => widget.onClose(); closeAll() => widget.onCloseAll(); - - static PopoverContainerState of(BuildContext context) { - if (context is StatefulElement && context.state is PopoverContainerState) { - return context.state as PopoverContainerState; - } - final PopoverContainerState? result = - context.findAncestorStateOfType(); - return result!; - } } diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/flowy_infra_ui.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/flowy_infra_ui.dart index a656077eb5..adc7dd1f9b 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/flowy_infra_ui.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/flowy_infra_ui.dart @@ -9,4 +9,4 @@ export 'src/flowy_overlay/flowy_overlay.dart'; export 'src/flowy_overlay/list_overlay.dart'; export 'src/flowy_overlay/option_overlay.dart'; export 'src/flowy_overlay/flowy_dialog.dart'; -export 'src/flowy_overlay/flowy_popover.dart'; +export 'src/flowy_overlay/appflowy_stype_popover.dart'; diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_stype_popover.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_stype_popover.dart new file mode 100644 index 0000000000..663a8c0646 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_stype_popover.dart @@ -0,0 +1,48 @@ +import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; +import 'package:appflowy_popover/popover.dart'; +import 'package:flutter/material.dart'; + +class AppFlowyStylePopover extends StatelessWidget { + final Widget child; + final PopoverController? controller; + final Widget Function(BuildContext context) popupBuilder; + final PopoverDirection direction; + final int triggerActions; + final BoxConstraints? constraints; + final void Function()? onClose; + final PopoverMutex? mutex; + final Offset? offset; + + const AppFlowyStylePopover({ + Key? key, + required this.child, + required this.popupBuilder, + this.direction = PopoverDirection.rightWithTopAligned, + this.onClose, + this.constraints, + this.mutex, + this.triggerActions = 0, + this.offset, + this.controller, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Popover( + controller: controller, + onClose: onClose, + direction: direction, + mutex: mutex, + triggerActions: triggerActions, + popupBuilder: (context) { + final child = popupBuilder(context); + debugPrint('$child popover'); + return OverlayContainer( + constraints: constraints, + child: popupBuilder(context), + ); + }, + child: child, + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_popover.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_popover.dart deleted file mode 100644 index d1a52e50d9..0000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_popover.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; -import 'package:flowy_infra_ui/style_widget/decoration.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:provider/provider.dart'; -import 'package:flutter/material.dart'; -import './flowy_popover_layout.dart'; - -const _overlayContainerPadding = EdgeInsets.all(12); - -class FlowyPopover extends StatefulWidget { - final Widget Function(BuildContext context) builder; - final ShapeBorder? shape; - final Rect anchorRect; - final AnchorDirection? anchorDirection; - final EdgeInsets padding; - final BoxConstraints? constraints; - - const FlowyPopover({ - Key? key, - required this.builder, - required this.anchorRect, - this.shape, - this.padding = _overlayContainerPadding, - this.anchorDirection, - this.constraints, - }) : super(key: key); - - @override - State createState() => _FlowyPopoverState(); -} - -class _FlowyPopoverState extends State { - final preRenderKey = GlobalKey(); - Size? size; - - @override - Widget build(BuildContext context) { - final theme = - context.watch() ?? AppTheme.fromType(ThemeType.light); - return Material( - type: MaterialType.transparency, - child: CustomSingleChildLayout( - delegate: PopoverLayoutDelegate( - anchorRect: widget.anchorRect, - anchorDirection: - widget.anchorDirection ?? AnchorDirection.rightWithTopAligned, - overlapBehaviour: OverlapBehaviour.stretch, - ), - child: Container( - padding: widget.padding, - constraints: widget.constraints ?? - BoxConstraints.loose(const Size(280, 400)), - decoration: FlowyDecoration.decoration( - theme.surface, theme.shadowColor.withOpacity(0.15)), - key: preRenderKey, - child: widget.builder(context), - ))); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart index 2c0725288c..63e9ce2698 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart @@ -1,22 +1,30 @@ +import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; import 'package:flowy_infra/theme.dart'; import 'base_styled_button.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; class PrimaryTextButton extends StatelessWidget { final String label; final VoidCallback? onPressed; final bool bigMode; - const PrimaryTextButton(this.label, {Key? key, this.onPressed, this.bigMode = false}) : super(key: key); + const PrimaryTextButton(this.label, + {Key? key, this.onPressed, this.bigMode = false}) + : super(key: key); @override Widget build(BuildContext context) { - TextStyle txtStyle = TextStyles.Btn.textColor(Colors.white); - return PrimaryButton(bigMode: bigMode, onPressed: onPressed, child: Text(label, style: txtStyle)); + final theme = context.watch(); + return PrimaryButton( + bigMode: bigMode, + onPressed: onPressed, + child: FlowyText.regular( + label, + color: theme.surface, + ), + ); } } @@ -25,14 +33,16 @@ class PrimaryButton extends StatelessWidget { final VoidCallback? onPressed; final bool bigMode; - const PrimaryButton({Key? key, required this.child, this.onPressed, this.bigMode = false}) : super(key: key); + const PrimaryButton( + {Key? key, required this.child, this.onPressed, this.bigMode = false}) + : super(key: key); @override Widget build(BuildContext context) { final theme = context.watch(); return BaseStyledButton( - minWidth: bigMode ? 170 : 78, - minHeight: bigMode ? 48 : 28, + minWidth: bigMode ? 100 : 80, + minHeight: bigMode ? 40 : 38, contentPadding: EdgeInsets.zero, bgColor: theme.main1, hoverColor: theme.main1, diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart index 9e6f7d331d..ef7a6e6051 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart @@ -1,9 +1,8 @@ +import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; // ignore: import_of_legacy_library_into_null_safe -import 'package:textstyle_extensions/textstyle_extensions.dart'; import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; import 'package:flowy_infra/theme.dart'; import 'base_styled_button.dart'; @@ -12,13 +11,21 @@ class SecondaryTextButton extends StatelessWidget { final VoidCallback? onPressed; final bool bigMode; - const SecondaryTextButton(this.label, {Key? key, this.onPressed, this.bigMode = false}) : super(key: key); + const SecondaryTextButton(this.label, + {Key? key, this.onPressed, this.bigMode = false}) + : super(key: key); @override Widget build(BuildContext context) { final theme = context.watch(); - TextStyle txtStyle = TextStyles.Btn.textColor(theme.main1); - return SecondaryButton(bigMode: bigMode, onPressed: onPressed, child: Text(label, style: txtStyle)); + return SecondaryButton( + bigMode: bigMode, + onPressed: onPressed, + child: FlowyText.regular( + label, + color: theme.main1, + ), + ); } } @@ -27,14 +34,16 @@ class SecondaryButton extends StatelessWidget { final VoidCallback? onPressed; final bool bigMode; - const SecondaryButton({Key? key, required this.child, this.onPressed, this.bigMode = false}) : super(key: key); + const SecondaryButton( + {Key? key, required this.child, this.onPressed, this.bigMode = false}) + : super(key: key); @override Widget build(BuildContext context) { final theme = context.watch(); return BaseStyledButton( - minWidth: bigMode ? 170 : 78, - minHeight: bigMode ? 48 : 28, + minWidth: bigMode ? 100 : 80, + minHeight: bigMode ? 40 : 38, contentPadding: EdgeInsets.zero, bgColor: theme.shader7, hoverColor: theme.hover, diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/dialog/dialog_size.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/dialog/dialog_size.dart index e4a95b7b5f..b0a13d6bd3 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/dialog/dialog_size.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/dialog/dialog_size.dart @@ -1,3 +1,3 @@ class DialogSize { - static double get minDialogWidth => 480; + static double get minDialogWidth => 400; } diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart index ecd8cfb4ae..5cf1641649 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart @@ -65,7 +65,7 @@ class StyledDialog extends StatelessWidget { return FocusTraversalGroup( child: Container( - margin: margin ?? EdgeInsets.all(Insets.lGutter * 2), + margin: margin ?? EdgeInsets.all(Insets.sm * 2), alignment: Alignment.center, child: Container( constraints: BoxConstraints( @@ -133,7 +133,9 @@ class StyledDialogRoute extends PopupRoute { super(settings: settings, filter: barrier.filter); @override - bool get barrierDismissible => barrier.dismissible; + bool get barrierDismissible { + return barrier.dismissible; + } @override String get barrierLabel => barrier.label; diff --git a/frontend/app_flowy/packages/flowy_infra_ui/pubspec.yaml b/frontend/app_flowy/packages/flowy_infra_ui/pubspec.yaml index c95ba1119e..9d9b25efab 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_infra_ui/pubspec.yaml @@ -27,6 +27,8 @@ dependencies: path: flowy_infra_ui_platform_interface flowy_infra_ui_web: path: flowy_infra_ui_web + appflowy_popover: + path: ../appflowy_popover # Flowy packages flowy_infra: diff --git a/frontend/rust-lib/flowy-net/src/http_server/document.rs b/frontend/rust-lib/flowy-net/src/http_server/document.rs index ca9cb47955..b286e5c102 100644 --- a/frontend/rust-lib/flowy-net/src/http_server/document.rs +++ b/frontend/rust-lib/flowy-net/src/http_server/document.rs @@ -4,7 +4,7 @@ use crate::{ }; use flowy_error::FlowyError; use flowy_sync::entities::text_block::{CreateTextBlockParams, DocumentPB, ResetTextBlockParams, TextBlockIdPB}; -use flowy_text_block::BlockCloudService; +use flowy_text_block::TextEditorCloudService; use http_flowy::response::FlowyResponse; use lazy_static::lazy_static; use lib_infra::future::FutureResult; @@ -20,20 +20,20 @@ impl BlockHttpCloudService { } } -impl BlockCloudService for BlockHttpCloudService { - fn create_block(&self, token: &str, params: CreateTextBlockParams) -> FutureResult<(), FlowyError> { +impl TextEditorCloudService for BlockHttpCloudService { + fn create_text_block(&self, token: &str, params: CreateTextBlockParams) -> FutureResult<(), FlowyError> { let token = token.to_owned(); let url = self.config.doc_url(); FutureResult::new(async move { create_document_request(&token, params, &url).await }) } - fn read_block(&self, token: &str, params: TextBlockIdPB) -> FutureResult, FlowyError> { + fn read_text_block(&self, token: &str, params: TextBlockIdPB) -> FutureResult, FlowyError> { let token = token.to_owned(); let url = self.config.doc_url(); FutureResult::new(async move { read_document_request(&token, params, &url).await }) } - fn update_block(&self, token: &str, params: ResetTextBlockParams) -> FutureResult<(), FlowyError> { + fn update_text_block(&self, token: &str, params: ResetTextBlockParams) -> FutureResult<(), FlowyError> { let token = token.to_owned(); let url = self.config.doc_url(); FutureResult::new(async move { reset_doc_request(&token, params, &url).await }) diff --git a/frontend/rust-lib/flowy-net/src/local_server/server.rs b/frontend/rust-lib/flowy-net/src/local_server/server.rs index 387ff4e885..060abf08bf 100644 --- a/frontend/rust-lib/flowy-net/src/local_server/server.rs +++ b/frontend/rust-lib/flowy-net/src/local_server/server.rs @@ -261,7 +261,7 @@ use flowy_folder::entities::{ use flowy_folder_data_model::revision::{ gen_app_id, gen_workspace_id, AppRevision, TrashRevision, ViewRevision, WorkspaceRevision, }; -use flowy_text_block::BlockCloudService; +use flowy_text_block::TextEditorCloudService; use flowy_user::entities::{ SignInParams, SignInResponse, SignUpParams, SignUpResponse, UpdateUserProfileParams, UserProfilePB, }; @@ -414,12 +414,12 @@ impl UserCloudService for LocalServer { } } -impl BlockCloudService for LocalServer { - fn create_block(&self, _token: &str, _params: CreateTextBlockParams) -> FutureResult<(), FlowyError> { +impl TextEditorCloudService for LocalServer { + fn create_text_block(&self, _token: &str, _params: CreateTextBlockParams) -> FutureResult<(), FlowyError> { FutureResult::new(async { Ok(()) }) } - fn read_block(&self, _token: &str, params: TextBlockIdPB) -> FutureResult, FlowyError> { + fn read_text_block(&self, _token: &str, params: TextBlockIdPB) -> FutureResult, FlowyError> { let doc = DocumentPB { block_id: params.value, text: initial_quill_delta_string(), @@ -429,7 +429,7 @@ impl BlockCloudService for LocalServer { FutureResult::new(async { Ok(Some(doc)) }) } - fn update_block(&self, _token: &str, _params: ResetTextBlockParams) -> FutureResult<(), FlowyError> { + fn update_text_block(&self, _token: &str, _params: ResetTextBlockParams) -> FutureResult<(), FlowyError> { FutureResult::new(async { Ok(()) }) } } diff --git a/frontend/rust-lib/flowy-sdk/src/deps_resolve/folder_deps.rs b/frontend/rust-lib/flowy-sdk/src/deps_resolve/folder_deps.rs index 0dc51c1a09..79b5468a56 100644 --- a/frontend/rust-lib/flowy-sdk/src/deps_resolve/folder_deps.rs +++ b/frontend/rust-lib/flowy-sdk/src/deps_resolve/folder_deps.rs @@ -18,7 +18,7 @@ use flowy_revision::{RevisionWebSocket, WSStateReceiver}; use flowy_sync::client_document::default::initial_quill_delta_string; use flowy_sync::entities::revision::{RepeatedRevision, Revision}; use flowy_sync::entities::ws_data::ClientRevisionWSData; -use flowy_text_block::TextBlockManager; +use flowy_text_block::TextEditorManager; use flowy_user::services::UserSession; use futures_core::future::BoxFuture; use lib_infra::future::{BoxResultFuture, FutureResult}; @@ -34,7 +34,7 @@ impl FolderDepsResolver { user_session: Arc, server_config: &ClientServerConfiguration, ws_conn: &Arc, - text_block_manager: &Arc, + text_block_manager: &Arc, grid_manager: &Arc, ) -> Arc { let user: Arc = Arc::new(WorkspaceUserImpl(user_session.clone())); @@ -63,7 +63,7 @@ impl FolderDepsResolver { } fn make_view_data_processor( - text_block_manager: Arc, + text_block_manager: Arc, grid_manager: Arc, ) -> ViewDataProcessorMap { let mut map: HashMap> = HashMap::new(); @@ -135,7 +135,7 @@ impl WSMessageReceiver for FolderWSMessageReceiverImpl { } } -struct TextBlockViewDataProcessor(Arc); +struct TextBlockViewDataProcessor(Arc); impl ViewDataProcessor for TextBlockViewDataProcessor { fn initialize(&self) -> FutureResult<(), FlowyError> { let manager = self.0.clone(); @@ -147,7 +147,7 @@ impl ViewDataProcessor for TextBlockViewDataProcessor { let view_id = view_id.to_string(); let manager = self.0.clone(); FutureResult::new(async move { - let _ = manager.create_block(view_id, repeated_revision).await?; + let _ = manager.create_text_block(view_id, repeated_revision).await?; Ok(()) }) } @@ -156,7 +156,7 @@ impl ViewDataProcessor for TextBlockViewDataProcessor { let manager = self.0.clone(); let view_id = view_id.to_string(); FutureResult::new(async move { - let _ = manager.delete_block(view_id)?; + let _ = manager.close_text_editor(view_id)?; Ok(()) }) } @@ -165,7 +165,7 @@ impl ViewDataProcessor for TextBlockViewDataProcessor { let manager = self.0.clone(); let view_id = view_id.to_string(); FutureResult::new(async move { - let _ = manager.close_block(view_id)?; + let _ = manager.close_text_editor(view_id)?; Ok(()) }) } @@ -174,7 +174,7 @@ impl ViewDataProcessor for TextBlockViewDataProcessor { let view_id = view_id.to_string(); let manager = self.0.clone(); FutureResult::new(async move { - let editor = manager.open_block(view_id).await?; + let editor = manager.open_text_editor(view_id).await?; let delta_bytes = Bytes::from(editor.delta_str().await?); Ok(delta_bytes) }) @@ -195,7 +195,7 @@ impl ViewDataProcessor for TextBlockViewDataProcessor { let delta_data = Bytes::from(view_data); let repeated_revision: RepeatedRevision = Revision::initial_revision(&user_id, &view_id, delta_data.clone()).into(); - let _ = manager.create_block(view_id, repeated_revision).await?; + let _ = manager.create_text_block(view_id, repeated_revision).await?; Ok(delta_data) }) } diff --git a/frontend/rust-lib/flowy-sdk/src/deps_resolve/text_block_deps.rs b/frontend/rust-lib/flowy-sdk/src/deps_resolve/text_block_deps.rs index 7b80e637ab..9fa5667bb1 100644 --- a/frontend/rust-lib/flowy-sdk/src/deps_resolve/text_block_deps.rs +++ b/frontend/rust-lib/flowy-sdk/src/deps_resolve/text_block_deps.rs @@ -8,7 +8,7 @@ use flowy_revision::{RevisionWebSocket, WSStateReceiver}; use flowy_sync::entities::ws_data::ClientRevisionWSData; use flowy_text_block::{ errors::{internal_error, FlowyError}, - BlockCloudService, TextBlockManager, TextBlockUser, + TextEditorCloudService, TextEditorManager, TextEditorUser, }; use flowy_user::services::UserSession; use futures_core::future::BoxFuture; @@ -23,15 +23,15 @@ impl TextBlockDepsResolver { ws_conn: Arc, user_session: Arc, server_config: &ClientServerConfiguration, - ) -> Arc { + ) -> Arc { let user = Arc::new(BlockUserImpl(user_session)); let rev_web_socket = Arc::new(TextBlockWebSocket(ws_conn.clone())); - let cloud_service: Arc = match local_server { + let cloud_service: Arc = match local_server { None => Arc::new(BlockHttpCloudService::new(server_config.clone())), Some(local_server) => local_server, }; - let manager = Arc::new(TextBlockManager::new(cloud_service, user, rev_web_socket)); + let manager = Arc::new(TextEditorManager::new(cloud_service, user, rev_web_socket)); let receiver = Arc::new(DocumentWSMessageReceiverImpl(manager.clone())); ws_conn.add_ws_message_receiver(receiver).unwrap(); @@ -40,7 +40,7 @@ impl TextBlockDepsResolver { } struct BlockUserImpl(Arc); -impl TextBlockUser for BlockUserImpl { +impl TextEditorUser for BlockUserImpl { fn user_dir(&self) -> Result { let dir = self.0.user_dir().map_err(|e| FlowyError::unauthorized().context(e))?; @@ -90,7 +90,7 @@ impl RevisionWebSocket for TextBlockWebSocket { } } -struct DocumentWSMessageReceiverImpl(Arc); +struct DocumentWSMessageReceiverImpl(Arc); impl WSMessageReceiver for DocumentWSMessageReceiverImpl { fn source(&self) -> WSChannel { WSChannel::Document diff --git a/frontend/rust-lib/flowy-sdk/src/lib.rs b/frontend/rust-lib/flowy-sdk/src/lib.rs index e0bd601987..e780f9f937 100644 --- a/frontend/rust-lib/flowy-sdk/src/lib.rs +++ b/frontend/rust-lib/flowy-sdk/src/lib.rs @@ -11,7 +11,7 @@ use flowy_net::{ local_server::LocalServer, ws::connection::{listen_on_websocket, FlowyWebSocketConnect}, }; -use flowy_text_block::TextBlockManager; +use flowy_text_block::TextEditorManager; use flowy_user::services::{notifier::UserStatus, UserSession, UserSessionConfig}; use lib_dispatch::prelude::*; use lib_dispatch::runtime::tokio_default_runtime; @@ -89,7 +89,7 @@ pub struct FlowySDK { #[allow(dead_code)] config: FlowySDKConfig, pub user_session: Arc, - pub text_block_manager: Arc, + pub text_block_manager: Arc, pub folder_manager: Arc, pub grid_manager: Arc, pub dispatcher: Arc, diff --git a/frontend/rust-lib/flowy-sdk/src/module.rs b/frontend/rust-lib/flowy-sdk/src/module.rs index cbb1673a17..2f6a20d6c6 100644 --- a/frontend/rust-lib/flowy-sdk/src/module.rs +++ b/frontend/rust-lib/flowy-sdk/src/module.rs @@ -1,7 +1,7 @@ use flowy_folder::manager::FolderManager; use flowy_grid::manager::GridManager; use flowy_net::ws::connection::FlowyWebSocketConnect; -use flowy_text_block::TextBlockManager; +use flowy_text_block::TextEditorManager; use flowy_user::services::UserSession; use lib_dispatch::prelude::Module; use std::sync::Arc; @@ -11,7 +11,7 @@ pub fn mk_modules( folder_manager: &Arc, grid_manager: &Arc, user_session: &Arc, - text_block_manager: &Arc, + text_block_manager: &Arc, ) -> Vec { let user_module = mk_user_module(user_session.clone()); let folder_module = mk_folder_module(folder_manager.clone()); @@ -43,6 +43,6 @@ fn mk_grid_module(grid_manager: Arc) -> Module { flowy_grid::event_map::create(grid_manager) } -fn mk_text_block_module(text_block_manager: Arc) -> Module { +fn mk_text_block_module(text_block_manager: Arc) -> Module { flowy_text_block::event_map::create(text_block_manager) } diff --git a/frontend/rust-lib/flowy-text-block/src/editor.rs b/frontend/rust-lib/flowy-text-block/src/editor.rs index 8f75a67d88..8ca41f3f5a 100644 --- a/frontend/rust-lib/flowy-text-block/src/editor.rs +++ b/frontend/rust-lib/flowy-text-block/src/editor.rs @@ -2,7 +2,7 @@ use crate::web_socket::EditorCommandSender; use crate::{ errors::FlowyError, queue::{EditBlockQueue, EditorCommand}, - TextBlockUser, + TextEditorUser, }; use bytes::Bytes; use flowy_error::{internal_error, FlowyResult}; @@ -24,7 +24,6 @@ use tokio::sync::{mpsc, oneshot}; pub struct TextBlockEditor { pub doc_id: String, - #[allow(dead_code)] rev_manager: Arc, #[cfg(feature = "sync")] ws_manager: Arc, @@ -35,7 +34,7 @@ impl TextBlockEditor { #[allow(unused_variables)] pub(crate) async fn new( doc_id: &str, - user: Arc, + user: Arc, mut rev_manager: RevisionManager, rev_web_socket: Arc, cloud_service: Arc, @@ -194,7 +193,7 @@ impl std::ops::Drop for TextBlockEditor { // The edit queue will exit after the EditorCommandSender was dropped. fn spawn_edit_queue( - user: Arc, + user: Arc, rev_manager: Arc, delta: TextDelta, ) -> EditorCommandSender { diff --git a/frontend/rust-lib/flowy-text-block/src/entities.rs b/frontend/rust-lib/flowy-text-block/src/entities.rs index ec8767285b..d7cc1d8665 100644 --- a/frontend/rust-lib/flowy-text-block/src/entities.rs +++ b/frontend/rust-lib/flowy-text-block/src/entities.rs @@ -29,6 +29,52 @@ impl std::convert::From for ExportType { } } +#[derive(Default, ProtoBuf)] +pub struct EditPayloadPB { + #[pb(index = 1)] + pub text_block_id: String, + + // Encode in JSON format + #[pb(index = 2)] + pub operations: String, + + // Encode in JSON format + #[pb(index = 3)] + pub delta: String, +} + +#[derive(Default)] +pub struct EditParams { + pub text_block_id: String, + + // Encode in JSON format + pub operations: String, + + // Encode in JSON format + pub delta: String, +} + +impl TryInto for EditPayloadPB { + type Error = ErrorCode; + fn try_into(self) -> Result { + Ok(EditParams { + text_block_id: self.text_block_id, + operations: self.operations, + delta: self.delta, + }) + } +} + +#[derive(Default, ProtoBuf)] +pub struct TextBlockPB { + #[pb(index = 1)] + pub text_block_id: String, + + /// Encode in JSON format + #[pb(index = 2)] + pub snapshot: String, +} + #[derive(Default, ProtoBuf)] pub struct ExportPayloadPB { #[pb(index = 1)] diff --git a/frontend/rust-lib/flowy-text-block/src/event_handler.rs b/frontend/rust-lib/flowy-text-block/src/event_handler.rs index dc9812d862..98ca7e19fe 100644 --- a/frontend/rust-lib/flowy-text-block/src/event_handler.rs +++ b/frontend/rust-lib/flowy-text-block/src/event_handler.rs @@ -1,39 +1,40 @@ -use crate::entities::{ExportDataPB, ExportParams, ExportPayloadPB}; -use crate::TextBlockManager; +use crate::entities::{EditParams, EditPayloadPB, ExportDataPB, ExportParams, ExportPayloadPB, TextBlockPB}; +use crate::TextEditorManager; use flowy_error::FlowyError; -use flowy_sync::entities::text_block::{TextBlockDeltaPB, TextBlockIdPB}; +use flowy_sync::entities::text_block::TextBlockIdPB; use lib_dispatch::prelude::{data_result, AppData, Data, DataResult}; use std::convert::TryInto; use std::sync::Arc; -pub(crate) async fn get_block_data_handler( +pub(crate) async fn get_text_block_handler( data: Data, - manager: AppData>, -) -> DataResult { - let block_id: TextBlockIdPB = data.into_inner(); - let editor = manager.open_block(&block_id).await?; + manager: AppData>, +) -> DataResult { + let text_block_id: TextBlockIdPB = data.into_inner(); + let editor = manager.open_text_editor(&text_block_id).await?; let delta_str = editor.delta_str().await?; - data_result(TextBlockDeltaPB { - block_id: block_id.into(), - delta_str, + data_result(TextBlockPB { + text_block_id: text_block_id.into(), + snapshot: delta_str, }) } -pub(crate) async fn apply_delta_handler( - data: Data, - manager: AppData>, -) -> DataResult { - let block_delta = manager.receive_local_delta(data.into_inner()).await?; - data_result(block_delta) +pub(crate) async fn apply_edit_handler( + data: Data, + manager: AppData>, +) -> Result<(), FlowyError> { + let params: EditParams = data.into_inner().try_into()?; + let _ = manager.apply_edit(params).await?; + Ok(()) } #[tracing::instrument(level = "debug", skip(data, manager), err)] pub(crate) async fn export_handler( data: Data, - manager: AppData>, + manager: AppData>, ) -> DataResult { let params: ExportParams = data.into_inner().try_into()?; - let editor = manager.open_block(¶ms.view_id).await?; + let editor = manager.open_text_editor(¶ms.view_id).await?; let delta_json = editor.delta_str().await?; data_result(ExportDataPB { data: delta_json, diff --git a/frontend/rust-lib/flowy-text-block/src/event_map.rs b/frontend/rust-lib/flowy-text-block/src/event_map.rs index cfc06bf32c..d66ee490ee 100644 --- a/frontend/rust-lib/flowy-text-block/src/event_map.rs +++ b/frontend/rust-lib/flowy-text-block/src/event_map.rs @@ -1,16 +1,16 @@ use crate::event_handler::*; -use crate::TextBlockManager; +use crate::TextEditorManager; use flowy_derive::{Flowy_Event, ProtoBuf_Enum}; use lib_dispatch::prelude::Module; use std::sync::Arc; use strum_macros::Display; -pub fn create(block_manager: Arc) -> Module { +pub fn create(block_manager: Arc) -> Module { let mut module = Module::new().name(env!("CARGO_PKG_NAME")).data(block_manager); module = module - .event(TextBlockEvent::GetBlockData, get_block_data_handler) - .event(TextBlockEvent::ApplyDelta, apply_delta_handler) + .event(TextBlockEvent::GetTextBlock, get_text_block_handler) + .event(TextBlockEvent::ApplyEdit, apply_edit_handler) .event(TextBlockEvent::ExportDocument, export_handler); module @@ -19,11 +19,11 @@ pub fn create(block_manager: Arc) -> Module { #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] #[event_err = "FlowyError"] pub enum TextBlockEvent { - #[event(input = "TextBlockIdPB", output = "TextBlockDeltaPB")] - GetBlockData = 0, + #[event(input = "TextBlockIdPB", output = "TextBlockPB")] + GetTextBlock = 0, - #[event(input = "TextBlockDeltaPB", output = "TextBlockDeltaPB")] - ApplyDelta = 1, + #[event(input = "EditPayloadPB")] + ApplyEdit = 1, #[event(input = "ExportPayloadPB", output = "ExportDataPB")] ExportDocument = 2, diff --git a/frontend/rust-lib/flowy-text-block/src/lib.rs b/frontend/rust-lib/flowy-text-block/src/lib.rs index 37ddf6ea1e..b3f9839fde 100644 --- a/frontend/rust-lib/flowy-text-block/src/lib.rs +++ b/frontend/rust-lib/flowy-text-block/src/lib.rs @@ -18,10 +18,10 @@ use crate::errors::FlowyError; use flowy_sync::entities::text_block::{CreateTextBlockParams, DocumentPB, ResetTextBlockParams, TextBlockIdPB}; use lib_infra::future::FutureResult; -pub trait BlockCloudService: Send + Sync { - fn create_block(&self, token: &str, params: CreateTextBlockParams) -> FutureResult<(), FlowyError>; +pub trait TextEditorCloudService: Send + Sync { + fn create_text_block(&self, token: &str, params: CreateTextBlockParams) -> FutureResult<(), FlowyError>; - fn read_block(&self, token: &str, params: TextBlockIdPB) -> FutureResult, FlowyError>; + fn read_text_block(&self, token: &str, params: TextBlockIdPB) -> FutureResult, FlowyError>; - fn update_block(&self, token: &str, params: ResetTextBlockParams) -> FutureResult<(), FlowyError>; + fn update_text_block(&self, token: &str, params: ResetTextBlockParams) -> FutureResult<(), FlowyError>; } diff --git a/frontend/rust-lib/flowy-text-block/src/manager.rs b/frontend/rust-lib/flowy-text-block/src/manager.rs index 57e875668e..2e10873523 100644 --- a/frontend/rust-lib/flowy-text-block/src/manager.rs +++ b/frontend/rust-lib/flowy-text-block/src/manager.rs @@ -1,5 +1,6 @@ +use crate::entities::EditParams; use crate::queue::TextBlockRevisionCompactor; -use crate::{editor::TextBlockEditor, errors::FlowyError, BlockCloudService}; +use crate::{editor::TextBlockEditor, errors::FlowyError, TextEditorCloudService}; use bytes::Bytes; use dashmap::DashMap; use flowy_database::ConnectionPool; @@ -16,30 +17,30 @@ use flowy_sync::entities::{ use lib_infra::future::FutureResult; use std::{convert::TryInto, sync::Arc}; -pub trait TextBlockUser: Send + Sync { +pub trait TextEditorUser: Send + Sync { fn user_dir(&self) -> Result; fn user_id(&self) -> Result; fn token(&self) -> Result; fn db_pool(&self) -> Result, FlowyError>; } -pub struct TextBlockManager { - cloud_service: Arc, +pub struct TextEditorManager { + cloud_service: Arc, rev_web_socket: Arc, - editor_map: Arc, - user: Arc, + editor_map: Arc, + user: Arc, } -impl TextBlockManager { +impl TextEditorManager { pub fn new( - cloud_service: Arc, - text_block_user: Arc, + cloud_service: Arc, + text_block_user: Arc, rev_web_socket: Arc, ) -> Self { Self { cloud_service, rev_web_socket, - editor_map: Arc::new(TextBlockEditorMap::new()), + editor_map: Arc::new(TextEditorMap::new()), user: text_block_user, } } @@ -50,45 +51,47 @@ impl TextBlockManager { Ok(()) } - #[tracing::instrument(level = "trace", skip(self, block_id), fields(block_id), err)] - pub async fn open_block>(&self, block_id: T) -> Result, FlowyError> { - let block_id = block_id.as_ref(); - tracing::Span::current().record("block_id", &block_id); - self.get_block_editor(block_id).await + #[tracing::instrument(level = "trace", skip(self, editor_id), fields(editor_id), err)] + pub async fn open_text_editor>(&self, editor_id: T) -> Result, FlowyError> { + let editor_id = editor_id.as_ref(); + tracing::Span::current().record("editor_id", &editor_id); + self.get_text_editor(editor_id).await } - #[tracing::instrument(level = "trace", skip(self, block_id), fields(block_id), err)] - pub fn close_block>(&self, block_id: T) -> Result<(), FlowyError> { - let block_id = block_id.as_ref(); - tracing::Span::current().record("block_id", &block_id); - self.editor_map.remove(block_id); + #[tracing::instrument(level = "trace", skip(self, editor_id), fields(editor_id), err)] + pub fn close_text_editor>(&self, editor_id: T) -> Result<(), FlowyError> { + let editor_id = editor_id.as_ref(); + tracing::Span::current().record("editor_id", &editor_id); + self.editor_map.remove(editor_id); Ok(()) } - #[tracing::instrument(level = "debug", skip(self, doc_id), fields(doc_id), err)] - pub fn delete_block>(&self, doc_id: T) -> Result<(), FlowyError> { - let doc_id = doc_id.as_ref(); - tracing::Span::current().record("doc_id", &doc_id); - self.editor_map.remove(doc_id); - Ok(()) - } - - #[tracing::instrument(level = "debug", skip(self, delta), fields(doc_id = %delta.block_id), err)] + #[tracing::instrument(level = "debug", skip(self, delta), err)] pub async fn receive_local_delta(&self, delta: TextBlockDeltaPB) -> Result { - let editor = self.get_block_editor(&delta.block_id).await?; + let editor = self.get_text_editor(&delta.text_block_id).await?; let _ = editor.compose_local_delta(Bytes::from(delta.delta_str)).await?; - let document_json = editor.delta_str().await?; + let delta_str = editor.delta_str().await?; Ok(TextBlockDeltaPB { - block_id: delta.block_id.clone(), - delta_str: document_json, + text_block_id: delta.text_block_id.clone(), + delta_str, }) } - pub async fn create_block>(&self, doc_id: T, revisions: RepeatedRevision) -> FlowyResult<()> { - let doc_id = doc_id.as_ref().to_owned(); + pub async fn apply_edit(&self, params: EditParams) -> FlowyResult<()> { + let editor = self.get_text_editor(¶ms.text_block_id).await?; + let _ = editor.compose_local_delta(Bytes::from(params.delta)).await?; + Ok(()) + } + + pub async fn create_text_block>( + &self, + text_block_id: T, + revisions: RepeatedRevision, + ) -> FlowyResult<()> { + let doc_id = text_block_id.as_ref().to_owned(); let db_pool = self.user.db_pool()?; // Maybe we could save the block to disk without creating the RevisionManager - let rev_manager = self.make_rev_manager(&doc_id, db_pool)?; + let rev_manager = self.make_text_block_rev_manager(&doc_id, db_pool)?; let _ = rev_manager.reset_object(revisions).await?; Ok(()) } @@ -110,26 +113,26 @@ impl TextBlockManager { } } -impl TextBlockManager { - async fn get_block_editor(&self, block_id: &str) -> FlowyResult> { +impl TextEditorManager { + async fn get_text_editor(&self, block_id: &str) -> FlowyResult> { match self.editor_map.get(block_id) { None => { let db_pool = self.user.db_pool()?; - self.make_text_block_editor(block_id, db_pool).await + self.make_text_editor(block_id, db_pool).await } Some(editor) => Ok(editor), } } #[tracing::instrument(level = "trace", skip(self, pool), err)] - async fn make_text_block_editor( + async fn make_text_editor( &self, block_id: &str, pool: Arc, ) -> Result, FlowyError> { let user = self.user.clone(); let token = self.user.token()?; - let rev_manager = self.make_rev_manager(block_id, pool.clone())?; + let rev_manager = self.make_text_block_rev_manager(block_id, pool.clone())?; let cloud_service = Arc::new(TextBlockRevisionCloudService { token, server: self.cloud_service.clone(), @@ -140,7 +143,11 @@ impl TextBlockManager { Ok(doc_editor) } - fn make_rev_manager(&self, doc_id: &str, pool: Arc) -> Result { + fn make_text_block_rev_manager( + &self, + doc_id: &str, + pool: Arc, + ) -> Result { let user_id = self.user.user_id()?; let disk_cache = SQLiteTextBlockRevisionPersistence::new(&user_id, pool.clone()); let rev_persistence = RevisionPersistence::new(&user_id, doc_id, disk_cache); @@ -161,7 +168,7 @@ impl TextBlockManager { struct TextBlockRevisionCloudService { token: String, - server: Arc, + server: Arc, } impl RevisionCloudService for TextBlockRevisionCloudService { @@ -173,7 +180,7 @@ impl RevisionCloudService for TextBlockRevisionCloudService { let user_id = user_id.to_string(); FutureResult::new(async move { - match server.read_block(&token, params).await? { + match server.read_text_block(&token, params).await? { None => Err(FlowyError::record_not_found().context("Remote doesn't have this document")), Some(doc) => { let delta_data = Bytes::from(doc.text.clone()); @@ -193,36 +200,36 @@ impl RevisionCloudService for TextBlockRevisionCloudService { } } -pub struct TextBlockEditorMap { +pub struct TextEditorMap { inner: DashMap>, } -impl TextBlockEditorMap { +impl TextEditorMap { fn new() -> Self { Self { inner: DashMap::new() } } - pub(crate) fn insert(&self, block_id: &str, doc: &Arc) { - if self.inner.contains_key(block_id) { - log::warn!("Doc:{} already exists in cache", block_id); + pub(crate) fn insert(&self, editor_id: &str, doc: &Arc) { + if self.inner.contains_key(editor_id) { + log::warn!("Doc:{} already exists in cache", editor_id); } - self.inner.insert(block_id.to_string(), doc.clone()); + self.inner.insert(editor_id.to_string(), doc.clone()); } - pub(crate) fn get(&self, block_id: &str) -> Option> { - Some(self.inner.get(block_id)?.clone()) + pub(crate) fn get(&self, editor_id: &str) -> Option> { + Some(self.inner.get(editor_id)?.clone()) } - pub(crate) fn remove(&self, block_id: &str) { - if let Some(editor) = self.get(block_id) { + pub(crate) fn remove(&self, editor_id: &str) { + if let Some(editor) = self.get(editor_id) { editor.stop() } - self.inner.remove(block_id); + self.inner.remove(editor_id); } } #[tracing::instrument(level = "trace", skip(web_socket, handlers))] -fn listen_ws_state_changed(web_socket: Arc, handlers: Arc) { +fn listen_ws_state_changed(web_socket: Arc, handlers: Arc) { tokio::spawn(async move { let mut notify = web_socket.subscribe_state_changed().await; while let Ok(state) = notify.recv().await { diff --git a/frontend/rust-lib/flowy-text-block/src/queue.rs b/frontend/rust-lib/flowy-text-block/src/queue.rs index d81461d25e..510205c1ba 100644 --- a/frontend/rust-lib/flowy-text-block/src/queue.rs +++ b/frontend/rust-lib/flowy-text-block/src/queue.rs @@ -1,5 +1,5 @@ use crate::web_socket::EditorCommandReceiver; -use crate::TextBlockUser; +use crate::TextEditorUser; use async_stream::stream; use bytes::Bytes; use flowy_error::{FlowyError, FlowyResult}; @@ -23,14 +23,14 @@ use tokio::sync::{oneshot, RwLock}; // serial. pub(crate) struct EditBlockQueue { document: Arc>, - user: Arc, + user: Arc, rev_manager: Arc, receiver: Option, } impl EditBlockQueue { pub(crate) fn new( - user: Arc, + user: Arc, rev_manager: Arc, delta: TextDelta, receiver: EditorCommandReceiver, diff --git a/frontend/rust-lib/flowy-text-block/tests/document/script.rs b/frontend/rust-lib/flowy-text-block/tests/document/script.rs index 254b94958e..ae3209020a 100644 --- a/frontend/rust-lib/flowy-text-block/tests/document/script.rs +++ b/frontend/rust-lib/flowy-text-block/tests/document/script.rs @@ -27,7 +27,7 @@ impl TextBlockEditorTest { let sdk = FlowySDKTest::default(); let _ = sdk.init_user().await; let test = ViewTest::new_text_block_view(&sdk).await; - let editor = sdk.text_block_manager.open_block(&test.view.id).await.unwrap(); + let editor = sdk.text_block_manager.open_text_editor(&test.view.id).await.unwrap(); Self { sdk, editor } } diff --git a/shared-lib/flowy-sync/src/entities/text_block.rs b/shared-lib/flowy-sync/src/entities/text_block.rs index d65ec683ee..cca7222bd0 100644 --- a/shared-lib/flowy-sync/src/entities/text_block.rs +++ b/shared-lib/flowy-sync/src/entities/text_block.rs @@ -69,7 +69,7 @@ pub struct ResetTextBlockParams { #[derive(ProtoBuf, Default, Debug, Clone)] pub struct TextBlockDeltaPB { #[pb(index = 1)] - pub block_id: String, + pub text_block_id: String, #[pb(index = 2)] pub delta_str: String, diff --git a/shared-lib/lib-ot/Cargo.toml b/shared-lib/lib-ot/Cargo.toml index 3d0bd5aadd..81b75507a0 100644 --- a/shared-lib/lib-ot/Cargo.toml +++ b/shared-lib/lib-ot/Cargo.toml @@ -7,7 +7,7 @@ edition = "2018" [dependencies] bytecount = "0.6.0" -serde = { version = "1.0", features = ["derive"] } +serde = { version = "1.0", features = ["derive", "rc"] } #protobuf = {version = "2.18.0"} #flowy-derive = { path = "../flowy-derive" } tokio = { version = "1", features = ["sync"] } diff --git a/shared-lib/lib-ot/src/core/document/operation.rs b/shared-lib/lib-ot/src/core/document/operation.rs deleted file mode 100644 index f9e772e5d0..0000000000 --- a/shared-lib/lib-ot/src/core/document/operation.rs +++ /dev/null @@ -1,143 +0,0 @@ -use crate::core::attributes::Attributes; -use crate::core::document::path::Path; -use crate::core::{NodeBodyChangeset, NodeData}; -use crate::errors::OTError; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Serialize, Deserialize)] -#[serde(tag = "op")] -pub enum NodeOperation { - #[serde(rename = "insert")] - Insert { path: Path, nodes: Vec }, - - #[serde(rename = "update")] - UpdateAttributes { - path: Path, - attributes: Attributes, - #[serde(rename = "oldAttributes")] - old_attributes: Attributes, - }, - - #[serde(rename = "update-body")] - // #[serde(serialize_with = "serialize_edit_body")] - // #[serde(deserialize_with = "deserialize_edit_body")] - UpdateBody { path: Path, changeset: NodeBodyChangeset }, - - #[serde(rename = "delete")] - Delete { path: Path, nodes: Vec }, -} - -impl NodeOperation { - pub fn path(&self) -> &Path { - match self { - NodeOperation::Insert { path, .. } => path, - NodeOperation::UpdateAttributes { path, .. } => path, - NodeOperation::Delete { path, .. } => path, - NodeOperation::UpdateBody { path, .. } => path, - } - } - pub fn invert(&self) -> NodeOperation { - match self { - NodeOperation::Insert { path, nodes } => NodeOperation::Delete { - path: path.clone(), - nodes: nodes.clone(), - }, - NodeOperation::UpdateAttributes { - path, - attributes, - old_attributes, - } => NodeOperation::UpdateAttributes { - path: path.clone(), - attributes: old_attributes.clone(), - old_attributes: attributes.clone(), - }, - NodeOperation::Delete { path, nodes } => NodeOperation::Insert { - path: path.clone(), - nodes: nodes.clone(), - }, - NodeOperation::UpdateBody { path, changeset: body } => NodeOperation::UpdateBody { - path: path.clone(), - changeset: body.inverted(), - }, - } - } - pub fn clone_with_new_path(&self, path: Path) -> NodeOperation { - match self { - NodeOperation::Insert { nodes, .. } => NodeOperation::Insert { - path, - nodes: nodes.clone(), - }, - NodeOperation::UpdateAttributes { - attributes, - old_attributes, - .. - } => NodeOperation::UpdateAttributes { - path, - attributes: attributes.clone(), - old_attributes: old_attributes.clone(), - }, - NodeOperation::Delete { nodes, .. } => NodeOperation::Delete { - path, - nodes: nodes.clone(), - }, - NodeOperation::UpdateBody { path, changeset } => NodeOperation::UpdateBody { - path: path.clone(), - changeset: changeset.clone(), - }, - } - } - pub fn transform(a: &NodeOperation, b: &NodeOperation) -> NodeOperation { - match a { - NodeOperation::Insert { path: a_path, nodes } => { - let new_path = Path::transform(a_path, b.path(), nodes.len() as i64); - b.clone_with_new_path(new_path) - } - NodeOperation::Delete { path: a_path, nodes } => { - let new_path = Path::transform(a_path, b.path(), nodes.len() as i64); - b.clone_with_new_path(new_path) - } - _ => b.clone(), - } - } -} - -#[derive(Serialize, Deserialize, Default)] -pub struct NodeOperationList { - operations: Vec, -} - -impl NodeOperationList { - pub fn into_inner(self) -> Vec { - self.operations - } -} - -impl std::ops::Deref for NodeOperationList { - type Target = Vec; - - fn deref(&self) -> &Self::Target { - &self.operations - } -} - -impl std::ops::DerefMut for NodeOperationList { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.operations - } -} - -impl NodeOperationList { - pub fn new(operations: Vec) -> Self { - Self { operations } - } - - pub fn from_bytes(bytes: Vec) -> Result { - let operation_list = serde_json::from_slice(&bytes).map_err(|err| OTError::serde().context(err))?; - Ok(operation_list) - } - - pub fn to_bytes(&self) -> Result, OTError> { - let bytes = serde_json::to_vec(self).map_err(|err| OTError::serde().context(err))?; - Ok(bytes) - } -} diff --git a/shared-lib/lib-ot/src/core/document/path.rs b/shared-lib/lib-ot/src/core/document/path.rs deleted file mode 100644 index 245abde36c..0000000000 --- a/shared-lib/lib-ot/src/core/document/path.rs +++ /dev/null @@ -1,127 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Serialize, Deserialize, Eq, PartialEq, Debug)] -pub struct Path(pub Vec); - -impl std::ops::Deref for Path { - type Target = Vec; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl std::convert::From for Path { - fn from(val: usize) -> Self { - Path(vec![val]) - } -} - -impl std::convert::From<&usize> for Path { - fn from(val: &usize) -> Self { - Path(vec![*val]) - } -} - -impl std::convert::From<&Path> for Path { - fn from(path: &Path) -> Self { - path.clone() - } -} - -impl From> for Path { - fn from(v: Vec) -> Self { - Path(v) - } -} - -impl From<&Vec> for Path { - fn from(values: &Vec) -> Self { - Path(values.clone()) - } -} - -impl From<&[usize]> for Path { - fn from(values: &[usize]) -> Self { - Path(values.to_vec()) - } -} - -impl Path { - // delta is default to be 1 - pub fn transform(pre_insert_path: &Path, b: &Path, offset: i64) -> Path { - if pre_insert_path.len() > b.len() { - return b.clone(); - } - if pre_insert_path.is_empty() || b.is_empty() { - return b.clone(); - } - // check the prefix - for i in 0..(pre_insert_path.len() - 1) { - if pre_insert_path.0[i] != b.0[i] { - return b.clone(); - } - } - let mut prefix: Vec = pre_insert_path.0[0..(pre_insert_path.len() - 1)].into(); - let mut suffix: Vec = b.0[pre_insert_path.0.len()..].into(); - let prev_insert_last: usize = *pre_insert_path.0.last().unwrap(); - let b_at_index = b.0[pre_insert_path.0.len() - 1]; - if prev_insert_last <= b_at_index { - prefix.push(((b_at_index as i64) + offset) as usize); - } else { - prefix.push(b_at_index); - } - prefix.append(&mut suffix); - - Path(prefix) - } -} - -#[cfg(test)] -mod tests { - use crate::core::Path; - #[test] - fn path_transform_test_1() { - assert_eq!( - { Path::transform(&Path(vec![0, 1]), &Path(vec![0, 1]), 1) }.0, - vec![0, 2] - ); - - assert_eq!( - { Path::transform(&Path(vec![0, 1]), &Path(vec![0, 1]), 5) }.0, - vec![0, 6] - ); - } - - #[test] - fn path_transform_test_2() { - assert_eq!( - { Path::transform(&Path(vec![0, 1]), &Path(vec![0, 2]), 1) }.0, - vec![0, 3] - ); - } - - #[test] - fn path_transform_test_3() { - assert_eq!( - { Path::transform(&Path(vec![0, 1]), &Path(vec![0, 2, 7, 8, 9]), 1) }.0, - vec![0, 3, 7, 8, 9] - ); - } - - #[test] - fn path_transform_no_changed_test() { - assert_eq!( - { Path::transform(&Path(vec![0, 1, 2]), &Path(vec![0, 0, 7, 8, 9]), 1) }.0, - vec![0, 0, 7, 8, 9] - ); - assert_eq!( - { Path::transform(&Path(vec![0, 1, 2]), &Path(vec![0, 1]), 1) }.0, - vec![0, 1] - ); - assert_eq!( - { Path::transform(&Path(vec![1, 1]), &Path(vec![1, 0]), 1) }.0, - vec![1, 0] - ); - } -} diff --git a/shared-lib/lib-ot/src/core/mod.rs b/shared-lib/lib-ot/src/core/mod.rs index e8253e6ebc..7d038c8b5d 100644 --- a/shared-lib/lib-ot/src/core/mod.rs +++ b/shared-lib/lib-ot/src/core/mod.rs @@ -1,12 +1,12 @@ pub mod attributes; mod delta; -mod document; mod interval; +mod node_tree; mod ot_str; pub use attributes::*; pub use delta::operation::*; pub use delta::*; -pub use document::*; pub use interval::*; +pub use node_tree::*; pub use ot_str::*; diff --git a/shared-lib/lib-ot/src/core/document/mod.rs b/shared-lib/lib-ot/src/core/node_tree/mod.rs similarity index 84% rename from shared-lib/lib-ot/src/core/document/mod.rs rename to shared-lib/lib-ot/src/core/node_tree/mod.rs index b60d8c0251..1f7205201d 100644 --- a/shared-lib/lib-ot/src/core/document/mod.rs +++ b/shared-lib/lib-ot/src/core/node_tree/mod.rs @@ -2,14 +2,14 @@ mod node; mod node_serde; -mod node_tree; mod operation; mod operation_serde; mod path; mod transaction; +mod tree; pub use node::*; -pub use node_tree::*; pub use operation::*; pub use path::*; pub use transaction::*; +pub use tree::*; diff --git a/shared-lib/lib-ot/src/core/document/node.rs b/shared-lib/lib-ot/src/core/node_tree/node.rs similarity index 98% rename from shared-lib/lib-ot/src/core/document/node.rs rename to shared-lib/lib-ot/src/core/node_tree/node.rs index 18651a6062..908cfa3f18 100644 --- a/shared-lib/lib-ot/src/core/document/node.rs +++ b/shared-lib/lib-ot/src/core/node_tree/node.rs @@ -6,7 +6,7 @@ use crate::errors::OTError; use crate::text_delta::TextDelta; use serde::{Deserialize, Serialize}; -#[derive(Default, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Default, Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] pub struct NodeData { #[serde(rename = "type")] pub node_type: String, diff --git a/shared-lib/lib-ot/src/core/document/node_serde.rs b/shared-lib/lib-ot/src/core/node_tree/node_serde.rs similarity index 100% rename from shared-lib/lib-ot/src/core/document/node_serde.rs rename to shared-lib/lib-ot/src/core/node_tree/node_serde.rs diff --git a/shared-lib/lib-ot/src/core/node_tree/operation.rs b/shared-lib/lib-ot/src/core/node_tree/operation.rs new file mode 100644 index 0000000000..cdd2b99c7b --- /dev/null +++ b/shared-lib/lib-ot/src/core/node_tree/operation.rs @@ -0,0 +1,174 @@ +use crate::core::attributes::Attributes; +use crate::core::{NodeBodyChangeset, NodeData, Path}; +use crate::errors::OTError; +use serde::{Deserialize, Serialize}; +use std::rc::Rc; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "op")] +pub enum NodeOperation { + #[serde(rename = "insert")] + Insert { path: Path, nodes: Vec }, + + #[serde(rename = "update-attribute")] + UpdateAttributes { + path: Path, + new: Attributes, + old: Attributes, + }, + + #[serde(rename = "update-body")] + // #[serde(serialize_with = "serialize_edit_body")] + // #[serde(deserialize_with = "deserialize_edit_body")] + UpdateBody { path: Path, changeset: NodeBodyChangeset }, + + #[serde(rename = "delete")] + Delete { path: Path, nodes: Vec }, +} + +impl NodeOperation { + pub fn get_path(&self) -> &Path { + match self { + NodeOperation::Insert { path, .. } => path, + NodeOperation::UpdateAttributes { path, .. } => path, + NodeOperation::Delete { path, .. } => path, + NodeOperation::UpdateBody { path, .. } => path, + } + } + + pub fn get_mut_path(&mut self) -> &mut Path { + match self { + NodeOperation::Insert { path, .. } => path, + NodeOperation::UpdateAttributes { path, .. } => path, + NodeOperation::Delete { path, .. } => path, + NodeOperation::UpdateBody { path, .. } => path, + } + } + + pub fn invert(&self) -> NodeOperation { + match self { + NodeOperation::Insert { path, nodes } => NodeOperation::Delete { + path: path.clone(), + nodes: nodes.clone(), + }, + NodeOperation::UpdateAttributes { + path, + new: attributes, + old: old_attributes, + } => NodeOperation::UpdateAttributes { + path: path.clone(), + new: old_attributes.clone(), + old: attributes.clone(), + }, + NodeOperation::Delete { path, nodes } => NodeOperation::Insert { + path: path.clone(), + nodes: nodes.clone(), + }, + NodeOperation::UpdateBody { path, changeset: body } => NodeOperation::UpdateBody { + path: path.clone(), + changeset: body.inverted(), + }, + } + } + + /// Make the `other` operation can be applied to the version after applying the `self` operation. + /// The semantics of transform is used when editing conflicts occur, which is often determined by the version id。 + /// For example, if the inserted position has been acquired by others, then it's needed to do the transform to + /// make sure the inserted position is right. + /// + /// # Arguments + /// + /// * `other`: The operation that is going to be transformed + /// + /// # Examples + /// + /// ``` + /// use lib_ot::core::{NodeDataBuilder, NodeOperation, Path}; + /// let node_1 = NodeDataBuilder::new("text_1").build(); + /// let node_2 = NodeDataBuilder::new("text_2").build(); + /// + /// let op_1 = NodeOperation::Insert { + /// path: Path(vec![0, 1]), + /// nodes: vec![node_1], + /// }; + /// + /// let mut op_2 = NodeOperation::Insert { + /// path: Path(vec![0, 1]), + /// nodes: vec![node_2], + /// }; + /// + /// assert_eq!(serde_json::to_string(&op_2).unwrap(), r#"{"op":"insert","path":[0,1],"nodes":[{"type":"text_2"}]}"#); + /// + /// op_1.transform(&mut op_2); + /// assert_eq!(serde_json::to_string(&op_2).unwrap(), r#"{"op":"insert","path":[0,2],"nodes":[{"type":"text_2"}]}"#); + /// + /// ``` + pub fn transform(&self, other: &mut NodeOperation) { + match self { + NodeOperation::Insert { path, nodes } => { + let new_path = path.transform(other.get_path(), nodes.len()); + *other.get_mut_path() = new_path; + } + NodeOperation::Delete { path, nodes } => { + let new_path = path.transform(other.get_path(), nodes.len()); + *other.get_mut_path() = new_path; + } + _ => { + // Only insert/delete will change the path. + } + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NodeOperationList { + operations: Vec>, +} + +impl NodeOperationList { + pub fn into_inner(self) -> Vec> { + self.operations + } + + pub fn add_op(&mut self, operation: NodeOperation) { + self.operations.push(Rc::new(operation)); + } +} + +impl std::ops::Deref for NodeOperationList { + type Target = Vec>; + + fn deref(&self) -> &Self::Target { + &self.operations + } +} + +impl std::ops::DerefMut for NodeOperationList { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.operations + } +} + +impl std::convert::From> for NodeOperationList { + fn from(operations: Vec) -> Self { + Self::new(operations) + } +} + +impl NodeOperationList { + pub fn new(operations: Vec) -> Self { + Self { + operations: operations.into_iter().map(Rc::new).collect(), + } + } + + pub fn from_bytes(bytes: Vec) -> Result { + let operation_list = serde_json::from_slice(&bytes).map_err(|err| OTError::serde().context(err))?; + Ok(operation_list) + } + + pub fn to_bytes(&self) -> Result, OTError> { + let bytes = serde_json::to_vec(self).map_err(|err| OTError::serde().context(err))?; + Ok(bytes) + } +} diff --git a/shared-lib/lib-ot/src/core/document/operation_serde.rs b/shared-lib/lib-ot/src/core/node_tree/operation_serde.rs similarity index 100% rename from shared-lib/lib-ot/src/core/document/operation_serde.rs rename to shared-lib/lib-ot/src/core/node_tree/operation_serde.rs diff --git a/shared-lib/lib-ot/src/core/node_tree/path.rs b/shared-lib/lib-ot/src/core/node_tree/path.rs new file mode 100644 index 0000000000..cf7ae647ed --- /dev/null +++ b/shared-lib/lib-ot/src/core/node_tree/path.rs @@ -0,0 +1,190 @@ +use serde::{Deserialize, Serialize}; + +/// The `Path` represents as a path to reference to the node in the `NodeTree`. +/// ┌─────────┐ +/// │ Root │ +/// └─────────┼──────────┐ +/// │0: Node A │ +/// └──────────┼────────────┐ +/// │0: Node A-1 │ +/// ├────────────┤ +/// │1: Node A-2 │ +/// ┌──────────┼────────────┘ +/// │1: Node B │ +/// └──────────┼────────────┐ +/// │0: Node B-1 │ +/// ├────────────┤ +/// │1: Node B-2 │ +/// ┌──────────┼────────────┘ +/// │2: Node C │ +/// └──────────┘ +/// +/// The path of Node A will be [0] +/// The path of Node A-1 will be [0,0] +/// The path of Node A-2 will be [0,1] +/// The path of Node B-2 will be [1,1] +#[derive(Clone, Serialize, Deserialize, Eq, PartialEq, Debug)] +pub struct Path(pub Vec); + +impl std::ops::Deref for Path { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::convert::From for Path { + fn from(val: usize) -> Self { + Path(vec![val]) + } +} + +impl std::convert::From<&usize> for Path { + fn from(val: &usize) -> Self { + Path(vec![*val]) + } +} + +impl std::convert::From<&Path> for Path { + fn from(path: &Path) -> Self { + path.clone() + } +} + +impl From> for Path { + fn from(v: Vec) -> Self { + Path(v) + } +} + +impl From<&Vec> for Path { + fn from(values: &Vec) -> Self { + Path(values.clone()) + } +} + +impl From<&[usize]> for Path { + fn from(values: &[usize]) -> Self { + Path(values.to_vec()) + } +} + +impl Path { + /// Calling this function if there are two changes want to modify the same path. + /// + /// # Arguments + /// + /// * `other`: the path that need to be transformed + /// * `offset`: represents the len of nodes referenced by the current path + /// + /// If two changes modify the same path or the path was shared by them. Then it needs to do the + /// transformation to make sure the changes are applied to the right path. + /// + /// returns: the path represents the position that the other path reference to. + /// + /// # Examples + /// + /// ``` + /// use lib_ot::core::Path; + /// let path = Path(vec![0, 1]); + /// for (old_path, len_of_nodes, expected_path) in vec![ + /// // Try to modify the path [0, 1], but someone has inserted one element before the + /// // current path [0,1] in advance. That causes the modified path [0,1] to no longer + /// // valid. It needs to do the transformation to get the right path. + /// // + /// // [0,2] is the path you want to modify. + /// (Path(vec![0, 1]), 1, Path(vec![0, 2])), + /// (Path(vec![0, 1]), 5, Path(vec![0, 6])), + /// (Path(vec![0, 2]), 1, Path(vec![0, 3])), + /// // Try to modify the path [0, 2,3,4], but someone has inserted one element before the + /// // current path [0,1] in advance. That cause the prefix path [0,2] of [0,2,3,4] + /// // no longer valid. + /// // It needs to do the transformation to get the right path. So [0,2] is transformed to [0,3] + /// // and the suffix [3,4] of the [0,2,3,4] remains the same. So the transformed result is + /// // + /// // [0,3,3,4] + /// (Path(vec![0, 2, 3, 4]), 1, Path(vec![0, 3, 3, 4])), + /// ] { + /// assert_eq!(path.transform(&old_path, len_of_nodes), expected_path); + /// } + /// // The path remains the same in the following test. Because the shared path is not changed. + /// let path = Path(vec![0, 1, 2]); + /// for (old_path, len_of_nodes, expected_path) in vec![ + /// // Try to modify the path [0,0,0,1,2], but someone has inserted one element + /// // before [0,1,2]. [0,0,0,1,2] and [0,1,2] share the same path [0,x], because + /// // the element was inserted at [0,1,2] that didn't affect the shared path [0, x]. + /// // So, after the transformation, the path is not changed. + /// (Path(vec![0, 0, 0, 1, 2]), 1, Path(vec![0, 0, 0, 1, 2])), + /// (Path(vec![0, 1]), 1, Path(vec![0, 1])), + /// ] { + /// assert_eq!(path.transform(&old_path, len_of_nodes), expected_path); + /// } + /// + /// let path = Path(vec![1, 1]); + /// for (old_path, len_of_nodes, expected_path) in vec![(Path(vec![1, 0]), 1, Path(vec![1, 0]))] { + /// assert_eq!(path.transform(&old_path, len_of_nodes), expected_path); + /// } + /// ``` + /// For example, client A and client B want to insert a node at the same index, the server applies + /// the changes made by client B. But, before applying the client A's changes, server transforms + /// the changes first in order to make sure that client A modify the right position. After that, + /// the changes can be applied to the server. + /// + /// ┌──────────┐ ┌──────────┐ ┌──────────┐ + /// │ Client A │ │ Server │ │ Client B │ + /// └─────┬────┘ └─────┬────┘ └────┬─────┘ + /// │ │ ┌ ─ ─ ─ ─ ─ ─ ─ ┐ │ + /// │ │ Root │ + /// │ │ │ 0:A │ │ + /// │ │ ─ ─ ─ ─ ─ ─ ─ ─ │ + /// │ │ ◀───────────────────────│ + /// │ │ Insert B at index 1 │ + /// │ │ │ + /// │ │ ┌ ─ ─ ─ ─ ─ ─ ─ ┐ │ + /// │ │ Root │ + /// │ │ │ 0:A │ │ + /// ├──────────────────────▶│ 1:B │ + /// │ Insert C at index 1 │ └ ─ ─ ─ ─ ─ ─ ─ ┘ │ + /// │ │ │ + /// │ │ transform index 1 to 2 │ + /// │ │ │ + /// │ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ │ + /// │ │ Root │ │ + /// │ │ │ 0:A │ + /// ▼ ▼ 1:B │ ▼ + /// │ 2:C + /// ─ ─ ─ ─ ─ ─ ─ ─ ┘ + pub fn transform(&self, other: &Path, offset: usize) -> Path { + if self.len() > other.len() { + return other.clone(); + } + if self.is_empty() || other.is_empty() { + return other.clone(); + } + for i in 0..(self.len() - 1) { + if self.0[i] != other.0[i] { + return other.clone(); + } + } + + // Splits the `Path` into two part. The suffix will contain the last element of the `Path`. + let second_last_index = self.0.len() - 1; + let mut prefix: Vec = self.0[0..second_last_index].into(); + let mut suffix: Vec = other.0[self.0.len()..].into(); + let last_value = *self.0.last().unwrap(); + + let other_second_last_value = other.0[second_last_index]; + + // + if last_value <= other_second_last_value { + prefix.push(other_second_last_value + offset); + } else { + prefix.push(other_second_last_value); + } + + // concat the prefix and suffix into a new path + prefix.append(&mut suffix); + Path(prefix) + } +} diff --git a/shared-lib/lib-ot/src/core/document/transaction.rs b/shared-lib/lib-ot/src/core/node_tree/transaction.rs similarity index 70% rename from shared-lib/lib-ot/src/core/document/transaction.rs rename to shared-lib/lib-ot/src/core/node_tree/transaction.rs index 9b48ddd92d..1f855698cf 100644 --- a/shared-lib/lib-ot/src/core/document/transaction.rs +++ b/shared-lib/lib-ot/src/core/node_tree/transaction.rs @@ -1,26 +1,57 @@ use crate::core::attributes::Attributes; -use crate::core::document::path::Path; -use crate::core::{NodeData, NodeOperation, NodeTree}; +use crate::core::{NodeData, NodeOperation, NodeTree, Path}; +use crate::errors::OTError; use indextree::NodeId; +use std::rc::Rc; use super::{NodeBodyChangeset, NodeOperationList}; +#[derive(Debug, Clone, Default)] pub struct Transaction { operations: NodeOperationList, } impl Transaction { - pub fn new(operations: NodeOperationList) -> Transaction { - Transaction { operations } + pub fn new() -> Self { + Self::default() } - pub fn into_operations(self) -> Vec { + pub fn from_operations>(operations: T) -> Self { + Self { + operations: operations.into(), + } + } + + pub fn into_operations(self) -> Vec> { self.operations.into_inner() } + + /// Make the `other` can be applied to the version after applying the `self` transaction. + /// + /// The semantics of transform is used when editing conflicts occur, which is often determined by the version id。 + /// the operations of the transaction will be transformed into the conflict operations. + pub fn transform(&self, other: &Transaction) -> Result { + let mut new_transaction = other.clone(); + for other_operation in new_transaction.iter_mut() { + let other_operation = Rc::make_mut(other_operation); + for operation in self.operations.iter() { + operation.transform(other_operation); + } + } + Ok(new_transaction) + } + + pub fn compose(&mut self, other: &Transaction) -> Result<(), OTError> { + // For the moment, just append `other` operations to the end of `self`. + for operation in other.operations.iter() { + self.operations.push(operation.clone()); + } + Ok(()) + } } impl std::ops::Deref for Transaction { - type Target = NodeOperationList; + type Target = Vec>; fn deref(&self) -> &Self::Target { &self.operations @@ -64,7 +95,7 @@ impl<'a> TransactionBuilder<'a> { /// let transaction = TransactionBuilder::new(&node_tree) /// .insert_nodes_at_path(0,vec![ NodeData::new("text_1"), NodeData::new("text_2")]) /// .finalize(); - /// node_tree.apply(transaction).unwrap(); + /// node_tree.apply_transaction(transaction).unwrap(); /// /// node_tree.node_id_at_path(vec![0, 0]); /// ``` @@ -94,7 +125,7 @@ impl<'a> TransactionBuilder<'a> { /// let transaction = TransactionBuilder::new(&node_tree) /// .insert_node_at_path(0, NodeData::new("text")) /// .finalize(); - /// node_tree.apply(transaction).unwrap(); + /// node_tree.apply_transaction(transaction).unwrap(); /// ``` /// pub fn insert_node_at_path>(self, path: T, node: NodeData) -> Self { @@ -112,10 +143,10 @@ impl<'a> TransactionBuilder<'a> { } } - self.operations.push(NodeOperation::UpdateAttributes { + self.operations.add_op(NodeOperation::UpdateAttributes { path: path.clone(), - attributes, - old_attributes, + new: attributes, + old: old_attributes, }); } None => tracing::warn!("Update attributes at path: {:?} failed. Node is not exist", path), @@ -126,7 +157,7 @@ impl<'a> TransactionBuilder<'a> { pub fn update_body_at_path(mut self, path: &Path, changeset: NodeBodyChangeset) -> Self { match self.node_tree.node_id_at_path(path) { Some(_) => { - self.operations.push(NodeOperation::UpdateBody { + self.operations.add_op(NodeOperation::UpdateBody { path: path.clone(), changeset, }); @@ -148,7 +179,7 @@ impl<'a> TransactionBuilder<'a> { node = self.node_tree.following_siblings(node).next().unwrap(); } - self.operations.push(NodeOperation::Delete { + self.operations.add_op(NodeOperation::Delete { path: path.clone(), nodes: deleted_nodes, }); @@ -172,11 +203,11 @@ impl<'a> TransactionBuilder<'a> { } pub fn push(mut self, op: NodeOperation) -> Self { - self.operations.push(op); + self.operations.add_op(op); self } pub fn finalize(self) -> Transaction { - Transaction::new(self.operations) + Transaction::from_operations(self.operations) } } diff --git a/shared-lib/lib-ot/src/core/document/node_tree.rs b/shared-lib/lib-ot/src/core/node_tree/tree.rs similarity index 86% rename from shared-lib/lib-ot/src/core/document/node_tree.rs rename to shared-lib/lib-ot/src/core/node_tree/tree.rs index 7f88224f24..fde8ccf20c 100644 --- a/shared-lib/lib-ot/src/core/document/node_tree.rs +++ b/shared-lib/lib-ot/src/core/node_tree/tree.rs @@ -1,8 +1,8 @@ use crate::core::attributes::Attributes; -use crate::core::document::path::Path; -use crate::core::{Node, NodeBodyChangeset, NodeData, NodeOperation, OperationTransform, Transaction}; +use crate::core::{Node, NodeBodyChangeset, NodeData, NodeOperation, OperationTransform, Path, Transaction}; use crate::errors::{ErrorBuilder, OTError, OTErrorCode}; use indextree::{Arena, Children, FollowingSiblings, NodeId}; +use std::rc::Rc; use super::NodeOperationList; @@ -26,14 +26,13 @@ impl NodeTree { } pub fn from_bytes(root_name: &str, bytes: Vec) -> Result { - let operations = NodeOperationList::from_bytes(bytes)?.into_inner(); + let operations = NodeOperationList::from_bytes(bytes)?; Self::from_operations(root_name, operations) } - pub fn from_operations(root_name: &str, operations: Vec) -> Result { + pub fn from_operations(root_name: &str, operations: NodeOperationList) -> Result { let mut node_tree = NodeTree::new(root_name); - - for operation in operations { + for operation in operations.into_inner().into_iter() { let _ = node_tree.apply_op(operation)?; } Ok(node_tree) @@ -54,13 +53,14 @@ impl NodeTree { /// # Examples /// /// ``` + /// use std::rc::Rc; /// use lib_ot::core::{NodeOperation, NodeTree, NodeData, Path}; /// let nodes = vec![NodeData::new("text".to_string())]; /// let root_path: Path = vec![0].into(); /// let op = NodeOperation::Insert {path: root_path.clone(),nodes }; /// /// let mut node_tree = NodeTree::new("root"); - /// node_tree.apply_op(op).unwrap(); + /// node_tree.apply_op(Rc::new(op)).unwrap(); /// let node_id = node_tree.node_id_at_path(&root_path).unwrap(); /// let node_path = node_tree.path_from_node_id(node_id); /// debug_assert_eq!(node_path, root_path); @@ -105,23 +105,25 @@ impl NodeTree { counter } - /// + /// Returns the note_id at the position of the tree with id note_id /// # Arguments /// - /// * `node_id`: - /// * `index`: + /// * `node_id`: the node id of the child's parent + /// * `index`: index of the node in parent children list /// /// returns: Option /// /// # Examples /// /// ``` + /// use std::rc::Rc; /// use lib_ot::core::{NodeOperation, NodeTree, NodeData, Path}; /// let node_1 = NodeData::new("text".to_string()); /// let inserted_path: Path = vec![0].into(); /// /// let mut node_tree = NodeTree::new("root"); - /// node_tree.apply_op(NodeOperation::Insert {path: inserted_path.clone(),nodes: vec![node_1.clone()] }).unwrap(); + /// let op = NodeOperation::Insert {path: inserted_path.clone(),nodes: vec![node_1.clone()] }; + /// node_tree.apply_op(Rc::new(op)).unwrap(); /// /// let node_2 = node_tree.get_node_at_path(&inserted_path).unwrap(); /// assert_eq!(node_2.node_type, node_1.node_type); @@ -137,6 +139,10 @@ impl NodeTree { None } + /// Returns all children whose parent node id is node_id + /// + /// * `node_id`: the children's parent node id + /// pub fn children_from_node(&self, node_id: NodeId) -> Children<'_, Node> { node_id.children(&self.arena) } @@ -159,7 +165,7 @@ impl NodeTree { node_id.following_siblings(&self.arena) } - pub fn apply(&mut self, transaction: Transaction) -> Result<(), OTError> { + pub fn apply_transaction(&mut self, transaction: Transaction) -> Result<(), OTError> { let operations = transaction.into_operations(); for operation in operations { self.apply_op(operation)?; @@ -167,10 +173,15 @@ impl NodeTree { Ok(()) } - pub fn apply_op(&mut self, op: NodeOperation) -> Result<(), OTError> { + pub fn apply_op(&mut self, op: Rc) -> Result<(), OTError> { + let op = match Rc::try_unwrap(op) { + Ok(op) => op, + Err(op) => op.as_ref().clone(), + }; + match op { NodeOperation::Insert { path, nodes } => self.insert_nodes(&path, nodes), - NodeOperation::UpdateAttributes { path, attributes, .. } => self.update_attributes(&path, attributes), + NodeOperation::UpdateAttributes { path, new, .. } => self.update_attributes(&path, new), NodeOperation::UpdateBody { path, changeset } => self.update_body(&path, changeset), NodeOperation::Delete { path, nodes } => self.delete_node(&path, nodes), } @@ -216,7 +227,9 @@ impl NodeTree { return Ok(()); } - if index == parent.children(&self.arena).count() { + // Append the node to the end of the children list if index greater or equal to the + // length of the children. + if index >= parent.children(&self.arena).count() { self.append_nodes(&parent, nodes); return Ok(()); } diff --git a/shared-lib/lib-ot/src/text_delta/delta.rs b/shared-lib/lib-ot/src/text_delta/delta.rs index c559f9cb0f..e8eb3c3fd8 100644 --- a/shared-lib/lib-ot/src/text_delta/delta.rs +++ b/shared-lib/lib-ot/src/text_delta/delta.rs @@ -2,5 +2,4 @@ use crate::core::{Attributes, Operation, OperationBuilder, Operations}; pub type TextDelta = Operations; pub type TextDeltaBuilder = OperationBuilder; - pub type TextOperation = Operation; diff --git a/shared-lib/lib-ot/tests/node/editor_test.rs b/shared-lib/lib-ot/tests/node/editor_test.rs index 6136fe5efd..abd294ecc0 100644 --- a/shared-lib/lib-ot/tests/node/editor_test.rs +++ b/shared-lib/lib-ot/tests/node/editor_test.rs @@ -26,7 +26,8 @@ fn editor_deserialize_node_test() { test.run_scripts(vec![ InsertNode { path, - node: node.clone(), + node_data: node.clone(), + rev_id: 1, }, AssertNumberOfNodesAtPath { path: None, len: 1 }, AssertNumberOfNodesAtPath { @@ -41,11 +42,11 @@ fn editor_deserialize_node_test() { path: vec![0, 1].into(), expected: expected_delta, }, - AssertNode { + AssertNodeData { path: vec![0, 0].into(), expected: Some(node.children[0].clone()), }, - AssertNode { + AssertNodeData { path: vec![0, 3].into(), expected: Some(node.children[3].clone()), }, diff --git a/shared-lib/lib-ot/tests/node/operation_test.rs b/shared-lib/lib-ot/tests/node/operation_test.rs index 6a7f8bb25a..a0be952afb 100644 --- a/shared-lib/lib-ot/tests/node/operation_test.rs +++ b/shared-lib/lib-ot/tests/node/operation_test.rs @@ -1,4 +1,6 @@ -use lib_ot::core::AttributeBuilder; +use crate::node::script::NodeScript::*; +use crate::node::script::NodeTest; +use lib_ot::core::{AttributeBuilder, Node}; use lib_ot::{ core::{NodeBodyChangeset, NodeData, NodeDataBuilder, NodeOperation, Path}, text_delta::TextDeltaBuilder, @@ -33,15 +35,14 @@ fn operation_insert_node_with_children_serde_test() { fn operation_update_node_attributes_serde_test() { let operation = NodeOperation::UpdateAttributes { path: Path(vec![0, 1]), - attributes: AttributeBuilder::new().insert("bold", true).build(), - old_attributes: AttributeBuilder::new().insert("bold", false).build(), + new: AttributeBuilder::new().insert("bold", true).build(), + old: AttributeBuilder::new().insert("bold", false).build(), }; let result = serde_json::to_string(&operation).unwrap(); - assert_eq!( result, - r#"{"op":"update","path":[0,1],"attributes":{"bold":true},"oldAttributes":{"bold":null}}"# + r#"{"op":"update-attribute","path":[0,1],"new":{"bold":true},"old":{"bold":null}}"# ); } @@ -69,3 +70,166 @@ fn operation_update_node_body_deserialize_test() { let json_2 = serde_json::to_string(&operation).unwrap(); assert_eq!(json_1, json_2); } + +#[test] +fn operation_insert_op_transform_test() { + let node_1 = NodeDataBuilder::new("text_1").build(); + let node_2 = NodeDataBuilder::new("text_2").build(); + let op_1 = NodeOperation::Insert { + path: Path(vec![0, 1]), + nodes: vec![node_1], + }; + + let mut insert_2 = NodeOperation::Insert { + path: Path(vec![0, 1]), + nodes: vec![node_2], + }; + + // let mut node_tree = NodeTree::new("root"); + // node_tree.apply_op(insert_1.clone()).unwrap(); + + op_1.transform(&mut insert_2); + let json = serde_json::to_string(&insert_2).unwrap(); + assert_eq!(json, r#"{"op":"insert","path":[0,2],"nodes":[{"type":"text_2"}]}"#); +} + +#[test] +fn operation_insert_transform_one_level_path_test() { + let mut test = NodeTest::new(); + let node_data_1 = NodeDataBuilder::new("text_1").build(); + let node_data_2 = NodeDataBuilder::new("text_2").build(); + let node_data_3 = NodeDataBuilder::new("text_3").build(); + let node_3: Node = node_data_3.clone().into(); + // 0: text_1 + // 1: text_2 + // + // Insert a new operation with rev_id 1,but the rev_id:1 is already exist, so + // it needs to be transformed. + // 1:text_3 => 2:text_3 + // + // 0: text_1 + // 1: text_2 + // 2: text_3 + // + // If the rev_id of the insert operation is 3. then the tree will be: + // 0: text_1 + // 1: text_3 + // 2: text_2 + let scripts = vec![ + InsertNode { + path: 0.into(), + node_data: node_data_1, + rev_id: 1, + }, + InsertNode { + path: 1.into(), + node_data: node_data_2, + rev_id: 2, + }, + InsertNode { + path: 1.into(), + node_data: node_data_3, + rev_id: 1, + }, + AssertNode { + path: 2.into(), + expected: Some(node_3), + }, + ]; + test.run_scripts(scripts); +} + +#[test] +fn operation_insert_transform_multiple_level_path_test() { + let mut test = NodeTest::new(); + let node_data_1 = NodeDataBuilder::new("text_1") + .add_node(NodeDataBuilder::new("text_1_1").build()) + .add_node(NodeDataBuilder::new("text_1_2").build()) + .build(); + + let node_data_2 = NodeDataBuilder::new("text_2") + .add_node(NodeDataBuilder::new("text_2_1").build()) + .add_node(NodeDataBuilder::new("text_2_2").build()) + .build(); + + let node_data_3 = NodeDataBuilder::new("text_3").build(); + let scripts = vec![ + InsertNode { + path: 0.into(), + node_data: node_data_1, + rev_id: 1, + }, + InsertNode { + path: 1.into(), + node_data: node_data_2, + rev_id: 2, + }, + InsertNode { + path: 1.into(), + node_data: node_data_3.clone(), + rev_id: 1, + }, + AssertNode { + path: 2.into(), + expected: Some(node_data_3.into()), + }, + ]; + test.run_scripts(scripts); +} + +#[test] +fn operation_delete_transform_path_test() { + let mut test = NodeTest::new(); + let node_data_1 = NodeDataBuilder::new("text_1").build(); + let node_data_2 = NodeDataBuilder::new("text_2").build(); + let node_data_3 = NodeDataBuilder::new("text_3").build(); + let node_3: Node = node_data_3.clone().into(); + + let scripts = vec![ + InsertNode { + path: 0.into(), + node_data: node_data_1, + rev_id: 1, + }, + InsertNode { + path: 1.into(), + node_data: node_data_2, + rev_id: 2, + }, + // The node's in the tree will be: + // 0: text_1 + // 2: text_2 + // + // The insert action is happened concurrently with the delete action, because they + // share the same rev_id. aka, 3. The delete action is want to delete the node at index 1, + // but it was moved to index 2. + InsertNode { + path: 1.into(), + node_data: node_data_3, + rev_id: 3, + }, + // 0: text_1 + // 1: text_3 + // 2: text_2 + // + // The path of the delete action will be transformed to a new path that point to the text_2. + // 1 -> 2 + DeleteNode { + path: 1.into(), + rev_id: 3, + }, + // After perform the delete action, the tree will be: + // 0: text_1 + // 1: text_3 + AssertNumberOfNodesAtPath { path: None, len: 2 }, + AssertNode { + path: 1.into(), + expected: Some(node_3), + }, + AssertNode { + path: 2.into(), + expected: None, + }, + ]; + test.run_scripts(scripts); +} diff --git a/shared-lib/lib-ot/tests/node/script.rs b/shared-lib/lib-ot/tests/node/script.rs index 2aac683de8..678138e183 100644 --- a/shared-lib/lib-ot/tests/node/script.rs +++ b/shared-lib/lib-ot/tests/node/script.rs @@ -1,26 +1,58 @@ +use lib_ot::core::{Node, Transaction}; use lib_ot::{ core::attributes::Attributes, core::{NodeBody, NodeBodyChangeset, NodeData, NodeTree, Path, TransactionBuilder}, text_delta::TextDelta, }; +use std::collections::HashMap; pub enum NodeScript { - InsertNode { path: Path, node: NodeData }, - UpdateAttributes { path: Path, attributes: Attributes }, - UpdateBody { path: Path, changeset: NodeBodyChangeset }, - DeleteNode { path: Path }, - AssertNumberOfNodesAtPath { path: Option, len: usize }, - AssertNode { path: Path, expected: Option }, - AssertNodeDelta { path: Path, expected: TextDelta }, + InsertNode { + path: Path, + node_data: NodeData, + rev_id: usize, + }, + UpdateAttributes { + path: Path, + attributes: Attributes, + }, + UpdateBody { + path: Path, + changeset: NodeBodyChangeset, + }, + DeleteNode { + path: Path, + rev_id: usize, + }, + AssertNumberOfNodesAtPath { + path: Option, + len: usize, + }, + AssertNodeData { + path: Path, + expected: Option, + }, + AssertNode { + path: Path, + expected: Option, + }, + AssertNodeDelta { + path: Path, + expected: TextDelta, + }, } pub struct NodeTest { + rev_id: usize, + rev_operations: HashMap, node_tree: NodeTree, } impl NodeTest { pub fn new() -> Self { Self { + rev_id: 0, + rev_operations: HashMap::new(), node_tree: NodeTree::new("root"), } } @@ -33,40 +65,54 @@ impl NodeTest { pub fn run_script(&mut self, script: NodeScript) { match script { - NodeScript::InsertNode { path, node } => { - let transaction = TransactionBuilder::new(&self.node_tree) + NodeScript::InsertNode { + path, + node_data: node, + rev_id, + } => { + let mut transaction = TransactionBuilder::new(&self.node_tree) .insert_node_at_path(path, node) .finalize(); - - self.node_tree.apply(transaction).unwrap(); + self.transform_transaction_if_need(&mut transaction, rev_id); + self.apply_transaction(transaction); } NodeScript::UpdateAttributes { path, attributes } => { let transaction = TransactionBuilder::new(&self.node_tree) .update_attributes_at_path(&path, attributes) .finalize(); - self.node_tree.apply(transaction).unwrap(); + self.apply_transaction(transaction); } NodeScript::UpdateBody { path, changeset } => { // let transaction = TransactionBuilder::new(&self.node_tree) .update_body_at_path(&path, changeset) .finalize(); - self.node_tree.apply(transaction).unwrap(); + self.apply_transaction(transaction); } - NodeScript::DeleteNode { path } => { - let transaction = TransactionBuilder::new(&self.node_tree) + NodeScript::DeleteNode { path, rev_id } => { + let mut transaction = TransactionBuilder::new(&self.node_tree) .delete_node_at_path(&path) .finalize(); - self.node_tree.apply(transaction).unwrap(); + self.transform_transaction_if_need(&mut transaction, rev_id); + self.apply_transaction(transaction); } NodeScript::AssertNode { path, expected } => { let node_id = self.node_tree.node_id_at_path(path); + if expected.is_none() && node_id.is_none() { + return; + } + + let node = self.node_tree.get_node(node_id.unwrap()).cloned(); + assert_eq!(node, expected); + } + NodeScript::AssertNodeData { path, expected } => { + let node_id = self.node_tree.node_id_at_path(path); match node_id { None => assert!(node_id.is_none()), Some(node_id) => { - let node_data = self.node_tree.get_node(node_id).cloned(); - assert_eq!(node_data, expected.map(|e| e.into())); + let node = self.node_tree.get_node(node_id).cloned(); + assert_eq!(node, expected.map(|e| e.into())); } } } @@ -94,4 +140,19 @@ impl NodeTest { } } } + + fn apply_transaction(&mut self, transaction: Transaction) { + self.rev_id += 1; + self.rev_operations.insert(self.rev_id, transaction.clone()); + self.node_tree.apply_transaction(transaction).unwrap(); + } + + fn transform_transaction_if_need(&mut self, transaction: &mut Transaction, rev_id: usize) { + if self.rev_id >= rev_id { + for rev_id in rev_id..=self.rev_id { + let old_transaction = self.rev_operations.get(&rev_id).unwrap(); + *transaction = old_transaction.transform(transaction).unwrap(); + } + } + } } diff --git a/shared-lib/lib-ot/tests/node/tree_test.rs b/shared-lib/lib-ot/tests/node/tree_test.rs index 95c05ea25a..c1f1532385 100644 --- a/shared-lib/lib-ot/tests/node/tree_test.rs +++ b/shared-lib/lib-ot/tests/node/tree_test.rs @@ -14,9 +14,10 @@ fn node_insert_test() { let scripts = vec![ InsertNode { path: path.clone(), - node: inserted_node.clone(), + node_data: inserted_node.clone(), + rev_id: 1, }, - AssertNode { + AssertNodeData { path, expected: Some(inserted_node), }, @@ -32,9 +33,10 @@ fn node_insert_node_with_children_test() { let scripts = vec![ InsertNode { path: path.clone(), - node: inserted_node.clone(), + node_data: inserted_node.clone(), + rev_id: 1, }, - AssertNode { + AssertNodeData { path, expected: Some(inserted_node), }, @@ -57,25 +59,28 @@ fn node_insert_multi_nodes_test() { let scripts = vec![ InsertNode { path: path_1.clone(), - node: node_1.clone(), + node_data: node_1.clone(), + rev_id: 1, }, InsertNode { path: path_2.clone(), - node: node_2.clone(), + node_data: node_2.clone(), + rev_id: 2, }, InsertNode { path: path_3.clone(), - node: node_3.clone(), + node_data: node_3.clone(), + rev_id: 3, }, - AssertNode { + AssertNodeData { path: path_1, expected: Some(node_1), }, - AssertNode { + AssertNodeData { path: path_2, expected: Some(node_2), }, - AssertNode { + AssertNodeData { path: path_3, expected: Some(node_3), }, @@ -96,48 +101,145 @@ fn node_insert_node_in_ordered_nodes_test() { let path_3: Path = 2.into(); let node_3 = NodeData::new("text_3"); - let path_4: Path = 3.into(); - let scripts = vec![ InsertNode { path: path_1.clone(), - node: node_1.clone(), + node_data: node_1.clone(), + rev_id: 1, }, InsertNode { path: path_2.clone(), - node: node_2_1.clone(), + node_data: node_2_1.clone(), + rev_id: 2, }, InsertNode { path: path_3.clone(), - node: node_3.clone(), + node_data: node_3, + rev_id: 3, }, - // 0:note_1 , 1: note_2_1, 2: note_3 + // 0:text_1 + // 1:text_2_1 + // 2:text_3 InsertNode { path: path_2.clone(), - node: node_2_2.clone(), + node_data: node_2_2.clone(), + rev_id: 4, }, - // 0:note_1 , 1:note_2_2, 2: note_2_1, 3: note_3 - AssertNode { + // 0:text_1 + // 1:text_2_2 + // 2:text_2_1 + // 3:text_3 + AssertNodeData { path: path_1, expected: Some(node_1), }, - AssertNode { + AssertNodeData { path: path_2, expected: Some(node_2_2), }, - AssertNode { + AssertNodeData { path: path_3, expected: Some(node_2_1), }, - AssertNode { - path: path_4, - expected: Some(node_3), - }, AssertNumberOfNodesAtPath { path: None, len: 4 }, ]; test.run_scripts(scripts); } +#[test] +fn node_insert_nested_nodes_test() { + let mut test = NodeTest::new(); + let node_data_1_1 = NodeDataBuilder::new("text_1_1").build(); + let node_data_1_2 = NodeDataBuilder::new("text_1_2").build(); + let node_data_1 = NodeDataBuilder::new("text_1") + .add_node(node_data_1_1.clone()) + .add_node(node_data_1_2.clone()) + .build(); + + let node_data_2_1 = NodeDataBuilder::new("text_2_1").build(); + let node_data_2_2 = NodeDataBuilder::new("text_2_2").build(); + let node_data_2 = NodeDataBuilder::new("text_2") + .add_node(node_data_2_1.clone()) + .add_node(node_data_2_2.clone()) + .build(); + + let scripts = vec![ + InsertNode { + path: 0.into(), + node_data: node_data_1, + rev_id: 1, + }, + InsertNode { + path: 1.into(), + node_data: node_data_2, + rev_id: 2, + }, + // the tree will be: + // 0:text_1 + // 0:text_1_1 + // 1:text_1_2 + // 1:text_2 + // 0:text_2_1 + // 1:text_2_2 + AssertNode { + path: vec![0, 0].into(), + expected: Some(node_data_1_1.into()), + }, + AssertNode { + path: vec![0, 1].into(), + expected: Some(node_data_1_2.into()), + }, + AssertNode { + path: vec![1, 0].into(), + expected: Some(node_data_2_1.into()), + }, + AssertNode { + path: vec![1, 1].into(), + expected: Some(node_data_2_2.into()), + }, + ]; + test.run_scripts(scripts); +} + +#[test] +fn node_insert_node_before_existing_nested_nodes_test() { + let mut test = NodeTest::new(); + let node_data_1_1 = NodeDataBuilder::new("text_1_1").build(); + let node_data_1_2 = NodeDataBuilder::new("text_1_2").build(); + let node_data_1 = NodeDataBuilder::new("text_1") + .add_node(node_data_1_1.clone()) + .add_node(node_data_1_2.clone()) + .build(); + + let scripts = vec![ + InsertNode { + path: 0.into(), + node_data: node_data_1, + rev_id: 1, + }, + // 0:text_1 + // 0:text_1_1 + // 1:text_1_2 + InsertNode { + path: 0.into(), + node_data: NodeDataBuilder::new("text_0").build(), + rev_id: 2, + }, + // 0:text_0 + // 1:text_1 + // 0:text_1_1 + // 1:text_1_2 + AssertNode { + path: vec![1, 0].into(), + expected: Some(node_data_1_1.into()), + }, + AssertNode { + path: vec![1, 1].into(), + expected: Some(node_data_1_2.into()), + }, + ]; + test.run_scripts(scripts); +} #[test] fn node_insert_with_attributes_test() { let mut test = NodeTest::new(); @@ -149,13 +251,14 @@ fn node_insert_with_attributes_test() { let scripts = vec![ InsertNode { path: path.clone(), - node: inserted_node.clone(), + node_data: inserted_node.clone(), + rev_id: 1, }, UpdateAttributes { path: path.clone(), attributes: inserted_node.attributes.clone(), }, - AssertNode { + AssertNodeData { path, expected: Some(inserted_node), }, @@ -172,10 +275,14 @@ fn node_delete_test() { let scripts = vec![ InsertNode { path: path.clone(), - node: inserted_node, + node_data: inserted_node, + rev_id: 1, }, - DeleteNode { path: path.clone() }, - AssertNode { path, expected: None }, + DeleteNode { + path: path.clone(), + rev_id: 2, + }, + AssertNodeData { path, expected: None }, ]; test.run_scripts(scripts); } @@ -198,7 +305,8 @@ fn node_update_body_test() { let scripts = vec![ InsertNode { path: path.clone(), - node, + node_data: node, + rev_id: 1, }, UpdateBody { path: path.clone(),